max / makenotwork
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 | + | } |