Skip to main content

max / makenotwork

10.3 KB · 304 lines History Blame Raw
1 //! Item detail screen — view/edit item fields, versions, publish status.
2
3 use ratatui::Frame;
4 use ratatui::layout::{Constraint, Layout};
5 use ratatui::style::{Color, Modifier, Style};
6 use ratatui::text::{Line, Span};
7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8
9 use crate::api::ItemDetail;
10 use crate::format;
11 use crate::staging;
12
13 use super::App;
14 use super::widgets;
15
16 pub(crate) fn render(frame: &mut Frame, app: &App) {
17 let area = frame.area();
18
19 let Some(item) = &app.item_detail else {
20 let loading = Paragraph::new(" Loading...");
21 frame.render_widget(loading, area);
22 return;
23 };
24
25 let status_label = if item.is_public { "Published" } else { "Draft" };
26
27 let title = Line::from(vec![
28 Span::styled(
29 " Makenot.work ",
30 Style::default().add_modifier(Modifier::BOLD),
31 ),
32 Span::raw(" -- "),
33 Span::styled(&item.title, Style::default().add_modifier(Modifier::BOLD)),
34 Span::raw(format!(
35 " -- {} -- {} ",
36 format::format_item_type(&item.item_type),
37 status_label
38 )),
39 ]);
40
41 let block = Block::default()
42 .title(title)
43 .borders(Borders::ALL)
44 .border_style(Style::default().fg(Color::Gray));
45
46 let inner = block.inner(area);
47 frame.render_widget(block, area);
48
49 let chunks = Layout::vertical([
50 Constraint::Length(1), // spacer
51 Constraint::Length(7), // item info (6 fields + tags)
52 Constraint::Length(1), // spacer
53 Constraint::Length(1), // versions header
54 Constraint::Min(3), // versions list
55 Constraint::Length(1), // status line
56 Constraint::Length(1), // keybindings
57 ])
58 .split(inner);
59
60 // Item info
61 render_item_info(frame, app, item, chunks[1]);
62
63 // Versions header
64 let version_count = app.item_versions.len();
65 let version_header = Paragraph::new(Line::from(vec![
66 Span::raw(" "),
67 Span::styled("Versions", Style::default().add_modifier(Modifier::BOLD)),
68 if version_count == 0 {
69 Span::raw("")
70 } else {
71 Span::raw(format!(" ({version_count})"))
72 },
73 ]));
74 frame.render_widget(version_header, chunks[3]);
75
76 // Versions list
77 if app.loading {
78 let loading = Paragraph::new(" Loading...");
79 frame.render_widget(loading, chunks[4]);
80 } else if app.item_versions.is_empty() {
81 let empty = Paragraph::new(" No versions.");
82 frame.render_widget(empty, chunks[4]);
83 } else {
84 render_versions_table(frame, app, chunks[4]);
85 }
86
87 // Status line
88 if let Some(ref status) = app.item_status {
89 let style = if status.starts_with("Error") {
90 Style::default().fg(Color::Red)
91 } else {
92 Style::default().fg(Color::Green)
93 };
94 let status_line = Paragraph::new(Line::from(vec![
95 Span::raw(" "),
96 Span::styled(status.as_str(), style),
97 ]));
98 frame.render_widget(status_line, chunks[5]);
99 }
100
101 // Keybindings
102 let mut key_spans = vec![Span::raw(" ")];
103
104 if app.item_editing.is_some() {
105 key_spans.extend([
106 Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
107 Span::raw(" Confirm "),
108 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
109 Span::raw(" Cancel"),
110 ]);
111 } else {
112 key_spans.extend([
113 Span::styled("[e]", Style::default().add_modifier(Modifier::BOLD)),
114 Span::raw(" Edit "),
115 ]);
116 if item.is_public {
117 key_spans.extend([
118 Span::styled("[u]", Style::default().add_modifier(Modifier::BOLD)),
119 Span::raw(" Unpublish "),
120 ]);
121 } else {
122 key_spans.extend([
123 Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)),
124 Span::raw(" Publish "),
125 ]);
126 }
127 key_spans.extend([
128 Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
129 Span::raw(" Delete "),
130 Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)),
131 Span::raw(" Tag "),
132 Span::styled("[l]", Style::default().add_modifier(Modifier::BOLD)),
133 Span::raw(" Keys "),
134 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
135 Span::raw(" Refresh "),
136 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
137 Span::raw(" Back"),
138 ]);
139 }
140
141 let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
142 frame.render_widget(keys, chunks[6]);
143 }
144
145 fn render_item_info(frame: &mut Frame, app: &App, item: &ItemDetail, area: ratatui::layout::Rect) {
146 let price = format::format_price(item.price_cents, app.currency());
147 let desc_preview = item
148 .description
149 .as_deref()
150 .unwrap_or("(no description)")
151 .chars()
152 .take(60)
153 .collect::<String>();
154
155 // Show edit indicator for editing field
156 let editing = app.item_editing;
157 let edit_marker =
158 |field: ItemEditField| -> &str { if editing == Some(field) { "> " } else { " " } };
159
160 let lines = vec![
161 Line::from(vec![
162 Span::raw(edit_marker(ItemEditField::Title)),
163 Span::styled("Title: ", Style::default().fg(Color::DarkGray)),
164 if editing == Some(ItemEditField::Title) {
165 Span::styled(
166 format!("{}_", app.edit_buffer),
167 Style::default().add_modifier(Modifier::UNDERLINED),
168 )
169 } else {
170 Span::raw(item.title.clone())
171 },
172 ]),
173 Line::from(vec![
174 Span::raw(edit_marker(ItemEditField::Description)),
175 Span::styled("Description: ", Style::default().fg(Color::DarkGray)),
176 if editing == Some(ItemEditField::Description) {
177 Span::styled(
178 format!("{}_", app.edit_buffer),
179 Style::default().add_modifier(Modifier::UNDERLINED),
180 )
181 } else {
182 Span::raw(desc_preview)
183 },
184 ]),
185 Line::from(vec![
186 Span::raw(edit_marker(ItemEditField::Price)),
187 Span::styled("Price: ", Style::default().fg(Color::DarkGray)),
188 if editing == Some(ItemEditField::Price) {
189 Span::styled(
190 // The symbol on the edit prompt is the creator's own: they are
191 // typing an amount in the currency they are paid in.
192 format!("{}{}_", app.currency().symbol(), app.edit_buffer),
193 Style::default().add_modifier(Modifier::UNDERLINED),
194 )
195 } else {
196 Span::raw(price)
197 },
198 ]),
199 Line::from(vec![
200 Span::raw(" "),
201 Span::styled("Type: ", Style::default().fg(Color::DarkGray)),
202 Span::raw(format::format_item_type(&item.item_type)),
203 Span::raw(" "),
204 Span::styled("Slug: ", Style::default().fg(Color::DarkGray)),
205 Span::raw(item.slug.clone()),
206 ]),
207 Line::from(vec![
208 Span::raw(" "),
209 Span::styled("Sales: ", Style::default().fg(Color::DarkGray)),
210 Span::raw(item.sales_count.to_string()),
211 Span::raw(" "),
212 Span::styled("Downloads: ", Style::default().fg(Color::DarkGray)),
213 Span::raw(item.download_count.to_string()),
214 if item.play_count > 0 {
215 Span::raw(format!(" Plays: {}", item.play_count))
216 } else {
217 Span::raw("")
218 },
219 ]),
220 Line::from(vec![
221 Span::raw(" "),
222 Span::styled("Audio: ", Style::default().fg(Color::DarkGray)),
223 Span::raw(if item.has_audio { "yes" } else { "no" }),
224 Span::raw(" "),
225 Span::styled("Cover: ", Style::default().fg(Color::DarkGray)),
226 Span::raw(if item.has_cover { "yes" } else { "no" }),
227 ]),
228 Line::from({
229 let mut spans = vec![
230 Span::raw(" "),
231 Span::styled("Tags: ", Style::default().fg(Color::DarkGray)),
232 ];
233 if app.item_tags.is_empty() {
234 spans.push(Span::styled("(none)", Style::default().fg(Color::DarkGray)));
235 } else {
236 for (i, tag) in app.item_tags.iter().enumerate() {
237 if i > 0 {
238 spans.push(Span::raw(", "));
239 }
240 if tag.is_primary {
241 spans.push(Span::styled(
242 &tag.name,
243 Style::default().add_modifier(Modifier::BOLD),
244 ));
245 } else {
246 spans.push(Span::raw(&tag.name));
247 }
248 }
249 }
250 spans
251 }),
252 ];
253
254 let info = Paragraph::new(lines);
255 frame.render_widget(info, area);
256 }
257
258 fn render_versions_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
259 let rows: Vec<Row> = app
260 .item_versions
261 .iter()
262 .map(|v| {
263 let current = if v.is_current { "*" } else { " " };
264 let size = v
265 .file_size_bytes
266 .map_or_else(|| "--".to_string(), |b| staging::format_bytes(b as u64));
267 let name = v.file_name.as_deref().unwrap_or("--");
268 let date = v.created_at.get(..10).unwrap_or(&v.created_at);
269
270 Row::new(vec![
271 format!(" {}{}", current, v.version_number),
272 name.to_string(),
273 size,
274 v.download_count.to_string(),
275 date.to_string(),
276 ])
277 })
278 .collect();
279
280 let widths = [
281 Constraint::Length(12),
282 Constraint::Min(16),
283 Constraint::Length(10),
284 Constraint::Length(10),
285 Constraint::Length(12),
286 ];
287
288 widgets::render_table(
289 frame,
290 area,
291 &[" Version", "File", "Size", "Downloads", "Date"],
292 &widths,
293 rows,
294 );
295 }
296
297 /// Which field is being edited on the item detail screen.
298 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
299 pub(crate) enum ItemEditField {
300 Title,
301 Description,
302 Price,
303 }
304