Skip to main content

max / makenotwork

64.5 KB · 1642 lines History Blame Raw
1 //! Screen-specific input handlers.
2
3 use std::path::Path;
4
5 use crossterm::event::{KeyCode, KeyEvent};
6 use tokio::sync::mpsc;
7
8 use crate::api::MnwApiClient;
9
10 use super::loading::*;
11 use super::{
12 item, App, AppEvent, BlogCreateStep, ConfirmAction, DataPayload, EditField, PromoCreateStep,
13 Screen,
14 };
15
16 /// Save the current value of an upload edit field.
17 fn save_upload_field(app: &mut App, field: EditField, idx: usize) {
18 match field {
19 EditField::Title => {
20 if !app.edit_buffer.is_empty() {
21 app.file_metadata[idx].title = Some(app.edit_buffer.clone());
22 }
23 }
24 EditField::Project => {
25 if let Ok(n) = app.edit_buffer.parse::<usize>()
26 && n > 0 && n <= app.projects.len()
27 {
28 let pidx = n - 1;
29 app.file_metadata[idx].project_idx = Some(pidx);
30 app.file_metadata[idx].project_name = Some(app.projects[pidx].title.clone());
31 }
32 }
33 EditField::Price => {
34 let cents = super::parse_price(&app.edit_buffer);
35 app.file_metadata[idx].price_cents = cents;
36 }
37 }
38 }
39
40 /// Generate the status prompt for an upload edit field.
41 fn upload_field_prompt(field: EditField, app: &App) -> String {
42 match field {
43 EditField::Title => "Title: _ (Enter to save, Tab for next field, Esc to cancel)".to_string(),
44 EditField::Project => {
45 let project_list = app
46 .projects
47 .iter()
48 .enumerate()
49 .map(|(i, p)| format!("{}:{}", i + 1, p.title))
50 .collect::<Vec<_>>()
51 .join(" ");
52 format!("Project #: _ | {}", project_list)
53 }
54 EditField::Price => "Price ($): _ (0 or empty for free)".to_string(),
55 }
56 }
57
58 pub(super) async fn handle_home_input(
59 key: KeyEvent,
60 app: &mut App,
61 screen: &mut Screen,
62 api: &MnwApiClient,
63 tx: &mpsc::Sender<AppEvent>,
64 staging_dir: &Path,
65 ) {
66 match key.code {
67 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
68 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
69 KeyCode::Enter => {
70 if !app.projects.is_empty() {
71 let idx = app.selected_index;
72 let project_id = app.projects[idx].id.clone();
73 let user_id = app.user.user_id.clone();
74 *screen = Screen::Project(idx);
75 app.items.clear();
76 app.selected_index = 0;
77 app.loading = true;
78
79 let api = api.clone();
80 let tx = tx.clone();
81 tokio::spawn(async move {
82 load_project_items(&api, &project_id, &user_id, &tx).await;
83 });
84 }
85 }
86 KeyCode::Char('u') | KeyCode::Char('U') => {
87 *screen = Screen::Upload;
88 app.selected_index = 0;
89 app.loading = true;
90 app.upload_status = None;
91 app.editing_field = None;
92
93 let staging_dir = staging_dir.to_path_buf();
94 let api = api.clone();
95 let user_id = app.user.user_id.clone();
96 let tx = tx.clone();
97 tokio::spawn(async move {
98 load_staged_files(&staging_dir, &api, &user_id, &tx).await;
99 });
100 }
101 KeyCode::Char('a') | KeyCode::Char('A') => {
102 *screen = Screen::Analytics;
103 app.analytics_data = None;
104 app.analytics_status = None;
105 app.analytics_show_transactions = false;
106 app.selected_index = 0;
107 app.loading = true;
108
109 let api = api.clone();
110 let user_id = app.user.user_id.clone();
111 let range = app.analytics_range.clone();
112 let tx = tx.clone();
113 tokio::spawn(async move {
114 load_analytics(&api, &user_id, &range, &tx).await;
115 });
116 }
117 KeyCode::Char('p') | KeyCode::Char('P') => {
118 *screen = Screen::Promo;
119 app.promo_codes.clear();
120 app.promo_status = None;
121 app.promo_editing_step = None;
122 app.selected_index = 0;
123 app.loading = true;
124
125 let api = api.clone();
126 let user_id = app.user.user_id.clone();
127 let tx = tx.clone();
128 tokio::spawn(async move {
129 load_promo_codes(&api, &user_id, &tx).await;
130 });
131 }
132 KeyCode::Char('c') | KeyCode::Char('C') => {
133 *screen = Screen::Collections;
134 app.collections.clear();
135 app.collections_status = None;
136 app.selected_index = 0;
137 app.loading = true;
138
139 let api = api.clone();
140 let user_id = app.user.user_id.clone();
141 let tx = tx.clone();
142 tokio::spawn(async move {
143 load_collections(&api, &user_id, &tx).await;
144 });
145 }
146 KeyCode::Char('s') | KeyCode::Char('S') => {
147 *screen = Screen::Settings;
148 app.ssh_keys.clear();
149 app.settings_status = None;
150 app.selected_index = 0;
151 app.loading = true;
152
153 let api = api.clone();
154 let user_id = app.user.user_id.clone();
155 let tx = tx.clone();
156 tokio::spawn(async move {
157 load_settings(&api, &user_id, &tx).await;
158 });
159 }
160 KeyCode::Char('r') | KeyCode::Char('R') => {
161 app.loading = true;
162 let api = api.clone();
163 let user_id = app.user.user_id.clone();
164 let tx = tx.clone();
165 tokio::spawn(async move {
166 load_home_data(&api, &user_id, &tx).await;
167 });
168 }
169 _ => {}
170 }
171 }
172
173 pub(super) async fn handle_project_input(
174 key: KeyEvent,
175 app: &mut App,
176 screen: &mut Screen,
177 api: &MnwApiClient,
178 tx: &mpsc::Sender<AppEvent>,
179 ) {
180 // Handle confirmation dialog
181 if let Some(ref action) = app.confirm_action {
182 match key.code {
183 KeyCode::Char('y') | KeyCode::Char('Y') => {
184 let action = action.clone();
185 app.confirm_action = None;
186 let ids: Vec<String> = app.selected_items.iter()
187 .filter_map(|&i| app.items.get(i).map(|item| item.id.clone()))
188 .collect();
189 let api = api.clone();
190 let user_id = app.user.user_id.clone();
191 let tx = tx.clone();
192 match action {
193 ConfirmAction::BulkPublish { .. } => {
194 tokio::spawn(async move { bulk_publish(&api, &user_id, ids, &tx).await; });
195 }
196 ConfirmAction::BulkUnpublish { .. } => {
197 tokio::spawn(async move { bulk_unpublish(&api, &user_id, ids, &tx).await; });
198 }
199 ConfirmAction::BulkDelete { .. } => {
200 tokio::spawn(async move { bulk_delete(&api, &user_id, ids, &tx).await; });
201 }
202 _ => {}
203 }
204 }
205 _ => {
206 app.confirm_action = None;
207 }
208 }
209 return;
210 }
211
212 match key.code {
213 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
214 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
215 KeyCode::Char(' ') => {
216 // Toggle selection
217 if !app.items.is_empty() {
218 let idx = app.selected_index;
219 if app.selected_items.contains(&idx) {
220 app.selected_items.remove(&idx);
221 } else {
222 app.selected_items.insert(idx);
223 }
224 }
225 }
226 KeyCode::Esc => {
227 if !app.selected_items.is_empty() {
228 app.selected_items.clear();
229 } else {
230 *screen = Screen::Home;
231 app.items.clear();
232 app.selected_index = 0;
233 }
234 }
235 KeyCode::Enter => {
236 if let Screen::Project(pidx) = screen
237 && !app.items.is_empty()
238 {
239 let item_id = app.items[app.selected_index].id.clone();
240 let pidx = *pidx;
241 *screen = Screen::Item(pidx, item_id.clone());
242 app.item_detail = None;
243 app.item_versions.clear();
244 app.item_status = None;
245 app.item_editing = None;
246 app.selected_items.clear();
247 app.selected_index = 0;
248 app.loading = true;
249
250 let api = api.clone();
251 let user_id = app.user.user_id.clone();
252 let tx = tx.clone();
253 tokio::spawn(async move {
254 load_item_detail(&api, &user_id, &item_id, &tx).await;
255 });
256 }
257 }
258 KeyCode::Char('p') | KeyCode::Char('P') => {
259 if !app.items.is_empty() {
260 if app.selected_items.is_empty() {
261 // Single item: toggle publish state
262 let item = &app.items[app.selected_index];
263 let item_id = item.id.clone();
264 let is_public = item.is_public;
265 let api = api.clone();
266 let user_id = app.user.user_id.clone();
267 let tx = tx.clone();
268 tokio::spawn(async move {
269 let result = if is_public {
270 api.unpublish_item(&user_id, &item_id).await
271 } else {
272 api.publish_item(&user_id, &item_id).await
273 };
274 let msg = match result {
275 Ok(_) => if is_public { "Unpublished" } else { "Published" }.to_string(),
276 Err(e) => format!("Error: {}", crate::commands::sanitize_api_error(&e)),
277 };
278 let _ = tx.send(AppEvent::DataLoaded(DataPayload::BulkActionComplete { message: msg })).await;
279 });
280 } else {
281 // Bulk: check if majority are draft → publish, else unpublish
282 let draft_count = app.selected_items.iter()
283 .filter(|&&i| app.items.get(i).is_some_and(|item| !item.is_public))
284 .count();
285 let count = app.selected_items.len();
286 if draft_count > count / 2 {
287 app.confirm_action = Some(ConfirmAction::BulkPublish { count });
288 } else {
289 app.confirm_action = Some(ConfirmAction::BulkUnpublish { count });
290 }
291 }
292 }
293 }
294 KeyCode::Char('d') | KeyCode::Char('D') => {
295 if !app.items.is_empty() {
296 if app.selected_items.is_empty() {
297 // Select current item for single delete
298 app.selected_items.insert(app.selected_index);
299 }
300 let count = app.selected_items.len();
301 app.confirm_action = Some(ConfirmAction::BulkDelete { count });
302 }
303 }
304 KeyCode::Char('b') | KeyCode::Char('B') => {
305 if let Screen::Project(idx) = screen
306 && let Some(p) = app.projects.get(*idx)
307 {
308 let pidx = *idx;
309 let project_id = p.id.clone();
310 let project_title = p.title.clone();
311 *screen = Screen::Blog(pidx, project_id.clone());
312 app.blog_posts.clear();
313 app.blog_project_title = Some(project_title);
314 app.blog_status = None;
315 app.blog_create_step = None;
316 app.selected_items.clear();
317 app.selected_index = 0;
318 app.loading = true;
319
320 let api = api.clone();
321 let user_id = app.user.user_id.clone();
322 let tx = tx.clone();
323 tokio::spawn(async move {
324 load_blog_posts(&api, &user_id, &project_id, &tx).await;
325 });
326 }
327 }
328 KeyCode::Char('t') | KeyCode::Char('T') => {
329 if let Screen::Project(idx) = screen
330 && let Some(p) = app.projects.get(*idx)
331 {
332 let pidx = *idx;
333 let project_id = p.id.clone();
334 let project_title = p.title.clone();
335 *screen = Screen::Tiers(pidx, project_id.clone());
336 app.tiers.clear();
337 app.tiers_project_title = Some(project_title);
338 app.tiers_status = None;
339 app.selected_items.clear();
340 app.selected_index = 0;
341 app.loading = true;
342
343 let api = api.clone();
344 let user_id = app.user.user_id.clone();
345 let tx = tx.clone();
346 tokio::spawn(async move {
347 load_tiers(&api, &user_id, &project_id, &tx).await;
348 });
349 }
350 }
351 KeyCode::Char('r') | KeyCode::Char('R') => {
352 if let Screen::Project(idx) = screen
353 && let Some(p) = app.projects.get(*idx)
354 {
355 app.loading = true;
356 let api = api.clone();
357 let project_id = p.id.clone();
358 let user_id = app.user.user_id.clone();
359 let tx = tx.clone();
360 tokio::spawn(async move {
361 load_project_items(&api, &project_id, &user_id, &tx).await;
362 });
363 }
364 }
365 _ => {}
366 }
367 }
368
369 pub(super) async fn handle_upload_input(
370 key: KeyEvent,
371 app: &mut App,
372 screen: &mut Screen,
373 api: &MnwApiClient,
374 tx: &mpsc::Sender<AppEvent>,
375 staging_dir: &Path,
376 ) {
377 // Handle editing mode
378 if let Some(field) = app.editing_field {
379 match key.code {
380 KeyCode::Esc => {
381 app.editing_field = None;
382 app.edit_buffer.clear();
383 app.upload_status = None;
384 }
385 KeyCode::Tab => {
386 // Save current field and advance to next
387 let idx = app.selected_index;
388 if idx < app.file_metadata.len() {
389 save_upload_field(app, field, idx);
390 }
391 let next = match field {
392 EditField::Title => EditField::Project,
393 EditField::Project => EditField::Price,
394 EditField::Price => EditField::Title,
395 };
396 app.editing_field = Some(next);
397 app.edit_buffer.clear();
398 app.upload_status = Some(upload_field_prompt(next, app));
399 return;
400 }
401 KeyCode::Enter => {
402 // Save current field and exit edit mode
403 let idx = app.selected_index;
404 if idx < app.file_metadata.len() {
405 save_upload_field(app, field, idx);
406 }
407 app.editing_field = None;
408 app.edit_buffer.clear();
409 app.upload_status = None;
410 }
411 KeyCode::Backspace => {
412 app.edit_buffer.pop();
413 if let Some(field) = app.editing_field {
414 app.upload_status = Some(super::format_edit_prompt(field, &app.edit_buffer));
415 }
416 }
417 KeyCode::Char(c) => {
418 app.edit_buffer.push(c);
419 if let Some(field) = app.editing_field {
420 app.upload_status = Some(super::format_edit_prompt(field, &app.edit_buffer));
421 }
422 }
423 _ => {}
424 }
425 return;
426 }
427
428 // Cancel pending delete confirmation on non-d keys
429 if !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
430 if app.upload_status.as_ref().is_some_and(|s| s.starts_with("Delete '") && s.ends_with("'? Press d again")) {
431 app.upload_status = None;
432 }
433 }
434
435 // Normal mode
436 match key.code {
437 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
438 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
439 KeyCode::Esc => {
440 *screen = Screen::Home;
441 app.staged_files.clear();
442 app.file_metadata.clear();
443 app.upload_status = None;
444 app.selected_index = 0;
445 }
446 KeyCode::Char('e') | KeyCode::Char('E') => {
447 if !app.staged_files.is_empty() {
448 let idx = app.selected_index;
449 let current_title = app.file_metadata.get(idx).and_then(|m| m.title.clone());
450 app.editing_field = Some(EditField::Title);
451 app.edit_buffer = current_title.unwrap_or_default();
452 app.upload_status =
453 Some("Title: _ (Enter to save, Tab for next field, Esc to cancel)".to_string());
454 }
455 }
456 KeyCode::Char('p') | KeyCode::Char('P') => {
457 if !app.staged_files.is_empty() && !app.publishing {
458 let idx = app.selected_index;
459 let file = &app.staged_files[idx];
460 let meta = app.file_metadata.get(idx).cloned().unwrap_or_default();
461
462 // Validate
463 let Some(classification) = file.classification else {
464 app.upload_status = Some(
465 "Unsupported file type. Supported: mp3, wav, flac, ogg, m4a, aac, zip, dmg, exe, appimage, deb, clap, vst3".to_string()
466 );
467 return;
468 };
469 let Some(project_idx) = meta.project_idx else {
470 app.upload_status =
471 Some("Set project first (press [e] to edit)".to_string());
472 return;
473 };
474 let Some(project) = app.projects.get(project_idx) else {
475 app.upload_status = Some("Invalid project".to_string());
476 return;
477 };
478
479 let title = meta
480 .title
481 .unwrap_or_else(|| crate::staging::derive_title(&file.filename));
482 let project_id = project.id.clone();
483 let user_id = app.user.user_id.clone();
484 let filename = file.filename.clone();
485 let file_path = staging_dir.join(&filename);
486 let price_cents = meta.price_cents;
487 let item_type = classification.item_type.to_string();
488 let file_type = classification.file_type.to_string();
489 let content_type = classification.content_type.to_string();
490 let api = api.clone();
491 let tx = tx.clone();
492
493 app.publishing = true;
494 app.upload_status = Some(format!("Publishing {}...", filename));
495
496 tokio::spawn(async move {
497 let result = publish_file(
498 &api,
499 &user_id,
500 &project_id,
501 &title,
502 &item_type,
503 &file_type,
504 &filename,
505 &content_type,
506 price_cents,
507 &file_path,
508 )
509 .await;
510
511 let (success, error) = match result {
512 Ok(()) => (true, None),
513 Err(e) => (false, Some(e.to_string())),
514 };
515
516 let _ = tx
517 .send(AppEvent::DataLoaded(DataPayload::PublishResult {
518 filename,
519 success,
520 error,
521 }))
522 .await;
523 });
524 }
525 }
526 KeyCode::Char('d') | KeyCode::Char('D') => {
527 if !app.staged_files.is_empty() {
528 let idx = app.selected_index;
529 let filename = &app.staged_files[idx].filename;
530 if app.upload_status.as_ref().is_some_and(|s| s.starts_with("Delete '") && s.ends_with("'? Press d again")) {
531 // Confirmed — execute delete
532 let file_path = staging_dir.join(filename);
533 let staging_dir = staging_dir.to_path_buf();
534 let api = api.clone();
535 let user_id = app.user.user_id.clone();
536 let tx = tx.clone();
537
538 app.upload_status = Some(format!("Deleting {}...", filename));
539 tokio::spawn(async move {
540 if let Err(e) = tokio::fs::remove_file(&file_path).await {
541 tracing::warn!(error = %e, "failed to delete staged file");
542 }
543 load_staged_files(&staging_dir, &api, &user_id, &tx).await;
544 });
545 } else {
546 // First press — ask for confirmation
547 app.upload_status = Some(format!("Delete '{}'? Press d again", filename));
548 }
549 }
550 }
551 KeyCode::Char('r') | KeyCode::Char('R') => {
552 app.loading = true;
553 let staging_dir = staging_dir.to_path_buf();
554 let api = api.clone();
555 let user_id = app.user.user_id.clone();
556 let tx = tx.clone();
557 tokio::spawn(async move {
558 load_staged_files(&staging_dir, &api, &user_id, &tx).await;
559 });
560 }
561 _ => {}
562 }
563 }
564
565 pub(super) async fn handle_item_input(
566 key: KeyEvent,
567 app: &mut App,
568 screen: &mut Screen,
569 api: &MnwApiClient,
570 tx: &mpsc::Sender<AppEvent>,
571 ) {
572 use item::ItemEditField;
573
574 // Handle tag search mode
575 if app.tag_searching {
576 match key.code {
577 KeyCode::Esc => {
578 app.tag_searching = false;
579 app.edit_buffer.clear();
580 app.tag_search_results.clear();
581 app.item_status = None;
582 }
583 KeyCode::Enter => {
584 // Add the first search result as a tag
585 if let (Some(tag), Some(detail)) = (app.tag_search_results.first(), &app.item_detail) {
586 let tag_id = tag.id.clone();
587 let item_id = detail.id.clone();
588 let user_id = app.user.user_id.clone();
589 let api = api.clone();
590 let tx = tx.clone();
591 let tag_name = tag.name.clone();
592 tokio::spawn(async move {
593 match api.add_item_tag(&user_id, &item_id, &tag_id).await {
594 Ok(()) => {
595 // Reload tags
596 let tags = api.list_item_tags(&user_id, &item_id).await.unwrap_or_default();
597 let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemTags { tags })).await;
598 let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemActionError {
599 error: format!("Added tag: {}", tag_name), // Reuse error channel for status
600 })).await;
601 }
602 Err(e) => {
603 let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemActionError {
604 error: e.to_string(),
605 })).await;
606 }
607 }
608 });
609 app.tag_searching = false;
610 app.edit_buffer.clear();
611 app.tag_search_results.clear();
612 app.item_status = None;
613 }
614 }
615 KeyCode::Backspace => {
616 app.edit_buffer.pop();
617 if app.edit_buffer.len() >= 2 {
618 let api = api.clone();
619 let query = app.edit_buffer.clone();
620 let tx = tx.clone();
621 tokio::spawn(async move { search_tags(&api, &query, &tx).await; });
622 } else {
623 app.tag_search_results.clear();
624 }
625 }
626 KeyCode::Char(c) => {
627 app.edit_buffer.push(c);
628 if app.edit_buffer.len() >= 2 {
629 let api = api.clone();
630 let query = app.edit_buffer.clone();
631 let tx = tx.clone();
632 tokio::spawn(async move { search_tags(&api, &query, &tx).await; });
633 }
634 let results_preview: String = app.tag_search_results.iter()
635 .take(3)
636 .map(|t| t.name.as_str())
637 .collect::<Vec<_>>()
638 .join(", ");
639 app.item_status = Some(format!(
640 "Tag: {}_ {}",
641 app.edit_buffer,
642 if results_preview.is_empty() { String::new() } else { format!("[{}]", results_preview) },
643 ));
644 }
645 _ => {}
646 }
647 return;
648 }
649
650 // Cancel pending confirmation on any key other than the confirmation key
651 if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
652 app.confirm_action = None;
653 app.item_status = None;
654 }
655
656 // Handle editing mode
657 if let Some(field) = app.item_editing {
658 match key.code {
659 KeyCode::Esc => {
660 app.item_editing = None;
661 app.edit_buffer.clear();
662 app.item_status = None;
663 }
664 KeyCode::Enter => {
665 if let Some(ref detail) = app.item_detail {
666 let item_id = detail.id.clone();
667 let user_id = app.user.user_id.clone();
668 let api = api.clone();
669 let tx = tx.clone();
670 let buffer = app.edit_buffer.clone();
671
672 match field {
673 ItemEditField::Title => {
674 if !buffer.is_empty() {
675 let title = buffer;
676 tokio::spawn(async move {
677 match api
678 .update_item(&user_id, &item_id, Some(&title), None, None, None)
679 .await
680 {
681 Ok(d) => {
682 let _ = tx
683 .send(AppEvent::DataLoaded(
684 DataPayload::ItemUpdated { detail: d },
685 ))
686 .await;
687 }
688 Err(e) => {
689 let _ = tx
690 .send(AppEvent::DataLoaded(
691 DataPayload::ItemActionError {
692 error: e.to_string(),
693 },
694 ))
695 .await;
696 }
697 }
698 });
699 } else {
700 app.item_editing = None;
701 app.edit_buffer.clear();
702 }
703 }
704 ItemEditField::Description => {
705 let desc = if buffer.is_empty() {
706 None
707 } else {
708 Some(buffer.as_str().to_string())
709 };
710 tokio::spawn(async move {
711 match api
712 .update_item(
713 &user_id,
714 &item_id,
715 None,
716 desc.as_deref(),
717 None,
718 None,
719 )
720 .await
721 {
722 Ok(d) => {
723 let _ = tx
724 .send(AppEvent::DataLoaded(
725 DataPayload::ItemUpdated { detail: d },
726 ))
727 .await;
728 }
729 Err(e) => {
730 let _ = tx
731 .send(AppEvent::DataLoaded(
732 DataPayload::ItemActionError {
733 error: e.to_string(),
734 },
735 ))
736 .await;
737 }
738 }
739 });
740 }
741 ItemEditField::Price => {
742 let cents = super::parse_price(&buffer);
743 tokio::spawn(async move {
744 match api
745 .update_item(&user_id, &item_id, None, None, Some(cents), None)
746 .await
747 {
748 Ok(d) => {
749 let _ = tx
750 .send(AppEvent::DataLoaded(
751 DataPayload::ItemUpdated { detail: d },
752 ))
753 .await;
754 }
755 Err(e) => {
756 let _ = tx
757 .send(AppEvent::DataLoaded(
758 DataPayload::ItemActionError {
759 error: e.to_string(),
760 },
761 ))
762 .await;
763 }
764 }
765 });
766 }
767 }
768 }
769 if app.item_editing.is_some() {
770 // Only clear if not already cleared by the empty-buffer path
771 app.item_editing = None;
772 app.edit_buffer.clear();
773 }
774 return;
775 }
776 KeyCode::Backspace => {
777 app.edit_buffer.pop();
778 }
779 KeyCode::Char(c) => {
780 app.edit_buffer.push(c);
781 }
782 _ => {}
783 }
784 return;
785 }
786
787 // Normal mode
788 match key.code {
789 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
790 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
791 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
792 // Go back to project view
793 if let Screen::Item(project_idx, _) = screen {
794 let pidx = *project_idx;
795 *screen = Screen::Project(pidx);
796 app.item_detail = None;
797 app.item_versions.clear();
798 app.item_status = None;
799 app.item_editing = None;
800 app.selected_index = 0;
801 }
802 }
803 KeyCode::Char('e') | KeyCode::Char('E') => {
804 // Start editing — cycle through Title → Description → Price
805 app.item_editing = Some(ItemEditField::Title);
806 app.edit_buffer.clear();
807 app.item_status = Some("Editing title (Enter to save, Esc to cancel)".to_string());
808 }
809 KeyCode::Tab => {
810 // Cycle edit fields when in edit mode (already started with [e])
811 if let Some(field) = app.item_editing {
812 let next = match field {
813 ItemEditField::Title => ItemEditField::Description,
814 ItemEditField::Description => ItemEditField::Price,
815 ItemEditField::Price => ItemEditField::Title,
816 };
817 app.item_editing = Some(next);
818 app.edit_buffer.clear();
819 let label = match next {
820 ItemEditField::Title => "title",
821 ItemEditField::Description => "description",
822 ItemEditField::Price => "price",
823 };
824 app.item_status = Some(format!("Editing {} (Enter to save, Esc to cancel)", label));
825 }
826 }
827 KeyCode::Char('p') | KeyCode::Char('P') => {
828 if let Some(ref detail) = app.item_detail
829 && !detail.is_public
830 {
831 let item_id = detail.id.clone();
832 let user_id = app.user.user_id.clone();
833 let api = api.clone();
834 let tx = tx.clone();
835 app.item_status = Some("Publishing...".to_string());
836 tokio::spawn(async move {
837 match api.publish_item(&user_id, &item_id).await {
838 Ok(d) => {
839 let _ = tx
840 .send(AppEvent::DataLoaded(DataPayload::ItemUpdated {
841 detail: d,
842 }))
843 .await;
844 }
845 Err(e) => {
846 let _ = tx
847 .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
848 error: e.to_string(),
849 }))
850 .await;
851 }
852 }
853 });
854 }
855 }
856 KeyCode::Char('u') | KeyCode::Char('U') => {
857 if let Some(ref detail) = app.item_detail
858 && detail.is_public
859 {
860 let item_id = detail.id.clone();
861 let user_id = app.user.user_id.clone();
862 let api = api.clone();
863 let tx = tx.clone();
864 app.item_status = Some("Unpublishing...".to_string());
865 tokio::spawn(async move {
866 match api.unpublish_item(&user_id, &item_id).await {
867 Ok(d) => {
868 let _ = tx
869 .send(AppEvent::DataLoaded(DataPayload::ItemUpdated {
870 detail: d,
871 }))
872 .await;
873 }
874 Err(e) => {
875 let _ = tx
876 .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
877 error: e.to_string(),
878 }))
879 .await;
880 }
881 }
882 });
883 }
884 }
885 KeyCode::Char('d') | KeyCode::Char('D') => {
886 if let Some(ref detail) = app.item_detail {
887 if matches!(app.confirm_action, Some(ConfirmAction::DeleteItem)) {
888 // Confirmed — execute delete
889 app.confirm_action = None;
890 let item_id = detail.id.clone();
891 let item_title = detail.title.clone();
892 let user_id = app.user.user_id.clone();
893 tracing::info!(
894 user_id = %user_id,
895 item_id = %item_id,
896 item_title = %item_title,
897 "delete item confirmed"
898 );
899 let api = api.clone();
900 let tx = tx.clone();
901 app.item_status = Some("Deleting...".to_string());
902 tokio::spawn(async move {
903 match api.delete_item(&user_id, &item_id).await {
904 Ok(()) => {
905 let _ = tx
906 .send(AppEvent::DataLoaded(DataPayload::ItemDeleted))
907 .await;
908 }
909 Err(e) => {
910 let _ = tx
911 .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
912 error: e.to_string(),
913 }))
914 .await;
915 }
916 }
917 });
918 } else {
919 // First press — ask for confirmation
920 app.confirm_action = Some(ConfirmAction::DeleteItem);
921 app.item_status = Some(format!(
922 "Delete '{}'? Press d again to confirm",
923 detail.title
924 ));
925 }
926 }
927 }
928 KeyCode::Char('t') | KeyCode::Char('T') => {
929 if app.item_detail.is_some() {
930 app.tag_searching = true;
931 app.edit_buffer.clear();
932 app.tag_search_results.clear();
933 app.item_status = Some("Tag: type to search, Enter to add first result, Esc to cancel".to_string());
934 }
935 }
936 KeyCode::Char('l') | KeyCode::Char('L') => {
937 // Open license keys screen
938 if let Screen::Item(project_idx, item_id) = &*screen {
939 let pidx = *project_idx;
940 let iid = item_id.clone();
941 let item_title = app
942 .item_detail
943 .as_ref()
944 .map(|d| d.title.clone())
945 .unwrap_or_default();
946
947 *screen = Screen::Keys(pidx, iid.clone());
948 app.license_keys.clear();
949 app.keys_item_title = Some(item_title);
950 app.keys_status = None;
951 app.selected_index = 0;
952 app.loading = true;
953
954 let api = api.clone();
955 let user_id = app.user.user_id.clone();
956 let tx = tx.clone();
957 tokio::spawn(async move {
958 load_license_keys(&api, &user_id, &iid, &tx).await;
959 });
960 }
961 }
962 KeyCode::Char('r') | KeyCode::Char('R') => {
963 if let Screen::Item(_, item_id) = &*screen {
964 app.loading = true;
965 let item_id = item_id.clone();
966 let api = api.clone();
967 let user_id = app.user.user_id.clone();
968 let tx = tx.clone();
969 tokio::spawn(async move {
970 load_item_detail(&api, &user_id, &item_id, &tx).await;
971 });
972 }
973 }
974 _ => {}
975 }
976 }
977
978 pub(super) async fn handle_blog_input(
979 key: KeyEvent,
980 app: &mut App,
981 screen: &mut Screen,
982 api: &MnwApiClient,
983 tx: &mpsc::Sender<AppEvent>,
984 ) {
985 // Cancel pending confirmation on any key other than the confirmation key
986 if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
987 app.confirm_action = None;
988 app.blog_status = None;
989 }
990
991 // Creating mode
992 if let Some(step) = app.blog_create_step {
993 // Ctrl+D in body step: advance to schedule
994 if step == BlogCreateStep::Body
995 && key.code == KeyCode::Char('d')
996 && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
997 {
998 app.blog_create_body = app.edit_buffer.clone();
999 app.edit_buffer.clear();
1000 app.blog_create_step = Some(BlogCreateStep::Schedule);
1001 app.blog_status = Some(
1002 "Schedule (YYYY-MM-DDTHH:MM:SSZ or Enter for draft):".to_string(),
1003 );
1004 return;
1005 }
1006
1007 match key.code {
1008 KeyCode::Esc => {
1009 app.blog_create_step = None;
1010 app.blog_create_title.clear();
1011 app.blog_create_body.clear();
1012 app.edit_buffer.clear();
1013 app.blog_status = None;
1014 }
1015 KeyCode::Enter => match step {
1016 BlogCreateStep::Title => {
1017 if !app.edit_buffer.is_empty() {
1018 app.blog_create_title = app.edit_buffer.clone();
1019 app.edit_buffer.clear();
1020 app.blog_create_step = Some(BlogCreateStep::Body);
1021 app.blog_status =
1022 Some("Body (markdown, Enter for newline, Ctrl+D when done):".to_string());
1023 }
1024 }
1025 BlogCreateStep::Body => {
1026 // Enter inserts a newline in body mode
1027 app.edit_buffer.push('\n');
1028 return;
1029 }
1030 BlogCreateStep::Schedule => {
1031 let title = app.blog_create_title.clone();
1032 let body = app.blog_create_body.clone();
1033 let schedule_input = app.edit_buffer.trim().to_string();
1034 let publish_at = if schedule_input.is_empty() {
1035 None
1036 } else {
1037 Some(schedule_input)
1038 };
1039
1040 if let Screen::Blog(_, project_id) = &*screen {
1041 let project_id = project_id.clone();
1042 let api = api.clone();
1043 let user_id = app.user.user_id.clone();
1044 let tx = tx.clone();
1045 app.blog_creating = true;
1046 let status_msg = if publish_at.is_some() {
1047 "Scheduling post..."
1048 } else {
1049 "Creating draft..."
1050 };
1051 app.blog_status = Some(status_msg.to_string());
1052
1053 tokio::spawn(async move {
1054 match api
1055 .create_blog_post(
1056 &user_id,
1057 &project_id,
1058 &title,
1059 &body,
1060 false,
1061 publish_at.as_deref(),
1062 )
1063 .await
1064 {
1065 Ok(_post) => {
1066 let _ = tx
1067 .send(AppEvent::DataLoaded(DataPayload::BlogCreated))
1068 .await;
1069 }
1070 Err(e) => {
1071 let _ = tx
1072 .send(AppEvent::DataLoaded(DataPayload::GenericError {
1073 error: e.to_string(),
1074 }))
1075 .await;
1076 }
1077 }
1078 });
1079 }
1080 app.blog_create_step = None;
1081 app.blog_create_title.clear();
1082 app.blog_create_body.clear();
1083 app.edit_buffer.clear();
1084 }
1085 },
1086 KeyCode::Backspace => {
1087 app.edit_buffer.pop();
1088 }
1089 KeyCode::Char(c) => {
1090 app.edit_buffer.push(c);
1091 }
1092 _ => {}
1093 }
1094 return;
1095 }
1096
1097 // Normal mode
1098 match key.code {
1099 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
1100 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
1101 KeyCode::Esc => {
1102 if let Screen::Blog(pidx, _) = screen {
1103 *screen = Screen::Project(*pidx);
1104 app.blog_posts.clear();
1105 app.blog_status = None;
1106 app.selected_index = 0;
1107 }
1108 }
1109 KeyCode::Char('n') | KeyCode::Char('N') => {
1110 app.blog_create_step = Some(BlogCreateStep::Title);
1111 app.edit_buffer.clear();
1112 app.blog_status = Some("Title: _".to_string());
1113 }
1114 KeyCode::Char('d') | KeyCode::Char('D') => {
1115 if !app.blog_posts.is_empty() {
1116 let idx = app.selected_index;
1117 if matches!(app.confirm_action, Some(ConfirmAction::DeleteBlogPost { post_idx }) if post_idx == idx) {
1118 // Confirmed — execute delete
1119 app.confirm_action = None;
1120 let post_id = app.blog_posts[idx].id.clone();
1121 let post_title = app.blog_posts[idx].title.clone();
1122 let user_id = app.user.user_id.clone();
1123 tracing::info!(
1124 user_id = %user_id,
1125 post_id = %post_id,
1126 post_title = %post_title,
1127 "delete blog post confirmed"
1128 );
1129 let api = api.clone();
1130 let tx = tx.clone();
1131
1132 if let Screen::Blog(_, project_id) = &*screen {
1133 let project_id = project_id.clone();
1134 app.blog_status = Some("Deleting...".to_string());
1135 tokio::spawn(async move {
1136 match api.delete_blog_post(&user_id, &post_id).await {
1137 Ok(()) => {
1138 load_blog_posts(&api, &user_id, &project_id, &tx).await;
1139 }
1140 Err(e) => {
1141 let _ = tx
1142 .send(AppEvent::DataLoaded(DataPayload::GenericError {
1143 error: e.to_string(),
1144 }))
1145 .await;
1146 }
1147 }
1148 });
1149 }
1150 } else {
1151 // First press — ask for confirmation
1152 app.confirm_action = Some(ConfirmAction::DeleteBlogPost { post_idx: idx });
1153 app.blog_status = Some(format!(
1154 "Delete '{}'? Press d again to confirm",
1155 app.blog_posts[idx].title
1156 ));
1157 }
1158 }
1159 }
1160 KeyCode::Char('r') | KeyCode::Char('R') => {
1161 if let Screen::Blog(_, project_id) = &*screen {
1162 app.loading = true;
1163 let project_id = project_id.clone();
1164 let api = api.clone();
1165 let user_id = app.user.user_id.clone();
1166 let tx = tx.clone();
1167 tokio::spawn(async move {
1168 load_blog_posts(&api, &user_id, &project_id, &tx).await;
1169 });
1170 }
1171 }
1172 _ => {}
1173 }
1174 }
1175
1176 pub(super) async fn handle_promo_input(
1177 key: KeyEvent,
1178 app: &mut App,
1179 screen: &mut Screen,
1180 api: &MnwApiClient,
1181 tx: &mpsc::Sender<AppEvent>,
1182 ) {
1183 // Cancel pending confirmation on any key other than the confirmation key
1184 if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
1185 app.confirm_action = None;
1186 app.promo_status = None;
1187 }
1188
1189 // Creating mode
1190 if let Some(step) = app.promo_editing_step {
1191 match key.code {
1192 KeyCode::Esc => {
1193 app.promo_editing_step = None;
1194 app.promo_create_code.clear();
1195 app.promo_create_discount.clear();
1196 app.edit_buffer.clear();
1197 app.promo_status = None;
1198 }
1199 KeyCode::Enter => match step {
1200 PromoCreateStep::Code => {
1201 if !app.edit_buffer.is_empty() {
1202 app.promo_create_code = app.edit_buffer.clone();
1203 app.edit_buffer.clear();
1204 app.promo_editing_step = Some(PromoCreateStep::Discount);
1205 app.promo_status = Some("Discount % (e.g. 25): _".to_string());
1206 }
1207 }
1208 PromoCreateStep::Discount => {
1209 let code = app.promo_create_code.clone();
1210 let discount: i32 = app.edit_buffer.parse().unwrap_or(0);
1211
1212 let api = api.clone();
1213 let user_id = app.user.user_id.clone();
1214 let tx = tx.clone();
1215 app.promo_status = Some("Creating...".to_string());
1216
1217 tokio::spawn(async move {
1218 match api
1219 .create_promo_code(
1220 &user_id,
1221 &code,
1222 "percentage",
1223 discount,
1224 None,
1225 None,
1226 )
1227 .await
1228 {
1229 Ok(_) => {
1230 load_promo_codes(&api, &user_id, &tx).await;
1231 }
1232 Err(e) => {
1233 let _ = tx
1234 .send(AppEvent::DataLoaded(DataPayload::GenericError {
1235 error: e.to_string(),
1236 }))
1237 .await;
1238 }
1239 }
1240 });
1241
1242 app.promo_editing_step = None;
1243 app.promo_create_code.clear();
1244 app.promo_create_discount.clear();
1245 app.edit_buffer.clear();
1246 }
1247 },
1248 KeyCode::Backspace => {
1249 app.edit_buffer.pop();
1250 }
1251 KeyCode::Char(c) => {
1252 app.edit_buffer.push(c);
1253 }
1254 _ => {}
1255 }
1256 return;
1257 }
1258
1259 // Normal mode
1260 match key.code {
1261 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
1262 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
1263 KeyCode::Esc => {
1264 *screen = Screen::Home;
1265 app.promo_codes.clear();
1266 app.promo_status = None;
1267 app.selected_index = 0;
1268 }
1269 KeyCode::Char('n') | KeyCode::Char('N') => {
1270 app.promo_editing_step = Some(PromoCreateStep::Code);
1271 app.edit_buffer.clear();
1272 app.promo_status = Some("Code: _".to_string());
1273 }
1274 KeyCode::Char('d') | KeyCode::Char('D') => {
1275 if !app.promo_codes.is_empty() {
1276 let idx = app.selected_index;
1277 if matches!(app.confirm_action, Some(ConfirmAction::DeletePromoCode { code_idx }) if code_idx == idx) {
1278 // Confirmed — execute delete
1279 app.confirm_action = None;
1280 let code_id = app.promo_codes[idx].id.clone();
1281 let code_value = app.promo_codes[idx].code.clone();
1282 let user_id = app.user.user_id.clone();
1283 tracing::info!(
1284 user_id = %user_id,
1285 code_id = %code_id,
1286 code = %code_value,
1287 "delete promo code confirmed"
1288 );
1289 let api = api.clone();
1290 let tx = tx.clone();
1291 app.promo_status = Some("Deleting...".to_string());
1292 tokio::spawn(async move {
1293 match api.delete_promo_code(&user_id, &code_id).await {
1294 Ok(()) => {
1295 load_promo_codes(&api, &user_id, &tx).await;
1296 }
1297 Err(e) => {
1298 let _ = tx
1299 .send(AppEvent::DataLoaded(DataPayload::GenericError {
1300 error: e.to_string(),
1301 }))
1302 .await;
1303 }
1304 }
1305 });
1306 } else {
1307 // First press — ask for confirmation
1308 app.confirm_action = Some(ConfirmAction::DeletePromoCode { code_idx: idx });
1309 app.promo_status = Some(format!(
1310 "Delete '{}'? Press d again to confirm",
1311 app.promo_codes[idx].code
1312 ));
1313 }
1314 }
1315 }
1316 KeyCode::Char('r') | KeyCode::Char('R') => {
1317 app.loading = true;
1318 let api = api.clone();
1319 let user_id = app.user.user_id.clone();
1320 let tx = tx.clone();
1321 tokio::spawn(async move {
1322 load_promo_codes(&api, &user_id, &tx).await;
1323 });
1324 }
1325 _ => {}
1326 }
1327 }
1328
1329 pub(super) async fn handle_keys_input(
1330 key: KeyEvent,
1331 app: &mut App,
1332 screen: &mut Screen,
1333 api: &MnwApiClient,
1334 tx: &mpsc::Sender<AppEvent>,
1335 ) {
1336 // Cancel pending confirmation on any key other than the confirmation key
1337 if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X')) {
1338 app.confirm_action = None;
1339 app.keys_status = None;
1340 }
1341
1342 match key.code {
1343 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
1344 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
1345 KeyCode::Esc => {
1346 if let Screen::Keys(pidx, item_id) = &*screen {
1347 let pidx = *pidx;
1348 let iid = item_id.clone();
1349 *screen = Screen::Item(pidx, iid.clone());
1350 app.license_keys.clear();
1351 app.keys_status = None;
1352 app.selected_index = 0;
1353 app.loading = true;
1354
1355 let api = api.clone();
1356 let user_id = app.user.user_id.clone();
1357 let tx = tx.clone();
1358 tokio::spawn(async move {
1359 load_item_detail(&api, &user_id, &iid, &tx).await;
1360 });
1361 }
1362 }
1363 KeyCode::Char('g') | KeyCode::Char('G') => {
1364 if let Screen::Keys(_, item_id) = &*screen {
1365 let item_id = item_id.clone();
1366 let api = api.clone();
1367 let user_id = app.user.user_id.clone();
1368 let tx = tx.clone();
1369 app.keys_status = Some("Generating...".to_string());
1370 let iid = item_id.clone();
1371 tokio::spawn(async move {
1372 match api.generate_license_key(&user_id, &item_id).await {
1373 Ok(key) => {
1374 let _ = tx
1375 .send(AppEvent::DataLoaded(DataPayload::GenericSuccess {
1376 message: format!("Generated: {}", key.key_code),
1377 }))
1378 .await;
1379 load_license_keys(&api, &user_id, &iid, &tx).await;
1380 }
1381 Err(e) => {
1382 let _ = tx
1383 .send(AppEvent::DataLoaded(DataPayload::GenericError {
1384 error: e.to_string(),
1385 }))
1386 .await;
1387 }
1388 }
1389 });
1390 }
1391 }
1392 KeyCode::Char('x') | KeyCode::Char('X') => {
1393 if !app.license_keys.is_empty() {
1394 let idx = app.selected_index;
1395 if matches!(app.confirm_action, Some(ConfirmAction::RevokeLicenseKey { key_idx }) if key_idx == idx) {
1396 // Confirmed — execute revoke
1397 app.confirm_action = None;
1398 let key_id = app.license_keys[idx].id.clone();
1399 let key_code = app.license_keys[idx].key_code.clone();
1400 let user_id = app.user.user_id.clone();
1401 tracing::info!(
1402 user_id = %user_id,
1403 key_id = %key_id,
1404 key_code = %key_code,
1405 "revoke license key confirmed"
1406 );
1407 if let Screen::Keys(_, item_id) = &*screen {
1408 let item_id = item_id.clone();
1409 let api = api.clone();
1410 let user_id = app.user.user_id.clone();
1411 let tx = tx.clone();
1412 app.keys_status = Some("Revoking...".to_string());
1413 tokio::spawn(async move {
1414 match api.revoke_license_key(&user_id, &key_id).await {
1415 Ok(()) => {
1416 load_license_keys(&api, &user_id, &item_id, &tx).await;
1417 }
1418 Err(e) => {
1419 let _ = tx
1420 .send(AppEvent::DataLoaded(DataPayload::GenericError {
1421 error: e.to_string(),
1422 }))
1423 .await;
1424 }
1425 }
1426 });
1427 }
1428 } else {
1429 // First press — ask for confirmation
1430 app.confirm_action = Some(ConfirmAction::RevokeLicenseKey { key_idx: idx });
1431 app.keys_status = Some(format!(
1432 "Revoke '{}'? Press x again to confirm",
1433 app.license_keys[idx].key_code
1434 ));
1435 }
1436 }
1437 }
1438 KeyCode::Char('r') | KeyCode::Char('R') => {
1439 if let Screen::Keys(_, item_id) = &*screen {
1440 app.loading = true;
1441 let item_id = item_id.clone();
1442 let api = api.clone();
1443 let user_id = app.user.user_id.clone();
1444 let tx = tx.clone();
1445 tokio::spawn(async move {
1446 load_license_keys(&api, &user_id, &item_id, &tx).await;
1447 });
1448 }
1449 }
1450 _ => {}
1451 }
1452 }
1453
1454 pub(super) async fn handle_analytics_input(
1455 key: KeyEvent,
1456 app: &mut App,
1457 screen: &mut Screen,
1458 api: &MnwApiClient,
1459 tx: &mpsc::Sender<AppEvent>,
1460 ) {
1461 match key.code {
1462 KeyCode::Char('j') | KeyCode::Down => {
1463 if app.analytics_show_transactions {
1464 app.move_down(screen);
1465 }
1466 }
1467 KeyCode::Char('k') | KeyCode::Up => {
1468 if app.analytics_show_transactions {
1469 app.move_up(screen);
1470 }
1471 }
1472 KeyCode::Esc => {
1473 if app.analytics_show_transactions {
1474 app.analytics_show_transactions = false;
1475 app.selected_index = 0;
1476 } else {
1477 *screen = Screen::Home;
1478 app.analytics_data = None;
1479 app.analytics_status = None;
1480 app.selected_index = 0;
1481 }
1482 }
1483 // Range selection: 1=7d, 2=30d, 3=90d, 4=all
1484 KeyCode::Char('1') => {
1485 app.analytics_range = "7d".to_string();
1486 reload_analytics(app, api, tx);
1487 }
1488 KeyCode::Char('2') => {
1489 app.analytics_range = "30d".to_string();
1490 reload_analytics(app, api, tx);
1491 }
1492 KeyCode::Char('3') => {
1493 app.analytics_range = "90d".to_string();
1494 reload_analytics(app, api, tx);
1495 }
1496 KeyCode::Char('4') => {
1497 app.analytics_range = "all".to_string();
1498 reload_analytics(app, api, tx);
1499 }
1500 KeyCode::Char('t') | KeyCode::Char('T') => {
1501 if app.analytics_show_transactions {
1502 app.analytics_show_transactions = false;
1503 } else {
1504 app.analytics_show_transactions = true;
1505 app.selected_index = 0;
1506 if app.transactions.is_empty() {
1507 app.loading = true;
1508 let api = api.clone();
1509 let user_id = app.user.user_id.clone();
1510 let tx = tx.clone();
1511 tokio::spawn(async move {
1512 load_transactions(&api, &user_id, &tx).await;
1513 });
1514 }
1515 }
1516 }
1517 KeyCode::Char('e') | KeyCode::Char('E') => {
1518 app.analytics_status = Some("Exporting...".to_string());
1519 let api = api.clone();
1520 let user_id = app.user.user_id.clone();
1521 let tx = tx.clone();
1522 tokio::spawn(async move {
1523 match api.export_sales_csv(&user_id).await {
1524 Ok(result) => {
1525 let _ = tx
1526 .send(AppEvent::DataLoaded(DataPayload::ExportCsv {
1527 csv: result.csv,
1528 row_count: result.row_count,
1529 }))
1530 .await;
1531 }
1532 Err(e) => {
1533 let _ = tx
1534 .send(AppEvent::DataLoaded(DataPayload::GenericError {
1535 error: e.to_string(),
1536 }))
1537 .await;
1538 }
1539 }
1540 });
1541 }
1542 KeyCode::Char('r') | KeyCode::Char('R') => {
1543 reload_analytics(app, api, tx);
1544 }
1545 _ => {}
1546 }
1547 }
1548
1549 fn reload_analytics(app: &mut App, api: &MnwApiClient, tx: &mpsc::Sender<AppEvent>) {
1550 app.loading = true;
1551 app.analytics_status = None;
1552 let api = api.clone();
1553 let user_id = app.user.user_id.clone();
1554 let range = app.analytics_range.clone();
1555 let tx = tx.clone();
1556 tokio::spawn(async move {
1557 load_analytics(&api, &user_id, &range, &tx).await;
1558 });
1559 }
1560
1561 pub(super) async fn handle_settings_input(
1562 key: KeyEvent,
1563 app: &mut App,
1564 screen: &mut Screen,
1565 api: &MnwApiClient,
1566 tx: &mpsc::Sender<AppEvent>,
1567 ) {
1568 match key.code {
1569 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
1570 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
1571 KeyCode::Esc => {
1572 *screen = Screen::Home;
1573 app.ssh_keys.clear();
1574 app.settings_status = None;
1575 app.selected_index = 0;
1576 }
1577 KeyCode::Char('r') | KeyCode::Char('R') => {
1578 app.loading = true;
1579 app.settings_status = None;
1580 let api = api.clone();
1581 let user_id = app.user.user_id.clone();
1582 let tx = tx.clone();
1583 tokio::spawn(async move {
1584 load_settings(&api, &user_id, &tx).await;
1585 });
1586 }
1587 _ => {}
1588 }
1589 }
1590
1591 pub(super) async fn handle_collections_input(
1592 key: KeyEvent,
1593 app: &mut App,
1594 screen: &mut Screen,
1595 api: &MnwApiClient,
1596 tx: &mpsc::Sender<AppEvent>,
1597 ) {
1598 match key.code {
1599 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
1600 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
1601 KeyCode::Esc => {
1602 *screen = Screen::Home;
1603 app.collections.clear();
1604 app.collections_status = None;
1605 app.selected_index = 0;
1606 }
1607 KeyCode::Char('r') | KeyCode::Char('R') => {
1608 app.loading = true;
1609 app.collections_status = None;
1610 let api = api.clone();
1611 let user_id = app.user.user_id.clone();
1612 let tx = tx.clone();
1613 tokio::spawn(async move {
1614 load_collections(&api, &user_id, &tx).await;
1615 });
1616 }
1617 _ => {}
1618 }
1619 }
1620
1621 pub(super) async fn handle_tiers_input(
1622 key: KeyEvent,
1623 app: &mut App,
1624 screen: &mut Screen,
1625 ) {
1626 match key.code {
1627 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
1628 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
1629 KeyCode::Esc => {
1630 if let Screen::Tiers(pidx, _) = screen {
1631 let pidx = *pidx;
1632 *screen = Screen::Project(pidx);
1633 app.tiers.clear();
1634 app.tiers_project_title = None;
1635 app.tiers_status = None;
1636 app.selected_index = 0;
1637 }
1638 }
1639 _ => {}
1640 }
1641 }
1642