Skip to main content

max / makenotwork

10.1 KB · 302 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);
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 format!("${}_", app.edit_buffer),
191 Style::default().add_modifier(Modifier::UNDERLINED),
192 )
193 } else {
194 Span::raw(price)
195 },
196 ]),
197 Line::from(vec![
198 Span::raw(" "),
199 Span::styled("Type: ", Style::default().fg(Color::DarkGray)),
200 Span::raw(format::format_item_type(&item.item_type)),
201 Span::raw(" "),
202 Span::styled("Slug: ", Style::default().fg(Color::DarkGray)),
203 Span::raw(item.slug.clone()),
204 ]),
205 Line::from(vec![
206 Span::raw(" "),
207 Span::styled("Sales: ", Style::default().fg(Color::DarkGray)),
208 Span::raw(item.sales_count.to_string()),
209 Span::raw(" "),
210 Span::styled("Downloads: ", Style::default().fg(Color::DarkGray)),
211 Span::raw(item.download_count.to_string()),
212 if item.play_count > 0 {
213 Span::raw(format!(" Plays: {}", item.play_count))
214 } else {
215 Span::raw("")
216 },
217 ]),
218 Line::from(vec![
219 Span::raw(" "),
220 Span::styled("Audio: ", Style::default().fg(Color::DarkGray)),
221 Span::raw(if item.has_audio { "yes" } else { "no" }),
222 Span::raw(" "),
223 Span::styled("Cover: ", Style::default().fg(Color::DarkGray)),
224 Span::raw(if item.has_cover { "yes" } else { "no" }),
225 ]),
226 Line::from({
227 let mut spans = vec![
228 Span::raw(" "),
229 Span::styled("Tags: ", Style::default().fg(Color::DarkGray)),
230 ];
231 if app.item_tags.is_empty() {
232 spans.push(Span::styled("(none)", Style::default().fg(Color::DarkGray)));
233 } else {
234 for (i, tag) in app.item_tags.iter().enumerate() {
235 if i > 0 {
236 spans.push(Span::raw(", "));
237 }
238 if tag.is_primary {
239 spans.push(Span::styled(
240 &tag.name,
241 Style::default().add_modifier(Modifier::BOLD),
242 ));
243 } else {
244 spans.push(Span::raw(&tag.name));
245 }
246 }
247 }
248 spans
249 }),
250 ];
251
252 let info = Paragraph::new(lines);
253 frame.render_widget(info, area);
254 }
255
256 fn render_versions_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
257 let rows: Vec<Row> = app
258 .item_versions
259 .iter()
260 .map(|v| {
261 let current = if v.is_current { "*" } else { " " };
262 let size = v
263 .file_size_bytes
264 .map_or_else(|| "--".to_string(), |b| staging::format_bytes(b as u64));
265 let name = v.file_name.as_deref().unwrap_or("--");
266 let date = v.created_at.get(..10).unwrap_or(&v.created_at);
267
268 Row::new(vec![
269 format!(" {}{}", current, v.version_number),
270 name.to_string(),
271 size,
272 v.download_count.to_string(),
273 date.to_string(),
274 ])
275 })
276 .collect();
277
278 let widths = [
279 Constraint::Length(12),
280 Constraint::Min(16),
281 Constraint::Length(10),
282 Constraint::Length(10),
283 Constraint::Length(12),
284 ];
285
286 widgets::render_table(
287 frame,
288 area,
289 &[" Version", "File", "Size", "Downloads", "Date"],
290 &widths,
291 rows,
292 );
293 }
294
295 /// Which field is being edited on the item detail screen.
296 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
297 pub(crate) enum ItemEditField {
298 Title,
299 Description,
300 Price,
301 }
302