Skip to main content

max / makenotwork

Split mnw-cli tui/input.rs into per-screen handler modules input.rs was 1641 lines: eleven giant per-screen key handlers (handle_item_input alone ~413 lines) in one file. Convert to an input/ directory with one module per screen (home, project, upload, item, blog, promo, keys, analytics, settings, collections, tiers), each holding its handler (and any screen-private helpers). input/mod.rs keeps the shared imports and re-exports every handler with `pub(crate) use <screen>::*`, so tui/mod.rs's `use input::*` and the launch() dispatch resolve unchanged. Handlers are now pub(crate) (a child module can't satisfy the old pub(super)-to-grandparent visibility). The item handler's `use item::ItemEditField` is rewritten to `crate::tui::item::` to disambiguate the render module from the new input::item module. Function set unchanged (14 fns). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 15:49 UTC
Commit: 7e6f787bf44fb03fd80ec181d947a28c27f61a37
Parent: ee1eec6
13 files changed, +1695 insertions, -500 deletions
@@ -1,1641 +0,0 @@
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,
Lines truncated
@@ -0,0 +1,109 @@
1 + //! analytics-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + pub(crate) async fn handle_analytics_input(
6 + key: KeyEvent,
7 + app: &mut App,
8 + screen: &mut Screen,
9 + api: &MnwApiClient,
10 + tx: &mpsc::Sender<AppEvent>,
11 + ) {
12 + match key.code {
13 + KeyCode::Char('j') | KeyCode::Down => {
14 + if app.analytics_show_transactions {
15 + app.move_down(screen);
16 + }
17 + }
18 + KeyCode::Char('k') | KeyCode::Up => {
19 + if app.analytics_show_transactions {
20 + app.move_up(screen);
21 + }
22 + }
23 + KeyCode::Esc => {
24 + if app.analytics_show_transactions {
25 + app.analytics_show_transactions = false;
26 + app.selected_index = 0;
27 + } else {
28 + *screen = Screen::Home;
29 + app.analytics_data = None;
30 + app.analytics_status = None;
31 + app.selected_index = 0;
32 + }
33 + }
34 + // Range selection: 1=7d, 2=30d, 3=90d, 4=all
35 + KeyCode::Char('1') => {
36 + app.analytics_range = "7d".to_string();
37 + reload_analytics(app, api, tx);
38 + }
39 + KeyCode::Char('2') => {
40 + app.analytics_range = "30d".to_string();
41 + reload_analytics(app, api, tx);
42 + }
43 + KeyCode::Char('3') => {
44 + app.analytics_range = "90d".to_string();
45 + reload_analytics(app, api, tx);
46 + }
47 + KeyCode::Char('4') => {
48 + app.analytics_range = "all".to_string();
49 + reload_analytics(app, api, tx);
50 + }
51 + KeyCode::Char('t') | KeyCode::Char('T') => {
52 + if app.analytics_show_transactions {
53 + app.analytics_show_transactions = false;
54 + } else {
55 + app.analytics_show_transactions = true;
56 + app.selected_index = 0;
57 + if app.transactions.is_empty() {
58 + app.loading = true;
59 + let api = api.clone();
60 + let user_id = app.user.user_id.clone();
61 + let tx = tx.clone();
62 + tokio::spawn(async move {
63 + load_transactions(&api, &user_id, &tx).await;
64 + });
65 + }
66 + }
67 + }
68 + KeyCode::Char('e') | KeyCode::Char('E') => {
69 + app.analytics_status = Some("Exporting...".to_string());
70 + let api = api.clone();
71 + let user_id = app.user.user_id.clone();
72 + let tx = tx.clone();
73 + tokio::spawn(async move {
74 + match api.export_sales_csv(&user_id).await {
75 + Ok(result) => {
76 + let _ = tx
77 + .send(AppEvent::DataLoaded(DataPayload::ExportCsv {
78 + csv: result.csv,
79 + row_count: result.row_count,
80 + }))
81 + .await;
82 + }
83 + Err(e) => {
84 + let _ = tx
85 + .send(AppEvent::DataLoaded(DataPayload::GenericError {
86 + error: e.to_string(),
87 + }))
88 + .await;
89 + }
90 + }
91 + });
92 + }
93 + KeyCode::Char('r') | KeyCode::Char('R') => {
94 + reload_analytics(app, api, tx);
95 + }
96 + _ => {}
97 + }
98 + }
99 + fn reload_analytics(app: &mut App, api: &MnwApiClient, tx: &mpsc::Sender<AppEvent>) {
100 + app.loading = true;
101 + app.analytics_status = None;
102 + let api = api.clone();
103 + let user_id = app.user.user_id.clone();
104 + let range = app.analytics_range.clone();
105 + let tx = tx.clone();
106 + tokio::spawn(async move {
107 + load_analytics(&api, &user_id, &range, &tx).await;
108 + });
109 + }
@@ -0,0 +1,201 @@
1 + //! blog-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + pub(crate) async fn handle_blog_input(
6 + key: KeyEvent,
7 + app: &mut App,
8 + screen: &mut Screen,
9 + api: &MnwApiClient,
10 + tx: &mpsc::Sender<AppEvent>,
11 + ) {
12 + // Cancel pending confirmation on any key other than the confirmation key
13 + if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
14 + app.confirm_action = None;
15 + app.blog_status = None;
16 + }
17 +
18 + // Creating mode
19 + if let Some(step) = app.blog_create_step {
20 + // Ctrl+D in body step: advance to schedule
21 + if step == BlogCreateStep::Body
22 + && key.code == KeyCode::Char('d')
23 + && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
24 + {
25 + app.blog_create_body = app.edit_buffer.clone();
26 + app.edit_buffer.clear();
27 + app.blog_create_step = Some(BlogCreateStep::Schedule);
28 + app.blog_status = Some(
29 + "Schedule (YYYY-MM-DDTHH:MM:SSZ or Enter for draft):".to_string(),
30 + );
31 + return;
32 + }
33 +
34 + match key.code {
35 + KeyCode::Esc => {
36 + app.blog_create_step = None;
37 + app.blog_create_title.clear();
38 + app.blog_create_body.clear();
39 + app.edit_buffer.clear();
40 + app.blog_status = None;
41 + }
42 + KeyCode::Enter => match step {
43 + BlogCreateStep::Title => {
44 + if !app.edit_buffer.is_empty() {
45 + app.blog_create_title = app.edit_buffer.clone();
46 + app.edit_buffer.clear();
47 + app.blog_create_step = Some(BlogCreateStep::Body);
48 + app.blog_status =
49 + Some("Body (markdown, Enter for newline, Ctrl+D when done):".to_string());
50 + }
51 + }
52 + BlogCreateStep::Body => {
53 + // Enter inserts a newline in body mode
54 + app.edit_buffer.push('\n');
55 + return;
56 + }
57 + BlogCreateStep::Schedule => {
58 + let title = app.blog_create_title.clone();
59 + let body = app.blog_create_body.clone();
60 + let schedule_input = app.edit_buffer.trim().to_string();
61 + let publish_at = if schedule_input.is_empty() {
62 + None
63 + } else {
64 + Some(schedule_input)
65 + };
66 +
67 + if let Screen::Blog(_, project_id) = &*screen {
68 + let project_id = project_id.clone();
69 + let api = api.clone();
70 + let user_id = app.user.user_id.clone();
71 + let tx = tx.clone();
72 + app.blog_creating = true;
73 + let status_msg = if publish_at.is_some() {
74 + "Scheduling post..."
75 + } else {
76 + "Creating draft..."
77 + };
78 + app.blog_status = Some(status_msg.to_string());
79 +
80 + tokio::spawn(async move {
81 + match api
82 + .create_blog_post(
83 + &user_id,
84 + &project_id,
85 + &title,
86 + &body,
87 + false,
88 + publish_at.as_deref(),
89 + )
90 + .await
91 + {
92 + Ok(_post) => {
93 + let _ = tx
94 + .send(AppEvent::DataLoaded(DataPayload::BlogCreated))
95 + .await;
96 + }
97 + Err(e) => {
98 + let _ = tx
99 + .send(AppEvent::DataLoaded(DataPayload::GenericError {
100 + error: e.to_string(),
101 + }))
102 + .await;
103 + }
104 + }
105 + });
106 + }
107 + app.blog_create_step = None;
108 + app.blog_create_title.clear();
109 + app.blog_create_body.clear();
110 + app.edit_buffer.clear();
111 + }
112 + },
113 + KeyCode::Backspace => {
114 + app.edit_buffer.pop();
115 + }
116 + KeyCode::Char(c) => {
117 + app.edit_buffer.push(c);
118 + }
119 + _ => {}
120 + }
121 + return;
122 + }
123 +
124 + // Normal mode
125 + match key.code {
126 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
127 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
128 + KeyCode::Esc => {
129 + if let Screen::Blog(pidx, _) = screen {
130 + *screen = Screen::Project(*pidx);
131 + app.blog_posts.clear();
132 + app.blog_status = None;
133 + app.selected_index = 0;
134 + }
135 + }
136 + KeyCode::Char('n') | KeyCode::Char('N') => {
137 + app.blog_create_step = Some(BlogCreateStep::Title);
138 + app.edit_buffer.clear();
139 + app.blog_status = Some("Title: _".to_string());
140 + }
141 + KeyCode::Char('d') | KeyCode::Char('D') => {
142 + if !app.blog_posts.is_empty() {
143 + let idx = app.selected_index;
144 + if matches!(app.confirm_action, Some(ConfirmAction::DeleteBlogPost { post_idx }) if post_idx == idx) {
145 + // Confirmed — execute delete
146 + app.confirm_action = None;
147 + let post_id = app.blog_posts[idx].id.clone();
148 + let post_title = app.blog_posts[idx].title.clone();
149 + let user_id = app.user.user_id.clone();
150 + tracing::info!(
151 + user_id = %user_id,
152 + post_id = %post_id,
153 + post_title = %post_title,
154 + "delete blog post confirmed"
155 + );
156 + let api = api.clone();
157 + let tx = tx.clone();
158 +
159 + if let Screen::Blog(_, project_id) = &*screen {
160 + let project_id = project_id.clone();
161 + app.blog_status = Some("Deleting...".to_string());
162 + tokio::spawn(async move {
163 + match api.delete_blog_post(&user_id, &post_id).await {
164 + Ok(()) => {
165 + load_blog_posts(&api, &user_id, &project_id, &tx).await;
166 + }
167 + Err(e) => {
168 + let _ = tx
169 + .send(AppEvent::DataLoaded(DataPayload::GenericError {
170 + error: e.to_string(),
171 + }))
172 + .await;
173 + }
174 + }
175 + });
176 + }
177 + } else {
178 + // First press — ask for confirmation
179 + app.confirm_action = Some(ConfirmAction::DeleteBlogPost { post_idx: idx });
180 + app.blog_status = Some(format!(
181 + "Delete '{}'? Press d again to confirm",
182 + app.blog_posts[idx].title
183 + ));
184 + }
185 + }
186 + }
187 + KeyCode::Char('r') | KeyCode::Char('R') => {
188 + if let Screen::Blog(_, project_id) = &*screen {
189 + app.loading = true;
190 + let project_id = project_id.clone();
191 + let api = api.clone();
192 + let user_id = app.user.user_id.clone();
193 + let tx = tx.clone();
194 + tokio::spawn(async move {
195 + load_blog_posts(&api, &user_id, &project_id, &tx).await;
196 + });
197 + }
198 + }
199 + _ => {}
200 + }
201 + }
@@ -0,0 +1,33 @@
1 + //! collections-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + pub(crate) async fn handle_collections_input(
6 + key: KeyEvent,
7 + app: &mut App,
8 + screen: &mut Screen,
9 + api: &MnwApiClient,
10 + tx: &mpsc::Sender<AppEvent>,
11 + ) {
12 + match key.code {
13 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
14 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
15 + KeyCode::Esc => {
16 + *screen = Screen::Home;
17 + app.collections.clear();
18 + app.collections_status = None;
19 + app.selected_index = 0;
20 + }
21 + KeyCode::Char('r') | KeyCode::Char('R') => {
22 + app.loading = true;
23 + app.collections_status = None;
24 + let api = api.clone();
25 + let user_id = app.user.user_id.clone();
26 + let tx = tx.clone();
27 + tokio::spawn(async move {
28 + load_collections(&api, &user_id, &tx).await;
29 + });
30 + }
31 + _ => {}
32 + }
33 + }
@@ -0,0 +1,118 @@
1 + //! home-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + pub(crate) async fn handle_home_input(
6 + key: KeyEvent,
7 + app: &mut App,
8 + screen: &mut Screen,
9 + api: &MnwApiClient,
10 + tx: &mpsc::Sender<AppEvent>,
11 + staging_dir: &Path,
12 + ) {
13 + match key.code {
14 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
15 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
16 + KeyCode::Enter => {
17 + if !app.projects.is_empty() {
18 + let idx = app.selected_index;
19 + let project_id = app.projects[idx].id.clone();
20 + let user_id = app.user.user_id.clone();
21 + *screen = Screen::Project(idx);
22 + app.items.clear();
23 + app.selected_index = 0;
24 + app.loading = true;
25 +
26 + let api = api.clone();
27 + let tx = tx.clone();
28 + tokio::spawn(async move {
29 + load_project_items(&api, &project_id, &user_id, &tx).await;
30 + });
31 + }
32 + }
33 + KeyCode::Char('u') | KeyCode::Char('U') => {
34 + *screen = Screen::Upload;
35 + app.selected_index = 0;
36 + app.loading = true;
37 + app.upload_status = None;
38 + app.editing_field = None;
39 +
40 + let staging_dir = staging_dir.to_path_buf();
41 + let api = api.clone();
42 + let user_id = app.user.user_id.clone();
43 + let tx = tx.clone();
44 + tokio::spawn(async move {
45 + load_staged_files(&staging_dir, &api, &user_id, &tx).await;
46 + });
47 + }
48 + KeyCode::Char('a') | KeyCode::Char('A') => {
49 + *screen = Screen::Analytics;
50 + app.analytics_data = None;
51 + app.analytics_status = None;
52 + app.analytics_show_transactions = false;
53 + app.selected_index = 0;
54 + app.loading = true;
55 +
56 + let api = api.clone();
57 + let user_id = app.user.user_id.clone();
58 + let range = app.analytics_range.clone();
59 + let tx = tx.clone();
60 + tokio::spawn(async move {
61 + load_analytics(&api, &user_id, &range, &tx).await;
62 + });
63 + }
64 + KeyCode::Char('p') | KeyCode::Char('P') => {
65 + *screen = Screen::Promo;
66 + app.promo_codes.clear();
67 + app.promo_status = None;
68 + app.promo_editing_step = None;
69 + app.selected_index = 0;
70 + app.loading = true;
71 +
72 + let api = api.clone();
73 + let user_id = app.user.user_id.clone();
74 + let tx = tx.clone();
75 + tokio::spawn(async move {
76 + load_promo_codes(&api, &user_id, &tx).await;
77 + });
78 + }
79 + KeyCode::Char('c') | KeyCode::Char('C') => {
80 + *screen = Screen::Collections;
81 + app.collections.clear();
82 + app.collections_status = None;
83 + app.selected_index = 0;
84 + app.loading = true;
85 +
86 + let api = api.clone();
87 + let user_id = app.user.user_id.clone();
88 + let tx = tx.clone();
89 + tokio::spawn(async move {
90 + load_collections(&api, &user_id, &tx).await;
91 + });
92 + }
93 + KeyCode::Char('s') | KeyCode::Char('S') => {
94 + *screen = Screen::Settings;
95 + app.ssh_keys.clear();
96 + app.settings_status = None;
97 + app.selected_index = 0;
98 + app.loading = true;
99 +
100 + let api = api.clone();
101 + let user_id = app.user.user_id.clone();
102 + let tx = tx.clone();
103 + tokio::spawn(async move {
104 + load_settings(&api, &user_id, &tx).await;
105 + });
106 + }
107 + KeyCode::Char('r') | KeyCode::Char('R') => {
108 + app.loading = true;
109 + let api = api.clone();
110 + let user_id = app.user.user_id.clone();
111 + let tx = tx.clone();
112 + tokio::spawn(async move {
113 + load_home_data(&api, &user_id, &tx).await;
114 + });
115 + }
116 + _ => {}
117 + }
118 + }
@@ -0,0 +1,416 @@
1 + //! item-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + pub(crate) async fn handle_item_input(
6 + key: KeyEvent,
7 + app: &mut App,
8 + screen: &mut Screen,
9 + api: &MnwApiClient,
10 + tx: &mpsc::Sender<AppEvent>,
11 + ) {
12 + use crate::tui::item::ItemEditField;
13 +
14 + // Handle tag search mode
15 + if app.tag_searching {
16 + match key.code {
17 + KeyCode::Esc => {
18 + app.tag_searching = false;
19 + app.edit_buffer.clear();
20 + app.tag_search_results.clear();
21 + app.item_status = None;
22 + }
23 + KeyCode::Enter => {
24 + // Add the first search result as a tag
25 + if let (Some(tag), Some(detail)) = (app.tag_search_results.first(), &app.item_detail) {
26 + let tag_id = tag.id.clone();
27 + let item_id = detail.id.clone();
28 + let user_id = app.user.user_id.clone();
29 + let api = api.clone();
30 + let tx = tx.clone();
31 + let tag_name = tag.name.clone();
32 + tokio::spawn(async move {
33 + match api.add_item_tag(&user_id, &item_id, &tag_id).await {
34 + Ok(()) => {
35 + // Reload tags
36 + let tags = api.list_item_tags(&user_id, &item_id).await.unwrap_or_default();
37 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemTags { tags })).await;
38 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemActionError {
39 + error: format!("Added tag: {}", tag_name), // Reuse error channel for status
40 + })).await;
41 + }
42 + Err(e) => {
43 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemActionError {
44 + error: e.to_string(),
45 + })).await;
46 + }
47 + }
48 + });
49 + app.tag_searching = false;
50 + app.edit_buffer.clear();
51 + app.tag_search_results.clear();
52 + app.item_status = None;
53 + }
54 + }
55 + KeyCode::Backspace => {
56 + app.edit_buffer.pop();
57 + if app.edit_buffer.len() >= 2 {
58 + let api = api.clone();
59 + let query = app.edit_buffer.clone();
60 + let tx = tx.clone();
61 + tokio::spawn(async move { search_tags(&api, &query, &tx).await; });
62 + } else {
63 + app.tag_search_results.clear();
64 + }
65 + }
66 + KeyCode::Char(c) => {
67 + app.edit_buffer.push(c);
68 + if app.edit_buffer.len() >= 2 {
69 + let api = api.clone();
70 + let query = app.edit_buffer.clone();
71 + let tx = tx.clone();
72 + tokio::spawn(async move { search_tags(&api, &query, &tx).await; });
73 + }
74 + let results_preview: String = app.tag_search_results.iter()
75 + .take(3)
76 + .map(|t| t.name.as_str())
77 + .collect::<Vec<_>>()
78 + .join(", ");
79 + app.item_status = Some(format!(
80 + "Tag: {}_ {}",
81 + app.edit_buffer,
82 + if results_preview.is_empty() { String::new() } else { format!("[{}]", results_preview) },
83 + ));
84 + }
85 + _ => {}
86 + }
87 + return;
88 + }
89 +
90 + // Cancel pending confirmation on any key other than the confirmation key
91 + if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
92 + app.confirm_action = None;
93 + app.item_status = None;
94 + }
95 +
96 + // Handle editing mode
97 + if let Some(field) = app.item_editing {
98 + match key.code {
99 + KeyCode::Esc => {
100 + app.item_editing = None;
101 + app.edit_buffer.clear();
102 + app.item_status = None;
103 + }
104 + KeyCode::Enter => {
105 + if let Some(ref detail) = app.item_detail {
106 + let item_id = detail.id.clone();
107 + let user_id = app.user.user_id.clone();
108 + let api = api.clone();
109 + let tx = tx.clone();
110 + let buffer = app.edit_buffer.clone();
111 +
112 + match field {
113 + ItemEditField::Title => {
114 + if !buffer.is_empty() {
115 + let title = buffer;
116 + tokio::spawn(async move {
117 + match api
118 + .update_item(&user_id, &item_id, Some(&title), None, None, None)
119 + .await
120 + {
121 + Ok(d) => {
122 + let _ = tx
123 + .send(AppEvent::DataLoaded(
124 + DataPayload::ItemUpdated { detail: d },
125 + ))
126 + .await;
127 + }
128 + Err(e) => {
129 + let _ = tx
130 + .send(AppEvent::DataLoaded(
131 + DataPayload::ItemActionError {
132 + error: e.to_string(),
133 + },
134 + ))
135 + .await;
136 + }
137 + }
138 + });
139 + } else {
140 + app.item_editing = None;
141 + app.edit_buffer.clear();
142 + }
143 + }
144 + ItemEditField::Description => {
145 + let desc = if buffer.is_empty() {
146 + None
147 + } else {
148 + Some(buffer.as_str().to_string())
149 + };
150 + tokio::spawn(async move {
151 + match api
152 + .update_item(
153 + &user_id,
154 + &item_id,
155 + None,
156 + desc.as_deref(),
157 + None,
158 + None,
159 + )
160 + .await
161 + {
162 + Ok(d) => {
163 + let _ = tx
164 + .send(AppEvent::DataLoaded(
165 + DataPayload::ItemUpdated { detail: d },
166 + ))
167 + .await;
168 + }
169 + Err(e) => {
170 + let _ = tx
171 + .send(AppEvent::DataLoaded(
172 + DataPayload::ItemActionError {
173 + error: e.to_string(),
174 + },
175 + ))
176 + .await;
177 + }
178 + }
179 + });
180 + }
181 + ItemEditField::Price => {
182 + let cents = crate::tui::parse_price(&buffer);
183 + tokio::spawn(async move {
184 + match api
185 + .update_item(&user_id, &item_id, None, None, Some(cents), None)
186 + .await
187 + {
188 + Ok(d) => {
189 + let _ = tx
190 + .send(AppEvent::DataLoaded(
191 + DataPayload::ItemUpdated { detail: d },
192 + ))
193 + .await;
194 + }
195 + Err(e) => {
196 + let _ = tx
197 + .send(AppEvent::DataLoaded(
198 + DataPayload::ItemActionError {
199 + error: e.to_string(),
200 + },
201 + ))
202 + .await;
203 + }
204 + }
205 + });
206 + }
207 + }
208 + }
209 + if app.item_editing.is_some() {
210 + // Only clear if not already cleared by the empty-buffer path
211 + app.item_editing = None;
212 + app.edit_buffer.clear();
213 + }
214 + return;
215 + }
216 + KeyCode::Backspace => {
217 + app.edit_buffer.pop();
218 + }
219 + KeyCode::Char(c) => {
220 + app.edit_buffer.push(c);
221 + }
222 + _ => {}
223 + }
224 + return;
225 + }
226 +
227 + // Normal mode
228 + match key.code {
229 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
230 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
231 + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
232 + // Go back to project view
233 + if let Screen::Item(project_idx, _) = screen {
234 + let pidx = *project_idx;
235 + *screen = Screen::Project(pidx);
236 + app.item_detail = None;
237 + app.item_versions.clear();
238 + app.item_status = None;
239 + app.item_editing = None;
240 + app.selected_index = 0;
241 + }
242 + }
243 + KeyCode::Char('e') | KeyCode::Char('E') => {
244 + // Start editing — cycle through Title → Description → Price
245 + app.item_editing = Some(ItemEditField::Title);
246 + app.edit_buffer.clear();
247 + app.item_status = Some("Editing title (Enter to save, Esc to cancel)".to_string());
248 + }
249 + KeyCode::Tab => {
250 + // Cycle edit fields when in edit mode (already started with [e])
251 + if let Some(field) = app.item_editing {
252 + let next = match field {
253 + ItemEditField::Title => ItemEditField::Description,
254 + ItemEditField::Description => ItemEditField::Price,
255 + ItemEditField::Price => ItemEditField::Title,
256 + };
257 + app.item_editing = Some(next);
258 + app.edit_buffer.clear();
259 + let label = match next {
260 + ItemEditField::Title => "title",
261 + ItemEditField::Description => "description",
262 + ItemEditField::Price => "price",
263 + };
264 + app.item_status = Some(format!("Editing {} (Enter to save, Esc to cancel)", label));
265 + }
266 + }
267 + KeyCode::Char('p') | KeyCode::Char('P') => {
268 + if let Some(ref detail) = app.item_detail
269 + && !detail.is_public
270 + {
271 + let item_id = detail.id.clone();
272 + let user_id = app.user.user_id.clone();
273 + let api = api.clone();
274 + let tx = tx.clone();
275 + app.item_status = Some("Publishing...".to_string());
276 + tokio::spawn(async move {
277 + match api.publish_item(&user_id, &item_id).await {
278 + Ok(d) => {
279 + let _ = tx
280 + .send(AppEvent::DataLoaded(DataPayload::ItemUpdated {
281 + detail: d,
282 + }))
283 + .await;
284 + }
285 + Err(e) => {
286 + let _ = tx
287 + .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
288 + error: e.to_string(),
289 + }))
290 + .await;
291 + }
292 + }
293 + });
294 + }
295 + }
296 + KeyCode::Char('u') | KeyCode::Char('U') => {
297 + if let Some(ref detail) = app.item_detail
298 + && detail.is_public
299 + {
300 + let item_id = detail.id.clone();
301 + let user_id = app.user.user_id.clone();
302 + let api = api.clone();
303 + let tx = tx.clone();
304 + app.item_status = Some("Unpublishing...".to_string());
305 + tokio::spawn(async move {
306 + match api.unpublish_item(&user_id, &item_id).await {
307 + Ok(d) => {
308 + let _ = tx
309 + .send(AppEvent::DataLoaded(DataPayload::ItemUpdated {
310 + detail: d,
311 + }))
312 + .await;
313 + }
314 + Err(e) => {
315 + let _ = tx
316 + .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
317 + error: e.to_string(),
318 + }))
319 + .await;
320 + }
321 + }
322 + });
323 + }
324 + }
325 + KeyCode::Char('d') | KeyCode::Char('D') => {
326 + if let Some(ref detail) = app.item_detail {
327 + if matches!(app.confirm_action, Some(ConfirmAction::DeleteItem)) {
328 + // Confirmed — execute delete
329 + app.confirm_action = None;
330 + let item_id = detail.id.clone();
331 + let item_title = detail.title.clone();
332 + let user_id = app.user.user_id.clone();
333 + tracing::info!(
334 + user_id = %user_id,
335 + item_id = %item_id,
336 + item_title = %item_title,
337 + "delete item confirmed"
338 + );
339 + let api = api.clone();
340 + let tx = tx.clone();
341 + app.item_status = Some("Deleting...".to_string());
342 + tokio::spawn(async move {
343 + match api.delete_item(&user_id, &item_id).await {
344 + Ok(()) => {
345 + let _ = tx
346 + .send(AppEvent::DataLoaded(DataPayload::ItemDeleted))
347 + .await;
348 + }
349 + Err(e) => {
350 + let _ = tx
351 + .send(AppEvent::DataLoaded(DataPayload::ItemActionError {
352 + error: e.to_string(),
353 + }))
354 + .await;
355 + }
356 + }
357 + });
358 + } else {
359 + // First press — ask for confirmation
360 + app.confirm_action = Some(ConfirmAction::DeleteItem);
361 + app.item_status = Some(format!(
362 + "Delete '{}'? Press d again to confirm",
363 + detail.title
364 + ));
365 + }
366 + }
367 + }
368 + KeyCode::Char('t') | KeyCode::Char('T') => {
369 + if app.item_detail.is_some() {
370 + app.tag_searching = true;
371 + app.edit_buffer.clear();
372 + app.tag_search_results.clear();
373 + app.item_status = Some("Tag: type to search, Enter to add first result, Esc to cancel".to_string());
374 + }
375 + }
376 + KeyCode::Char('l') | KeyCode::Char('L') => {
377 + // Open license keys screen
378 + if let Screen::Item(project_idx, item_id) = &*screen {
379 + let pidx = *project_idx;
380 + let iid = item_id.clone();
381 + let item_title = app
382 + .item_detail
383 + .as_ref()
384 + .map(|d| d.title.clone())
385 + .unwrap_or_default();
386 +
387 + *screen = Screen::Keys(pidx, iid.clone());
388 + app.license_keys.clear();
389 + app.keys_item_title = Some(item_title);
390 + app.keys_status = None;
391 + app.selected_index = 0;
392 + app.loading = true;
393 +
394 + let api = api.clone();
395 + let user_id = app.user.user_id.clone();
396 + let tx = tx.clone();
397 + tokio::spawn(async move {
398 + load_license_keys(&api, &user_id, &iid, &tx).await;
399 + });
400 + }
401 + }
402 + KeyCode::Char('r') | KeyCode::Char('R') => {
403 + if let Screen::Item(_, item_id) = &*screen {
404 + app.loading = true;
405 + let item_id = item_id.clone();
406 + let api = api.clone();
407 + let user_id = app.user.user_id.clone();
408 + let tx = tx.clone();
409 + tokio::spawn(async move {
410 + load_item_detail(&api, &user_id, &item_id, &tx).await;
411 + });
412 + }
413 + }
414 + _ => {}
415 + }
416 + }
@@ -0,0 +1,128 @@
1 + //! keys-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + pub(crate) async fn handle_keys_input(
6 + key: KeyEvent,
7 + app: &mut App,
8 + screen: &mut Screen,
9 + api: &MnwApiClient,
10 + tx: &mpsc::Sender<AppEvent>,
11 + ) {
12 + // Cancel pending confirmation on any key other than the confirmation key
13 + if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X')) {
14 + app.confirm_action = None;
15 + app.keys_status = None;
16 + }
17 +
18 + match key.code {
19 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
20 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
21 + KeyCode::Esc => {
22 + if let Screen::Keys(pidx, item_id) = &*screen {
23 + let pidx = *pidx;
24 + let iid = item_id.clone();
25 + *screen = Screen::Item(pidx, iid.clone());
26 + app.license_keys.clear();
27 + app.keys_status = None;
28 + app.selected_index = 0;
29 + app.loading = true;
30 +
31 + let api = api.clone();
32 + let user_id = app.user.user_id.clone();
33 + let tx = tx.clone();
34 + tokio::spawn(async move {
35 + load_item_detail(&api, &user_id, &iid, &tx).await;
36 + });
37 + }
38 + }
39 + KeyCode::Char('g') | KeyCode::Char('G') => {
40 + if let Screen::Keys(_, item_id) = &*screen {
41 + let item_id = item_id.clone();
42 + let api = api.clone();
43 + let user_id = app.user.user_id.clone();
44 + let tx = tx.clone();
45 + app.keys_status = Some("Generating...".to_string());
46 + let iid = item_id.clone();
47 + tokio::spawn(async move {
48 + match api.generate_license_key(&user_id, &item_id).await {
49 + Ok(key) => {
50 + let _ = tx
51 + .send(AppEvent::DataLoaded(DataPayload::GenericSuccess {
52 + message: format!("Generated: {}", key.key_code),
53 + }))
54 + .await;
55 + load_license_keys(&api, &user_id, &iid, &tx).await;
56 + }
57 + Err(e) => {
58 + let _ = tx
59 + .send(AppEvent::DataLoaded(DataPayload::GenericError {
60 + error: e.to_string(),
61 + }))
62 + .await;
63 + }
64 + }
65 + });
66 + }
67 + }
68 + KeyCode::Char('x') | KeyCode::Char('X') => {
69 + if !app.license_keys.is_empty() {
70 + let idx = app.selected_index;
71 + if matches!(app.confirm_action, Some(ConfirmAction::RevokeLicenseKey { key_idx }) if key_idx == idx) {
72 + // Confirmed — execute revoke
73 + app.confirm_action = None;
74 + let key_id = app.license_keys[idx].id.clone();
75 + let key_code = app.license_keys[idx].key_code.clone();
76 + let user_id = app.user.user_id.clone();
77 + tracing::info!(
78 + user_id = %user_id,
79 + key_id = %key_id,
80 + key_code = %key_code,
81 + "revoke license key confirmed"
82 + );
83 + if let Screen::Keys(_, item_id) = &*screen {
84 + let item_id = item_id.clone();
85 + let api = api.clone();
86 + let user_id = app.user.user_id.clone();
87 + let tx = tx.clone();
88 + app.keys_status = Some("Revoking...".to_string());
89 + tokio::spawn(async move {
90 + match api.revoke_license_key(&user_id, &key_id).await {
91 + Ok(()) => {
92 + load_license_keys(&api, &user_id, &item_id, &tx).await;
93 + }
94 + Err(e) => {
95 + let _ = tx
96 + .send(AppEvent::DataLoaded(DataPayload::GenericError {
97 + error: e.to_string(),
98 + }))
99 + .await;
100 + }
101 + }
102 + });
103 + }
104 + } else {
105 + // First press — ask for confirmation
106 + app.confirm_action = Some(ConfirmAction::RevokeLicenseKey { key_idx: idx });
107 + app.keys_status = Some(format!(
108 + "Revoke '{}'? Press x again to confirm",
109 + app.license_keys[idx].key_code
110 + ));
111 + }
112 + }
113 + }
114 + KeyCode::Char('r') | KeyCode::Char('R') => {
115 + if let Screen::Keys(_, item_id) = &*screen {
116 + app.loading = true;
117 + let item_id = item_id.clone();
118 + let api = api.clone();
119 + let user_id = app.user.user_id.clone();
120 + let tx = tx.clone();
121 + tokio::spawn(async move {
122 + load_license_keys(&api, &user_id, &item_id, &tx).await;
123 + });
124 + }
125 + }
126 + _ => {}
127 + }
128 + }
@@ -0,0 +1,38 @@
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 + App, AppEvent, BlogCreateStep, ConfirmAction, DataPayload, EditField, PromoCreateStep,
13 + Screen,
14 + };
15 +
16 + mod home;
17 + mod project;
18 + mod upload;
19 + mod item;
20 + mod blog;
21 + mod promo;
22 + mod keys;
23 + mod analytics;
24 + mod settings;
25 + mod collections;
26 + mod tiers;
27 +
28 + pub(crate) use home::*;
29 + pub(crate) use project::*;
30 + pub(crate) use upload::*;
31 + pub(crate) use item::*;
32 + pub(crate) use blog::*;
33 + pub(crate) use promo::*;
34 + pub(crate) use keys::*;
35 + pub(crate) use analytics::*;
36 + pub(crate) use settings::*;
37 + pub(crate) use collections::*;
38 + pub(crate) use tiers::*;
@@ -0,0 +1,199 @@
1 + //! project-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + pub(crate) async fn handle_project_input(
6 + key: KeyEvent,
7 + app: &mut App,
8 + screen: &mut Screen,
9 + api: &MnwApiClient,
10 + tx: &mpsc::Sender<AppEvent>,
11 + ) {
12 + // Handle confirmation dialog
13 + if let Some(ref action) = app.confirm_action {
14 + match key.code {
15 + KeyCode::Char('y') | KeyCode::Char('Y') => {
16 + let action = action.clone();
17 + app.confirm_action = None;
18 + let ids: Vec<String> = app.selected_items.iter()
19 + .filter_map(|&i| app.items.get(i).map(|item| item.id.clone()))
20 + .collect();
21 + let api = api.clone();
22 + let user_id = app.user.user_id.clone();
23 + let tx = tx.clone();
24 + match action {
25 + ConfirmAction::BulkPublish { .. } => {
26 + tokio::spawn(async move { bulk_publish(&api, &user_id, ids, &tx).await; });
27 + }
28 + ConfirmAction::BulkUnpublish { .. } => {
29 + tokio::spawn(async move { bulk_unpublish(&api, &user_id, ids, &tx).await; });
30 + }
31 + ConfirmAction::BulkDelete { .. } => {
32 + tokio::spawn(async move { bulk_delete(&api, &user_id, ids, &tx).await; });
33 + }
34 + _ => {}
35 + }
36 + }
37 + _ => {
38 + app.confirm_action = None;
39 + }
40 + }
41 + return;
42 + }
43 +
44 + match key.code {
45 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
46 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
47 + KeyCode::Char(' ') => {
48 + // Toggle selection
49 + if !app.items.is_empty() {
50 + let idx = app.selected_index;
51 + if app.selected_items.contains(&idx) {
52 + app.selected_items.remove(&idx);
53 + } else {
54 + app.selected_items.insert(idx);
55 + }
56 + }
57 + }
58 + KeyCode::Esc => {
59 + if !app.selected_items.is_empty() {
60 + app.selected_items.clear();
61 + } else {
62 + *screen = Screen::Home;
63 + app.items.clear();
64 + app.selected_index = 0;
65 + }
66 + }
67 + KeyCode::Enter => {
68 + if let Screen::Project(pidx) = screen
69 + && !app.items.is_empty()
70 + {
71 + let item_id = app.items[app.selected_index].id.clone();
72 + let pidx = *pidx;
73 + *screen = Screen::Item(pidx, item_id.clone());
74 + app.item_detail = None;
75 + app.item_versions.clear();
76 + app.item_status = None;
77 + app.item_editing = None;
78 + app.selected_items.clear();
79 + app.selected_index = 0;
80 + app.loading = true;
81 +
82 + let api = api.clone();
83 + let user_id = app.user.user_id.clone();
84 + let tx = tx.clone();
85 + tokio::spawn(async move {
86 + load_item_detail(&api, &user_id, &item_id, &tx).await;
87 + });
88 + }
89 + }
90 + KeyCode::Char('p') | KeyCode::Char('P') => {
91 + if !app.items.is_empty() {
92 + if app.selected_items.is_empty() {
93 + // Single item: toggle publish state
94 + let item = &app.items[app.selected_index];
95 + let item_id = item.id.clone();
96 + let is_public = item.is_public;
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 + let result = if is_public {
102 + api.unpublish_item(&user_id, &item_id).await
103 + } else {
104 + api.publish_item(&user_id, &item_id).await
105 + };
106 + let msg = match result {
107 + Ok(_) => if is_public { "Unpublished" } else { "Published" }.to_string(),
108 + Err(e) => format!("Error: {}", crate::commands::sanitize_api_error(&e)),
109 + };
110 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::BulkActionComplete { message: msg })).await;
111 + });
112 + } else {
113 + // Bulk: check if majority are draft → publish, else unpublish
114 + let draft_count = app.selected_items.iter()
115 + .filter(|&&i| app.items.get(i).is_some_and(|item| !item.is_public))
116 + .count();
117 + let count = app.selected_items.len();
118 + if draft_count > count / 2 {
119 + app.confirm_action = Some(ConfirmAction::BulkPublish { count });
120 + } else {
121 + app.confirm_action = Some(ConfirmAction::BulkUnpublish { count });
122 + }
123 + }
124 + }
125 + }
126 + KeyCode::Char('d') | KeyCode::Char('D') => {
127 + if !app.items.is_empty() {
128 + if app.selected_items.is_empty() {
129 + // Select current item for single delete
130 + app.selected_items.insert(app.selected_index);
131 + }
132 + let count = app.selected_items.len();
133 + app.confirm_action = Some(ConfirmAction::BulkDelete { count });
134 + }
135 + }
136 + KeyCode::Char('b') | KeyCode::Char('B') => {
137 + if let Screen::Project(idx) = screen
138 + && let Some(p) = app.projects.get(*idx)
139 + {
140 + let pidx = *idx;
141 + let project_id = p.id.clone();
142 + let project_title = p.title.clone();
143 + *screen = Screen::Blog(pidx, project_id.clone());
144 + app.blog_posts.clear();
145 + app.blog_project_title = Some(project_title);
146 + app.blog_status = None;
147 + app.blog_create_step = None;
148 + app.selected_items.clear();
149 + app.selected_index = 0;
150 + app.loading = true;
151 +
152 + let api = api.clone();
153 + let user_id = app.user.user_id.clone();
154 + let tx = tx.clone();
155 + tokio::spawn(async move {
156 + load_blog_posts(&api, &user_id, &project_id, &tx).await;
157 + });
158 + }
159 + }
160 + KeyCode::Char('t') | KeyCode::Char('T') => {
161 + if let Screen::Project(idx) = screen
162 + && let Some(p) = app.projects.get(*idx)
163 + {
164 + let pidx = *idx;
165 + let project_id = p.id.clone();
166 + let project_title = p.title.clone();
167 + *screen = Screen::Tiers(pidx, project_id.clone());
168 + app.tiers.clear();
169 + app.tiers_project_title = Some(project_title);
170 + app.tiers_status = None;
171 + app.selected_items.clear();
172 + app.selected_index = 0;
173 + app.loading = true;
174 +
175 + let api = api.clone();
176 + let user_id = app.user.user_id.clone();
177 + let tx = tx.clone();
178 + tokio::spawn(async move {
179 + load_tiers(&api, &user_id, &project_id, &tx).await;
180 + });
181 + }
182 + }
183 + KeyCode::Char('r') | KeyCode::Char('R') => {
184 + if let Screen::Project(idx) = screen
185 + && let Some(p) = app.projects.get(*idx)
186 + {
187 + app.loading = true;
188 + let api = api.clone();
189 + let project_id = p.id.clone();
190 + let user_id = app.user.user_id.clone();
191 + let tx = tx.clone();
192 + tokio::spawn(async move {
193 + load_project_items(&api, &project_id, &user_id, &tx).await;
194 + });
195 + }
196 + }
197 + _ => {}
198 + }
199 + }
@@ -0,0 +1,156 @@
1 + //! promo-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + pub(crate) async fn handle_promo_input(
6 + key: KeyEvent,
7 + app: &mut App,
8 + screen: &mut Screen,
9 + api: &MnwApiClient,
10 + tx: &mpsc::Sender<AppEvent>,
11 + ) {
12 + // Cancel pending confirmation on any key other than the confirmation key
13 + if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
14 + app.confirm_action = None;
15 + app.promo_status = None;
16 + }
17 +
18 + // Creating mode
19 + if let Some(step) = app.promo_editing_step {
20 + match key.code {
21 + KeyCode::Esc => {
22 + app.promo_editing_step = None;
23 + app.promo_create_code.clear();
24 + app.promo_create_discount.clear();
25 + app.edit_buffer.clear();
26 + app.promo_status = None;
27 + }
28 + KeyCode::Enter => match step {
29 + PromoCreateStep::Code => {
30 + if !app.edit_buffer.is_empty() {
31 + app.promo_create_code = app.edit_buffer.clone();
32 + app.edit_buffer.clear();
33 + app.promo_editing_step = Some(PromoCreateStep::Discount);
34 + app.promo_status = Some("Discount % (e.g. 25): _".to_string());
35 + }
36 + }
37 + PromoCreateStep::Discount => {
38 + let code = app.promo_create_code.clone();
39 + let discount: i32 = app.edit_buffer.parse().unwrap_or(0);
40 +
41 + let api = api.clone();
42 + let user_id = app.user.user_id.clone();
43 + let tx = tx.clone();
44 + app.promo_status = Some("Creating...".to_string());
45 +
46 + tokio::spawn(async move {
47 + match api
48 + .create_promo_code(
49 + &user_id,
50 + &code,
51 + "percentage",
52 + discount,
53 + None,
54 + None,
55 + )
56 + .await
57 + {
58 + Ok(_) => {
59 + load_promo_codes(&api, &user_id, &tx).await;
60 + }
61 + Err(e) => {
62 + let _ = tx
63 + .send(AppEvent::DataLoaded(DataPayload::GenericError {
64 + error: e.to_string(),
65 + }))
66 + .await;
67 + }
68 + }
69 + });
70 +
71 + app.promo_editing_step = None;
72 + app.promo_create_code.clear();
73 + app.promo_create_discount.clear();
74 + app.edit_buffer.clear();
75 + }
76 + },
77 + KeyCode::Backspace => {
78 + app.edit_buffer.pop();
79 + }
80 + KeyCode::Char(c) => {
81 + app.edit_buffer.push(c);
82 + }
83 + _ => {}
84 + }
85 + return;
86 + }
87 +
88 + // Normal mode
89 + match key.code {
90 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
91 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
92 + KeyCode::Esc => {
93 + *screen = Screen::Home;
94 + app.promo_codes.clear();
95 + app.promo_status = None;
96 + app.selected_index = 0;
97 + }
98 + KeyCode::Char('n') | KeyCode::Char('N') => {
99 + app.promo_editing_step = Some(PromoCreateStep::Code);
100 + app.edit_buffer.clear();
101 + app.promo_status = Some("Code: _".to_string());
102 + }
103 + KeyCode::Char('d') | KeyCode::Char('D') => {
104 + if !app.promo_codes.is_empty() {
105 + let idx = app.selected_index;
106 + if matches!(app.confirm_action, Some(ConfirmAction::DeletePromoCode { code_idx }) if code_idx == idx) {
107 + // Confirmed — execute delete
108 + app.confirm_action = None;
109 + let code_id = app.promo_codes[idx].id.clone();
110 + let code_value = app.promo_codes[idx].code.clone();
111 + let user_id = app.user.user_id.clone();
112 + tracing::info!(
113 + user_id = %user_id,
114 + code_id = %code_id,
115 + code = %code_value,
116 + "delete promo code confirmed"
117 + );
118 + let api = api.clone();
119 + let tx = tx.clone();
120 + app.promo_status = Some("Deleting...".to_string());
121 + tokio::spawn(async move {
122 + match api.delete_promo_code(&user_id, &code_id).await {
123 + Ok(()) => {
124 + load_promo_codes(&api, &user_id, &tx).await;
125 + }
126 + Err(e) => {
127 + let _ = tx
128 + .send(AppEvent::DataLoaded(DataPayload::GenericError {
129 + error: e.to_string(),
130 + }))
131 + .await;
132 + }
133 + }
134 + });
135 + } else {
136 + // First press — ask for confirmation
137 + app.confirm_action = Some(ConfirmAction::DeletePromoCode { code_idx: idx });
138 + app.promo_status = Some(format!(
139 + "Delete '{}'? Press d again to confirm",
140 + app.promo_codes[idx].code
141 + ));
142 + }
143 + }
144 + }
145 + KeyCode::Char('r') | KeyCode::Char('R') => {
146 + app.loading = true;
147 + let api = api.clone();
148 + let user_id = app.user.user_id.clone();
149 + let tx = tx.clone();
150 + tokio::spawn(async move {
151 + load_promo_codes(&api, &user_id, &tx).await;
152 + });
153 + }
154 + _ => {}
155 + }
156 + }
@@ -0,0 +1,33 @@
1 + //! settings-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + pub(crate) async fn handle_settings_input(
6 + key: KeyEvent,
7 + app: &mut App,
8 + screen: &mut Screen,
9 + api: &MnwApiClient,
10 + tx: &mpsc::Sender<AppEvent>,
11 + ) {
12 + match key.code {
13 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
14 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
15 + KeyCode::Esc => {
16 + *screen = Screen::Home;
17 + app.ssh_keys.clear();
18 + app.settings_status = None;
19 + app.selected_index = 0;
20 + }
21 + KeyCode::Char('r') | KeyCode::Char('R') => {
22 + app.loading = true;
23 + app.settings_status = None;
24 + let api = api.clone();
25 + let user_id = app.user.user_id.clone();
26 + let tx = tx.clone();
27 + tokio::spawn(async move {
28 + load_settings(&api, &user_id, &tx).await;
29 + });
30 + }
31 + _ => {}
32 + }
33 + }
@@ -0,0 +1,25 @@
1 + //! tiers-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + pub(crate) async fn handle_tiers_input(
6 + key: KeyEvent,
7 + app: &mut App,
8 + screen: &mut Screen,
9 + ) {
10 + match key.code {
11 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
12 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
13 + KeyCode::Esc => {
14 + if let Screen::Tiers(pidx, _) = screen {
15 + let pidx = *pidx;
16 + *screen = Screen::Project(pidx);
17 + app.tiers.clear();
18 + app.tiers_project_title = None;
19 + app.tiers_status = None;
20 + app.selected_index = 0;
21 + }
22 + }
23 + _ => {}
24 + }
25 + }
@@ -0,0 +1,239 @@
1 + //! upload-screen key-input handler (split out of tui/input.rs).
2 +
3 + use super::*;
4 +
5 + /// Save the current value of an upload edit field.
6 + fn save_upload_field(app: &mut App, field: EditField, idx: usize) {
7 + match field {
8 + EditField::Title => {
9 + if !app.edit_buffer.is_empty() {
10 + app.file_metadata[idx].title = Some(app.edit_buffer.clone());
11 + }
12 + }
13 + EditField::Project => {
14 + if let Ok(n) = app.edit_buffer.parse::<usize>()
15 + && n > 0 && n <= app.projects.len()
16 + {
17 + let pidx = n - 1;
18 + app.file_metadata[idx].project_idx = Some(pidx);
19 + app.file_metadata[idx].project_name = Some(app.projects[pidx].title.clone());
20 + }
21 + }
22 + EditField::Price => {
23 + let cents = crate::tui::parse_price(&app.edit_buffer);
24 + app.file_metadata[idx].price_cents = cents;
25 + }
26 + }
27 + }
28 + /// Generate the status prompt for an upload edit field.
29 + fn upload_field_prompt(field: EditField, app: &App) -> String {
30 + match field {
31 + EditField::Title => "Title: _ (Enter to save, Tab for next field, Esc to cancel)".to_string(),
32 + EditField::Project => {
33 + let project_list = app
34 + .projects
35 + .iter()
36 + .enumerate()
37 + .map(|(i, p)| format!("{}:{}", i + 1, p.title))
38 + .collect::<Vec<_>>()
39 + .join(" ");
40 + format!("Project #: _ | {}", project_list)
41 + }
42 + EditField::Price => "Price ($): _ (0 or empty for free)".to_string(),
43 + }
44 + }
45 + pub(crate) async fn handle_upload_input(
46 + key: KeyEvent,
47 + app: &mut App,
48 + screen: &mut Screen,
49 + api: &MnwApiClient,
50 + tx: &mpsc::Sender<AppEvent>,
51 + staging_dir: &Path,
52 + ) {
53 + // Handle editing mode
54 + if let Some(field) = app.editing_field {
55 + match key.code {
56 + KeyCode::Esc => {
57 + app.editing_field = None;
58 + app.edit_buffer.clear();
59 + app.upload_status = None;
60 + }
61 + KeyCode::Tab => {
62 + // Save current field and advance to next
63 + let idx = app.selected_index;
64 + if idx < app.file_metadata.len() {
65 + save_upload_field(app, field, idx);
66 + }
67 + let next = match field {
68 + EditField::Title => EditField::Project,
69 + EditField::Project => EditField::Price,
70 + EditField::Price => EditField::Title,
71 + };
72 + app.editing_field = Some(next);
73 + app.edit_buffer.clear();
74 + app.upload_status = Some(upload_field_prompt(next, app));
75 + return;
76 + }
77 + KeyCode::Enter => {
78 + // Save current field and exit edit mode
79 + let idx = app.selected_index;
80 + if idx < app.file_metadata.len() {
81 + save_upload_field(app, field, idx);
82 + }
83 + app.editing_field = None;
84 + app.edit_buffer.clear();
85 + app.upload_status = None;
86 + }
87 + KeyCode::Backspace => {
88 + app.edit_buffer.pop();
89 + if let Some(field) = app.editing_field {
90 + app.upload_status = Some(crate::tui::format_edit_prompt(field, &app.edit_buffer));
91 + }
92 + }
93 + KeyCode::Char(c) => {
94 + app.edit_buffer.push(c);
95 + if let Some(field) = app.editing_field {
96 + app.upload_status = Some(crate::tui::format_edit_prompt(field, &app.edit_buffer));
97 + }
98 + }
99 + _ => {}
100 + }
101 + return;
102 + }
103 +
104 + // Cancel pending delete confirmation on non-d keys
105 + if !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
106 + if app.upload_status.as_ref().is_some_and(|s| s.starts_with("Delete '") && s.ends_with("'? Press d again")) {
107 + app.upload_status = None;
108 + }
109 + }
110 +
111 + // Normal mode
112 + match key.code {
113 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
114 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
115 + KeyCode::Esc => {
116 + *screen = Screen::Home;
117 + app.staged_files.clear();
118 + app.file_metadata.clear();
119 + app.upload_status = None;
120 + app.selected_index = 0;
121 + }
122 + KeyCode::Char('e') | KeyCode::Char('E') => {
123 + if !app.staged_files.is_empty() {
124 + let idx = app.selected_index;
125 + let current_title = app.file_metadata.get(idx).and_then(|m| m.title.clone());
126 + app.editing_field = Some(EditField::Title);
127 + app.edit_buffer = current_title.unwrap_or_default();
128 + app.upload_status =
129 + Some("Title: _ (Enter to save, Tab for next field, Esc to cancel)".to_string());
130 + }
131 + }
132 + KeyCode::Char('p') | KeyCode::Char('P') => {
133 + if !app.staged_files.is_empty() && !app.publishing {
134 + let idx = app.selected_index;
135 + let file = &app.staged_files[idx];
136 + let meta = app.file_metadata.get(idx).cloned().unwrap_or_default();
137 +
138 + // Validate
139 + let Some(classification) = file.classification else {
140 + app.upload_status = Some(
141 + "Unsupported file type. Supported: mp3, wav, flac, ogg, m4a, aac, zip, dmg, exe, appimage, deb, clap, vst3".to_string()
142 + );
143 + return;
144 + };
145 + let Some(project_idx) = meta.project_idx else {
146 + app.upload_status =
147 + Some("Set project first (press [e] to edit)".to_string());
148 + return;
149 + };
150 + let Some(project) = app.projects.get(project_idx) else {
151 + app.upload_status = Some("Invalid project".to_string());
152 + return;
153 + };
154 +
155 + let title = meta
156 + .title
157 + .unwrap_or_else(|| crate::staging::derive_title(&file.filename));
158 + let project_id = project.id.clone();
159 + let user_id = app.user.user_id.clone();
160 + let filename = file.filename.clone();
161 + let file_path = staging_dir.join(&filename);
162 + let price_cents = meta.price_cents;
163 + let item_type = classification.item_type.to_string();
164 + let file_type = classification.file_type.to_string();
165 + let content_type = classification.content_type.to_string();
166 + let api = api.clone();
167 + let tx = tx.clone();
168 +
169 + app.publishing = true;
170 + app.upload_status = Some(format!("Publishing {}...", filename));
171 +
172 + tokio::spawn(async move {
173 + let result = publish_file(
174 + &api,
175 + &user_id,
176 + &project_id,
177 + &title,
178 + &item_type,
179 + &file_type,
180 + &filename,
181 + &content_type,
182 + price_cents,
183 + &file_path,
184 + )
185 + .await;
186 +
187 + let (success, error) = match result {
188 + Ok(()) => (true, None),
189 + Err(e) => (false, Some(e.to_string())),
190 + };
191 +
192 + let _ = tx
193 + .send(AppEvent::DataLoaded(DataPayload::PublishResult {
194 + filename,
195 + success,
196 + error,
197 + }))
198 + .await;
199 + });
200 + }
201 + }
202 + KeyCode::Char('d') | KeyCode::Char('D') => {
203 + if !app.staged_files.is_empty() {
204 + let idx = app.selected_index;
205 + let filename = &app.staged_files[idx].filename;
206 + if app.upload_status.as_ref().is_some_and(|s| s.starts_with("Delete '") && s.ends_with("'? Press d again")) {
207 + // Confirmed — execute delete
208 + let file_path = staging_dir.join(filename);
209 + let staging_dir = staging_dir.to_path_buf();
210 + let api = api.clone();
211 + let user_id = app.user.user_id.clone();
212 + let tx = tx.clone();
213 +
214 + app.upload_status = Some(format!("Deleting {}...", filename));
215 + tokio::spawn(async move {
216 + if let Err(e) = tokio::fs::remove_file(&file_path).await {
217 + tracing::warn!(error = %e, "failed to delete staged file");
218 + }
219 + load_staged_files(&staging_dir, &api, &user_id, &tx).await;
220 + });
221 + } else {
222 + // First press — ask for confirmation
223 + app.upload_status = Some(format!("Delete '{}'? Press d again", filename));
224 + }
225 + }
226 + }
227 + KeyCode::Char('r') | KeyCode::Char('R') => {
228 + app.loading = true;
229 + let staging_dir = staging_dir.to_path_buf();
230 + let api = api.clone();
231 + let user_id = app.user.user_id.clone();
232 + let tx = tx.clone();
233 + tokio::spawn(async move {
234 + load_staged_files(&staging_dir, &api, &user_id, &tx).await;
235 + });
236 + }
237 + _ => {}
238 + }
239 + }