max / makenotwork
9 files changed,
+350 insertions,
-75 deletions
| @@ -1,7 +1,22 @@ | |||
| 1 | 1 | # mnw-cli TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | Done: Phases 1-8, Git proxy A-D (except D5). Active: None. Next: D5 DNS, PoM health check. | |
| 4 | + | Done: Phases 1-8, Git proxy A-D (except D5), UX audit fixes (8/8). Active: None. Next: D5 DNS, PoM health check, deploy. | |
| 5 | + | ||
| 6 | + | --- | |
| 7 | + | ||
| 8 | + | ## UX Audit Fixes (2026-05-02) | |
| 9 | + | ||
| 10 | + | Priority order (highest impact first): | |
| 11 | + | ||
| 12 | + | - [x] Fix broadcast argument parsing — use `--subject`/`--body` flags instead of positional args | |
| 13 | + | - [x] Add confirmation to upload file deletion — double-press d to confirm, consistent with other destructive actions | |
| 14 | + | - [x] Improve blog post body input — Enter inserts newline, Ctrl+D advances to schedule step | |
| 15 | + | - [x] Add project creation — `ssh cli.makenot.work project create --title "X" --type audio` + server endpoint | |
| 16 | + | - [x] Show supported file types on upload rejection — lists all supported extensions in error message | |
| 17 | + | - [x] Allow direct field editing on upload screen — Enter saves current field, Tab advances to next | |
| 18 | + | - [x] Add empty state guidance — Home + Collections screens now point to makenot.work/dashboard | |
| 19 | + | - [x] Document git hosting in help text — added Git hosting and SFTP sections to help output | |
| 5 | 20 | ||
| 6 | 21 | --- | |
| 7 | 22 |
| @@ -359,6 +359,34 @@ impl MnwApiClient { | |||
| 359 | 359 | json_response(resp, "get_projects").await | |
| 360 | 360 | } | |
| 361 | 361 | ||
| 362 | + | /// Create a new project. | |
| 363 | + | pub async fn create_project( | |
| 364 | + | &self, | |
| 365 | + | user_id: &str, | |
| 366 | + | title: &str, | |
| 367 | + | project_type: &str, | |
| 368 | + | description: Option<&str>, | |
| 369 | + | ) -> anyhow::Result<Project> { | |
| 370 | + | let url = format!("{}/api/internal/creator/projects", self.base_url); | |
| 371 | + | let mut body = serde_json::json!({ | |
| 372 | + | "user_id": user_id, | |
| 373 | + | "title": title, | |
| 374 | + | "project_type": project_type, | |
| 375 | + | }); | |
| 376 | + | if let Some(desc) = description { | |
| 377 | + | body["description"] = serde_json::Value::String(desc.to_string()); | |
| 378 | + | } | |
| 379 | + | let resp = self | |
| 380 | + | .http | |
| 381 | + | .post(&url) | |
| 382 | + | .bearer_auth(&self.service_token) | |
| 383 | + | .json(&body) | |
| 384 | + | .send() | |
| 385 | + | .await?; | |
| 386 | + | ||
| 387 | + | json_response(resp, "create_project").await | |
| 388 | + | } | |
| 389 | + | ||
| 362 | 390 | /// Fetch items in a project. | |
| 363 | 391 | pub async fn get_project_items( | |
| 364 | 392 | &self, |
| @@ -43,6 +43,15 @@ pub async fn execute( | |||
| 43 | 43 | let parts: Vec<&str> = parts.into_iter().filter(|p| *p != "--json").collect(); | |
| 44 | 44 | ||
| 45 | 45 | match parts[0] { | |
| 46 | + | "project" => match parts.get(1).copied() { | |
| 47 | + | Some("create") => { | |
| 48 | + | let title = extract_flag(&parts, &["--title", "-t"]).unwrap_or_default(); | |
| 49 | + | let ptype = extract_flag(&parts, &["--type"]).unwrap_or_else(|| "digital".to_string()); | |
| 50 | + | let desc = extract_flag(&parts, &["--description", "--desc", "-d"]); | |
| 51 | + | cmd_project_create(user, api, &title, &ptype, desc.as_deref()).await | |
| 52 | + | } | |
| 53 | + | _ => b"Usage: project create --title \"Name\" [--type audio|digital|video|mixed|subscription] [--description \"...\"]\r\n".to_vec(), | |
| 54 | + | }, | |
| 46 | 55 | "upload" => { | |
| 47 | 56 | return b"Pipe uploads use stdin. Example:\r\n cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-project\r\n".to_vec(); | |
| 48 | 57 | } | |
| @@ -73,9 +82,9 @@ pub async fn execute( | |||
| 73 | 82 | _ => b"Usage: blog list <project-slug>\r\n".to_vec(), | |
| 74 | 83 | }, | |
| 75 | 84 | "broadcast" => { | |
| 76 | - | let subject = parts.get(1).unwrap_or(&""); | |
| 77 | - | let body = parts.get(2).unwrap_or(&""); | |
| 78 | - | cmd_broadcast(user, api, subject, body).await | |
| 85 | + | let subject = extract_flag(&parts, &["--subject", "-s"]).unwrap_or_default(); | |
| 86 | + | let body = extract_flag(&parts, &["--body", "-b"]).unwrap_or_default(); | |
| 87 | + | cmd_broadcast(user, api, &subject, &body).await | |
| 79 | 88 | } | |
| 80 | 89 | "collections" => cmd_collections(user, api, json).await, | |
| 81 | 90 | "domain" => match parts.get(1).copied() { | |
| @@ -93,6 +102,28 @@ pub async fn execute( | |||
| 93 | 102 | } | |
| 94 | 103 | } | |
| 95 | 104 | ||
| 105 | + | async fn cmd_project_create( | |
| 106 | + | user: &UserInfo, | |
| 107 | + | api: &MnwApiClient, | |
| 108 | + | title: &str, | |
| 109 | + | project_type: &str, | |
| 110 | + | description: Option<&str>, | |
| 111 | + | ) -> Vec<u8> { | |
| 112 | + | if title.is_empty() { | |
| 113 | + | return b"Usage: project create --title \"Name\" [--type audio|digital|video|mixed|subscription] [--description \"...\"]\r\n".to_vec(); | |
| 114 | + | } | |
| 115 | + | if !user.can_create_projects { | |
| 116 | + | return b"Error: your account cannot create projects. Upgrade your tier at makenot.work/pricing\r\n".to_vec(); | |
| 117 | + | } | |
| 118 | + | match api.create_project(&user.user_id, title, project_type, description).await { | |
| 119 | + | Ok(p) => format!( | |
| 120 | + | "Created project: {} (slug: {}, type: {})\r\n", | |
| 121 | + | p.title, p.slug, p.project_type | |
| 122 | + | ).into_bytes(), | |
| 123 | + | Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(), | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 96 | 127 | async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> { | |
| 97 | 128 | match api.get_projects(&user.user_id).await { | |
| 98 | 129 | Ok(projects) => { | |
| @@ -326,7 +357,9 @@ async fn cmd_blog_list( | |||
| 326 | 357 | ||
| 327 | 358 | async fn cmd_broadcast(user: &UserInfo, api: &MnwApiClient, subject: &str, body: &str) -> Vec<u8> { | |
| 328 | 359 | if subject.is_empty() || body.is_empty() { | |
| 329 | - | return b"Usage: broadcast \"Subject\" \"Body text\"\r\n".to_vec(); | |
| 360 | + | return b"Usage: broadcast --subject \"Subject line\" --body \"Body text\"\r\n\ | |
| 361 | + | \x20 Aliases: -s, -b\r\n\ | |
| 362 | + | \x20 Sends an email to all your followers (1/24h limit).\r\n".to_vec(); | |
| 330 | 363 | } | |
| 331 | 364 | match api.send_broadcast(&user.user_id, subject, body).await { | |
| 332 | 365 | Ok(result) => format!("Broadcast sent to {} followers.\r\n", result.recipient_count).into_bytes(), | |
| @@ -425,7 +458,9 @@ pub async fn execute_pipe_upload( | |||
| 425 | 458 | ||
| 426 | 459 | let ext = upload.filename.rsplit('.').next().unwrap_or("").to_lowercase(); | |
| 427 | 460 | let classification = staging::classify_extension(&ext) | |
| 428 | - | .ok_or_else(|| anyhow::anyhow!("unsupported file type: .{ext}"))?; | |
| 461 | + | .ok_or_else(|| anyhow::anyhow!( | |
| 462 | + | "unsupported file type: .{ext}\r\nSupported: mp3, wav, flac, ogg, m4a, aac, zip, dmg, exe, appimage, deb, clap, vst3" | |
| 463 | + | ))?; | |
| 429 | 464 | ||
| 430 | 465 | // Find project by slug | |
| 431 | 466 | let projects = api.get_projects(&user.user_id).await?; | |
| @@ -487,28 +522,68 @@ fn help_text() -> Vec<u8> { | |||
| 487 | 522 | \r\n\ | |
| 488 | 523 | Commands:\r\n\ | |
| 489 | 524 | \x20 projects List your projects\r\n\ | |
| 525 | + | \x20 project create [opts] Create a new project\r\n\ | |
| 490 | 526 | \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\ | |
| 491 | 527 | \x20 transactions Recent transactions\r\n\ | |
| 492 | 528 | \x20 export sales Export sales as CSV\r\n\ | |
| 493 | 529 | \x20 promo list List promo codes\r\n\ | |
| 494 | 530 | \x20 promo create CODE PCT Create a promo code\r\n\ | |
| 495 | 531 | \x20 blog list SLUG List blog posts for project\r\n\ | |
| 496 | - | \x20 broadcast SUBJ BODY Email followers (1/24h limit)\r\n\ | |
| 532 | + | \x20 broadcast -s SUBJ -b BODY Email followers (1/24h limit)\r\n\ | |
| 497 | 533 | \x20 collections List your collections\r\n\ | |
| 498 | 534 | \x20 domain Show custom domain\r\n\ | |
| 499 | 535 | \x20 domain add DOMAIN Add a custom domain\r\n\ | |
| 500 | 536 | \x20 domain verify Verify DNS record\r\n\ | |
| 501 | 537 | \x20 domain remove Remove custom domain\r\n\ | |
| 502 | - | \x20 upload [args] Pipe upload (see upload --help)\r\n\ | |
| 538 | + | \x20 upload [args] Pipe upload (see below)\r\n\ | |
| 503 | 539 | \r\n\ | |
| 504 | 540 | Add --json to any command for machine-readable output.\r\n\ | |
| 505 | 541 | \r\n\ | |
| 506 | 542 | Pipe uploads:\r\n\ | |
| 507 | 543 | \x20 cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-slug\r\n\ | |
| 508 | - | \x20 Options: --filename/-f NAME --project/-p SLUG [--title/-t TITLE] [--price CENTS]\r\n" | |
| 544 | + | \x20 Options: --filename/-f NAME --project/-p SLUG [--title/-t TITLE] [--price CENTS]\r\n\ | |
| 545 | + | \r\n\ | |
| 546 | + | File uploads (SFTP):\r\n\ | |
| 547 | + | \x20 scp file.wav cli.makenot.work:upload/\r\n\ | |
| 548 | + | \x20 Then publish via the interactive TUI (ssh cli.makenot.work)\r\n\ | |
| 549 | + | \r\n\ | |
| 550 | + | Git hosting:\r\n\ | |
| 551 | + | \x20 git remote add mnw cli.makenot.work:username/repo-name\r\n\ | |
| 552 | + | \x20 git push mnw main\r\n" | |
| 509 | 553 | .to_vec() | |
| 510 | 554 | } | |
| 511 | 555 | ||
| 556 | + | /// Extract a flag value from command parts. Supports both `--flag value` and `-f value`. | |
| 557 | + | /// Handles quoted values that were split by whitespace by rejoining until the closing quote. | |
| 558 | + | fn extract_flag(parts: &[&str], flags: &[&str]) -> Option<String> { | |
| 559 | + | for (i, part) in parts.iter().enumerate() { | |
| 560 | + | if flags.contains(part) { | |
| 561 | + | if let Some(&next) = parts.get(i + 1) { | |
| 562 | + | // If the value starts with a quote, collect until closing quote | |
| 563 | + | if next.starts_with('"') || next.starts_with('\'') { | |
| 564 | + | let quote = next.as_bytes()[0] as char; | |
| 565 | + | let stripped = &next[1..]; | |
| 566 | + | if stripped.ends_with(quote) { | |
| 567 | + | return Some(stripped[..stripped.len() - 1].to_string()); | |
| 568 | + | } | |
| 569 | + | let mut collected = stripped.to_string(); | |
| 570 | + | for &subsequent in &parts[i + 2..] { | |
| 571 | + | collected.push(' '); | |
| 572 | + | if subsequent.ends_with(quote) { | |
| 573 | + | collected.push_str(&subsequent[..subsequent.len() - 1]); | |
| 574 | + | return Some(collected); | |
| 575 | + | } | |
| 576 | + | collected.push_str(subsequent); | |
| 577 | + | } | |
| 578 | + | return Some(collected); | |
| 579 | + | } | |
| 580 | + | return Some(next.to_string()); | |
| 581 | + | } | |
| 582 | + | } | |
| 583 | + | } | |
| 584 | + | None | |
| 585 | + | } | |
| 586 | + | ||
| 512 | 587 | fn truncate(s: &str, max_len: usize) -> &str { | |
| 513 | 588 | if s.len() <= max_len { | |
| 514 | 589 | s | |
| @@ -549,4 +624,33 @@ mod tests { | |||
| 549 | 624 | let result = truncate("🎵🎶🎸", 5); | |
| 550 | 625 | assert_eq!(result, "🎵"); | |
| 551 | 626 | } | |
| 627 | + | ||
| 628 | + | #[test] | |
| 629 | + | fn extract_flag_simple() { | |
| 630 | + | let parts = vec!["broadcast", "--subject", "Hello", "--body", "World"]; | |
| 631 | + | assert_eq!(extract_flag(&parts, &["--subject", "-s"]), Some("Hello".to_string())); | |
| 632 | + | assert_eq!(extract_flag(&parts, &["--body", "-b"]), Some("World".to_string())); | |
| 633 | + | } | |
| 634 | + | ||
| 635 | + | #[test] | |
| 636 | + | fn extract_flag_short() { | |
| 637 | + | let parts = vec!["broadcast", "-s", "Hi", "-b", "There"]; | |
| 638 | + | assert_eq!(extract_flag(&parts, &["--subject", "-s"]), Some("Hi".to_string())); | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | #[test] | |
| 642 | + | fn extract_flag_quoted_multiword() { | |
| 643 | + | let parts = vec!["broadcast", "--subject", "\"Hello", "everyone\"", "--body", "\"text\""]; | |
| 644 | + | assert_eq!( | |
| 645 | + | extract_flag(&parts, &["--subject", "-s"]), | |
| 646 | + | Some("Hello everyone".to_string()) | |
| 647 | + | ); | |
| 648 | + | assert_eq!(extract_flag(&parts, &["--body", "-b"]), Some("text".to_string())); | |
| 649 | + | } | |
| 650 | + | ||
| 651 | + | #[test] | |
| 652 | + | fn extract_flag_missing() { | |
| 653 | + | let parts = vec!["broadcast", "--subject", "Hi"]; | |
| 654 | + | assert_eq!(extract_flag(&parts, &["--body", "-b"]), None); | |
| 655 | + | } | |
| 552 | 656 | } |
| @@ -48,7 +48,7 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 48 | 48 | let loading = Paragraph::new(" Loading..."); | |
| 49 | 49 | frame.render_widget(loading, chunks[2]); | |
| 50 | 50 | } else if app.collections.is_empty() { | |
| 51 | - | let empty = Paragraph::new(" No collections."); | |
| 51 | + | let empty = Paragraph::new(" No collections. Manage collections at makenot.work/dashboard"); | |
| 52 | 52 | frame.render_widget(empty, chunks[2]); | |
| 53 | 53 | } else { | |
| 54 | 54 | let rows: Vec<Row> = app |
| @@ -71,7 +71,7 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 71 | 71 | let loading = Paragraph::new(" Loading..."); | |
| 72 | 72 | frame.render_widget(loading, chunks[4]); | |
| 73 | 73 | } else if app.projects.is_empty() { | |
| 74 | - | let empty = Paragraph::new(" No projects yet."); | |
| 74 | + | let empty = Paragraph::new(" No projects yet. Create one at makenot.work/dashboard"); | |
| 75 | 75 | frame.render_widget(empty, chunks[4]); | |
| 76 | 76 | } else { | |
| 77 | 77 | render_project_table(frame, app, chunks[4]); |
| @@ -13,6 +13,48 @@ use super::{ | |||
| 13 | 13 | Screen, | |
| 14 | 14 | }; | |
| 15 | 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 | + | ||
| 16 | 58 | pub(super) async fn handle_home_input( | |
| 17 | 59 | key: KeyEvent, | |
| 18 | 60 | app: &mut App, | |
| @@ -338,50 +380,29 @@ pub(super) async fn handle_upload_input( | |||
| 338 | 380 | KeyCode::Esc => { | |
| 339 | 381 | app.editing_field = None; | |
| 340 | 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; | |
| 341 | 400 | } | |
| 342 | 401 | KeyCode::Enter => { | |
| 402 | + | // Save current field and exit edit mode | |
| 343 | 403 | let idx = app.selected_index; | |
| 344 | 404 | if idx < app.file_metadata.len() { | |
| 345 | - | match field { | |
| 346 | - | EditField::Title => { | |
| 347 | - | if !app.edit_buffer.is_empty() { | |
| 348 | - | app.file_metadata[idx].title = Some(app.edit_buffer.clone()); | |
| 349 | - | } | |
| 350 | - | // Show project selection | |
| 351 | - | app.edit_buffer.clear(); | |
| 352 | - | app.editing_field = Some(EditField::Project); | |
| 353 | - | let project_list = app | |
| 354 | - | .projects | |
| 355 | - | .iter() | |
| 356 | - | .enumerate() | |
| 357 | - | .map(|(i, p)| format!("{}:{}", i + 1, p.title)) | |
| 358 | - | .collect::<Vec<_>>() | |
| 359 | - | .join(" "); | |
| 360 | - | app.upload_status = | |
| 361 | - | Some(format!("Project #: _ | {}", project_list)); | |
| 362 | - | return; | |
| 363 | - | } | |
| 364 | - | EditField::Project => { | |
| 365 | - | if let Ok(n) = app.edit_buffer.parse::<usize>() | |
| 366 | - | && n > 0 && n <= app.projects.len() | |
| 367 | - | { | |
| 368 | - | let pidx = n - 1; | |
| 369 | - | app.file_metadata[idx].project_idx = Some(pidx); | |
| 370 | - | app.file_metadata[idx].project_name = | |
| 371 | - | Some(app.projects[pidx].title.clone()); | |
| 372 | - | } | |
| 373 | - | // Advance to Price field | |
| 374 | - | app.edit_buffer.clear(); | |
| 375 | - | app.editing_field = Some(EditField::Price); | |
| 376 | - | app.upload_status = | |
| 377 | - | Some("Price ($): _ (0 or empty for free)".to_string()); | |
| 378 | - | return; | |
| 379 | - | } | |
| 380 | - | EditField::Price => { | |
| 381 | - | let cents = super::parse_price(&app.edit_buffer); | |
| 382 | - | app.file_metadata[idx].price_cents = cents; | |
| 383 | - | } | |
| 384 | - | } | |
| 405 | + | save_upload_field(app, field, idx); | |
| 385 | 406 | } | |
| 386 | 407 | app.editing_field = None; | |
| 387 | 408 | app.edit_buffer.clear(); | |
| @@ -389,6 +410,9 @@ pub(super) async fn handle_upload_input( | |||
| 389 | 410 | } | |
| 390 | 411 | KeyCode::Backspace => { | |
| 391 | 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 | + | } | |
| 392 | 416 | } | |
| 393 | 417 | KeyCode::Char(c) => { | |
| 394 | 418 | app.edit_buffer.push(c); | |
| @@ -401,6 +425,13 @@ pub(super) async fn handle_upload_input( | |||
| 401 | 425 | return; | |
| 402 | 426 | } | |
| 403 | 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 | + | ||
| 404 | 435 | // Normal mode | |
| 405 | 436 | match key.code { | |
| 406 | 437 | KeyCode::Char('j') | KeyCode::Down => app.move_down(screen), | |
| @@ -419,7 +450,7 @@ pub(super) async fn handle_upload_input( | |||
| 419 | 450 | app.editing_field = Some(EditField::Title); | |
| 420 | 451 | app.edit_buffer = current_title.unwrap_or_default(); | |
| 421 | 452 | app.upload_status = | |
| 422 | - | Some(super::format_edit_prompt(EditField::Title, &app.edit_buffer)); | |
| 453 | + | Some("Title: _ (Enter to save, Tab for next field, Esc to cancel)".to_string()); | |
| 423 | 454 | } | |
| 424 | 455 | } | |
| 425 | 456 | KeyCode::Char('p') | KeyCode::Char('P') => { | |
| @@ -430,7 +461,9 @@ pub(super) async fn handle_upload_input( | |||
| 430 | 461 | ||
| 431 | 462 | // Validate | |
| 432 | 463 | let Some(classification) = file.classification else { | |
| 433 | - | app.upload_status = Some("Unsupported file type".to_string()); | |
| 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 | + | ); | |
| 434 | 467 | return; | |
| 435 | 468 | }; | |
| 436 | 469 | let Some(project_idx) = meta.project_idx else { | |
| @@ -493,20 +526,26 @@ pub(super) async fn handle_upload_input( | |||
| 493 | 526 | KeyCode::Char('d') | KeyCode::Char('D') => { | |
| 494 | 527 | if !app.staged_files.is_empty() { | |
| 495 | 528 | let idx = app.selected_index; | |
| 496 | - | let filename = app.staged_files[idx].filename.clone(); | |
| 497 | - | let file_path = staging_dir.join(&filename); | |
| 498 | - | let staging_dir = staging_dir.to_path_buf(); | |
| 499 | - | let api = api.clone(); | |
| 500 | - | let user_id = app.user.user_id.clone(); | |
| 501 | - | let tx = tx.clone(); | |
| 529 | + | let filename = &app.staged_files[idx].filename; | |
| 530 | + | if app.upload_status.as_ref().is_some_and(|s| s.starts_with("Delete '") && s.ends_with("'? Press d again")) { | |
| 531 | + | // Confirmed — execute delete | |
| 532 | + | let file_path = staging_dir.join(filename); | |
| 533 | + | let staging_dir = staging_dir.to_path_buf(); | |
| 534 | + | let api = api.clone(); | |
| 535 | + | let user_id = app.user.user_id.clone(); | |
| 536 | + | let tx = tx.clone(); | |
| 502 | 537 | ||
| 503 | - | app.upload_status = Some(format!("Deleting {}...", filename)); | |
| 504 | - | tokio::spawn(async move { | |
| 505 | - | if let Err(e) = tokio::fs::remove_file(&file_path).await { | |
| 506 | - | tracing::warn!(error = %e, "failed to delete staged file"); | |
| 507 | - | } | |
| 508 | - | load_staged_files(&staging_dir, &api, &user_id, &tx).await; | |
| 509 | - | }); | |
| 538 | + | app.upload_status = Some(format!("Deleting {}...", filename)); | |
| 539 | + | tokio::spawn(async move { | |
| 540 | + | if let Err(e) = tokio::fs::remove_file(&file_path).await { | |
| 541 | + | tracing::warn!(error = %e, "failed to delete staged file"); | |
| 542 | + | } | |
| 543 | + | load_staged_files(&staging_dir, &api, &user_id, &tx).await; | |
| 544 | + | }); | |
| 545 | + | } else { | |
| 546 | + | // First press — ask for confirmation | |
| 547 | + | app.upload_status = Some(format!("Delete '{}'? Press d again", filename)); | |
| 548 | + | } | |
| 510 | 549 | } | |
| 511 | 550 | } | |
| 512 | 551 | KeyCode::Char('r') | KeyCode::Char('R') => { | |
| @@ -951,6 +990,20 @@ pub(super) async fn handle_blog_input( | |||
| 951 | 990 | ||
| 952 | 991 | // Creating mode | |
| 953 | 992 | if let Some(step) = app.blog_create_step { | |
| 993 | + | // Ctrl+D in body step: advance to schedule | |
| 994 | + | if step == BlogCreateStep::Body | |
| 995 | + | && key.code == KeyCode::Char('d') | |
| 996 | + | && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) | |
| 997 | + | { | |
| 998 | + | app.blog_create_body = app.edit_buffer.clone(); | |
| 999 | + | app.edit_buffer.clear(); | |
| 1000 | + | app.blog_create_step = Some(BlogCreateStep::Schedule); | |
| 1001 | + | app.blog_status = Some( | |
| 1002 | + | "Schedule (YYYY-MM-DDTHH:MM:SSZ or Enter for draft):".to_string(), | |
| 1003 | + | ); | |
| 1004 | + | return; | |
| 1005 | + | } | |
| 1006 | + | ||
| 954 | 1007 | match key.code { | |
| 955 | 1008 | KeyCode::Esc => { | |
| 956 | 1009 | app.blog_create_step = None; | |
| @@ -966,16 +1019,13 @@ pub(super) async fn handle_blog_input( | |||
| 966 | 1019 | app.edit_buffer.clear(); | |
| 967 | 1020 | app.blog_create_step = Some(BlogCreateStep::Body); | |
| 968 | 1021 | app.blog_status = | |
| 969 | - | Some("Body (markdown, Enter when done):".to_string()); | |
| 1022 | + | Some("Body (markdown, Enter for newline, Ctrl+D when done):".to_string()); | |
| 970 | 1023 | } | |
| 971 | 1024 | } | |
| 972 | 1025 | BlogCreateStep::Body => { | |
| 973 | - | app.blog_create_body = app.edit_buffer.clone(); | |
| 974 | - | app.edit_buffer.clear(); | |
| 975 | - | app.blog_create_step = Some(BlogCreateStep::Schedule); | |
| 976 | - | app.blog_status = Some( | |
| 977 | - | "Schedule (YYYY-MM-DDTHH:MM:SSZ or Enter for draft):".to_string(), | |
| 978 | - | ); | |
| 1026 | + | // Enter inserts a newline in body mode | |
| 1027 | + | app.edit_buffer.push('\n'); | |
| 1028 | + | return; | |
| 979 | 1029 | } | |
| 980 | 1030 | BlogCreateStep::Schedule => { | |
| 981 | 1031 | let title = app.blog_create_title.clone(); |
| @@ -100,7 +100,9 @@ pub fn render(frame: &mut Frame, app: &App) { | |||
| 100 | 100 | if app.editing_field.is_some() { | |
| 101 | 101 | key_spans.extend([ | |
| 102 | 102 | Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)), | |
| 103 | - | Span::raw(" Confirm "), | |
| 103 | + | Span::raw(" Save "), | |
| 104 | + | Span::styled("[Tab]", Style::default().add_modifier(Modifier::BOLD)), | |
| 105 | + | Span::raw(" Next field "), | |
| 104 | 106 | Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)), | |
| 105 | 107 | Span::raw(" Cancel"), | |
| 106 | 108 | ]); |
| @@ -466,3 +466,79 @@ fn generate_verification_token() -> String { | |||
| 466 | 466 | rand::RngCore::fill_bytes(&mut rand::rng(), &mut bytes); | |
| 467 | 467 | format!("mnw-verify-{}", hex::encode(bytes)) | |
| 468 | 468 | } | |
| 469 | + | ||
| 470 | + | // ── Project creation ── | |
| 471 | + | ||
| 472 | + | #[derive(Deserialize)] | |
| 473 | + | pub(super) struct CreateProjectRequest { | |
| 474 | + | user_id: UserId, | |
| 475 | + | title: String, | |
| 476 | + | project_type: String, | |
| 477 | + | description: Option<String>, | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | #[derive(Serialize)] | |
| 481 | + | struct CreateProjectResponse { | |
| 482 | + | id: String, | |
| 483 | + | slug: String, | |
| 484 | + | title: String, | |
| 485 | + | project_type: String, | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | /// POST /api/internal/creator/projects | |
| 489 | + | pub(super) async fn create_project( | |
| 490 | + | State(state): State<AppState>, | |
| 491 | + | _auth: ServiceAuth, | |
| 492 | + | Json(req): Json<CreateProjectRequest>, | |
| 493 | + | ) -> Result<impl IntoResponse> { | |
| 494 | + | // Verify user can create projects | |
| 495 | + | let user = db::users::get_user_by_id(&state.db, req.user_id) | |
| 496 | + | .await? | |
| 497 | + | .ok_or(AppError::NotFound)?; | |
| 498 | + | ||
| 499 | + | if !user.can_create_projects { | |
| 500 | + | return Err(AppError::Forbidden); | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | if req.title.is_empty() || req.title.len() > 100 { | |
| 504 | + | return Err(AppError::BadRequest("Title must be 1-100 characters".to_string())); | |
| 505 | + | } | |
| 506 | + | ||
| 507 | + | // Derive features from project_type | |
| 508 | + | let features: Vec<String> = match req.project_type.as_str() { | |
| 509 | + | "audio" => vec!["audio".to_string()], | |
| 510 | + | "digital" => vec!["downloads".to_string()], | |
| 511 | + | "video" => vec!["video".to_string()], | |
| 512 | + | "mixed" => vec!["audio".to_string(), "downloads".to_string()], | |
| 513 | + | "subscription" => vec!["subscriptions".to_string()], | |
| 514 | + | _ => vec!["downloads".to_string()], | |
| 515 | + | }; | |
| 516 | + | ||
| 517 | + | // Generate slug from title | |
| 518 | + | let slug_str: String = req.title | |
| 519 | + | .to_lowercase() | |
| 520 | + | .chars() | |
| 521 | + | .map(|c| if c.is_alphanumeric() || c == ' ' { c } else { ' ' }) | |
| 522 | + | .collect::<String>() | |
| 523 | + | .split_whitespace() | |
| 524 | + | .collect::<Vec<_>>() | |
| 525 | + | .join("-"); | |
| 526 | + | let slug = Slug::from_trusted(if slug_str.is_empty() { "project".to_string() } else { slug_str }); | |
| 527 | + | ||
| 528 | + | let project = db::projects::create_project( | |
| 529 | + | &state.db, | |
| 530 | + | req.user_id, | |
| 531 | + | &slug, | |
| 532 | + | &req.title, | |
| 533 | + | req.description.as_deref(), | |
| 534 | + | &features, | |
| 535 | + | ) | |
| 536 | + | .await?; | |
| 537 | + | ||
| 538 | + | Ok(Json(CreateProjectResponse { | |
| 539 | + | id: project.id.to_string(), | |
| 540 | + | slug: project.slug.to_string(), | |
| 541 | + | title: project.title, | |
| 542 | + | project_type: project.project_type.to_string(), | |
| 543 | + | })) | |
| 544 | + | } |
| @@ -23,7 +23,7 @@ use crate::AppState; | |||
| 23 | 23 | pub(super) fn internal_routes() -> Router<AppState> { | |
| 24 | 24 | Router::new() | |
| 25 | 25 | .route("/api/internal/ssh-key-lookup", get(git::ssh_key_lookup)) | |
| 26 | - | .route("/api/internal/creator/projects", get(creators::creator_projects)) | |
| 26 | + | .route("/api/internal/creator/projects", get(creators::creator_projects).post(cli_features::create_project)) | |
| 27 | 27 | .route("/api/internal/creator/projects/{id}/items", get(creators::creator_project_items)) | |
| 28 | 28 | .route("/api/internal/creator/stats", get(creators::creator_stats)) | |
| 29 | 29 | .route("/api/internal/creator/items", post(items::create_item)) |