Skip to main content

max / mnw-cli

9.0 KB · 276 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(6), // item info
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("[r]", Style::default().add_modifier(Modifier::BOLD)),
127 Span::raw(" Refresh "),
128 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
129 Span::raw(" Back"),
130 ]);
131 }
132
133 let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
134 frame.render_widget(keys, chunks[6]);
135 }
136
137 fn render_item_info(
138 frame: &mut Frame,
139 app: &App,
140 item: &ItemDetail,
141 area: ratatui::layout::Rect,
142 ) {
143 let price = format::format_price(item.price_cents);
144 let desc_preview = item
145 .description
146 .as_deref()
147 .unwrap_or("(no description)")
148 .chars()
149 .take(60)
150 .collect::<String>();
151
152 // Show edit indicator for editing field
153 let editing = app.item_editing;
154 let edit_marker = |field: ItemEditField| -> &str {
155 if editing == Some(field) {
156 "> "
157 } else {
158 " "
159 }
160 };
161
162 let lines = vec![
163 Line::from(vec![
164 Span::raw(edit_marker(ItemEditField::Title)),
165 Span::styled("Title: ", Style::default().fg(Color::DarkGray)),
166 if editing == Some(ItemEditField::Title) {
167 Span::styled(
168 format!("{}_", app.edit_buffer),
169 Style::default().add_modifier(Modifier::UNDERLINED),
170 )
171 } else {
172 Span::raw(item.title.clone())
173 },
174 ]),
175 Line::from(vec![
176 Span::raw(edit_marker(ItemEditField::Description)),
177 Span::styled("Description: ", Style::default().fg(Color::DarkGray)),
178 if editing == Some(ItemEditField::Description) {
179 Span::styled(
180 format!("{}_", app.edit_buffer),
181 Style::default().add_modifier(Modifier::UNDERLINED),
182 )
183 } else {
184 Span::raw(desc_preview)
185 },
186 ]),
187 Line::from(vec![
188 Span::raw(edit_marker(ItemEditField::Price)),
189 Span::styled("Price: ", Style::default().fg(Color::DarkGray)),
190 if editing == Some(ItemEditField::Price) {
191 Span::styled(
192 format!("${}_", 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 ];
229
230 let info = Paragraph::new(lines);
231 frame.render_widget(info, area);
232 }
233
234 fn render_versions_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
235 let rows: Vec<Row> = app
236 .item_versions
237 .iter()
238 .map(|v| {
239 let current = if v.is_current { "*" } else { " " };
240 let size = v
241 .file_size_bytes
242 .map(|b| staging::format_bytes(b as u64))
243 .unwrap_or_else(|| "--".to_string());
244 let name = v.file_name.as_deref().unwrap_or("--");
245 let date = v.created_at.get(..10).unwrap_or(&v.created_at);
246
247 Row::new(vec![
248 format!(" {}{}", current, v.version_number),
249 name.to_string(),
250 size,
251 v.download_count.to_string(),
252 date.to_string(),
253 ])
254 })
255 .collect();
256
257 let widths = [
258 Constraint::Length(12),
259 Constraint::Min(16),
260 Constraint::Length(10),
261 Constraint::Length(10),
262 Constraint::Length(12),
263 ];
264
265 widgets::render_table(frame, area, &[" Version", "File", "Size", "Downloads", "Date"], &widths, rows);
266 }
267
268 /// Which field is being edited on the item detail screen.
269 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
270 pub enum ItemEditField {
271 Title,
272 Description,
273 Price,
274 }
275
276