Skip to main content

max / makenotwork

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