Skip to main content

max / makenotwork

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