Skip to main content

max / makenotwork

15.0 KB · 522 lines History Blame Raw
1 //! TUI application state and event loop.
2
3 pub(crate) mod analytics;
4 pub(crate) mod blog;
5 pub(crate) mod collections;
6 pub(crate) mod home;
7 mod input;
8 pub(crate) mod item;
9 pub(crate) mod keys;
10 mod loading;
11 pub(crate) mod project;
12 pub(crate) mod promo;
13 pub(crate) mod settings;
14 pub(crate) mod tiers;
15 pub(crate) mod upload;
16 pub(crate) mod widgets;
17
18 use std::collections::HashSet;
19 use std::path::PathBuf;
20
21 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
22 use ratatui::Terminal;
23 use ratatui::backend::CrosstermBackend;
24 use tokio::sync::mpsc;
25
26 use crate::api::{
27 AnalyticsData, BlogPost, CollectionInfo, CreatorStats, Item, ItemDetail, LicenseKey,
28 MnwApiClient, Project, PromoCode, SshKeyInfo, StorageInfo, TagInfo, TierInfo, Transaction,
29 UserInfo, Version,
30 };
31 use crate::ssh::terminal::TerminalHandle;
32 use crate::staging::{self, StagedFile};
33
34 use input::{
35 handle_analytics_input, handle_blog_input, handle_collections_input, handle_home_input,
36 handle_item_input, handle_keys_input, handle_project_input, handle_promo_input,
37 handle_settings_input, handle_tiers_input, handle_upload_input,
38 };
39 use loading::{load_blog_posts, load_home_data, load_project_items, load_staged_files};
40
41 mod run;
42 pub(crate) use run::launch;
43
44 /// Events sent to the TUI event loop.
45 pub(crate) enum AppEvent {
46 /// Raw input bytes from the SSH channel.
47 Input(Vec<u8>),
48 /// Terminal resize.
49 Resize(u16, u16),
50 /// Data loaded from the API.
51 DataLoaded(DataPayload),
52 }
53
54 /// Payload variants for async data loading.
55 pub(crate) enum DataPayload {
56 Home {
57 projects: Vec<Project>,
58 stats: CreatorStats,
59 },
60 ProjectItems {
61 items: Vec<Item>,
62 },
63 StagedFiles {
64 files: Vec<StagedFile>,
65 storage: Option<StorageInfo>,
66 },
67 PublishResult {
68 filename: String,
69 success: bool,
70 error: Option<String>,
71 },
72 ItemDetail {
73 detail: ItemDetail,
74 versions: Vec<Version>,
75 },
76 ItemUpdated {
77 detail: ItemDetail,
78 },
79 ItemDeleted,
80 ItemActionError {
81 error: String,
82 },
83 /// Signal to reload the project items list after a mutation.
84 #[allow(dead_code)]
85 ProjectReload {
86 project_idx: usize,
87 },
88 BlogPosts {
89 posts: Vec<BlogPost>,
90 },
91 BlogCreated,
92 PromoCodes {
93 codes: Vec<PromoCode>,
94 },
95 LicenseKeys {
96 keys: Vec<LicenseKey>,
97 },
98 GenericSuccess {
99 message: String,
100 },
101 GenericError {
102 error: String,
103 },
104 Analytics {
105 data: AnalyticsData,
106 },
107 Transactions {
108 txs: Vec<Transaction>,
109 },
110 ExportCsv {
111 csv: String,
112 row_count: usize,
113 },
114 Settings {
115 keys: Vec<SshKeyInfo>,
116 storage: Option<StorageInfo>,
117 },
118 ItemTags {
119 tags: Vec<TagInfo>,
120 },
121 TagSearchResults {
122 results: Vec<TagInfo>,
123 },
124 CollectionsList {
125 collections: Vec<CollectionInfo>,
126 },
127 TiersList {
128 tiers: Vec<TierInfo>,
129 },
130 BulkActionComplete {
131 message: String,
132 },
133 }
134
135 /// Handle for sending events to a running TUI session.
136 #[derive(Clone)]
137 pub(crate) struct AppHandle {
138 tx: mpsc::Sender<AppEvent>,
139 }
140
141 impl AppHandle {
142 pub(crate) async fn send_input(&self, data: &[u8]) {
143 let _ = self.tx.send(AppEvent::Input(data.to_vec())).await;
144 }
145
146 pub(crate) async fn send_resize(&self, cols: u16, rows: u16) {
147 let _ = self.tx.send(AppEvent::Resize(cols, rows)).await;
148 }
149 }
150
151 /// Active screen in the TUI.
152 enum Screen {
153 Home,
154 /// Project detail view. Index is into `app.projects`.
155 Project(usize),
156 /// Upload management screen.
157 Upload,
158 /// Item detail view. Stores (project_index, item_id).
159 Item(usize, String),
160 /// Blog post list for a project. Stores (project_index, project_id).
161 Blog(usize, String),
162 /// Promo code management.
163 Promo,
164 /// License key management for an item. Stores (project_index, item_id).
165 Keys(usize, String),
166 /// Analytics dashboard.
167 Analytics,
168 /// Settings screen (profile, storage, SSH keys).
169 Settings,
170 /// Collections management.
171 Collections,
172 /// Subscription tiers for a project. Stores (project_index, project_id).
173 /// The id is carried for the tier-mutation calls the screen will need; the
174 /// render path only uses the index today.
175 Tiers(usize, #[allow(dead_code)] String),
176 }
177
178 /// User-editable metadata for a staged file.
179 #[derive(Debug, Clone, Default)]
180 pub(crate) struct FileMetadata {
181 pub title: Option<String>,
182 pub project_idx: Option<usize>,
183 pub project_name: Option<String>,
184 pub price_cents: i32,
185 }
186
187 /// Which field is being edited on the upload screen.
188 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
189 pub(crate) enum EditField {
190 Title,
191 Project,
192 Price,
193 }
194
195 /// Steps for creating a blog post.
196 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
197 pub(crate) enum BlogCreateStep {
198 Title,
199 Body,
200 /// Optional scheduling step — enter datetime or leave empty to publish as draft.
201 Schedule,
202 }
203
204 /// Steps for creating a promo code.
205 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
206 pub(crate) enum PromoCreateStep {
207 Code,
208 Discount,
209 }
210
211 /// Pending destructive action awaiting confirmation.
212 #[derive(Debug, Clone)]
213 pub(crate) enum ConfirmAction {
214 DeleteItem,
215 DeleteBlogPost { post_idx: usize },
216 DeletePromoCode { code_idx: usize },
217 RevokeLicenseKey { key_idx: usize },
218 BulkPublish { count: usize },
219 BulkUnpublish { count: usize },
220 BulkDelete { count: usize },
221 }
222
223 /// Application state shared across screens.
224 pub(crate) struct App {
225 pub user: UserInfo,
226 pub projects: Vec<Project>,
227 pub stats: Option<CreatorStats>,
228 pub items: Vec<Item>,
229 pub selected_index: usize,
230 pub selected_items: HashSet<usize>,
231 pub loading: bool,
232 pub staged_files: Vec<StagedFile>,
233 pub storage_info: Option<StorageInfo>,
234 pub file_metadata: Vec<FileMetadata>,
235 pub upload_status: Option<String>,
236 pub editing_field: Option<EditField>,
237 pub edit_buffer: String,
238 pub publishing: bool,
239 pub item_detail: Option<ItemDetail>,
240 pub item_versions: Vec<Version>,
241 pub item_status: Option<String>,
242 pub item_editing: Option<item::ItemEditField>,
243 // Blog
244 pub blog_posts: Vec<BlogPost>,
245 pub blog_project_title: Option<String>,
246 pub blog_status: Option<String>,
247 pub blog_creating: bool,
248 pub blog_create_step: Option<BlogCreateStep>,
249 pub blog_create_title: String,
250 pub blog_create_body: String,
251 // Promo codes
252 pub promo_codes: Vec<PromoCode>,
253 pub promo_status: Option<String>,
254 pub promo_editing_step: Option<PromoCreateStep>,
255 pub promo_create_code: String,
256 pub promo_create_discount: String,
257 // License keys
258 pub license_keys: Vec<LicenseKey>,
259 pub keys_item_title: Option<String>,
260 pub keys_status: Option<String>,
261 // Analytics
262 pub analytics_data: Option<AnalyticsData>,
263 pub analytics_range: String,
264 pub analytics_status: Option<String>,
265 pub analytics_show_transactions: bool,
266 pub transactions: Vec<Transaction>,
267 // Settings
268 pub ssh_keys: Vec<SshKeyInfo>,
269 pub settings_status: Option<String>,
270 // Tags (on item detail)
271 pub item_tags: Vec<TagInfo>,
272 pub tag_search_results: Vec<TagInfo>,
273 pub tag_searching: bool,
274 // Collections
275 pub collections: Vec<CollectionInfo>,
276 pub collections_status: Option<String>,
277 // Tiers
278 pub tiers: Vec<TierInfo>,
279 pub tiers_project_title: Option<String>,
280 pub tiers_status: Option<String>,
281 // Confirmation dialog
282 pub confirm_action: Option<ConfirmAction>,
283 }
284
285 impl App {
286 fn new(user: UserInfo) -> Self {
287 Self {
288 user,
289 projects: Vec::new(),
290 stats: None,
291 items: Vec::new(),
292 selected_index: 0,
293 selected_items: HashSet::new(),
294 loading: true,
295 staged_files: Vec::new(),
296 storage_info: None,
297 file_metadata: Vec::new(),
298 upload_status: None,
299 editing_field: None,
300 edit_buffer: String::new(),
301 publishing: false,
302 item_detail: None,
303 item_versions: Vec::new(),
304 item_status: None,
305 item_editing: None,
306 blog_posts: Vec::new(),
307 blog_project_title: None,
308 blog_status: None,
309 blog_creating: false,
310 blog_create_step: None,
311 blog_create_title: String::new(),
312 blog_create_body: String::new(),
313 promo_codes: Vec::new(),
314 promo_status: None,
315 promo_editing_step: None,
316 promo_create_code: String::new(),
317 promo_create_discount: String::new(),
318 license_keys: Vec::new(),
319 keys_item_title: None,
320 keys_status: None,
321 analytics_data: None,
322 analytics_range: "30d".to_string(),
323 analytics_status: None,
324 analytics_show_transactions: false,
325 transactions: Vec::new(),
326 ssh_keys: Vec::new(),
327 settings_status: None,
328 item_tags: Vec::new(),
329 tag_search_results: Vec::new(),
330 tag_searching: false,
331 collections: Vec::new(),
332 collections_status: None,
333 tiers: Vec::new(),
334 tiers_project_title: None,
335 tiers_status: None,
336 confirm_action: None,
337 }
338 }
339
340 fn list_len(&self, screen: &Screen) -> usize {
341 match screen {
342 Screen::Home => self.projects.len(),
343 Screen::Project(_) => self.items.len(),
344 Screen::Upload => self.staged_files.len(),
345 Screen::Item(..) => self.item_versions.len(),
346 Screen::Blog(..) => self.blog_posts.len(),
347 Screen::Promo => self.promo_codes.len(),
348 Screen::Keys(..) => self.license_keys.len(),
349 Screen::Analytics => self.transactions.len(),
350 Screen::Settings => self.ssh_keys.len(),
351 Screen::Collections => self.collections.len(),
352 Screen::Tiers(..) => self.tiers.len(),
353 }
354 }
355
356 fn move_up(&mut self, screen: &Screen) {
357 if self.selected_index > 0 {
358 self.selected_index -= 1;
359 } else {
360 // Wrap to bottom
361 let len = self.list_len(screen);
362 if len > 0 {
363 self.selected_index = len - 1;
364 }
365 }
366 }
367
368 fn move_down(&mut self, screen: &Screen) {
369 let len = self.list_len(screen);
370 if len > 0 {
371 if self.selected_index < len - 1 {
372 self.selected_index += 1;
373 } else {
374 // Wrap to top
375 self.selected_index = 0;
376 }
377 }
378 }
379
380 /// Ensure file_metadata vec matches staged_files length.
381 fn sync_metadata(&mut self) {
382 while self.file_metadata.len() < self.staged_files.len() {
383 let idx = self.file_metadata.len();
384 let title = staging::derive_title(&self.staged_files[idx].filename);
385 self.file_metadata.push(FileMetadata {
386 title: Some(title),
387 ..Default::default()
388 });
389 }
390 self.file_metadata.truncate(self.staged_files.len());
391 }
392 }
393
394 fn format_edit_prompt(field: EditField, buffer: &str) -> String {
395 let field_name = match field {
396 EditField::Title => "Title",
397 EditField::Project => "Project #",
398 EditField::Price => "Price ($)",
399 };
400 format!("{field_name}: {buffer}_")
401 }
402
403 // NOTE: parse_price, parse_key, and tests are below.
404 // All handle_*_input functions are in input.rs.
405 // All load_* functions and publish_file are in loading.rs.
406
407 fn parse_price(input: &str) -> i32 {
408 // Accept "5", "5.00", "5.99", "0" etc.
409 if input.is_empty() || input == "0" || input.eq_ignore_ascii_case("free") {
410 return 0;
411 }
412 if let Some((dollars, cents)) = input.split_once('.') {
413 let d: i32 = dollars.parse().unwrap_or(0);
414 let cents_str = cents.get(..2).unwrap_or(cents);
415 let c: i32 = if cents_str.len() == 1 {
416 cents_str.parse::<i32>().unwrap_or(0) * 10
417 } else {
418 cents_str.parse().unwrap_or(0)
419 };
420 d * 100 + c
421 } else {
422 input.parse::<i32>().unwrap_or(0) * 100
423 }
424 }
425
426 /// Parse raw SSH input bytes into a crossterm KeyEvent.
427 fn parse_key(data: &[u8]) -> Option<KeyEvent> {
428 match data {
429 // Ctrl+C
430 [3] => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
431 // Escape
432 [27] => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
433 // Enter
434 [13 | 10] => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
435 // Tab
436 [9] => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
437 // Backspace
438 [127 | 8] => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
439 // Arrow keys
440 [27, 91, 65] => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
441 [27, 91, 66] => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
442 [27, 91, 67] => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
443 [27, 91, 68] => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
444 // Single printable ASCII byte
445 [b] if b.is_ascii_graphic() || *b == b' ' => {
446 Some(KeyEvent::new(KeyCode::Char(*b as char), KeyModifiers::NONE))
447 }
448 // Ctrl+letter (1-26 maps to a-z)
449 [b] if *b >= 1 && *b <= 26 => Some(KeyEvent::new(
450 KeyCode::Char((b + b'a' - 1) as char),
451 KeyModifiers::CONTROL,
452 )),
453 _ => None,
454 }
455 }
456
457 #[cfg(test)]
458 mod tests {
459 use super::*;
460
461 #[test]
462 fn parse_price_whole_dollars() {
463 assert_eq!(parse_price("5"), 500);
464 assert_eq!(parse_price("10"), 1000);
465 }
466
467 #[test]
468 fn parse_price_with_cents() {
469 assert_eq!(parse_price("5.99"), 599);
470 assert_eq!(parse_price("0.50"), 50);
471 }
472
473 #[test]
474 fn parse_price_single_digit_cents() {
475 assert_eq!(parse_price("5.5"), 550);
476 assert_eq!(parse_price("1.1"), 110);
477 }
478
479 #[test]
480 fn parse_price_free() {
481 assert_eq!(parse_price("0"), 0);
482 assert_eq!(parse_price("free"), 0);
483 assert_eq!(parse_price("FREE"), 0);
484 assert_eq!(parse_price(""), 0);
485 }
486
487 #[test]
488 fn parse_price_truncates_extra_decimals() {
489 assert_eq!(parse_price("5.999"), 599);
490 }
491
492 #[test]
493 fn parse_key_ctrl_c() {
494 let key = parse_key(&[3]).unwrap();
495 assert_eq!(key.code, KeyCode::Char('c'));
496 assert!(key.modifiers.contains(KeyModifiers::CONTROL));
497 }
498
499 #[test]
500 fn parse_key_enter() {
501 let key = parse_key(&[13]).unwrap();
502 assert_eq!(key.code, KeyCode::Enter);
503 }
504
505 #[test]
506 fn parse_key_arrow_up() {
507 let key = parse_key(&[27, 91, 65]).unwrap();
508 assert_eq!(key.code, KeyCode::Up);
509 }
510
511 #[test]
512 fn parse_key_printable_char() {
513 let key = parse_key(b"a").unwrap();
514 assert_eq!(key.code, KeyCode::Char('a'));
515 }
516
517 #[test]
518 fn parse_key_unknown() {
519 assert!(parse_key(&[27, 91, 100, 100]).is_none());
520 }
521 }
522