Skip to main content

max / mnw-cli

49.7 KB · 1268 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 pub(super) async fn handle_home_input(
17 key: KeyEvent,
18 app: &mut App,
19 screen: &mut Screen,
20 api: &MnwApiClient,
21 tx: &mpsc::Sender<AppEvent>,
22 staging_dir: &Path,
23 ) {
24 match key.code {
25 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
26 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
27 KeyCode::Enter => {
28 if !app.projects.is_empty() {
29 let idx = app.selected_index;
30 let project_id = app.projects[idx].id.clone();
31 let user_id = app.user.user_id.clone();
32 *screen = Screen::Project(idx);
33 app.items.clear();
34 app.selected_index = 0;
35 app.loading = true;
36
37 let api = api.clone();
38 let tx = tx.clone();
39 tokio::spawn(async move {
40 load_project_items(&api, &project_id, &user_id, &tx).await;
41 });
42 }
43 }
44 KeyCode::Char('u') | KeyCode::Char('U') => {
45 *screen = Screen::Upload;
46 app.selected_index = 0;
47 app.loading = true;
48 app.upload_status = None;
49 app.editing_field = None;
50
51 let staging_dir = staging_dir.to_path_buf();
52 let api = api.clone();
53 let user_id = app.user.user_id.clone();
54 let tx = tx.clone();
55 tokio::spawn(async move {
56 load_staged_files(&staging_dir, &api, &user_id, &tx).await;
57 });
58 }
59 KeyCode::Char('a') | KeyCode::Char('A') => {
60 *screen = Screen::Analytics;
61 app.analytics_data = None;
62 app.analytics_status = None;
63 app.analytics_show_transactions = false;
64 app.selected_index = 0;
65 app.loading = true;
66
67 let api = api.clone();
68 let user_id = app.user.user_id.clone();
69 let range = app.analytics_range.clone();
70 let tx = tx.clone();
71 tokio::spawn(async move {
72 load_analytics(&api, &user_id, &range, &tx).await;
73 });
74 }
75 KeyCode::Char('p') | KeyCode::Char('P') => {
76 *screen = Screen::Promo;
77 app.promo_codes.clear();
78 app.promo_status = None;
79 app.promo_editing_step = None;
80 app.selected_index = 0;
81 app.loading = true;
82
83 let api = api.clone();
84 let user_id = app.user.user_id.clone();
85 let tx = tx.clone();
86 tokio::spawn(async move {
87 load_promo_codes(&api, &user_id, &tx).await;
88 });
89 }
90 KeyCode::Char('s') | KeyCode::Char('S') => {
91 *screen = Screen::Settings;
92 app.ssh_keys.clear();
93 app.settings_status = None;
94 app.selected_index = 0;
95 app.loading = true;
96
97 let api = api.clone();
98 let user_id = app.user.user_id.clone();
99 let tx = tx.clone();
100 tokio::spawn(async move {
101 load_settings(&api, &user_id, &tx).await;
102 });
103 }
104 KeyCode::Char('r') | KeyCode::Char('R') => {
105 app.loading = true;
106 let api = api.clone();
107 let user_id = app.user.user_id.clone();
108 let tx = tx.clone();
109 tokio::spawn(async move {
110 load_home_data(&api, &user_id, &tx).await;
111 });
112 }
113 _ => {}
114 }
115 }
116
117 pub(super) async fn handle_project_input(
118 key: KeyEvent,
119 app: &mut App,
120 screen: &mut Screen,
121 api: &MnwApiClient,
122 tx: &mpsc::Sender<AppEvent>,
123 ) {
124 match key.code {
125 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
126 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
127 KeyCode::Esc => {
128 *screen = Screen::Home;
129 app.items.clear();
130 app.selected_index = 0;
131 }
132 KeyCode::Enter => {
133 if let Screen::Project(pidx) = screen
134 && !app.items.is_empty()
135 {
136 let item_id = app.items[app.selected_index].id.clone();
137 let pidx = *pidx;
138 *screen = Screen::Item(pidx, item_id.clone());
139 app.item_detail = None;
140 app.item_versions.clear();
141 app.item_status = None;
142 app.item_editing = None;
143 app.selected_index = 0;
144 app.loading = true;
145
146 let api = api.clone();
147 let user_id = app.user.user_id.clone();
148 let tx = tx.clone();
149 tokio::spawn(async move {
150 load_item_detail(&api, &user_id, &item_id, &tx).await;
151 });
152 }
153 }
154 KeyCode::Char('b') | KeyCode::Char('B') => {
155 // Open blog screen for this project
156 if let Screen::Project(idx) = screen
157 && let Some(p) = app.projects.get(*idx)
158 {
159 let pidx = *idx;
160 let project_id = p.id.clone();
161 let project_title = p.title.clone();
162 *screen = Screen::Blog(pidx, project_id.clone());
163 app.blog_posts.clear();
164 app.blog_project_title = Some(project_title);
165 app.blog_status = None;
166 app.blog_create_step = None;
167 app.selected_index = 0;
168 app.loading = true;
169
170 let api = api.clone();
171 let user_id = app.user.user_id.clone();
172 let tx = tx.clone();
173 tokio::spawn(async move {
174 load_blog_posts(&api, &user_id, &project_id, &tx).await;
175 });
176 }
177 }
178 KeyCode::Char('r') | KeyCode::Char('R') => {
179 if let Screen::Project(idx) = screen
180 && let Some(p) = app.projects.get(*idx)
181 {
182 app.loading = true;
183 let api = api.clone();
184 let project_id = p.id.clone();
185 let user_id = app.user.user_id.clone();
186 let tx = tx.clone();
187 tokio::spawn(async move {
188 load_project_items(&api, &project_id, &user_id, &tx).await;
189 });
190 }
191 }
192 _ => {}
193 }
194 }
195
196 pub(super) async fn handle_upload_input(
197 key: KeyEvent,
198 app: &mut App,
199 screen: &mut Screen,
200 api: &MnwApiClient,
201 tx: &mpsc::Sender<AppEvent>,
202 staging_dir: &Path,
203 ) {
204 // Handle editing mode
205 if let Some(field) = app.editing_field {
206 match key.code {
207 KeyCode::Esc => {
208 app.editing_field = None;
209 app.edit_buffer.clear();
210 }
211 KeyCode::Enter => {
212 let idx = app.selected_index;
213 if idx < app.file_metadata.len() {
214 match field {
215 EditField::Title => {
216 if !app.edit_buffer.is_empty() {
217 app.file_metadata[idx].title = Some(app.edit_buffer.clone());
218 }
219 // Show project selection
220 app.edit_buffer.clear();
221 app.editing_field = Some(EditField::Project);
222 let project_list = app
223 .projects
224 .iter()
225 .enumerate()
226 .map(|(i, p)| format!("{}:{}", i + 1, p.title))
227 .collect::<Vec<_>>()
228 .join(" ");
229 app.upload_status =
230 Some(format!("Project #: _ | {}", project_list));
231 return;
232 }
233 EditField::Project => {
234 if let Ok(n) = app.edit_buffer.parse::<usize>()
235 && n > 0 && n <= app.projects.len()
236 {
237 let pidx = n - 1;
238 app.file_metadata[idx].project_idx = Some(pidx);
239 app.file_metadata[idx].project_name =
240 Some(app.projects[pidx].title.clone());
241 }
242 // Advance to Price field
243 app.edit_buffer.clear();
244 app.editing_field = Some(EditField::Price);
245 app.upload_status =
246 Some("Price ($): _ (0 or empty for free)".to_string());
247 return;
248 }
249 EditField::Price => {
250 let cents = super::parse_price(&app.edit_buffer);
251 app.file_metadata[idx].price_cents = cents;
252 }
253 }
254 }
255 app.editing_field = None;
256 app.edit_buffer.clear();
257 app.upload_status = None;
258 }
259 KeyCode::Backspace => {
260 app.edit_buffer.pop();
261 }
262 KeyCode::Char(c) => {
263 app.edit_buffer.push(c);
264 if let Some(field) = app.editing_field {
265 app.upload_status = Some(super::format_edit_prompt(field, &app.edit_buffer));
266 }
267 }
268 _ => {}
269 }
270 return;
271 }
272
273 // Normal mode
274 match key.code {
275 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
276 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
277 KeyCode::Esc => {
278 *screen = Screen::Home;
279 app.staged_files.clear();
280 app.file_metadata.clear();
281 app.upload_status = None;
282 app.selected_index = 0;
283 }
284 KeyCode::Char('e') | KeyCode::Char('E') => {
285 if !app.staged_files.is_empty() {
286 let idx = app.selected_index;
287 let current_title = app.file_metadata.get(idx).and_then(|m| m.title.clone());
288 app.editing_field = Some(EditField::Title);
289 app.edit_buffer = current_title.unwrap_or_default();
290 app.upload_status =
291 Some(super::format_edit_prompt(EditField::Title, &app.edit_buffer));
292 }
293 }
294 KeyCode::Char('p') | KeyCode::Char('P') => {
295 if !app.staged_files.is_empty() && !app.publishing {
296 let idx = app.selected_index;
297 let file = &app.staged_files[idx];
298 let meta = app.file_metadata.get(idx).cloned().unwrap_or_default();
299
300 // Validate
301 let Some(classification) = file.classification else {
302 app.upload_status = Some("Unsupported file type".to_string());
303 return;
304 };
305 let Some(project_idx) = meta.project_idx else {
306 app.upload_status =
307 Some("Set project first (press [e] to edit)".to_string());
308 return;
309 };
310 let Some(project) = app.projects.get(project_idx) else {
311 app.upload_status = Some("Invalid project".to_string());
312 return;
313 };
314
315 let title = meta
316 .title
317 .unwrap_or_else(|| crate::staging::derive_title(&file.filename));
318 let project_id = project.id.clone();
319 let user_id = app.user.user_id.clone();
320 let filename = file.filename.clone();
321 let file_path = staging_dir.join(&filename);
322 let price_cents = meta.price_cents;
323 let item_type = classification.item_type.to_string();
324 let file_type = classification.file_type.to_string();
325 let content_type = classification.content_type.to_string();
326 let api = api.clone();
327 let tx = tx.clone();
328
329 app.publishing = true;
330 app.upload_status = Some(format!("Publishing {}...", filename));
331
332 tokio::spawn(async move {
333 let result = publish_file(
334 &api,
335 &user_id,
336 &project_id,
337 &title,
338 &item_type,
339 &file_type,
340 &filename,
341 &content_type,
342 price_cents,
343 &file_path,
344 )
345 .await;
346
347 let (success, error) = match result {
348 Ok(()) => (true, None),
349 Err(e) => (false, Some(e.to_string())),
350 };
351
352 let _ = tx
353 .send(AppEvent::DataLoaded(DataPayload::PublishResult {
354 filename,
355 success,
356 error,
357 }))
358 .await;
359 });
360 }
361 }
362 KeyCode::Char('d') | KeyCode::Char('D') => {
363 if !app.staged_files.is_empty() {
364 let idx = app.selected_index;
365 let filename = app.staged_files[idx].filename.clone();
366 let file_path = staging_dir.join(&filename);
367 let staging_dir = staging_dir.to_path_buf();
368 let api = api.clone();
369 let user_id = app.user.user_id.clone();
370 let tx = tx.clone();
371
372 app.upload_status = Some(format!("Deleting {}...", filename));
373 tokio::spawn(async move {
374 if let Err(e) = tokio::fs::remove_file(&file_path).await {
375 tracing::warn!(error = %e, "failed to delete staged file");
376 }
377 load_staged_files(&staging_dir, &api, &user_id, &tx).await;
378 });
379 }
380 }
381 KeyCode::Char('r') | KeyCode::Char('R') => {
382 app.loading = true;
383 let staging_dir = staging_dir.to_path_buf();
384 let api = api.clone();
385 let user_id = app.user.user_id.clone();
386 let tx = tx.clone();
387 tokio::spawn(async move {
388 load_staged_files(&staging_dir, &api, &user_id, &tx).await;
389 });
390 }
391 _ => {}
392 }
393 }
394
395 pub(super) async fn handle_item_input(
396 key: KeyEvent,
397 app: &mut App,
398 screen: &mut Screen,
399 api: &MnwApiClient,
400 tx: &mpsc::Sender<AppEvent>,
401 ) {
402 use item::ItemEditField;
403
404 // Cancel pending confirmation on any key other than the confirmation key
405 if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
406 app.confirm_action = None;
407 app.item_status = None;
408 }
409
410 // Handle editing mode
411 if let Some(field) = app.item_editing {
412 match key.code {
413 KeyCode::Esc => {
414 app.item_editing = None;
415 app.edit_buffer.clear();
416 app.item_status = None;
417 }
418 KeyCode::Enter => {
419 if let Some(ref detail) = app.item_detail {
420 let item_id = detail.id.clone();
421 let user_id = app.user.user_id.clone();
422 let api = api.clone();
423 let tx = tx.clone();
424 let buffer = app.edit_buffer.clone();
425
426 match field {
427 ItemEditField::Title => {
428 if !buffer.is_empty() {
429 let title = buffer;
430 tokio::spawn(async move {
431 match api
432 .update_item(&user_id, &item_id, Some(&title), None, None, None)
433 .await
434 {
435 Ok(d) => {
436 let _ = tx
437 .send(AppEvent::DataLoaded(
438 DataPayload::ItemUpdated { detail: d },
439 ))
440 .await;
441 }
442 Err(e) => {
443 let _ = tx
444 .send(AppEvent::DataLoaded(
445 DataPayload::ItemActionError {
446 error: e.to_string(),
447 },
448 ))
449 .await;
450 }
451 }
452 });
453 } else {
454 app.item_editing = None;
455 app.edit_buffer.clear();
456 }
457 }
458 ItemEditField::Description => {
459 let desc = if buffer.is_empty() {
460 None
461 } else {
462 Some(buffer.as_str().to_string())
463 };
464 tokio::spawn(async move {
465 match api
466 .update_item(
467 &user_id,
468 &item_id,
469 None,
470 desc.as_deref(),
471 None,
472 None,
473 )
474 .await
475 {
476 Ok(d) => {
477 let _ = tx
478 .send(AppEvent::DataLoaded(
479 DataPayload::ItemUpdated { detail: d },
480 ))
481 .await;
482 }
483 Err(e) => {
484 let _ = tx
485 .send(AppEvent::DataLoaded(
486 DataPayload::ItemActionError {
487 error: e.to_string(),
488 },
489 ))
490 .await;
491 }
492 }
493 });
494 }
495 ItemEditField::Price => {
496 let cents = super::parse_price(&buffer);
497 tokio::spawn(async move {
498 match api
499 .update_item(&user_id, &item_id, None, None, Some(cents), None)
500 .await
501 {
502 Ok(d) => {
503 let _ = tx
504 .send(AppEvent::DataLoaded(
505 DataPayload::ItemUpdated { detail: d },
506 ))
507 .await;
508 }
509 Err(e) => {
510 let _ = tx
511 .send(AppEvent::DataLoaded(
512 DataPayload::ItemActionError {
513 error: e.to_string(),
514 },
515 ))
516 .await;
517 }
518 }
519 });
520 }
521 }
522 }
523 if app.item_editing.is_some() {
524 // Only clear if not already cleared by the empty-buffer path
525 app.item_editing = None;
526 app.edit_buffer.clear();
527 }
528 return;
529 }
530 KeyCode::Backspace => {
531 app.edit_buffer.pop();
532 }
533 KeyCode::Char(c) => {
534 app.edit_buffer.push(c);
535 }
536 _ => {}
537 }
538 return;
539 }
540
541 // Normal mode
542 match key.code {
543 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
544 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
545 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
546 // Go back to project view
547 if let Screen::Item(project_idx, _) = screen {
548 let pidx = *project_idx;
549 *screen = Screen::Project(pidx);
550 app.item_detail = None;
551 app.item_versions.clear();
552 app.item_status = None;
553 app.item_editing = None;
554 app.selected_index = 0;
555 }
556 }
557 KeyCode::Char('e') | KeyCode::Char('E') => {
558 // Start editing — cycle through Title → Description → Price
559 app.item_editing = Some(ItemEditField::Title);
560 app.edit_buffer.clear();
561 app.item_status = Some("Editing title (Enter to save, Esc to cancel)".to_string());
562 }
563 KeyCode::Tab => {
564 // Cycle edit fields when in edit mode (already started with [e])
565 if let Some(field) = app.item_editing {
566 let next = match field {
567 ItemEditField::Title => ItemEditField::Description,
568 ItemEditField::Description => ItemEditField::Price,
569 ItemEditField::Price => ItemEditField::Title,
570 };
571 app.item_editing = Some(next);
572 app.edit_buffer.clear();
573 let label = match next {
574 ItemEditField::Title => "title",
575 ItemEditField::Description => "description",
576 ItemEditField::Price => "price",
577 };
578 app.item_status = Some(format!("Editing {} (Enter to save, Esc to cancel)", label));
579 }
580 }
581 KeyCode::Char('p') | KeyCode::Char('P') => {
582 if let Some(ref detail) = app.item_detail
583 && !detail.is_public
584 {
585 let item_id = detail.id.clone();
586 let user_id = app.user.user_id.clone();
587 let api = api.clone();
588 let tx = tx.clone();
589 app.item_status = Some("Publishing...".to_string());
590 tokio::spawn(async move {
591 match api.publish_item(&user_id, &item_id).await {
592 Ok(d) => {
593 let _ = tx
594 .send(AppEvent::DataLoaded(DataPayload::ItemUpdated {
595 detail: d,
596 }))
597 .await;
598 }
599 Err(e) => {
600 let _ = tx
601 .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
602 error: e.to_string(),
603 }))
604 .await;
605 }
606 }
607 });
608 }
609 }
610 KeyCode::Char('u') | KeyCode::Char('U') => {
611 if let Some(ref detail) = app.item_detail
612 && detail.is_public
613 {
614 let item_id = detail.id.clone();
615 let user_id = app.user.user_id.clone();
616 let api = api.clone();
617 let tx = tx.clone();
618 app.item_status = Some("Unpublishing...".to_string());
619 tokio::spawn(async move {
620 match api.unpublish_item(&user_id, &item_id).await {
621 Ok(d) => {
622 let _ = tx
623 .send(AppEvent::DataLoaded(DataPayload::ItemUpdated {
624 detail: d,
625 }))
626 .await;
627 }
628 Err(e) => {
629 let _ = tx
630 .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
631 error: e.to_string(),
632 }))
633 .await;
634 }
635 }
636 });
637 }
638 }
639 KeyCode::Char('d') | KeyCode::Char('D') => {
640 if let Some(ref detail) = app.item_detail {
641 if matches!(app.confirm_action, Some(ConfirmAction::DeleteItem)) {
642 // Confirmed — execute delete
643 app.confirm_action = None;
644 let item_id = detail.id.clone();
645 let user_id = app.user.user_id.clone();
646 let api = api.clone();
647 let tx = tx.clone();
648 app.item_status = Some("Deleting...".to_string());
649 tokio::spawn(async move {
650 match api.delete_item(&user_id, &item_id).await {
651 Ok(()) => {
652 let _ = tx
653 .send(AppEvent::DataLoaded(DataPayload::ItemDeleted))
654 .await;
655 }
656 Err(e) => {
657 let _ = tx
658 .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
659 error: e.to_string(),
660 }))
661 .await;
662 }
663 }
664 });
665 } else {
666 // First press — ask for confirmation
667 app.confirm_action = Some(ConfirmAction::DeleteItem);
668 app.item_status = Some(format!(
669 "Delete '{}'? Press d again to confirm",
670 detail.title
671 ));
672 }
673 }
674 }
675 KeyCode::Char('l') | KeyCode::Char('L') => {
676 // Open license keys screen
677 if let Screen::Item(project_idx, item_id) = &*screen {
678 let pidx = *project_idx;
679 let iid = item_id.clone();
680 let item_title = app
681 .item_detail
682 .as_ref()
683 .map(|d| d.title.clone())
684 .unwrap_or_default();
685
686 *screen = Screen::Keys(pidx, iid.clone());
687 app.license_keys.clear();
688 app.keys_item_title = Some(item_title);
689 app.keys_status = None;
690 app.selected_index = 0;
691 app.loading = true;
692
693 let api = api.clone();
694 let user_id = app.user.user_id.clone();
695 let tx = tx.clone();
696 tokio::spawn(async move {
697 load_license_keys(&api, &user_id, &iid, &tx).await;
698 });
699 }
700 }
701 KeyCode::Char('r') | KeyCode::Char('R') => {
702 if let Screen::Item(_, item_id) = &*screen {
703 app.loading = true;
704 let item_id = item_id.clone();
705 let api = api.clone();
706 let user_id = app.user.user_id.clone();
707 let tx = tx.clone();
708 tokio::spawn(async move {
709 load_item_detail(&api, &user_id, &item_id, &tx).await;
710 });
711 }
712 }
713 _ => {}
714 }
715 }
716
717 pub(super) async fn handle_blog_input(
718 key: KeyEvent,
719 app: &mut App,
720 screen: &mut Screen,
721 api: &MnwApiClient,
722 tx: &mpsc::Sender<AppEvent>,
723 ) {
724 // Cancel pending confirmation on any key other than the confirmation key
725 if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
726 app.confirm_action = None;
727 app.blog_status = None;
728 }
729
730 // Creating mode
731 if let Some(step) = app.blog_create_step {
732 match key.code {
733 KeyCode::Esc => {
734 app.blog_create_step = None;
735 app.blog_create_title.clear();
736 app.edit_buffer.clear();
737 app.blog_status = None;
738 }
739 KeyCode::Enter => match step {
740 BlogCreateStep::Title => {
741 if !app.edit_buffer.is_empty() {
742 app.blog_create_title = app.edit_buffer.clone();
743 app.edit_buffer.clear();
744 app.blog_create_step = Some(BlogCreateStep::Body);
745 app.blog_status =
746 Some("Body (markdown, Enter to submit empty for draft):".to_string());
747 }
748 }
749 BlogCreateStep::Body => {
750 let title = app.blog_create_title.clone();
751 let body = app.edit_buffer.clone();
752
753 if let Screen::Blog(_, project_id) = &*screen {
754 let project_id = project_id.clone();
755 let api = api.clone();
756 let user_id = app.user.user_id.clone();
757 let tx = tx.clone();
758 app.blog_creating = true;
759 app.blog_status = Some("Creating post...".to_string());
760
761 tokio::spawn(async move {
762 match api
763 .create_blog_post(&user_id, &project_id, &title, &body, false)
764 .await
765 {
766 Ok(_post) => {
767 let _ = tx
768 .send(AppEvent::DataLoaded(DataPayload::BlogCreated))
769 .await;
770 }
771 Err(e) => {
772 let _ = tx
773 .send(AppEvent::DataLoaded(DataPayload::GenericError {
774 error: e.to_string(),
775 }))
776 .await;
777 }
778 }
779 });
780 }
781 app.blog_create_step = None;
782 app.blog_create_title.clear();
783 app.edit_buffer.clear();
784 }
785 },
786 KeyCode::Backspace => {
787 app.edit_buffer.pop();
788 }
789 KeyCode::Char(c) => {
790 app.edit_buffer.push(c);
791 }
792 _ => {}
793 }
794 return;
795 }
796
797 // Normal mode
798 match key.code {
799 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
800 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
801 KeyCode::Esc => {
802 if let Screen::Blog(pidx, _) = screen {
803 *screen = Screen::Project(*pidx);
804 app.blog_posts.clear();
805 app.blog_status = None;
806 app.selected_index = 0;
807 }
808 }
809 KeyCode::Char('n') | KeyCode::Char('N') => {
810 app.blog_create_step = Some(BlogCreateStep::Title);
811 app.edit_buffer.clear();
812 app.blog_status = Some("Title: _".to_string());
813 }
814 KeyCode::Char('d') | KeyCode::Char('D') => {
815 if !app.blog_posts.is_empty() {
816 let idx = app.selected_index;
817 if matches!(app.confirm_action, Some(ConfirmAction::DeleteBlogPost { post_idx }) if post_idx == idx) {
818 // Confirmed — execute delete
819 app.confirm_action = None;
820 let post_id = app.blog_posts[idx].id.clone();
821 let user_id = app.user.user_id.clone();
822 let api = api.clone();
823 let tx = tx.clone();
824
825 if let Screen::Blog(_, project_id) = &*screen {
826 let project_id = project_id.clone();
827 app.blog_status = Some("Deleting...".to_string());
828 tokio::spawn(async move {
829 match api.delete_blog_post(&user_id, &post_id).await {
830 Ok(()) => {
831 load_blog_posts(&api, &user_id, &project_id, &tx).await;
832 }
833 Err(e) => {
834 let _ = tx
835 .send(AppEvent::DataLoaded(DataPayload::GenericError {
836 error: e.to_string(),
837 }))
838 .await;
839 }
840 }
841 });
842 }
843 } else {
844 // First press — ask for confirmation
845 app.confirm_action = Some(ConfirmAction::DeleteBlogPost { post_idx: idx });
846 app.blog_status = Some(format!(
847 "Delete '{}'? Press d again to confirm",
848 app.blog_posts[idx].title
849 ));
850 }
851 }
852 }
853 KeyCode::Char('r') | KeyCode::Char('R') => {
854 if let Screen::Blog(_, project_id) = &*screen {
855 app.loading = true;
856 let project_id = project_id.clone();
857 let api = api.clone();
858 let user_id = app.user.user_id.clone();
859 let tx = tx.clone();
860 tokio::spawn(async move {
861 load_blog_posts(&api, &user_id, &project_id, &tx).await;
862 });
863 }
864 }
865 _ => {}
866 }
867 }
868
869 pub(super) async fn handle_promo_input(
870 key: KeyEvent,
871 app: &mut App,
872 screen: &mut Screen,
873 api: &MnwApiClient,
874 tx: &mpsc::Sender<AppEvent>,
875 ) {
876 // Cancel pending confirmation on any key other than the confirmation key
877 if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
878 app.confirm_action = None;
879 app.promo_status = None;
880 }
881
882 // Creating mode
883 if let Some(step) = app.promo_editing_step {
884 match key.code {
885 KeyCode::Esc => {
886 app.promo_editing_step = None;
887 app.promo_create_code.clear();
888 app.promo_create_discount.clear();
889 app.edit_buffer.clear();
890 app.promo_status = None;
891 }
892 KeyCode::Enter => match step {
893 PromoCreateStep::Code => {
894 if !app.edit_buffer.is_empty() {
895 app.promo_create_code = app.edit_buffer.clone();
896 app.edit_buffer.clear();
897 app.promo_editing_step = Some(PromoCreateStep::Discount);
898 app.promo_status = Some("Discount % (e.g. 25): _".to_string());
899 }
900 }
901 PromoCreateStep::Discount => {
902 let code = app.promo_create_code.clone();
903 let discount: i32 = app.edit_buffer.parse().unwrap_or(0);
904
905 let api = api.clone();
906 let user_id = app.user.user_id.clone();
907 let tx = tx.clone();
908 app.promo_status = Some("Creating...".to_string());
909
910 tokio::spawn(async move {
911 match api
912 .create_promo_code(
913 &user_id,
914 &code,
915 "percentage",
916 discount,
917 None,
918 None,
919 )
920 .await
921 {
922 Ok(_) => {
923 load_promo_codes(&api, &user_id, &tx).await;
924 }
925 Err(e) => {
926 let _ = tx
927 .send(AppEvent::DataLoaded(DataPayload::GenericError {
928 error: e.to_string(),
929 }))
930 .await;
931 }
932 }
933 });
934
935 app.promo_editing_step = None;
936 app.promo_create_code.clear();
937 app.promo_create_discount.clear();
938 app.edit_buffer.clear();
939 }
940 },
941 KeyCode::Backspace => {
942 app.edit_buffer.pop();
943 }
944 KeyCode::Char(c) => {
945 app.edit_buffer.push(c);
946 }
947 _ => {}
948 }
949 return;
950 }
951
952 // Normal mode
953 match key.code {
954 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
955 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
956 KeyCode::Esc => {
957 *screen = Screen::Home;
958 app.promo_codes.clear();
959 app.promo_status = None;
960 app.selected_index = 0;
961 }
962 KeyCode::Char('n') | KeyCode::Char('N') => {
963 app.promo_editing_step = Some(PromoCreateStep::Code);
964 app.edit_buffer.clear();
965 app.promo_status = Some("Code: _".to_string());
966 }
967 KeyCode::Char('d') | KeyCode::Char('D') => {
968 if !app.promo_codes.is_empty() {
969 let idx = app.selected_index;
970 if matches!(app.confirm_action, Some(ConfirmAction::DeletePromoCode { code_idx }) if code_idx == idx) {
971 // Confirmed — execute delete
972 app.confirm_action = None;
973 let code_id = app.promo_codes[idx].id.clone();
974 let api = api.clone();
975 let user_id = app.user.user_id.clone();
976 let tx = tx.clone();
977 app.promo_status = Some("Deleting...".to_string());
978 tokio::spawn(async move {
979 match api.delete_promo_code(&user_id, &code_id).await {
980 Ok(()) => {
981 load_promo_codes(&api, &user_id, &tx).await;
982 }
983 Err(e) => {
984 let _ = tx
985 .send(AppEvent::DataLoaded(DataPayload::GenericError {
986 error: e.to_string(),
987 }))
988 .await;
989 }
990 }
991 });
992 } else {
993 // First press — ask for confirmation
994 app.confirm_action = Some(ConfirmAction::DeletePromoCode { code_idx: idx });
995 app.promo_status = Some(format!(
996 "Delete '{}'? Press d again to confirm",
997 app.promo_codes[idx].code
998 ));
999 }
1000 }
1001 }
1002 KeyCode::Char('r') | KeyCode::Char('R') => {
1003 app.loading = true;
1004 let api = api.clone();
1005 let user_id = app.user.user_id.clone();
1006 let tx = tx.clone();
1007 tokio::spawn(async move {
1008 load_promo_codes(&api, &user_id, &tx).await;
1009 });
1010 }
1011 _ => {}
1012 }
1013 }
1014
1015 pub(super) async fn handle_keys_input(
1016 key: KeyEvent,
1017 app: &mut App,
1018 screen: &mut Screen,
1019 api: &MnwApiClient,
1020 tx: &mpsc::Sender<AppEvent>,
1021 ) {
1022 // Cancel pending confirmation on any key other than the confirmation key
1023 if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X')) {
1024 app.confirm_action = None;
1025 app.keys_status = None;
1026 }
1027
1028 match key.code {
1029 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
1030 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
1031 KeyCode::Esc => {
1032 if let Screen::Keys(pidx, item_id) = &*screen {
1033 let pidx = *pidx;
1034 let iid = item_id.clone();
1035 *screen = Screen::Item(pidx, iid.clone());
1036 app.license_keys.clear();
1037 app.keys_status = None;
1038 app.selected_index = 0;
1039 app.loading = true;
1040
1041 let api = api.clone();
1042 let user_id = app.user.user_id.clone();
1043 let tx = tx.clone();
1044 tokio::spawn(async move {
1045 load_item_detail(&api, &user_id, &iid, &tx).await;
1046 });
1047 }
1048 }
1049 KeyCode::Char('g') | KeyCode::Char('G') => {
1050 if let Screen::Keys(_, item_id) = &*screen {
1051 let item_id = item_id.clone();
1052 let api = api.clone();
1053 let user_id = app.user.user_id.clone();
1054 let tx = tx.clone();
1055 app.keys_status = Some("Generating...".to_string());
1056 let iid = item_id.clone();
1057 tokio::spawn(async move {
1058 match api.generate_license_key(&user_id, &item_id).await {
1059 Ok(key) => {
1060 let _ = tx
1061 .send(AppEvent::DataLoaded(DataPayload::GenericSuccess {
1062 message: format!("Generated: {}", key.key_code),
1063 }))
1064 .await;
1065 load_license_keys(&api, &user_id, &iid, &tx).await;
1066 }
1067 Err(e) => {
1068 let _ = tx
1069 .send(AppEvent::DataLoaded(DataPayload::GenericError {
1070 error: e.to_string(),
1071 }))
1072 .await;
1073 }
1074 }
1075 });
1076 }
1077 }
1078 KeyCode::Char('x') | KeyCode::Char('X') => {
1079 if !app.license_keys.is_empty() {
1080 let idx = app.selected_index;
1081 if matches!(app.confirm_action, Some(ConfirmAction::RevokeLicenseKey { key_idx }) if key_idx == idx) {
1082 // Confirmed — execute revoke
1083 app.confirm_action = None;
1084 let key_id = app.license_keys[idx].id.clone();
1085 if let Screen::Keys(_, item_id) = &*screen {
1086 let item_id = item_id.clone();
1087 let api = api.clone();
1088 let user_id = app.user.user_id.clone();
1089 let tx = tx.clone();
1090 app.keys_status = Some("Revoking...".to_string());
1091 tokio::spawn(async move {
1092 match api.revoke_license_key(&user_id, &key_id).await {
1093 Ok(()) => {
1094 load_license_keys(&api, &user_id, &item_id, &tx).await;
1095 }
1096 Err(e) => {
1097 let _ = tx
1098 .send(AppEvent::DataLoaded(DataPayload::GenericError {
1099 error: e.to_string(),
1100 }))
1101 .await;
1102 }
1103 }
1104 });
1105 }
1106 } else {
1107 // First press — ask for confirmation
1108 app.confirm_action = Some(ConfirmAction::RevokeLicenseKey { key_idx: idx });
1109 app.keys_status = Some(format!(
1110 "Revoke '{}'? Press x again to confirm",
1111 app.license_keys[idx].key_code
1112 ));
1113 }
1114 }
1115 }
1116 KeyCode::Char('r') | KeyCode::Char('R') => {
1117 if let Screen::Keys(_, item_id) = &*screen {
1118 app.loading = true;
1119 let item_id = item_id.clone();
1120 let api = api.clone();
1121 let user_id = app.user.user_id.clone();
1122 let tx = tx.clone();
1123 tokio::spawn(async move {
1124 load_license_keys(&api, &user_id, &item_id, &tx).await;
1125 });
1126 }
1127 }
1128 _ => {}
1129 }
1130 }
1131
1132 pub(super) async fn handle_analytics_input(
1133 key: KeyEvent,
1134 app: &mut App,
1135 screen: &mut Screen,
1136 api: &MnwApiClient,
1137 tx: &mpsc::Sender<AppEvent>,
1138 ) {
1139 match key.code {
1140 KeyCode::Char('j') | KeyCode::Down => {
1141 if app.analytics_show_transactions {
1142 app.move_down(screen);
1143 }
1144 }
1145 KeyCode::Char('k') | KeyCode::Up => {
1146 if app.analytics_show_transactions {
1147 app.move_up(screen);
1148 }
1149 }
1150 KeyCode::Esc => {
1151 if app.analytics_show_transactions {
1152 app.analytics_show_transactions = false;
1153 app.selected_index = 0;
1154 } else {
1155 *screen = Screen::Home;
1156 app.analytics_data = None;
1157 app.analytics_status = None;
1158 app.selected_index = 0;
1159 }
1160 }
1161 // Range selection: 1=7d, 2=30d, 3=90d, 4=all
1162 KeyCode::Char('1') => {
1163 app.analytics_range = "7d".to_string();
1164 reload_analytics(app, api, tx);
1165 }
1166 KeyCode::Char('2') => {
1167 app.analytics_range = "30d".to_string();
1168 reload_analytics(app, api, tx);
1169 }
1170 KeyCode::Char('3') => {
1171 app.analytics_range = "90d".to_string();
1172 reload_analytics(app, api, tx);
1173 }
1174 KeyCode::Char('4') => {
1175 app.analytics_range = "all".to_string();
1176 reload_analytics(app, api, tx);
1177 }
1178 KeyCode::Char('t') | KeyCode::Char('T') => {
1179 if app.analytics_show_transactions {
1180 app.analytics_show_transactions = false;
1181 } else {
1182 app.analytics_show_transactions = true;
1183 app.selected_index = 0;
1184 if app.transactions.is_empty() {
1185 app.loading = true;
1186 let api = api.clone();
1187 let user_id = app.user.user_id.clone();
1188 let tx = tx.clone();
1189 tokio::spawn(async move {
1190 load_transactions(&api, &user_id, &tx).await;
1191 });
1192 }
1193 }
1194 }
1195 KeyCode::Char('e') | KeyCode::Char('E') => {
1196 app.analytics_status = Some("Exporting...".to_string());
1197 let api = api.clone();
1198 let user_id = app.user.user_id.clone();
1199 let tx = tx.clone();
1200 tokio::spawn(async move {
1201 match api.export_sales_csv(&user_id).await {
1202 Ok(result) => {
1203 let _ = tx
1204 .send(AppEvent::DataLoaded(DataPayload::ExportCsv {
1205 csv: result.csv,
1206 row_count: result.row_count,
1207 }))
1208 .await;
1209 }
1210 Err(e) => {
1211 let _ = tx
1212 .send(AppEvent::DataLoaded(DataPayload::GenericError {
1213 error: e.to_string(),
1214 }))
1215 .await;
1216 }
1217 }
1218 });
1219 }
1220 KeyCode::Char('r') | KeyCode::Char('R') => {
1221 reload_analytics(app, api, tx);
1222 }
1223 _ => {}
1224 }
1225 }
1226
1227 fn reload_analytics(app: &mut App, api: &MnwApiClient, tx: &mpsc::Sender<AppEvent>) {
1228 app.loading = true;
1229 app.analytics_status = None;
1230 let api = api.clone();
1231 let user_id = app.user.user_id.clone();
1232 let range = app.analytics_range.clone();
1233 let tx = tx.clone();
1234 tokio::spawn(async move {
1235 load_analytics(&api, &user_id, &range, &tx).await;
1236 });
1237 }
1238
1239 pub(super) async fn handle_settings_input(
1240 key: KeyEvent,
1241 app: &mut App,
1242 screen: &mut Screen,
1243 api: &MnwApiClient,
1244 tx: &mpsc::Sender<AppEvent>,
1245 ) {
1246 match key.code {
1247 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
1248 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
1249 KeyCode::Esc => {
1250 *screen = Screen::Home;
1251 app.ssh_keys.clear();
1252 app.settings_status = None;
1253 app.selected_index = 0;
1254 }
1255 KeyCode::Char('r') | KeyCode::Char('R') => {
1256 app.loading = true;
1257 app.settings_status = None;
1258 let api = api.clone();
1259 let user_id = app.user.user_id.clone();
1260 let tx = tx.clone();
1261 tokio::spawn(async move {
1262 load_settings(&api, &user_id, &tx).await;
1263 });
1264 }
1265 _ => {}
1266 }
1267 }
1268