Skip to main content

max / makenotwork

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