Skip to main content

max / makenotwork

mnw-cli: UX audit fixes + project create endpoint - Broadcast: use --subject/--body flags instead of broken positional args - Upload delete: require double-press confirmation (consistency) - Blog body: Enter inserts newline, Ctrl+D advances to schedule - Project create: new CLI command + POST /api/internal/creator/projects - Upload rejection: show supported file types in error message - Upload edit: Enter saves field, Tab cycles (no forced sequence) - Empty states: guide users to makenot.work/dashboard - Help text: document git hosting and SFTP upload Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-03 04:13 UTC
Commit: 91d1029c7798b4556a34a78dfe1791ea1c7bce43
Parent: 9639f3c
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))