Skip to main content

max / makenotwork

8.6 KB · 271 lines History Blame Raw
1 //! Upload screen — staged files, metadata editing, publish flow.
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, TableState};
10
11 use crate::format;
12 use crate::staging;
13
14 use super::App;
15
16 /// The staged file list's columns.
17 ///
18 /// The filename is what the creator recognises a staged file by, and it carries
19 /// the editing marker, so it is the essential one. The title and the project
20 /// are what publishing needs filled in, which keeps them above the size, the
21 /// type and the price.
22 const FILE_COLUMNS: [Column<'static>; 6] = [
23 Column {
24 name: "File",
25 width: Width::Fill,
26 priority: Priority::Essential,
27 sortable: false,
28 sorted: None,
29 },
30 Column {
31 name: "Size",
32 width: Width::Fixed,
33 priority: Priority::Optional,
34 sortable: false,
35 sorted: None,
36 },
37 Column {
38 name: "Type",
39 width: Width::Fixed,
40 priority: Priority::Optional,
41 sortable: false,
42 sorted: None,
43 },
44 Column {
45 name: "Title",
46 width: Width::Fixed,
47 priority: Priority::Secondary,
48 sortable: false,
49 sorted: None,
50 },
51 Column {
52 name: "Project",
53 width: Width::Fixed,
54 priority: Priority::Secondary,
55 sortable: false,
56 sorted: None,
57 },
58 Column {
59 name: "Price",
60 width: Width::Fixed,
61 priority: Priority::Optional,
62 sortable: false,
63 sorted: None,
64 },
65 ];
66
67 /// The tracks the hand-written `Constraint`s carried.
68 const FILE_SIZING: Sizing<'static> = Sizing {
69 lengths: &[
70 ("File", 16),
71 ("Size", 10),
72 ("Type", 8),
73 ("Title", 16),
74 ("Project", 14),
75 ("Price", 8),
76 ],
77 fallback: 8,
78 };
79
80 pub(crate) fn render(frame: &mut Frame, app: &App) {
81 let area = frame.area();
82
83 let title = Line::from(vec![
84 Span::styled(
85 " Makenot.work ",
86 Style::default().add_modifier(Modifier::BOLD),
87 ),
88 Span::raw(" ── "),
89 Span::styled("Upload", Style::default().add_modifier(Modifier::BOLD)),
90 Span::raw(" "),
91 ]);
92
93 let block = Block::default()
94 .title(title)
95 .borders(Borders::ALL)
96 .border_style(Style::default().fg(app.theme.line_border));
97
98 let inner = block.inner(area);
99 frame.render_widget(block, area);
100
101 let chunks = Layout::vertical([
102 Constraint::Length(1), // spacer
103 Constraint::Length(1), // storage info
104 Constraint::Length(1), // spacer
105 Constraint::Length(1), // section header
106 Constraint::Min(3), // file list
107 Constraint::Length(1), // status line
108 Constraint::Length(1), // keybindings
109 ])
110 .split(inner);
111
112 // Storage info
113 render_storage_line(frame, app, chunks[1]);
114
115 // Section header
116 let file_count = app.staged_files.len();
117 let header = Paragraph::new(Line::from(vec![
118 Span::raw(" "),
119 Span::styled(
120 "Staged Files",
121 Style::default().add_modifier(Modifier::BOLD),
122 ),
123 if file_count == 0 {
124 Span::raw("")
125 } else {
126 Span::raw(format!(" ({file_count})"))
127 },
128 ]));
129 frame.render_widget(header, chunks[3]);
130
131 // File list
132 if app.loading {
133 let loading = Paragraph::new(" Loading...");
134 frame.render_widget(loading, chunks[4]);
135 } else if app.staged_files.is_empty() {
136 let empty = Paragraph::new(Line::from(vec![
137 Span::raw(" No staged files. Upload with: "),
138 Span::styled(
139 "scp file.wav cli.makenot.work:upload/",
140 Style::default().add_modifier(Modifier::BOLD),
141 ),
142 ]));
143 frame.render_widget(empty, chunks[4]);
144 } else {
145 render_file_table(frame, app, chunks[4]);
146 }
147
148 // Status line (publish progress, errors, etc.)
149 if let Some(ref status) = app.upload_status {
150 let style = if status.starts_with("Error") {
151 Style::default().fg(app.theme.status_danger)
152 } else if status.starts_with("Published") {
153 Style::default().fg(app.theme.status_success)
154 } else {
155 Style::default().fg(app.theme.status_warning)
156 };
157 let status_line = Paragraph::new(Line::from(vec![
158 Span::raw(" "),
159 Span::styled(status.as_str(), style),
160 ]));
161 frame.render_widget(status_line, chunks[5]);
162 }
163
164 // Keybindings
165 let mut key_spans = vec![
166 Span::raw(" "),
167 Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
168 Span::raw(" Nav "),
169 ];
170
171 if app.editing_field.is_some() {
172 key_spans.extend([
173 Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
174 Span::raw(" Save "),
175 Span::styled("[Tab]", Style::default().add_modifier(Modifier::BOLD)),
176 Span::raw(" Next field "),
177 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
178 Span::raw(" Cancel"),
179 ]);
180 } else {
181 if !app.staged_files.is_empty() {
182 key_spans.extend([
183 Span::styled("[e]", Style::default().add_modifier(Modifier::BOLD)),
184 Span::raw(" Edit "),
185 Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)),
186 Span::raw(" Publish "),
187 Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
188 Span::raw(" Delete "),
189 ]);
190 }
191 key_spans.extend([
192 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
193 Span::raw(" Refresh "),
194 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
195 Span::raw(" Back"),
196 ]);
197 }
198
199 let keys =
200 Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
201 frame.render_widget(keys, chunks[6]);
202 }
203
204 fn render_storage_line(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
205 let text = if let Some(ref info) = app.storage_info {
206 let used = staging::format_bytes(info.storage_used_bytes as u64);
207 let max = staging::format_bytes(info.max_storage_bytes as u64);
208 let tier_label = app
209 .user
210 .creator_tier
211 .as_deref()
212 .map_or("No tier", format::format_tier);
213
214 if info.allows_file_uploads {
215 format!(" Storage: {used} / {max} ({tier_label})")
216 } else {
217 format!(" {tier_label} -- file uploads not available")
218 }
219 } else {
220 " Storage: --".to_string()
221 };
222
223 frame.render_widget(Paragraph::new(text), area);
224 }
225
226 fn render_file_table(frame: &mut Frame, app: &App, area: Rect) {
227 let area = super::indent(area, 2);
228 let selected = app.selected_index;
229
230 let rows: Vec<Vec<Cell>> = app
231 .staged_files
232 .iter()
233 .enumerate()
234 .map(|(i, sf)| {
235 // Check if user has edited metadata for this file
236 let meta = app.file_metadata.get(i);
237 let title = meta
238 .and_then(|m| m.title.clone())
239 .unwrap_or_else(|| staging::derive_title(&sf.filename));
240 let project = meta
241 .and_then(|m| m.project_name.as_deref())
242 .unwrap_or("[none]");
243 let price = meta.map_or_else(
244 || "Free".to_string(),
245 |m| format::format_price(m.price_cents, app.currency()),
246 );
247
248 // Show edit indicator for editing field
249 let editing_marker = if app.editing_field.is_some() && i == selected {
250 "*"
251 } else {
252 " "
253 };
254
255 vec![
256 Cell::new("File", format!("{editing_marker}{}", sf.filename)),
257 Cell::new("Size", staging::format_bytes(sf.size)),
258 Cell::new("Type", sf.classification.map_or("?", |c| c.item_type)),
259 Cell::new("Title", title),
260 Cell::new("Project", project),
261 Cell::new("Price", price),
262 ]
263 })
264 .collect();
265
266 let style = TableStyle::from_theme(&app.theme);
267 let table = table::table(&FILE_COLUMNS, &rows, &FILE_SIZING, &style, area.width);
268 let mut state = TableState::default().with_selected(Some(app.selected_index));
269 frame.render_stateful_widget(table, area, &mut state);
270 }
271