Skip to main content

max / makenotwork

Add git access provisioning, mnw-cli features, internal API endpoints Server: - SSH key management promoted to dedicated dashboard tab - Per-repo collaborator access (migration 087, SSH auth, API, dashboard UI) - mnw-admin setup-git replaces manual setup-ssh-keys.sh - Internal API endpoints for tags, broadcast, tiers, collections, domains - Blog scheduling via publish_at on internal blog create endpoint mnw-cli: - Bulk item operations (multi-select, bulk publish/unpublish/delete) - Pipe mode uploads (stdin via SSH exec) - SSH commands: broadcast, collections, domain management - TUI screens: collections, tiers, tag search on item detail - Blog post scheduling (Title -> Body -> Schedule flow) - API client methods for all new server endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-02 21:33 UTC
Commit: 1ccddc930370d6bb79496b3555ff2195444bf928
Parent: 1003d3a
37 files changed, +2316 insertions, -81 deletions
@@ -23,14 +23,15 @@ Done: Phases 1-8, Git proxy A-C. Active: None. Next: Deploy.
23 23 - [ ] Add PoM health check for mnw-cli (port 22 SSH banner check)
24 24
25 25 ## Remaining Features (from design doc)
26 - - [ ] Bulk item operations (select multiple, publish/unpublish/delete)
27 - - [ ] Tag management in TUI
28 - - [ ] Pipe mode uploads (`cat file | ssh cli.makenot.work upload ...`)
29 - - [ ] Blog post scheduling
30 - - [ ] Subscription tier management
31 - - [ ] Collection management
32 - - [ ] Custom domain management screen
33 - - [ ] Broadcast to followers
26 + - [x] Bulk item operations — multi-select (Space) on project items screen, bulk publish/unpublish/delete with confirmation dialog. Selection count shown in status bar.
27 + - [x] Pipe mode uploads — `cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-slug [--title TITLE] [--price CENTS]`. Reads stdin via SSH channel, auto-creates item, uploads to S3, publishes.
28 + - [x] Broadcast to followers — SSH command: `broadcast "Subject" "Body"`. Server internal endpoint with 24h rate limit, fire-and-forget email delivery.
29 + - [x] Custom domain management — SSH commands: `domain`, `domain add`, `domain verify`, `domain remove`. Full flow: add domain, DNS TXT verification via Cloudflare DoH, removal with cache invalidation.
30 + - [x] Collection management — SSH command: `collections` (list). Server internal endpoints for create/delete. API client wired.
31 + - [x] Tag management — Server internal endpoints for list/add/remove item tags + tag search. API client wired. TUI screen deferred.
32 + - [x] Subscription tier management — Server internal endpoint for listing tiers by project. API client wired. TUI screen deferred (read-only — tier creation requires Stripe).
33 + - [x] Blog post scheduling — server internal blog create now accepts `publish_at` (ISO 8601). TUI blog create flow: Title -> Body -> Schedule (enter datetime or leave empty for draft). Scheduled posts shown as "sched YYYY-MM-DDTHH:MM" in post list. Server scheduler auto-publishes when time arrives.
34 + - [x] TUI screens — Tags: inline on item detail screen (shows current tags, `t` to search+add, Enter to confirm). Tiers: `t` from project screen opens read-only tier list. Collections: `c` from home screen opens collection list with navigation.
34 35
35 36 ## Key Paths
36 37 ```
M mnw-cli/src/api.rs +176 -8
@@ -109,6 +109,7 @@ pub struct BlogPost {
109 109 pub title: String,
110 110 pub slug: String,
111 111 pub is_published: bool,
112 + pub publish_at: Option<String>,
112 113 pub created_at: String,
113 114 pub updated_at: String,
114 115 }
@@ -203,6 +204,60 @@ pub struct SshKeyInfo {
203 204 pub created_at: String,
204 205 }
205 206
207 + /// A tag on an item or from search.
208 + #[derive(Debug, Clone, Deserialize, Serialize)]
209 + pub struct TagInfo {
210 + pub id: String,
211 + pub name: String,
212 + pub slug: String,
213 + pub is_primary: bool,
214 + }
215 +
216 + /// Result of a broadcast send.
217 + #[derive(Debug, Deserialize)]
218 + pub struct BroadcastResult {
219 + pub success: bool,
220 + pub recipient_count: usize,
221 + }
222 +
223 + /// A subscription tier.
224 + #[derive(Debug, Clone, Deserialize, Serialize)]
225 + pub struct TierInfo {
226 + pub id: String,
227 + pub name: String,
228 + pub description: String,
229 + pub price_cents: i32,
230 + pub is_active: bool,
231 + }
232 +
233 + /// A collection.
234 + #[derive(Debug, Clone, Deserialize, Serialize)]
235 + pub struct CollectionInfo {
236 + pub id: String,
237 + pub slug: String,
238 + pub title: String,
239 + pub description: String,
240 + pub is_public: bool,
241 + pub item_count: i64,
242 + }
243 +
244 + /// Custom domain info.
245 + #[derive(Debug, Clone, Deserialize, Serialize)]
246 + pub struct DomainInfo {
247 + pub id: String,
248 + pub domain: String,
249 + pub verified: bool,
250 + pub verification_token: String,
251 + pub instructions: Option<String>,
252 + }
253 +
254 + /// Domain verification result.
255 + #[derive(Debug, Deserialize)]
256 + pub struct DomainVerifyResult {
257 + pub verified: bool,
258 + pub message: String,
259 + }
260 +
206 261 /// Response from the git authorize endpoint.
207 262 #[derive(Debug, Deserialize)]
208 263 pub struct GitAuthResponse {
@@ -619,7 +674,7 @@ impl MnwApiClient {
619 674 json_response(resp, "list_blog_posts").await
620 675 }
621 676
622 - /// Create a blog post.
677 + /// Create a blog post, optionally scheduled for future publication.
623 678 pub async fn create_blog_post(
624 679 &self,
625 680 user_id: &str,
@@ -627,19 +682,24 @@ impl MnwApiClient {
627 682 title: &str,
628 683 body_markdown: &str,
629 684 publish: bool,
685 + publish_at: Option<&str>,
630 686 ) -> anyhow::Result<BlogPost> {
631 687 let url = format!("{}/api/internal/creator/blog", self.base_url);
688 + let mut body = serde_json::json!({
689 + "user_id": user_id,
690 + "project_id": project_id,
691 + "title": title,
692 + "body_markdown": body_markdown,
693 + "publish": publish,
694 + });
695 + if let Some(pa) = publish_at {
696 + body["publish_at"] = serde_json::Value::String(pa.to_string());
697 + }
632 698 let resp = self
633 699 .http
634 700 .post(&url)
635 701 .bearer_auth(&self.service_token)
636 - .json(&serde_json::json!({
637 - "user_id": user_id,
638 - "project_id": project_id,
639 - "title": title,
640 - "body_markdown": body_markdown,
641 - "publish": publish,
642 - }))
702 + .json(&body)
643 703 .send()
644 704 .await?;
645 705
@@ -896,4 +956,112 @@ impl MnwApiClient {
896 956
897 957 json_response(resp, "list_ssh_keys").await
898 958 }
959 +
960 + // ── Tags ──
961 +
962 + pub async fn list_item_tags(&self, user_id: &str, item_id: &str) -> anyhow::Result<Vec<TagInfo>> {
963 + let url = format!("{}/api/internal/creator/items/{}/tags", self.base_url, item_id);
964 + let resp = self.http.get(&url).bearer_auth(&self.service_token)
965 + .query(&[("user_id", user_id)]).send().await?;
966 + json_response(resp, "list_item_tags").await
967 + }
968 +
969 + pub async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> {
970 + let url = format!("{}/api/internal/tags/search", self.base_url);
971 + let resp = self.http.get(&url).bearer_auth(&self.service_token)
972 + .query(&[("q", query)]).send().await?;
973 + json_response(resp, "search_tags").await
974 + }
975 +
976 + pub async fn add_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> {
977 + let url = format!("{}/api/internal/creator/items/tags", self.base_url);
978 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
979 + .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
980 + .send().await?;
981 + empty_response(resp, "add_item_tag").await
982 + }
983 +
984 + pub async fn remove_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> {
985 + let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url);
986 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
987 + .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
988 + .send().await?;
989 + empty_response(resp, "remove_item_tag").await
990 + }
991 +
992 + // ── Broadcast ──
993 +
994 + pub async fn send_broadcast(&self, user_id: &str, subject: &str, body: &str) -> anyhow::Result<BroadcastResult> {
995 + let url = format!("{}/api/internal/creator/broadcast", self.base_url);
996 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
997 + .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body}))
998 + .send().await?;
999 + json_response(resp, "send_broadcast").await
1000 + }
1001 +
1002 + // ── Tiers ──
1003 +
1004 + pub async fn list_tiers(&self, user_id: &str, project_id: &str) -> anyhow::Result<Vec<TierInfo>> {
1005 + let url = format!("{}/api/internal/creator/projects/{}/tiers", self.base_url, project_id);
1006 + let resp = self.http.get(&url).bearer_auth(&self.service_token)
1007 + .query(&[("user_id", user_id)]).send().await?;
1008 + json_response(resp, "list_tiers").await
1009 + }
1010 +
1011 + // ── Collections ──
1012 +
1013 + pub async fn list_collections(&self, user_id: &str) -> anyhow::Result<Vec<CollectionInfo>> {
1014 + let url = format!("{}/api/internal/creator/collections", self.base_url);
1015 + let resp = self.http.get(&url).bearer_auth(&self.service_token)
1016 + .query(&[("user_id", user_id)]).send().await?;
1017 + json_response(resp, "list_collections").await
1018 + }
1019 +
1020 + pub async fn create_collection(&self, user_id: &str, slug: &str, title: &str) -> anyhow::Result<serde_json::Value> {
1021 + let url = format!("{}/api/internal/creator/collections", self.base_url);
1022 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
1023 + .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title}))
1024 + .send().await?;
1025 + json_response(resp, "create_collection").await
1026 + }
1027 +
1028 + pub async fn delete_collection(&self, user_id: &str, collection_id: &str) -> anyhow::Result<()> {
1029 + let url = format!("{}/api/internal/creator/collections/{}", self.base_url, collection_id);
1030 + let resp = self.http.delete(&url).bearer_auth(&self.service_token)
1031 + .query(&[("user_id", user_id)]).send().await?;
1032 + empty_response(resp, "delete_collection").await
1033 + }
1034 +
1035 + // ── Custom Domains ──
1036 +
1037 + pub async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> {
1038 + let url = format!("{}/api/internal/creator/domain", self.base_url);
1039 + let resp = self.http.get(&url).bearer_auth(&self.service_token)
1040 + .query(&[("user_id", user_id)]).send().await?;
1041 + let val: serde_json::Value = json_response(resp, "get_domain").await?;
1042 + if val.is_null() { return Ok(None); }
1043 + Ok(serde_json::from_value(val).ok())
1044 + }
1045 +
1046 + pub async fn add_domain(&self, user_id: &str, domain: &str) -> anyhow::Result<DomainInfo> {
1047 + let url = format!("{}/api/internal/creator/domain", self.base_url);
1048 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
1049 + .json(&serde_json::json!({"user_id": user_id, "domain": domain}))
1050 + .send().await?;
1051 + json_response(resp, "add_domain").await
1052 + }
1053 +
1054 + pub async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> {
1055 + let url = format!("{}/api/internal/creator/domain/verify", self.base_url);
1056 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
1057 + .query(&[("user_id", user_id)]).send().await?;
1058 + json_response(resp, "verify_domain").await
1059 + }
1060 +
1061 + pub async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> {
1062 + let url = format!("{}/api/internal/creator/domain", self.base_url);
1063 + let resp = self.http.delete(&url).bearer_auth(&self.service_token)
1064 + .query(&[("user_id", user_id)]).send().await?;
1065 + empty_response(resp, "remove_domain").await
1066 + }
899 1067 }
@@ -6,6 +6,7 @@
6 6
7 7 use crate::api::{MnwApiClient, UserInfo};
8 8 use crate::format;
9 + use crate::staging;
9 10
10 11 /// Sanitize an API error for display to SSH clients.
11 12 ///
@@ -42,6 +43,9 @@ pub async fn execute(
42 43 let parts: Vec<&str> = parts.into_iter().filter(|p| *p != "--json").collect();
43 44
44 45 match parts[0] {
46 + "upload" => {
47 + 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 + }
45 49 "projects" => cmd_projects(user, api, json).await,
46 50 "analytics" => {
47 51 let range = parts
@@ -68,6 +72,21 @@ pub async fn execute(
68 72 }
69 73 _ => b"Usage: blog list <project-slug>\r\n".to_vec(),
70 74 },
75 + "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
79 + }
80 + "collections" => cmd_collections(user, api, json).await,
81 + "domain" => match parts.get(1).copied() {
82 + Some("add") => {
83 + let domain = parts.get(2).unwrap_or(&"");
84 + cmd_domain_add(user, api, domain).await
85 + }
86 + Some("verify") => cmd_domain_verify(user, api).await,
87 + Some("remove") => cmd_domain_remove(user, api).await,
88 + _ => cmd_domain_show(user, api).await,
89 + },
71 90 "help" | "--help" | "-h" => help_text(),
72 91 other => format!("Unknown command: {other}\r\nRun without arguments for usage help.\r\n")
73 92 .into_bytes(),
@@ -305,19 +324,188 @@ async fn cmd_blog_list(
305 324 }
306 325 }
307 326
327 + async fn cmd_broadcast(user: &UserInfo, api: &MnwApiClient, subject: &str, body: &str) -> Vec<u8> {
328 + if subject.is_empty() || body.is_empty() {
329 + return b"Usage: broadcast \"Subject\" \"Body text\"\r\n".to_vec();
330 + }
331 + match api.send_broadcast(&user.user_id, subject, body).await {
332 + Ok(result) => format!("Broadcast sent to {} followers.\r\n", result.recipient_count).into_bytes(),
333 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
334 + }
335 + }
336 +
337 + async fn cmd_collections(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
338 + match api.list_collections(&user.user_id).await {
339 + Ok(collections) => {
340 + if json {
341 + return serde_json::to_vec_pretty(&collections).unwrap_or_default();
342 + }
343 + if collections.is_empty() {
344 + return b"No collections.\r\n".to_vec();
345 + }
346 + let mut out = format!(
347 + "{:<25} {:<25} {:<8} {:<6}\r\n",
348 + "Title", "Slug", "Status", "Items"
349 + );
350 + out.push_str(&"-".repeat(66));
351 + out.push_str("\r\n");
352 + for c in &collections {
353 + let status = if c.is_public { "public" } else { "draft" };
354 + out.push_str(&format!(
355 + "{:<25} {:<25} {:<8} {:<6}\r\n",
356 + truncate(&c.title, 24),
357 + truncate(&c.slug, 24),
358 + status,
359 + c.item_count,
360 + ));
361 + }
362 + out.into_bytes()
363 + }
364 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
365 + }
366 + }
367 +
368 + async fn cmd_domain_show(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
369 + match api.get_domain(&user.user_id).await {
370 + Ok(Some(d)) => {
371 + let status = if d.verified { "verified" } else { "pending" };
372 + let mut out = format!("Domain: {} ({})\r\n", d.domain, status);
373 + if !d.verified {
374 + if let Some(ref instr) = d.instructions {
375 + out.push_str(&format!("{}\r\n", instr));
376 + }
377 + }
378 + out.into_bytes()
379 + }
380 + Ok(None) => b"No custom domain configured.\r\nUsage: domain add <domain>\r\n".to_vec(),
381 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
382 + }
383 + }
384 +
385 + async fn cmd_domain_add(user: &UserInfo, api: &MnwApiClient, domain: &str) -> Vec<u8> {
386 + if domain.is_empty() {
387 + return b"Usage: domain add <domain>\r\n".to_vec();
388 + }
389 + match api.add_domain(&user.user_id, domain).await {
390 + Ok(d) => {
391 + let mut out = format!("Domain added: {}\r\n", d.domain);
392 + if let Some(ref instr) = d.instructions {
393 + out.push_str(&format!("{}\r\n", instr));
394 + }
395 + out.push_str("Run `domain verify` after adding the DNS record.\r\n");
396 + out.into_bytes()
397 + }
398 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
399 + }
400 + }
401 +
402 + async fn cmd_domain_verify(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
403 + match api.verify_domain(&user.user_id).await {
404 + Ok(result) => format!("{}\r\n", result.message).into_bytes(),
405 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
406 + }
407 + }
408 +
409 + async fn cmd_domain_remove(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
410 + match api.remove_domain(&user.user_id).await {
411 + Ok(()) => b"Domain removed.\r\n".to_vec(),
412 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
413 + }
414 + }
415 +
416 + /// Execute a pipe-mode file upload (called from handler after stdin EOF).
417 + pub async fn execute_pipe_upload(
418 + api: &MnwApiClient,
419 + upload: crate::ssh::handler::PipeUpload,
420 + ) -> anyhow::Result<String> {
421 + let user = &upload.user;
422 + if upload.data.is_empty() {
423 + anyhow::bail!("no data received on stdin");
424 + }
425 +
426 + let ext = upload.filename.rsplit('.').next().unwrap_or("").to_lowercase();
427 + let classification = staging::classify_extension(&ext)
428 + .ok_or_else(|| anyhow::anyhow!("unsupported file type: .{ext}"))?;
429 +
430 + // Find project by slug
431 + let projects = api.get_projects(&user.user_id).await?;
432 + let project = projects.iter()
433 + .find(|p| p.slug == upload.project_slug)
434 + .ok_or_else(|| anyhow::anyhow!("project not found: {}", upload.project_slug))?;
435 +
436 + // Create item
437 + let item = api.create_item(
438 + &user.user_id,
439 + &project.id,
440 + &upload.title,
441 + classification.item_type,
442 + upload.price_cents,
443 + ).await?;
444 +
445 + // Presign upload
446 + let presign = api.presign_upload(
447 + &user.user_id,
448 + &item.item_id,
449 + classification.file_type,
450 + &upload.filename,
451 + classification.content_type,
452 + ).await?;
453 +
454 + // Upload data directly to S3
455 + let resp = reqwest::Client::new()
456 + .put(&presign.upload_url)
457 + .header("content-type", classification.content_type)
458 + .body(upload.data)
459 + .send()
460 + .await?;
461 +
462 + if !resp.status().is_success() {
463 + anyhow::bail!("S3 upload failed: HTTP {}", resp.status());
464 + }
465 +
466 + // Confirm
467 + api.confirm_upload(
468 + &user.user_id,
469 + &item.item_id,
470 + classification.file_type,
471 + &presign.s3_key,
472 + ).await?;
473 +
474 + // Publish
475 + api.publish_item(&user.user_id, &item.item_id).await?;
476 +
477 + Ok(format!(
478 + "Uploaded and published: {} ({}, {})\r\n",
479 + upload.title,
480 + staging::format_bytes(resp.content_length().unwrap_or(0)),
481 + classification.item_type,
482 + ))
483 + }
484 +
308 485 fn help_text() -> Vec<u8> {
309 486 b"Usage: ssh cli.makenot.work <command>\r\n\
310 487 \r\n\
311 488 Commands:\r\n\
312 - \x20 projects List your projects\r\n\
313 - \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\
314 - \x20 transactions Recent transactions\r\n\
315 - \x20 export sales Export sales as CSV\r\n\
316 - \x20 promo list List promo codes\r\n\
317 - \x20 promo create CODE PCT Create a promo code\r\n\
318 - \x20 blog list SLUG List blog posts for project\r\n\
489 + \x20 projects List your projects\r\n\
490 + \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\
491 + \x20 transactions Recent transactions\r\n\
492 + \x20 export sales Export sales as CSV\r\n\
493 + \x20 promo list List promo codes\r\n\
494 + \x20 promo create CODE PCT Create a promo code\r\n\
495 + \x20 blog list SLUG List blog posts for project\r\n\
496 + \x20 broadcast SUBJ BODY Email followers (1/24h limit)\r\n\
497 + \x20 collections List your collections\r\n\
498 + \x20 domain Show custom domain\r\n\
499 + \x20 domain add DOMAIN Add a custom domain\r\n\
500 + \x20 domain verify Verify DNS record\r\n\
501 + \x20 domain remove Remove custom domain\r\n\
502 + \x20 upload [args] Pipe upload (see upload --help)\r\n\
503 + \r\n\
504 + Add --json to any command for machine-readable output.\r\n\
319 505 \r\n\
320 - Add --json to any command for machine-readable output.\r\n"
506 + Pipe uploads:\r\n\
507 + \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"
321 509 .to_vec()
322 510 }
323 511
@@ -35,6 +35,18 @@ pub struct MnwHandler {
35 35 channels: Arc<Mutex<HashMap<ChannelId, Channel<Msg>>>>,
36 36 /// Active git subprocess stdin handles, keyed by channel.
37 37 git_processes: HashMap<ChannelId, tokio::process::ChildStdin>,
38 + /// Pending pipe upload: buffered stdin data + parsed args.
39 + pipe_uploads: HashMap<ChannelId, PipeUpload>,
40 + }
41 +
42 + /// State for a pipe-mode upload (`cat file | ssh ... upload ...`).
43 + pub struct PipeUpload {
44 + pub user: UserInfo,
45 + pub filename: String,
46 + pub project_slug: String,
47 + pub title: String,
48 + pub price_cents: i32,
49 + pub data: Vec<u8>,
38 50 }
39 51
40 52 impl MnwHandler {
@@ -56,6 +68,7 @@ impl MnwHandler {
56 68 app: None,
57 69 channels: Arc::new(Mutex::new(HashMap::new())),
58 70 git_processes: HashMap::new(),
71 + pipe_uploads: HashMap::new(),
59 72 }
60 73 }
61 74 }
@@ -323,6 +336,53 @@ impl russh::server::Handler for MnwHandler {
323 336 return Ok(());
324 337 }
325 338
339 + // Pipe upload: `cat file | ssh ... upload --filename X --project SLUG ...`
340 + if command_line.starts_with("upload ") || command_line.as_ref() == "upload" {
341 + let parts: Vec<&str> = command_line.split_whitespace().collect();
342 + let mut filename = String::new();
343 + let mut project_slug = String::new();
344 + let mut title = String::new();
345 + let mut price_cents: i32 = 0;
346 + let mut i = 1;
347 + while i < parts.len() {
348 + match parts[i] {
349 + "--filename" | "-f" => { if i + 1 < parts.len() { filename = parts[i + 1].to_string(); i += 1; } }
350 + "--project" | "-p" => { if i + 1 < parts.len() { project_slug = parts[i + 1].to_string(); i += 1; } }
351 + "--title" | "-t" => { if i + 1 < parts.len() { title = parts[i + 1].to_string(); i += 1; } }
352 + "--price" => { if i + 1 < parts.len() { price_cents = parts[i + 1].parse().unwrap_or(0); i += 1; } }
353 + _ => {}
354 + }
355 + i += 1;
356 + }
357 +
358 + if filename.is_empty() || project_slug.is_empty() {
359 + let msg = b"Usage: upload --filename NAME.ext --project SLUG [--title TITLE] [--price CENTS]\r\nPipe file data via stdin: cat file.wav | ssh cli.makenot.work upload ...\r\n";
360 + let bytes = bytes::Bytes::copy_from_slice(msg);
361 + tokio::spawn(async move {
362 + let _ = handle.data(channel, bytes).await;
363 + let _ = handle.exit_status_request(channel, 1).await;
364 + let _ = handle.eof(channel).await;
365 + let _ = handle.close(channel).await;
366 + });
367 + return Ok(());
368 + }
369 +
370 + if title.is_empty() {
371 + title = staging::derive_title(&filename);
372 + }
373 +
374 + self.pipe_uploads.insert(channel, PipeUpload {
375 + user: user.clone(),
376 + filename,
377 + project_slug,
378 + title,
379 + price_cents,
380 + data: Vec::new(),
381 + });
382 + session.channel_success(channel)?;
383 + return Ok(());
384 + }
385 +
326 386 // Check if this looks like a legacy SCP transfer
327 387 if command_line.starts_with("scp ") {
328 388 let msg: &[u8] =
@@ -362,6 +422,8 @@ impl russh::server::Handler for MnwHandler {
362 422 if let Some(stdin) = self.git_processes.get_mut(&channel) {
363 423 use tokio::io::AsyncWriteExt;
364 424 let _ = stdin.write_all(data).await;
425 + } else if let Some(upload) = self.pipe_uploads.get_mut(&channel) {
426 + upload.data.extend_from_slice(data);
365 427 } else if let Some(ref app) = self.app {
366 428 app.send_input(data).await;
367 429 }
@@ -371,10 +433,24 @@ impl russh::server::Handler for MnwHandler {
371 433 async fn channel_eof(
372 434 &mut self,
373 435 channel: ChannelId,
374 - _session: &mut Session,
436 + session: &mut Session,
375 437 ) -> Result<(), Self::Error> {
376 438 if let Some(stdin) = self.git_processes.remove(&channel) {
377 439 drop(stdin); // Closes pipe → subprocess sees EOF
440 + } else if let Some(upload) = self.pipe_uploads.remove(&channel) {
441 + let handle = session.handle();
442 + let api = self.api.clone();
443 + tokio::spawn(async move {
444 + let result = crate::commands::execute_pipe_upload(&api, upload).await;
445 + let (msg, exit_code) = match result {
446 + Ok(msg) => (msg, 0),
447 + Err(e) => (format!("Error: {}\r\n", crate::commands::sanitize_api_error(&e)), 1),
448 + };
449 + let _ = handle.data(channel, bytes::Bytes::from(msg)).await;
450 + let _ = handle.exit_status_request(channel, exit_code).await;
451 + let _ = handle.eof(channel).await;
452 + let _ = handle.close(channel).await;
453 + });
378 454 }
379 455 Ok(())
380 456 }
@@ -113,16 +113,19 @@ fn render_post_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
113 113 .enumerate()
114 114 .map(|(i, post)| {
115 115 let status = if post.is_published {
116 - "published"
116 + "published".to_string()
117 + } else if let Some(ref pa) = post.publish_at {
118 + let scheduled = pa.get(..16).unwrap_or(pa);
119 + format!("sched {}", scheduled)
117 120 } else {
118 - "draft"
121 + "draft".to_string()
119 122 };
120 123 let date = post.created_at.get(..10).unwrap_or(&post.created_at);
121 124
122 125 Row::new(vec![
123 126 format!(" {}", post.title),
124 127 post.slug.clone(),
125 - status.to_string(),
128 + status,
126 129 date.to_string(),
127 130 ])
128 131 .style(widgets::selected_style(i, Some(app.selected_index)))
@@ -132,7 +135,7 @@ fn render_post_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
132 135 let widths = [
133 136 Constraint::Min(20),
134 137 Constraint::Length(20),
135 - Constraint::Length(10),
138 + Constraint::Length(22),
136 139 Constraint::Length(12),
137 140 ];
138 141
@@ -0,0 +1,102 @@
1 + //! Collections management screen — list collections.
2 +
3 + use ratatui::Frame;
4 + use ratatui::layout::{Constraint, Layout};
5 + use ratatui::style::{Color, Modifier, Style};
6 + use ratatui::text::{Line, Span};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 +
9 + use super::App;
10 + use super::widgets;
11 +
12 + pub fn render(frame: &mut Frame, app: &App) {
13 + let area = frame.area();
14 +
15 + let title = Line::from(vec![
16 + Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
17 + Span::raw(" -- "),
18 + Span::styled("Collections", Style::default().add_modifier(Modifier::BOLD)),
19 + Span::raw(" "),
20 + ]);
21 +
22 + let block = Block::default()
23 + .title(title)
24 + .borders(Borders::ALL)
25 + .border_style(Style::default().fg(Color::Gray));
26 +
27 + let inner = block.inner(area);
28 + frame.render_widget(block, area);
29 +
30 + let chunks = Layout::vertical([
31 + Constraint::Length(1), // spacer
32 + Constraint::Length(1), // section header
33 + Constraint::Min(3), // list
34 + Constraint::Length(1), // status line
35 + Constraint::Length(1), // keybindings
36 + ])
37 + .split(inner);
38 +
39 + let count = app.collections.len();
40 + let header = Paragraph::new(Line::from(vec![
41 + Span::raw(" "),
42 + Span::styled("Collections", Style::default().add_modifier(Modifier::BOLD)),
43 + if count == 0 { Span::raw("") } else { Span::raw(format!(" ({})", count)) },
44 + ]));
45 + frame.render_widget(header, chunks[1]);
46 +
47 + if app.loading {
48 + let loading = Paragraph::new(" Loading...");
49 + frame.render_widget(loading, chunks[2]);
50 + } else if app.collections.is_empty() {
51 + let empty = Paragraph::new(" No collections.");
52 + frame.render_widget(empty, chunks[2]);
53 + } else {
54 + let rows: Vec<Row> = app
55 + .collections
56 + .iter()
57 + .enumerate()
58 + .map(|(i, c)| {
59 + let status = if c.is_public { "public" } else { "draft" };
60 + Row::new(vec![
61 + format!(" {}", c.title),
62 + c.slug.clone(),
63 + status.to_string(),
64 + c.item_count.to_string(),
65 + ])
66 + .style(widgets::selected_style(i, Some(app.selected_index)))
67 + })
68 + .collect();
69 +
70 + let widths = [
71 + Constraint::Min(20),
72 + Constraint::Length(20),
73 + Constraint::Length(8),
74 + Constraint::Length(6),
75 + ];
76 +
77 + widgets::render_table(frame, chunks[2], &[" Title", "Slug", "Status", "Items"], &widths, rows);
78 + }
79 +
80 + if let Some(ref status) = app.collections_status {
81 + let style = if status.starts_with("Error") {
82 + Style::default().fg(Color::Red)
83 + } else {
84 + Style::default().fg(Color::Green)
85 + };
86 + let status_line = Paragraph::new(format!(" {}", status)).style(style);
87 + frame.render_widget(status_line, chunks[3]);
88 + }
89 +
90 + let key_spans = vec![
91 + Span::raw(" "),
92 + Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
93 + Span::raw(" Nav "),
94 + Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
95 + Span::raw(" Refresh "),
96 + Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
97 + Span::raw(" Back"),
98 + ];
99 +
100 + let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
101 + frame.render_widget(keys, chunks[4]);
102 + }
@@ -81,19 +81,19 @@ pub fn render(frame: &mut Frame, app: &App) {
81 81 let keys = Paragraph::new(Line::from(vec![
82 82 Span::raw(" "),
83 83 Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
84 - Span::raw(" Navigate "),
84 + Span::raw(" Nav "),
85 85 Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
86 86 Span::raw(" Open "),
87 87 Span::styled("[u]", Style::default().add_modifier(Modifier::BOLD)),
88 88 Span::raw(" Upload "),
89 89 Span::styled("[a]", Style::default().add_modifier(Modifier::BOLD)),
90 90 Span::raw(" Analytics "),
91 - Span::styled("[c]", Style::default().add_modifier(Modifier::BOLD)),
91 + Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)),
92 92 Span::raw(" Promo "),
93 + Span::styled("[c]", Style::default().add_modifier(Modifier::BOLD)),
94 + Span::raw(" Collections "),
93 95 Span::styled("[s]", Style::default().add_modifier(Modifier::BOLD)),
94 96 Span::raw(" Settings "),
95 - Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
96 - Span::raw(" Refresh "),
97 97 Span::styled("[q]", Style::default().add_modifier(Modifier::BOLD)),
98 98 Span::raw(" Quit"),
99 99 ]))
@@ -87,6 +87,20 @@ pub(super) async fn handle_home_input(
87 87 load_promo_codes(&api, &user_id, &tx).await;
88 88 });
89 89 }
90 + KeyCode::Char('c') | KeyCode::Char('C') => {
91 + *screen = Screen::Collections;
92 + app.collections.clear();
93 + app.collections_status = None;
94 + app.selected_index = 0;
95 + app.loading = true;
96 +
97 + let api = api.clone();
98 + let user_id = app.user.user_id.clone();
99 + let tx = tx.clone();
100 + tokio::spawn(async move {
101 + load_collections(&api, &user_id, &tx).await;
102 + });
103 + }
90 104 KeyCode::Char('s') | KeyCode::Char('S') => {
91 105 *screen = Screen::Settings;
92 106 app.ssh_keys.clear();
@@ -121,13 +135,60 @@ pub(super) async fn handle_project_input(
121 135 api: &MnwApiClient,
122 136 tx: &mpsc::Sender<AppEvent>,
123 137 ) {
138 + // Handle confirmation dialog
139 + if let Some(ref action) = app.confirm_action {
140 + match key.code {
141 + KeyCode::Char('y') | KeyCode::Char('Y') => {
142 + let action = action.clone();
143 + app.confirm_action = None;
144 + let ids: Vec<String> = app.selected_items.iter()
145 + .filter_map(|&i| app.items.get(i).map(|item| item.id.clone()))
146 + .collect();
147 + let api = api.clone();
148 + let user_id = app.user.user_id.clone();
149 + let tx = tx.clone();
150 + match action {
151 + ConfirmAction::BulkPublish { .. } => {
152 + tokio::spawn(async move { bulk_publish(&api, &user_id, ids, &tx).await; });
153 + }
154 + ConfirmAction::BulkUnpublish { .. } => {
155 + tokio::spawn(async move { bulk_unpublish(&api, &user_id, ids, &tx).await; });
156 + }
157 + ConfirmAction::BulkDelete { .. } => {
158 + tokio::spawn(async move { bulk_delete(&api, &user_id, ids, &tx).await; });
159 + }
160 + _ => {}
161 + }
162 + }
163 + _ => {
164 + app.confirm_action = None;
165 + }
166 + }
167 + return;
168 + }
169 +
124 170 match key.code {
125 171 KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
126 172 KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
173 + KeyCode::Char(' ') => {
174 + // Toggle selection
175 + if !app.items.is_empty() {
176 + let idx = app.selected_index;
177 + if app.selected_items.contains(&idx) {
178 + app.selected_items.remove(&idx);
179 + } else {
180 + app.selected_items.insert(idx);
181 + }
182 + }
183 + }
127 184 KeyCode::Esc => {
128 - *screen = Screen::Home;
129 - app.items.clear();
130 - app.selected_index = 0;
185 + if !app.selected_items.is_empty() {
186 + app.selected_items.clear();
187 + } else {
188 + *screen = Screen::Home;
189 + app.items.clear();
190 + app.selected_index = 0;
191 + }
131 192 }
132 193 KeyCode::Enter => {
133 194 if let Screen::Project(pidx) = screen
@@ -140,6 +201,7 @@ pub(super) async fn handle_project_input(
140 201 app.item_versions.clear();
141 202 app.item_status = None;
142 203 app.item_editing = None;
204 + app.selected_items.clear();
143 205 app.selected_index = 0;
144 206 app.loading = true;
145 207
@@ -151,8 +213,53 @@ pub(super) async fn handle_project_input(
151 213 });
152 214 }
153 215 }
216 + KeyCode::Char('p') | KeyCode::Char('P') => {
217 + if !app.items.is_empty() {
218 + if app.selected_items.is_empty() {
219 + // Single item: toggle publish state
220 + let item = &app.items[app.selected_index];
221 + let item_id = item.id.clone();
222 + let is_public = item.is_public;
223 + let api = api.clone();
224 + let user_id = app.user.user_id.clone();
225 + let tx = tx.clone();
226 + tokio::spawn(async move {
227 + let result = if is_public {
228 + api.unpublish_item(&user_id, &item_id).await
229 + } else {
230 + api.publish_item(&user_id, &item_id).await
231 + };
232 + let msg = match result {
233 + Ok(_) => if is_public { "Unpublished" } else { "Published" }.to_string(),
234 + Err(e) => format!("Error: {}", crate::commands::sanitize_api_error(&e)),
235 + };
236 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::BulkActionComplete { message: msg })).await;
237 + });
238 + } else {
239 + // Bulk: check if majority are draft → publish, else unpublish
240 + let draft_count = app.selected_items.iter()
241 + .filter(|&&i| app.items.get(i).is_some_and(|item| !item.is_public))
242 + .count();
243 + let count = app.selected_items.len();
244 + if draft_count > count / 2 {
245 + app.confirm_action = Some(ConfirmAction::BulkPublish { count });
246 + } else {
247 + app.confirm_action = Some(ConfirmAction::BulkUnpublish { count });
248 + }
249 + }
250 + }
251 + }
252 + KeyCode::Char('d') | KeyCode::Char('D') => {
253 + if !app.items.is_empty() {
254 + if app.selected_items.is_empty() {
255 + // Select current item for single delete
256 + app.selected_items.insert(app.selected_index);
257 + }
258 + let count = app.selected_items.len();
259 + app.confirm_action = Some(ConfirmAction::BulkDelete { count });
260 + }
261 + }
154 262 KeyCode::Char('b') | KeyCode::Char('B') => {
155 - // Open blog screen for this project
156 263 if let Screen::Project(idx) = screen
157 264 && let Some(p) = app.projects.get(*idx)
158 265 {
@@ -164,6 +271,7 @@ pub(super) async fn handle_project_input(
164 271 app.blog_project_title = Some(project_title);
165 272 app.blog_status = None;
166 273 app.blog_create_step = None;
274 + app.selected_items.clear();
167 275 app.selected_index = 0;
168 276 app.loading = true;
169 277
@@ -175,6 +283,29 @@ pub(super) async fn handle_project_input(
175 283 });
176 284 }
177 285 }
286 + KeyCode::Char('t') | KeyCode::Char('T') => {
287 + if let Screen::Project(idx) = screen
288 + && let Some(p) = app.projects.get(*idx)
289 + {
290 + let pidx = *idx;
291 + let project_id = p.id.clone();
292 + let project_title = p.title.clone();
293 + *screen = Screen::Tiers(pidx, project_id.clone());
294 + app.tiers.clear();
295 + app.tiers_project_title = Some(project_title);
296 + app.tiers_status = None;
297 + app.selected_items.clear();
298 + app.selected_index = 0;
299 + app.loading = true;
300 +
301 + let api = api.clone();
302 + let user_id = app.user.user_id.clone();
303 + let tx = tx.clone();
304 + tokio::spawn(async move {
305 + load_tiers(&api, &user_id, &project_id, &tx).await;
306 + });
307 + }
308 + }
178 309 KeyCode::Char('r') | KeyCode::Char('R') => {
179 310 if let Screen::Project(idx) = screen
180 311 && let Some(p) = app.projects.get(*idx)
@@ -401,6 +532,82 @@ pub(super) async fn handle_item_input(
401 532 ) {
402 533 use item::ItemEditField;
403 534
535 + // Handle tag search mode
536 + if app.tag_searching {
537 + match key.code {
538 + KeyCode::Esc => {
539 + app.tag_searching = false;
540 + app.edit_buffer.clear();
541 + app.tag_search_results.clear();
542 + app.item_status = None;
543 + }
544 + KeyCode::Enter => {
545 + // Add the first search result as a tag
546 + if let (Some(tag), Some(detail)) = (app.tag_search_results.first(), &app.item_detail) {
547 + let tag_id = tag.id.clone();
548 + let item_id = detail.id.clone();
549 + let user_id = app.user.user_id.clone();
550 + let api = api.clone();
551 + let tx = tx.clone();
552 + let tag_name = tag.name.clone();
553 + tokio::spawn(async move {
554 + match api.add_item_tag(&user_id, &item_id, &tag_id).await {
555 + Ok(()) => {
556 + // Reload tags
557 + let tags = api.list_item_tags(&user_id, &item_id).await.unwrap_or_default();
558 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemTags { tags })).await;
559 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemActionError {
560 + error: format!("Added tag: {}", tag_name), // Reuse error channel for status
561 + })).await;
562 + }
563 + Err(e) => {
564 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemActionError {
565 + error: e.to_string(),
566 + })).await;
567 + }
568 + }
569 + });
570 + app.tag_searching = false;
571 + app.edit_buffer.clear();
572 + app.tag_search_results.clear();
573 + app.item_status = None;
574 + }
575 + }
576 + KeyCode::Backspace => {
577 + app.edit_buffer.pop();
578 + if app.edit_buffer.len() >= 2 {
579 + let api = api.clone();
580 + let query = app.edit_buffer.clone();
581 + let tx = tx.clone();
582 + tokio::spawn(async move { search_tags(&api, &query, &tx).await; });
583 + } else {
584 + app.tag_search_results.clear();
585 + }
586 + }
587 + KeyCode::Char(c) => {
588 + app.edit_buffer.push(c);
589 + if app.edit_buffer.len() >= 2 {
590 + let api = api.clone();
591 + let query = app.edit_buffer.clone();
592 + let tx = tx.clone();
593 + tokio::spawn(async move { search_tags(&api, &query, &tx).await; });
594 + }
595 + let results_preview: String = app.tag_search_results.iter()
596 + .take(3)
597 + .map(|t| t.name.as_str())
598 + .collect::<Vec<_>>()
599 + .join(", ");
600 + app.item_status = Some(format!(
601 + "Tag: {}_ {}",
602 + app.edit_buffer,
603 + if results_preview.is_empty() { String::new() } else { format!("[{}]", results_preview) },
604 + ));
605 + }
606 + _ => {}
607 + }
608 + return;
609 + }
610 +
404 611 // Cancel pending confirmation on any key other than the confirmation key
405 612 if app.confirm_action.is_some() && !matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) {
406 613 app.confirm_action = None;
@@ -679,6 +886,14 @@ pub(super) async fn handle_item_input(
679 886 }
680 887 }
681 888 }
889 + KeyCode::Char('t') | KeyCode::Char('T') => {
890 + if app.item_detail.is_some() {
891 + app.tag_searching = true;
892 + app.edit_buffer.clear();
893 + app.tag_search_results.clear();
894 + app.item_status = Some("Tag: type to search, Enter to add first result, Esc to cancel".to_string());
895 + }
896 + }
682 897 KeyCode::Char('l') | KeyCode::Char('L') => {
683 898 // Open license keys screen
684 899 if let Screen::Item(project_idx, item_id) = &*screen {
@@ -740,6 +955,7 @@ pub(super) async fn handle_blog_input(
740 955 KeyCode::Esc => {
741 956 app.blog_create_step = None;
742 957 app.blog_create_title.clear();
958 + app.blog_create_body.clear();
743 959 app.edit_buffer.clear();
744 960 app.blog_status = None;
745 961 }
@@ -750,12 +966,26 @@ pub(super) async fn handle_blog_input(
750 966 app.edit_buffer.clear();
751 967 app.blog_create_step = Some(BlogCreateStep::Body);
752 968 app.blog_status =
753 - Some("Body (markdown, Enter to submit empty for draft):".to_string());
969 + Some("Body (markdown, Enter when done):".to_string());
754 970 }
755 971 }
756 972 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 + );
979 + }
980 + BlogCreateStep::Schedule => {
757 981 let title = app.blog_create_title.clone();
758 - let body = app.edit_buffer.clone();
982 + let body = app.blog_create_body.clone();
983 + let schedule_input = app.edit_buffer.trim().to_string();
984 + let publish_at = if schedule_input.is_empty() {
985 + None
986 + } else {
987 + Some(schedule_input)
988 + };
759 989
760 990 if let Screen::Blog(_, project_id) = &*screen {
761 991 let project_id = project_id.clone();
@@ -763,11 +993,23 @@ pub(super) async fn handle_blog_input(
763 993 let user_id = app.user.user_id.clone();
764 994 let tx = tx.clone();
765 995 app.blog_creating = true;
766 - app.blog_status = Some("Creating post...".to_string());
996 + let status_msg = if publish_at.is_some() {
997 + "Scheduling post..."
998 + } else {
999 + "Creating draft..."
1000 + };
1001 + app.blog_status = Some(status_msg.to_string());
767 1002
768 1003 tokio::spawn(async move {
769 1004 match api
770 - .create_blog_post(&user_id, &project_id, &title, &body, false)
1005 + .create_blog_post(
1006 + &user_id,
1007 + &project_id,
1008 + &title,
1009 + &body,
1010 + false,
1011 + publish_at.as_deref(),
1012 + )
771 1013 .await
772 1014 {
773 1015 Ok(_post) => {
@@ -787,6 +1029,7 @@ pub(super) async fn handle_blog_input(
787 1029 }
788 1030 app.blog_create_step = None;
789 1031 app.blog_create_title.clear();
1032 + app.blog_create_body.clear();
790 1033 app.edit_buffer.clear();
791 1034 }
792 1035 },
@@ -1294,3 +1537,55 @@ pub(super) async fn handle_settings_input(
1294 1537 _ => {}
1295 1538 }
1296 1539 }
1540 +
1541 + pub(super) async fn handle_collections_input(
1542 + key: KeyEvent,
1543 + app: &mut App,
1544 + screen: &mut Screen,
1545 + api: &MnwApiClient,
1546 + tx: &mpsc::Sender<AppEvent>,
1547 + ) {
1548 + match key.code {
1549 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
1550 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
1551 + KeyCode::Esc => {
1552 + *screen = Screen::Home;
1553 + app.collections.clear();
1554 + app.collections_status = None;
1555 + app.selected_index = 0;
1556 + }
1557 + KeyCode::Char('r') | KeyCode::Char('R') => {
1558 + app.loading = true;
1559 + app.collections_status = None;
1560 + let api = api.clone();
1561 + let user_id = app.user.user_id.clone();
1562 + let tx = tx.clone();
1563 + tokio::spawn(async move {
1564 + load_collections(&api, &user_id, &tx).await;
1565 + });
1566 + }
1567 + _ => {}
1568 + }
1569 + }
1570 +
1571 + pub(super) async fn handle_tiers_input(
1572 + key: KeyEvent,
1573 + app: &mut App,
1574 + screen: &mut Screen,
1575 + ) {
1576 + match key.code {
1577 + KeyCode::Char('j') | KeyCode::Down => app.move_down(screen),
1578 + KeyCode::Char('k') | KeyCode::Up => app.move_up(screen),
1579 + KeyCode::Esc => {
1580 + if let Screen::Tiers(pidx, _) = screen {
1581 + let pidx = *pidx;
1582 + *screen = Screen::Project(pidx);
1583 + app.tiers.clear();
1584 + app.tiers_project_title = None;
1585 + app.tiers_status = None;
1586 + app.selected_index = 0;
1587 + }
1588 + }
1589 + _ => {}
1590 + }
1591 + }
@@ -44,7 +44,7 @@ pub fn render(frame: &mut Frame, app: &App) {
44 44
45 45 let chunks = Layout::vertical([
46 46 Constraint::Length(1), // spacer
47 - Constraint::Length(6), // item info
47 + Constraint::Length(7), // item info (6 fields + tags)
48 48 Constraint::Length(1), // spacer
49 49 Constraint::Length(1), // versions header
50 50 Constraint::Min(3), // versions list
@@ -123,6 +123,10 @@ pub fn render(frame: &mut Frame, app: &App) {
123 123 key_spans.extend([
124 124 Span::styled("[d]", Style::default().add_modifier(Modifier::BOLD)),
125 125 Span::raw(" Delete "),
126 + Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)),
127 + Span::raw(" Tag "),
128 + Span::styled("[l]", Style::default().add_modifier(Modifier::BOLD)),
129 + Span::raw(" Keys "),
126 130 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
127 131 Span::raw(" Refresh "),
128 132 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
@@ -225,6 +229,25 @@ fn render_item_info(
225 229 Span::styled("Cover: ", Style::default().fg(Color::DarkGray)),
226 230 Span::raw(if item.has_cover { "yes" } else { "no" }),
227 231 ]),
232 + Line::from({
233 + let mut spans = vec![
234 + Span::raw(" "),
235 + Span::styled("Tags: ", Style::default().fg(Color::DarkGray)),
236 + ];
237 + if app.item_tags.is_empty() {
238 + spans.push(Span::styled("(none)", Style::default().fg(Color::DarkGray)));
239 + } else {
240 + for (i, tag) in app.item_tags.iter().enumerate() {
241 + if i > 0 { spans.push(Span::raw(", ")); }
242 + if tag.is_primary {
243 + spans.push(Span::styled(&tag.name, Style::default().add_modifier(Modifier::BOLD)));
244 + } else {
245 + spans.push(Span::raw(&tag.name));
246 + }
247 + }
248 + }
249 + spans
250 + }),
228 251 ];
229 252
230 253 let info = Paragraph::new(lines);
@@ -88,6 +88,7 @@ pub(super) async fn load_item_detail(
88 88 ) {
89 89 let detail = api.get_item_detail(user_id, item_id).await;
90 90 let versions = api.get_item_versions(user_id, item_id).await;
91 + let tags = api.list_item_tags(user_id, item_id).await.unwrap_or_default();
91 92
92 93 match detail {
93 94 Ok(detail) => {
@@ -98,6 +99,7 @@ pub(super) async fn load_item_detail(
98 99 versions,
99 100 }))
100 101 .await;
102 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::ItemTags { tags })).await;
101 103 }
102 104 Err(e) => {
103 105 let _ = tx
@@ -109,6 +111,52 @@ pub(super) async fn load_item_detail(
109 111 }
110 112 }
111 113
114 + pub(super) async fn load_collections(
115 + api: &MnwApiClient,
116 + user_id: &str,
117 + tx: &mpsc::Sender<AppEvent>,
118 + ) {
119 + match api.list_collections(user_id).await {
120 + Ok(collections) => {
121 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::CollectionsList { collections })).await;
122 + }
123 + Err(e) => {
124 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::GenericError { error: e.to_string() })).await;
125 + }
126 + }
127 + }
128 +
129 + pub(super) async fn load_tiers(
130 + api: &MnwApiClient,
131 + user_id: &str,
132 + project_id: &str,
133 + tx: &mpsc::Sender<AppEvent>,
134 + ) {
135 + match api.list_tiers(user_id, project_id).await {
136 + Ok(tiers) => {
137 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::TiersList { tiers })).await;
138 + }
139 + Err(e) => {
140 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::GenericError { error: e.to_string() })).await;
141 + }
142 + }
143 + }
144 +
145 + pub(super) async fn search_tags(
146 + api: &MnwApiClient,
147 + query: &str,
148 + tx: &mpsc::Sender<AppEvent>,
149 + ) {
150 + match api.search_tags(query).await {
151 + Ok(results) => {
152 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::TagSearchResults { results })).await;
153 + }
154 + Err(_) => {
155 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::TagSearchResults { results: vec![] })).await;
156 + }
157 + }
158 + }
159 +
112 160 pub(super) async fn load_blog_posts(
113 161 api: &MnwApiClient,
114 162 user_id: &str,
@@ -263,3 +311,57 @@ pub(super) async fn publish_file(
263 311
264 312 Ok(())
265 313 }
314 +
315 + /// Bulk publish items.
316 + pub(super) async fn bulk_publish(
317 + api: &MnwApiClient,
318 + user_id: &str,
319 + item_ids: Vec<String>,
320 + tx: &mpsc::Sender<AppEvent>,
321 + ) {
322 + let total = item_ids.len();
323 + let mut ok = 0;
324 + for id in &item_ids {
325 + if api.publish_item(user_id, id).await.is_ok() {
326 + ok += 1;
327 + }
328 + }
329 + let msg = format!("Published {}/{} items", ok, total);
330 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::BulkActionComplete { message: msg })).await;
331 + }
332 +
333 + /// Bulk unpublish items.
334 + pub(super) async fn bulk_unpublish(
335 + api: &MnwApiClient,
336 + user_id: &str,
337 + item_ids: Vec<String>,
338 + tx: &mpsc::Sender<AppEvent>,
339 + ) {
340 + let total = item_ids.len();
341 + let mut ok = 0;
342 + for id in &item_ids {
343 + if api.unpublish_item(user_id, id).await.is_ok() {
344 + ok += 1;
345 + }
346 + }
347 + let msg = format!("Unpublished {}/{} items", ok, total);
348 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::BulkActionComplete { message: msg })).await;
349 + }
350 +
351 + /// Bulk delete items.
352 + pub(super) async fn bulk_delete(
353 + api: &MnwApiClient,
354 + user_id: &str,
355 + item_ids: Vec<String>,
356 + tx: &mpsc::Sender<AppEvent>,
357 + ) {
358 + let total = item_ids.len();
359 + let mut ok = 0;
360 + for id in &item_ids {
361 + if api.delete_item(user_id, id).await.is_ok() {
362 + ok += 1;
363 + }
364 + }
365 + let msg = format!("Deleted {}/{} items", ok, total);
366 + let _ = tx.send(AppEvent::DataLoaded(DataPayload::BulkActionComplete { message: msg })).await;
367 + }
@@ -2,6 +2,7 @@
2 2
3 3 pub mod analytics;
4 4 pub mod blog;
5 + pub mod collections;
5 6 pub mod home;
6 7 mod input;
7 8 pub mod item;
@@ -10,9 +11,11 @@ mod loading;
10 11 pub mod project;
11 12 pub mod promo;
12 13 pub mod settings;
14 + pub mod tiers;
13 15 pub mod upload;
14 16 pub mod widgets;
15 17
18 + use std::collections::HashSet;
16 19 use std::path::PathBuf;
17 20
18 21 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@@ -21,8 +24,9 @@ use ratatui::backend::CrosstermBackend;
21 24 use tokio::sync::mpsc;
22 25
23 26 use crate::api::{
24 - AnalyticsData, BlogPost, CreatorStats, Item, ItemDetail, LicenseKey, MnwApiClient, Project,
25 - PromoCode, SshKeyInfo, StorageInfo, Transaction, UserInfo, Version,
27 + AnalyticsData, BlogPost, CollectionInfo, CreatorStats, Item, ItemDetail, LicenseKey,
28 + MnwApiClient, Project, PromoCode, SshKeyInfo, StorageInfo, TagInfo, TierInfo, Transaction,
29 + UserInfo, Version,
26 30 };
27 31 use crate::ssh::terminal::TerminalHandle;
28 32 use crate::staging::{self, StagedFile};
@@ -104,6 +108,21 @@ pub enum DataPayload {
104 108 keys: Vec<SshKeyInfo>,
105 109 storage: Option<StorageInfo>,
106 110 },
111 + ItemTags {
112 + tags: Vec<TagInfo>,
113 + },
114 + TagSearchResults {
115 + results: Vec<TagInfo>,
116 + },
117 + CollectionsList {
118 + collections: Vec<CollectionInfo>,
119 + },
120 + TiersList {
121 + tiers: Vec<TierInfo>,
122 + },
123 + BulkActionComplete {
124 + message: String,
125 + },
107 126 }
108 127
109 128 /// Handle for sending events to a running TUI session.
@@ -141,6 +160,10 @@ enum Screen {
141 160 Analytics,
142 161 /// Settings screen (profile, storage, SSH keys).
143 162 Settings,
163 + /// Collections management.
164 + Collections,
165 + /// Subscription tiers for a project. Stores (project_index, project_id).
166 + Tiers(usize, String),
144 167 }
145 168
146 169 /// User-editable metadata for a staged file.
@@ -165,6 +188,8 @@ pub(crate) enum EditField {
165 188 pub(crate) enum BlogCreateStep {
166 189 Title,
167 190 Body,
191 + /// Optional scheduling step — enter datetime or leave empty to publish as draft.
192 + Schedule,
168 193 }
169 194
170 195 /// Steps for creating a promo code.
@@ -181,6 +206,9 @@ pub(crate) enum ConfirmAction {
181 206 DeleteBlogPost { post_idx: usize },
182 207 DeletePromoCode { code_idx: usize },
183 208 RevokeLicenseKey { key_idx: usize },
209 + BulkPublish { count: usize },
210 + BulkUnpublish { count: usize },
211 + BulkDelete { count: usize },
184 212 }
185 213
186 214 /// Application state shared across screens.
@@ -190,6 +218,7 @@ pub struct App {
190 218 pub stats: Option<CreatorStats>,
191 219 pub items: Vec<Item>,
192 220 pub selected_index: usize,
221 + pub selected_items: HashSet<usize>,
193 222 pub loading: bool,
194 223 pub staged_files: Vec<StagedFile>,
195 224 pub storage_info: Option<StorageInfo>,
@@ -209,6 +238,7 @@ pub struct App {
209 238 pub blog_creating: bool,
210 239 pub blog_create_step: Option<BlogCreateStep>,
211 240 pub blog_create_title: String,
241 + pub blog_create_body: String,
212 242 // Promo codes
213 243 pub promo_codes: Vec<PromoCode>,
214 244 pub promo_status: Option<String>,
@@ -228,6 +258,17 @@ pub struct App {
228 258 // Settings
229 259 pub ssh_keys: Vec<SshKeyInfo>,
230 260 pub settings_status: Option<String>,
261 + // Tags (on item detail)
262 + pub item_tags: Vec<TagInfo>,
263 + pub tag_search_results: Vec<TagInfo>,
264 + pub tag_searching: bool,
265 + // Collections
266 + pub collections: Vec<CollectionInfo>,
267 + pub collections_status: Option<String>,
268 + // Tiers
269 + pub tiers: Vec<TierInfo>,
270 + pub tiers_project_title: Option<String>,
271 + pub tiers_status: Option<String>,
231 272 // Confirmation dialog
232 273 pub confirm_action: Option<ConfirmAction>,
233 274 }
@@ -240,6 +281,7 @@ impl App {
240 281 stats: None,
241 282 items: Vec::new(),
242 283 selected_index: 0,
284 + selected_items: HashSet::new(),
243 285 loading: true,
244 286 staged_files: Vec::new(),
245 287 storage_info: None,
@@ -258,6 +300,7 @@ impl App {
258 300 blog_creating: false,
259 301 blog_create_step: None,
260 302 blog_create_title: String::new(),
303 + blog_create_body: String::new(),
261 304 promo_codes: Vec::new(),
262 305 promo_status: None,
263 306 promo_editing_step: None,
@@ -273,6 +316,14 @@ impl App {
273 316 transactions: Vec::new(),
274 317 ssh_keys: Vec::new(),
275 318 settings_status: None,
319 + item_tags: Vec::new(),
320 + tag_search_results: Vec::new(),
321 + tag_searching: false,
322 + collections: Vec::new(),
323 + collections_status: None,
324 + tiers: Vec::new(),
325 + tiers_project_title: None,
326 + tiers_status: None,
276 327 confirm_action: None,
277 328 }
278 329 }
@@ -288,6 +339,8 @@ impl App {
288 339 Screen::Keys(..) => self.license_keys.len(),
289 340 Screen::Analytics => self.transactions.len(),
290 341 Screen::Settings => self.ssh_keys.len(),
342 + Screen::Collections => self.collections.len(),
343 + Screen::Tiers(..) => self.tiers.len(),
291 344 }
292 345 }
293 346
@@ -456,6 +509,18 @@ pub fn launch(
456 509 )
457 510 .await;
458 511 }
512 + Screen::Collections => {
513 + handle_collections_input(
514 + key, &mut app, &mut screen, &api, &tx,
515 + )
516 + .await;
517 + }
518 + Screen::Tiers(..) => {
519 + handle_tiers_input(
520 + key, &mut app, &mut screen,
521 + )
522 + .await;
523 + }
459 524 }
460 525 }
461 526 }
@@ -474,6 +539,7 @@ pub fn launch(
474 539 app.items = items;
475 540 app.loading = false;
476 541 app.selected_index = 0;
542 + app.selected_items.clear();
477 543 }
478 544 DataPayload::StagedFiles { files, storage } => {
479 545 app.staged_files = files;
@@ -533,6 +599,7 @@ pub fn launch(
533 599 app.blog_creating = false;
534 600 app.blog_create_step = None;
535 601 app.blog_create_title.clear();
602 + app.blog_create_body.clear();
536 603 app.edit_buffer.clear();
537 604 app.blog_status = Some("Post created".to_string());
538 605 // Reload blog posts
@@ -600,6 +667,40 @@ pub fn launch(
600 667 app.loading = false;
601 668 app.selected_index = 0;
602 669 }
670 + DataPayload::ItemTags { tags } => {
671 + app.item_tags = tags;
672 + }
673 + DataPayload::TagSearchResults { results } => {
674 + app.tag_search_results = results;
675 + app.tag_searching = false;
676 + }
677 + DataPayload::CollectionsList { collections: c } => {
678 + app.collections = c;
679 + app.loading = false;
680 + app.selected_index = 0;
681 + }
682 + DataPayload::TiersList { tiers: t } => {
683 + app.tiers = t;
684 + app.loading = false;
685 + app.selected_index = 0;
686 + }
687 + DataPayload::BulkActionComplete { message } => {
688 + app.item_status = Some(message);
689 + app.selected_items.clear();
690 + // Reload project items
691 + if let Screen::Project(pidx) = &screen {
692 + if let Some(p) = app.projects.get(*pidx) {
693 + app.loading = true;
694 + let api = api.clone();
695 + let project_id = p.id.clone();
696 + let user_id = app.user.user_id.clone();
697 + let tx = tx.clone();
698 + tokio::spawn(async move {
699 + load_project_items(&api, &project_id, &user_id, &tx).await;
700 + });
701 + }
702 + }
703 + }
603 704 DataPayload::ProjectReload { project_idx } => {
604 705 if let Some(p) = app.projects.get(project_idx) {
605 706 app.loading = true;
@@ -655,6 +756,8 @@ pub fn launch(
655 756 Screen::Keys(..) => keys::render(frame, &app),
656 757 Screen::Analytics => analytics::render(frame, &app),
657 758 Screen::Settings => settings::render(frame, &app),
759 + Screen::Collections => collections::render(frame, &app),
760 + Screen::Tiers(..) => tiers::render(frame, &app),
658 761 }) {
659 762 tracing::error!(error = ?e, "render failed");
660 763 cleanup(&mut terminal);
@@ -30,12 +30,16 @@ pub fn render(frame: &mut Frame, app: &App, project: &Project) {
30 30 let inner = block.inner(area);
31 31 frame.render_widget(block, area);
32 32
33 + let has_confirm = app.confirm_action.is_some();
34 + let has_status = app.item_status.is_some();
35 +
33 36 let chunks = Layout::vertical([
34 37 Constraint::Length(1), // spacer
35 38 Constraint::Length(1), // project info line
36 39 Constraint::Length(1), // spacer
37 40 Constraint::Length(1), // section header
38 41 Constraint::Min(3), // item list
42 + Constraint::Length(if has_confirm || has_status { 1 } else { 0 }), // status/confirm
39 43 Constraint::Length(1), // keybindings
40 44 ])
41 45 .split(inner);
@@ -82,7 +86,28 @@ pub fn render(frame: &mut Frame, app: &App, project: &Project) {
82 86 render_item_table(frame, app, chunks[4]);
83 87 }
84 88
89 + // Status / confirmation line
90 + if let Some(ref action) = app.confirm_action {
91 + let msg = match action {
92 + super::ConfirmAction::BulkPublish { count } => format!(" Publish {} items? [y/n]", count),
93 + super::ConfirmAction::BulkUnpublish { count } => format!(" Unpublish {} items? [y/n]", count),
94 + super::ConfirmAction::BulkDelete { count } => format!(" Delete {} items? This cannot be undone. [y/n]", count),
95 + _ => String::new(),
96 + };
97 + let confirm = Paragraph::new(msg).style(Style::default().fg(Color::Yellow));
98 + frame.render_widget(confirm, chunks[5]);
99 + } else if let Some(ref status) = app.item_status {
100 + let style = if status.starts_with("Error") {
101 + Style::default().fg(Color::Red)
102 + } else {
103 + Style::default().fg(Color::Green)
104 + };
105 + let status_line = Paragraph::new(format!(" {}", status)).style(style);
106 + frame.render_widget(status_line, chunks[5]);
107 + }
108 +
85 109 // Keybindings
110 + let sel_count = app.selected_items.len();
86 111 let mut key_spans = vec![
87 112 Span::raw(" "),
88 113 Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
@@ -91,6 +116,8 @@ pub fn render(frame: &mut Frame, app: &App, project: &Project) {
91 116
92 117 if !app.items.is_empty() {
93 118 key_spans.extend([
119 + Span::styled("[Space]", Style::default().add_modifier(Modifier::BOLD)),
120 + Span::raw(" Select "),
94 121 Span::styled("[Enter]", Style::default().add_modifier(Modifier::BOLD)),
95 122 Span::raw(" Open "),
96 123 Span::styled("[p]", Style::default().add_modifier(Modifier::BOLD)),
@@ -100,9 +127,21 @@ pub fn render(frame: &mut Frame, app: &App, project: &Project) {
100 127 ]);
101 128 }
102 129
130 + if sel_count > 0 {
131 + key_spans.extend([
132 + Span::styled(
133 + format!("{} selected", sel_count),
134 + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
135 + ),
136 + Span::raw(" "),
137 + ]);
138 + }
139 +
103 140 key_spans.extend([
104 141 Span::styled("[b]", Style::default().add_modifier(Modifier::BOLD)),
105 142 Span::raw(" Blog "),
143 + Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)),
144 + Span::raw(" Tiers "),
106 145 Span::styled("[r]", Style::default().add_modifier(Modifier::BOLD)),
107 146 Span::raw(" Refresh "),
108 147 Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
@@ -111,19 +150,28 @@ pub fn render(frame: &mut Frame, app: &App, project: &Project) {
111 150
112 151 let keys = Paragraph::new(Line::from(key_spans))
113 152 .style(Style::default().fg(Color::DarkGray));
114 - frame.render_widget(keys, chunks[5]);
153 + frame.render_widget(keys, chunks[6]);
115 154 }
116 155
117 156 fn render_item_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
157 + let has_selections = !app.selected_items.is_empty();
158 +
118 159 let rows: Vec<Row> = app
119 160 .items
120 161 .iter()
121 162 .enumerate()
122 163 .map(|(i, item)| {
123 164 let visibility = if item.is_public { "public" } else { "draft" };
165 + let marker = if app.selected_items.contains(&i) {
166 + "[x]"
167 + } else if has_selections {
168 + "[ ]"
169 + } else {
170 + " "
171 + };
124 172
125 173 Row::new(vec![
126 - format!(" {}", item.title),
174 + format!(" {} {}", marker, item.title),
127 175 format::format_item_type(&item.item_type).to_string(),
128 176 format::format_price(item.price_cents),
129 177 visibility.to_string(),
@@ -0,0 +1,111 @@
1 + //! Subscription tier list screen — read-only view of project tiers.
2 +
3 + use ratatui::Frame;
4 + use ratatui::layout::{Constraint, Layout};
5 + use ratatui::style::{Color, Modifier, Style};
6 + use ratatui::text::{Line, Span};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 +
9 + use crate::format;
10 +
11 + use super::App;
12 + use super::widgets;
13 +
14 + pub fn render(frame: &mut Frame, app: &App) {
15 + let area = frame.area();
16 +
17 + let project_title = app.tiers_project_title.as_deref().unwrap_or("Project");
18 +
19 + let title = Line::from(vec![
20 + Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
21 + Span::raw(" -- "),
22 + Span::styled(project_title, Style::default().add_modifier(Modifier::BOLD)),
23 + Span::raw(" -- "),
24 + Span::styled("Subscription Tiers", Style::default().add_modifier(Modifier::BOLD)),
25 + Span::raw(" "),
26 + ]);
27 +
28 + let block = Block::default()
29 + .title(title)
30 + .borders(Borders::ALL)
31 + .border_style(Style::default().fg(Color::Gray));
32 +
33 + let inner = block.inner(area);
34 + frame.render_widget(block, area);
35 +
36 + let chunks = Layout::vertical([
37 + Constraint::Length(1), // spacer
38 + Constraint::Length(1), // section header
39 + Constraint::Min(3), // list
40 + Constraint::Length(1), // status line
41 + Constraint::Length(1), // keybindings
42 + ])
43 + .split(inner);
44 +
45 + let count = app.tiers.len();
46 + let header = Paragraph::new(Line::from(vec![
47 + Span::raw(" "),
48 + Span::styled("Tiers", Style::default().add_modifier(Modifier::BOLD)),
49 + if count == 0 { Span::raw("") } else { Span::raw(format!(" ({})", count)) },
50 + ]));
51 + frame.render_widget(header, chunks[1]);
52 +
53 + if app.loading {
54 + let loading = Paragraph::new(" Loading...");
55 + frame.render_widget(loading, chunks[2]);
56 + } else if app.tiers.is_empty() {
57 + let empty = Paragraph::new(" No subscription tiers for this project.");
58 + frame.render_widget(empty, chunks[2]);
59 + } else {
60 + let rows: Vec<Row> = app
61 + .tiers
62 + .iter()
63 + .enumerate()
64 + .map(|(i, t)| {
65 + let status = if t.is_active { "active" } else { "inactive" };
66 + let desc = if t.description.len() > 30 {
67 + format!("{}...", &t.description[..27])
68 + } else {
69 + t.description.clone()
70 + };
71 + Row::new(vec![
72 + format!(" {}", t.name),
73 + format::format_price(t.price_cents),
74 + status.to_string(),
75 + desc,
76 + ])
77 + .style(widgets::selected_style(i, Some(app.selected_index)))
78 + })
79 + .collect();
80 +
81 + let widths = [
82 + Constraint::Min(16),
83 + Constraint::Length(10),
84 + Constraint::Length(10),
85 + Constraint::Length(30),
86 + ];
87 +
88 + widgets::render_table(frame, chunks[2], &[" Name", "Price", "Status", "Description"], &widths, rows);
89 + }
90 +
91 + if let Some(ref status) = app.tiers_status {
92 + let style = if status.starts_with("Error") {
93 + Style::default().fg(Color::Red)
94 + } else {
95 + Style::default().fg(Color::Green)
96 + };
97 + let status_line = Paragraph::new(format!(" {}", status)).style(style);
98 + frame.render_widget(status_line, chunks[3]);
99 + }
100 +
101 + let key_spans = vec![
102 + Span::raw(" "),
103 + Span::styled("[j/k]", Style::default().add_modifier(Modifier::BOLD)),
104 + Span::raw(" Nav "),
105 + Span::styled("[Esc]", Style::default().add_modifier(Modifier::BOLD)),
106 + Span::raw(" Back"),
107 + ];
108 +
109 + let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
110 + frame.render_widget(keys, chunks[4]);
111 + }
@@ -3385,7 +3385,7 @@ dependencies = [
3385 3385
3386 3386 [[package]]
3387 3387 name = "makenotwork"
3388 - version = "0.4.6"
3388 + version = "0.4.7"
3389 3389 dependencies = [
3390 3390 "anyhow",
3391 3391 "argon2",
@@ -49,9 +49,9 @@ All 34 previously-failing tests resolved (10 root causes). Additional 3 sandbox
49 49 - [ ] Add key rotation mechanism (requires server-side re-encryption of all sync_log entries) — deferred post-beta
50 50
51 51 ### Git Access Provisioning
52 - - [ ] Dashboard page for SSH key management (API + HTMX partials exist at `routes/api/ssh_keys.rs`, needs dashboard tab)
53 - - [ ] Per-repo collaborator access (grant push by MNW username, stored in DB, wired to authorized_keys rebuild)
54 - - [ ] Replace manual `setup-ssh-keys.sh` with account-driven key management
52 + - [x] Dashboard page for SSH key management — promoted from collapsed `<details>` in Account tab to dedicated "SSH Keys" dashboard tab. Tab conditionally shown when `git_enabled`. Reuses existing API endpoints and HTMX partials.
53 + - [x] Per-repo collaborator access — `repo_collaborators` table (migration 087) with per-user `can_push` flag. SSH auth (`git_ssh.rs`) checks collaborator table for push and private repo read access. API: `POST/GET/DELETE /api/repos/{id}/collaborators`. Dashboard Code tab shows collaborators per linked repo with add/remove UI.
54 + - [x] Replace manual `setup-ssh-keys.sh` — added `mnw-admin setup-git` subcommand that creates `/opt/git/.ssh`, sets permissions, installs sudoers rule, and verifies syntax. Shell script superseded.
55 55
56 56 ### Moderation
57 57 - [x] Admin "send warning" action: `POST /api/admin/users/{id}/warn` sends policy-violation email without suspending. Records in moderation_actions.
@@ -0,0 +1,12 @@
1 + -- Per-repo collaborator access for git push/pull.
2 + CREATE TABLE repo_collaborators (
3 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 + repo_id UUID NOT NULL REFERENCES git_repos(id) ON DELETE CASCADE,
5 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6 + can_push BOOLEAN NOT NULL DEFAULT true,
7 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8 + UNIQUE (repo_id, user_id)
9 + );
10 +
11 + CREATE INDEX idx_repo_collaborators_repo_id ON repo_collaborators(repo_id);
12 + CREATE INDEX idx_repo_collaborators_user_id ON repo_collaborators(user_id);
@@ -18,6 +18,7 @@
18 18 //! mnw-admin storage <user> S3 storage audit for a user
19 19 //! mnw-admin rebuild-keys Rebuild authorized_keys from DB
20 20 //! mnw-admin git-auth <key_id> Authenticate SSH git/management operations
21 + //! mnw-admin setup-git Set up SSH directories, permissions, sudoers
21 22 //!
22 23 //! SSH management commands (via git-auth dispatcher):
23 24 //! repo list List your repositories
@@ -110,6 +111,8 @@ enum Command {
110 111 },
111 112 /// Install post-receive hooks on all git repos for build triggers
112 113 InstallHooks,
114 + /// Set up SSH infrastructure for git access (directories, permissions, sudoers)
115 + SetupGit,
113 116 }
114 117
115 118 #[tokio::main]
@@ -144,6 +147,7 @@ async fn main() -> anyhow::Result<()> {
144 147 Command::RebuildKeys => cmd_rebuild_keys(&pool).await?,
145 148 Command::GitAuth { key_id } => cmd_git_auth(&pool, &key_id).await?,
146 149 Command::InstallHooks => cmd_install_hooks().await?,
150 + Command::SetupGit => cmd_setup_git()?,
147 151 }
148 152
149 153 Ok(())
@@ -651,6 +655,75 @@ async fn cmd_install_hooks() -> anyhow::Result<()> {
651 655 Ok(())
652 656 }
653 657
658 + fn cmd_setup_git() -> anyhow::Result<()> {
659 + use std::fs;
660 + use std::os::unix::fs::PermissionsExt;
661 + use std::path::Path;
662 +
663 + let ssh_dir = Path::new("/opt/git/.ssh");
664 + let authorized_keys = ssh_dir.join("authorized_keys");
665 + let sudoers_file = Path::new("/etc/sudoers.d/mnw-git-ssh");
666 + let mnw_admin = Path::new(makenotwork::git_ssh::MNW_ADMIN_PATH);
667 +
668 + // 1. Create git user's .ssh directory
669 + if !ssh_dir.exists() {
670 + fs::create_dir_all(ssh_dir)?;
671 + println!("[setup] Created {}", ssh_dir.display());
672 + }
673 + fs::set_permissions(ssh_dir, fs::Permissions::from_mode(0o700))?;
674 + chown("git:git", ssh_dir)?;
675 +
676 + // 2. Create authorized_keys
677 + if !authorized_keys.exists() {
678 + fs::write(&authorized_keys, "")?;
679 + println!("[setup] Created {}", authorized_keys.display());
680 + }
681 + fs::set_permissions(&authorized_keys, fs::Permissions::from_mode(0o600))?;
682 + chown("git:git", &authorized_keys)?;
683 +
684 + // 3. Check mnw-admin binary
685 + if !mnw_admin.exists() {
686 + println!("[setup] WARNING: {} not found. Deploy the binary first.", mnw_admin.display());
687 + }
688 +
689 + // 4. Install sudoers rule
690 + if !sudoers_file.exists() {
691 + let rule = format!(
692 + "makenotwork ALL=(git) NOPASSWD: {} rebuild-keys\n",
693 + mnw_admin.display(),
694 + );
695 + fs::write(sudoers_file, &rule)?;
696 + fs::set_permissions(sudoers_file, fs::Permissions::from_mode(0o440))?;
697 + println!("[setup] Created sudoers rule: {}", sudoers_file.display());
698 +
699 + // Verify syntax
700 + let status = std::process::Command::new("visudo")
701 + .args(["-cf", &sudoers_file.to_string_lossy()])
702 + .status()?;
703 + if !status.success() {
704 + anyhow::bail!("sudoers syntax check failed — fix {} manually", sudoers_file.display());
705 + }
706 + } else {
707 + println!("[setup] Sudoers rule already exists: {}", sudoers_file.display());
708 + }
709 +
710 + println!("[setup] Git SSH infrastructure ready.");
711 + println!(" Users add SSH keys via the dashboard.");
712 + println!(" Clone: git clone git@makenot.work:{{username}}/{{repo}}.git");
713 + Ok(())
714 + }
715 +
716 + /// Run `chown <spec> <path>`.
717 + fn chown(spec: &str, path: &std::path::Path) -> anyhow::Result<()> {
718 + let status = std::process::Command::new("chown")
719 + .args([spec, &path.to_string_lossy()])
720 + .status()?;
721 + if !status.success() {
722 + anyhow::bail!("chown {} {} failed", spec, path.display());
723 + }
724 + Ok(())
725 + }
726 +
654 727 async fn cmd_rebuild_keys(pool: &PgPool) -> anyhow::Result<()> {
655 728 let key_count = db::ssh_keys::get_all_keys_with_username(pool).await?.len();
656 729 makenotwork::git_ssh::write_authorized_keys(pool, true).await?;
@@ -40,6 +40,7 @@ pub(crate) mod invites;
40 40 pub(crate) mod analytics;
41 41 pub(crate) mod email_suppressions;
42 42 pub mod git_repos;
43 + pub mod repo_collaborators;
43 44 pub mod ssh_keys;
44 45 pub mod issues;
45 46 pub(crate) mod reports;
@@ -0,0 +1,137 @@
1 + //! Per-repo collaborator access queries.
2 +
3 + use chrono::{DateTime, Utc};
4 + use sqlx::{FromRow, PgPool};
5 + use uuid::Uuid;
6 +
7 + use super::{GitRepoId, UserId};
8 + use crate::error::Result;
9 +
10 + #[derive(Debug, FromRow)]
11 + pub struct DbRepoCollaborator {
12 + pub id: Uuid,
13 + pub repo_id: GitRepoId,
14 + pub user_id: UserId,
15 + pub can_push: bool,
16 + pub created_at: DateTime<Utc>,
17 + }
18 +
19 + /// Collaborator with joined username for display.
20 + #[derive(Debug, FromRow)]
21 + pub struct CollaboratorWithUsername {
22 + pub id: Uuid,
23 + pub user_id: UserId,
24 + pub username: String,
25 + pub can_push: bool,
26 + pub created_at: DateTime<Utc>,
27 + }
28 +
29 + /// Add a collaborator to a repo. Returns the new record.
30 + #[tracing::instrument(skip_all)]
31 + pub async fn add_collaborator(
32 + pool: &PgPool,
33 + repo_id: GitRepoId,
34 + user_id: UserId,
35 + can_push: bool,
36 + ) -> Result<DbRepoCollaborator> {
37 + let row = sqlx::query_as::<_, DbRepoCollaborator>(
38 + r#"
39 + INSERT INTO repo_collaborators (repo_id, user_id, can_push)
40 + VALUES ($1, $2, $3)
41 + RETURNING *
42 + "#,
43 + )
44 + .bind(repo_id)
45 + .bind(user_id)
46 + .bind(can_push)
47 + .fetch_one(pool)
48 + .await?;
49 +
50 + Ok(row)
51 + }
52 +
53 + /// Remove a collaborator from a repo. Returns true if a row was deleted.
54 + #[tracing::instrument(skip_all)]
55 + pub async fn remove_collaborator(
56 + pool: &PgPool,
57 + repo_id: GitRepoId,
58 + user_id: UserId,
59 + ) -> Result<bool> {
60 + let result = sqlx::query(
61 + "DELETE FROM repo_collaborators WHERE repo_id = $1 AND user_id = $2",
62 + )
63 + .bind(repo_id)
64 + .bind(user_id)
65 + .execute(pool)
66 + .await?;
67 +
68 + Ok(result.rows_affected() > 0)
69 + }
70 +
71 + /// List collaborators for a repo, with usernames.
72 + #[tracing::instrument(skip_all)]
73 + pub async fn list_collaborators(
74 + pool: &PgPool,
75 + repo_id: GitRepoId,
76 + ) -> Result<Vec<CollaboratorWithUsername>> {
77 + let rows = sqlx::query_as::<_, CollaboratorWithUsername>(
78 + r#"
79 + SELECT rc.id, rc.user_id, u.username, rc.can_push, rc.created_at
80 + FROM repo_collaborators rc
81 + JOIN users u ON u.id = rc.user_id
82 + WHERE rc.repo_id = $1
83 + ORDER BY rc.created_at ASC
84 + "#,
85 + )
86 + .bind(repo_id)
87 + .fetch_all(pool)
88 + .await?;
89 +
90 + Ok(rows)
91 + }
92 +
93 + /// Check if a user has push access to a repo (either owner or collaborator with can_push).
94 + #[tracing::instrument(skip_all)]
95 + pub async fn can_user_push(
96 + pool: &PgPool,
97 + repo_id: GitRepoId,
98 + user_id: UserId,
99 + ) -> Result<bool> {
100 + let row: (bool,) = sqlx::query_as(
101 + r#"
102 + SELECT EXISTS(
103 + SELECT 1 FROM repo_collaborators
104 + WHERE repo_id = $1 AND user_id = $2 AND can_push = true
105 + )
106 + "#,
107 + )
108 + .bind(repo_id)
109 + .bind(user_id)
110 + .fetch_one(pool)
111 + .await?;
112 +
113 + Ok(row.0)
114 + }
115 +
116 + /// Check if a user has read access to a repo (any collaborator record, regardless of can_push).
117 + #[tracing::instrument(skip_all)]
118 + pub async fn is_collaborator(
119 + pool: &PgPool,
120 + repo_id: GitRepoId,
121 + user_id: UserId,
122 + ) -> Result<bool> {
123 + let row: (bool,) = sqlx::query_as(
124 + r#"
125 + SELECT EXISTS(
126 + SELECT 1 FROM repo_collaborators
127 + WHERE repo_id = $1 AND user_id = $2
128 + )
129 + "#,
130 + )
131 + .bind(repo_id)
132 + .bind(user_id)
133 + .fetch_one(pool)
134 + .await?;
135 +
136 + Ok(row.0)
137 + }
@@ -95,16 +95,27 @@ async fn exec_git_operation(
95 95 }
96 96 };
97 97
98 - // Permission check
98 + // Permission check — owner always has full access, collaborators checked via DB
99 + let is_owner = user_id == owner_user.id;
99 100 match operation {
100 101 GitOperation::ReceivePack => {
101 - if user_id != owner_user.id {
102 - anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name);
102 + if !is_owner {
103 + let can_push = db::repo_collaborators::can_user_push(pool, repo.id, user_id)
104 + .await
105 + .unwrap_or(false);
106 + if !can_push {
107 + anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name);
108 + }
103 109 }
104 110 }
105 111 GitOperation::UploadPack | GitOperation::Archive => {
106 - if repo.visibility == db::Visibility::Private && user_id != owner_user.id {
107 - anyhow::bail!("repository not found");
112 + if repo.visibility == db::Visibility::Private && !is_owner {
113 + let is_collab = db::repo_collaborators::is_collaborator(pool, repo.id, user_id)
114 + .await
115 + .unwrap_or(false);
116 + if !is_collab {
117 + anyhow::bail!("repository not found");
118 + }
108 119 }
109 120 }
110 121 }