Skip to main content

max / mnw-cli

Extract format helpers, add TUI widgets, move project docs into repo Deduplicate formatting logic into src/format.rs and reusable TUI table/detail widgets into src/tui/widgets.rs. Move project docs into repo for ~/Code directory layout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-30 02:26 UTC
Commit: 54ab7718f961e04af6288b66bca76d1bc8ba5abf
Parent: 1ae8151
16 files changed, +281 insertions, -539 deletions
A docs/todo.md +46
@@ -0,0 +1,46 @@
1 + # mnw-cli TODO
2 +
3 + ## Status
4 + Phases 1-8 implemented. Git proxy (Parts A-C) implemented. Code quality pass complete (format.rs, api.rs helpers, widgets.rs). Not yet deployed — needs cargo check, astra testing, then hetzner deploy. Design doc: `docs/mnw/cli.md`.
5 +
6 + ---
7 +
8 + ## Verify Code Quality Pass
9 + - [ ] Run `cargo check` (format.rs extraction, api.rs response helpers, tui/widgets.rs table renderer)
10 + - [ ] Run `cargo clippy` for warnings
11 +
12 + ## Git Proxy — Part D: Server Configuration
13 + Port 22 takeover — mnw-cli owns port 22, sshd moves to 2200 (Tailscale only).
14 +
15 + - [ ] D1: Move sshd to port 2200, `ListenAddress 100.120.174.96` (Tailscale only)
16 + - [ ] D2: Update mnw-cli .env (`SSH_PORT=22`, `GIT_SUDO_USER=git`)
17 + - [ ] D3: Sudoers rule (`/etc/sudoers.d/mnw-cli-git` — git-upload-pack, git-receive-pack, git-upload-archive)
18 + - [ ] D4: Firewall — `ufw delete allow 2222/tcp` (port 22 already open)
19 + - [ ] D5: DNS — `cli.makenot.work` A record -> `5.78.144.244`, proxy OFF
20 + - [ ] D6: Restart sequence (sshd -> verify admin SSH -> mnw-cli -> verify TUI + git clone)
21 +
22 + ## Deploy
23 + - [ ] Test on astra (full TUI + SFTP + git push/pull)
24 + - [ ] Deploy to hetzner (cross-compile via deploy.sh)
25 + - [ ] Add PoM health check for mnw-cli (port 22 SSH banner check)
26 +
27 + ## Remaining Features (from design doc)
28 + - [ ] Bulk item operations (select multiple, publish/unpublish/delete)
29 + - [ ] Tag management in TUI
30 + - [ ] Pipe mode uploads (`cat file | ssh cli.makenot.work upload ...`)
31 + - [ ] Blog post scheduling
32 + - [ ] Subscription tier management
33 + - [ ] Collection management
34 + - [ ] Custom domain management screen
35 + - [ ] Broadcast to followers
36 +
37 + ## Key Paths
38 + ```
39 + mnw-cli/src/
40 + main.rs, config.rs, api.rs, commands.rs, format.rs, staging.rs
41 + ssh/ (mod.rs, handler.rs, terminal.rs, sftp.rs, git.rs)
42 + tui/ (mod.rs, home.rs, project.rs, upload.rs, item.rs, analytics.rs,
43 + blog.rs, promo.rs, keys.rs, settings.rs, widgets.rs)
44 + mnw-cli/deploy/ (deploy.sh, mnw-cli.service)
45 + docs/mnw/server/cli.md (design doc)
46 + ```
M src/api.rs +55 -166
@@ -4,7 +4,6 @@ use serde::{Deserialize, Serialize};
4 4
5 5 /// User info returned from the SSH key lookup endpoint.
6 6 #[derive(Debug, Clone, Deserialize, Serialize)]
7 - #[allow(dead_code)]
8 7 pub struct UserInfo {
9 8 pub user_id: String,
10 9 pub username: String,
@@ -28,7 +27,6 @@ pub struct Project {
28 27
29 28 /// An item within a project.
30 29 #[derive(Debug, Clone, Deserialize, Serialize)]
31 - #[allow(dead_code)]
32 30 pub struct Item {
33 31 pub id: String,
34 32 pub title: String,
@@ -40,7 +38,6 @@ pub struct Item {
40 38
41 39 /// Period comparison stats for the creator.
42 40 #[derive(Debug, Clone, Deserialize, Serialize)]
43 - #[allow(dead_code)]
44 41 pub struct CreatorStats {
45 42 pub current_revenue_cents: i64,
46 43 pub previous_revenue_cents: i64,
@@ -54,7 +51,6 @@ pub struct CreatorStats {
54 51
55 52 /// Response from the create-item internal endpoint.
56 53 #[derive(Debug, Deserialize)]
57 - #[allow(dead_code)]
58 54 pub struct ItemCreated {
59 55 pub item_id: String,
60 56 pub project_id: String,
@@ -62,7 +58,6 @@ pub struct ItemCreated {
62 58
63 59 /// Response from the presign-upload internal endpoint.
64 60 #[derive(Debug, Deserialize)]
65 - #[allow(dead_code)]
66 61 pub struct PresignResponse {
67 62 pub upload_url: String,
68 63 pub s3_key: String,
@@ -72,7 +67,6 @@ pub struct PresignResponse {
72 67
73 68 /// Full item detail returned from the get/update endpoints.
74 69 #[derive(Debug, Clone, Deserialize, Serialize)]
75 - #[allow(dead_code)]
76 70 pub struct ItemDetail {
77 71 pub id: String,
78 72 pub title: String,
@@ -95,7 +89,6 @@ pub struct ItemDetail {
95 89
96 90 /// A version of an item.
97 91 #[derive(Debug, Clone, Deserialize, Serialize)]
98 - #[allow(dead_code)]
99 92 pub struct Version {
100 93 pub id: String,
101 94 pub version_number: String,
@@ -109,7 +102,6 @@ pub struct Version {
109 102
110 103 /// A blog post summary.
111 104 #[derive(Debug, Clone, Deserialize, Serialize)]
112 - #[allow(dead_code)]
113 105 pub struct BlogPost {
114 106 pub id: String,
115 107 pub title: String,
@@ -121,7 +113,6 @@ pub struct BlogPost {
121 113
122 114 /// A promo code.
123 115 #[derive(Debug, Clone, Deserialize, Serialize)]
124 - #[allow(dead_code)]
125 116 pub struct PromoCode {
126 117 pub id: String,
127 118 pub code: String,
@@ -137,7 +128,6 @@ pub struct PromoCode {
137 128
138 129 /// A license key.
139 130 #[derive(Debug, Clone, Deserialize, Serialize)]
140 - #[allow(dead_code)]
141 131 pub struct LicenseKey {
142 132 pub id: String,
143 133 pub key_code: String,
@@ -157,7 +147,6 @@ pub struct StorageInfo {
157 147
158 148 /// A revenue bucket for analytics timeseries.
159 149 #[derive(Debug, Clone, Deserialize, Serialize)]
160 - #[allow(dead_code)]
161 150 pub struct AnalyticsBucket {
162 151 pub label: String,
163 152 pub revenue_cents: i64,
@@ -166,7 +155,6 @@ pub struct AnalyticsBucket {
166 155
167 156 /// Per-project revenue summary.
168 157 #[derive(Debug, Clone, Deserialize, Serialize)]
169 - #[allow(dead_code)]
170 158 pub struct ProjectRevenue {
171 159 pub id: String,
172 160 pub title: String,
@@ -188,7 +176,6 @@ pub struct AnalyticsData {
188 176
189 177 /// A seller transaction.
190 178 #[derive(Debug, Clone, Deserialize, Serialize)]
191 - #[allow(dead_code)]
192 179 pub struct Transaction {
193 180 pub id: String,
194 181 pub item_title: Option<String>,
@@ -207,7 +194,6 @@ pub struct ExportResult {
207 194
208 195 /// A registered SSH key.
209 196 #[derive(Debug, Clone, Deserialize, Serialize)]
210 - #[allow(dead_code)]
211 197 pub struct SshKeyInfo {
212 198 pub id: String,
213 199 pub label: String,
@@ -221,6 +207,35 @@ pub struct GitAuthResponse {
221 207 pub repo_path: String,
222 208 }
223 209
210 + /// Check response status and deserialize JSON body, or bail with error details.
211 + async fn json_response<T: serde::de::DeserializeOwned>(
212 + resp: reqwest::Response,
213 + context: &str,
214 + ) -> anyhow::Result<T> {
215 + if !resp.status().is_success() {
216 + let status = resp.status();
217 + let body = resp.text().await.unwrap_or_default();
218 + if body.is_empty() {
219 + anyhow::bail!("{context} failed: HTTP {status}");
220 + }
221 + anyhow::bail!("{context} failed: HTTP {status} — {body}");
222 + }
223 + Ok(resp.json().await?)
224 + }
225 +
226 + /// Check response status for success, or bail with error details.
227 + async fn empty_response(resp: reqwest::Response, context: &str) -> anyhow::Result<()> {
228 + if !resp.status().is_success() {
229 + let status = resp.status();
230 + let body = resp.text().await.unwrap_or_default();
231 + if body.is_empty() {
232 + anyhow::bail!("{context} failed: HTTP {status}");
233 + }
234 + anyhow::bail!("{context} failed: HTTP {status} — {body}");
235 + }
236 + Ok(())
237 + }
238 +
224 239 /// Client for calling MNW internal API endpoints.
225 240 #[derive(Clone)]
226 241 pub struct MnwApiClient {
@@ -278,11 +293,7 @@ impl MnwApiClient {
278 293 .send()
279 294 .await?;
280 295
281 - if !resp.status().is_success() {
282 - anyhow::bail!("get_projects failed: HTTP {}", resp.status());
283 - }
284 -
285 - Ok(resp.json().await?)
296 + json_response(resp, "get_projects").await
286 297 }
287 298
288 299 /// Fetch items in a project.
@@ -303,11 +314,7 @@ impl MnwApiClient {
303 314 .send()
304 315 .await?;
305 316
306 - if !resp.status().is_success() {
307 - anyhow::bail!("get_project_items failed: HTTP {}", resp.status());
308 - }
309 -
310 - Ok(resp.json().await?)
317 + json_response(resp, "get_project_items").await
311 318 }
312 319
313 320 /// Fetch period comparison stats for a creator.
@@ -321,11 +328,7 @@ impl MnwApiClient {
321 328 .send()
322 329 .await?;
323 330
324 - if !resp.status().is_success() {
325 - anyhow::bail!("get_stats failed: HTTP {}", resp.status());
326 - }
327 -
328 - Ok(resp.json().await?)
331 + json_response(resp, "get_stats").await
329 332 }
330 333
331 334 /// Fetch storage usage and limits for a creator.
@@ -339,11 +342,7 @@ impl MnwApiClient {
339 342 .send()
340 343 .await?;
341 344
342 - if !resp.status().is_success() {
343 - anyhow::bail!("get_storage_info failed: HTTP {}", resp.status());
344 - }
345 -
346 - Ok(resp.json().await?)
345 + json_response(resp, "get_storage_info").await
347 346 }
348 347
349 348 /// Create an item in a project.
@@ -370,13 +369,7 @@ impl MnwApiClient {
370 369 .send()
371 370 .await?;
372 371
373 - if !resp.status().is_success() {
374 - let status = resp.status();
375 - let body = resp.text().await.unwrap_or_default();
376 - anyhow::bail!("create_item failed: HTTP {} — {}", status, body);
377 - }
378 -
379 - Ok(resp.json().await?)
372 + json_response(resp, "create_item").await
380 373 }
381 374
382 375 /// Get a presigned S3 upload URL.
@@ -403,13 +396,7 @@ impl MnwApiClient {
403 396 .send()
404 397 .await?;
405 398
406 - if !resp.status().is_success() {
407 - let status = resp.status();
408 - let body = resp.text().await.unwrap_or_default();
409 - anyhow::bail!("presign_upload failed: HTTP {} — {}", status, body);
410 - }
411 -
412 - Ok(resp.json().await?)
399 + json_response(resp, "presign_upload").await
413 400 }
414 401
415 402 /// Confirm a completed S3 upload.
@@ -434,17 +421,11 @@ impl MnwApiClient {
434 421 .send()
435 422 .await?;
436 423
437 - if !resp.status().is_success() {
438 - let status = resp.status();
439 - let body = resp.text().await.unwrap_or_default();
440 - anyhow::bail!("confirm_upload failed: HTTP {} — {}", status, body);
441 - }
442 -
443 424 #[derive(Deserialize)]
444 425 struct Resp {
445 426 success: bool,
446 427 }
447 - let r: Resp = resp.json().await?;
428 + let r: Resp = json_response(resp, "confirm_upload").await?;
448 429 Ok(r.success)
449 430 }
450 431
@@ -463,13 +444,7 @@ impl MnwApiClient {
463 444 .send()
464 445 .await?;
465 446
466 - if !resp.status().is_success() {
467 - let status = resp.status();
468 - let body = resp.text().await.unwrap_or_default();
469 - anyhow::bail!("get_item_detail failed: HTTP {} — {}", status, body);
470 - }
471 -
472 - Ok(resp.json().await?)
447 + json_response(resp, "get_item_detail").await
473 448 }
474 449
475 450 /// Update item fields. Only non-None fields are changed.
@@ -505,13 +480,7 @@ impl MnwApiClient {
505 480 .send()
506 481 .await?;
507 482
508 - if !resp.status().is_success() {
509 - let status = resp.status();
510 - let body = resp.text().await.unwrap_or_default();
511 - anyhow::bail!("update_item failed: HTTP {} — {}", status, body);
512 - }
513 -
514 - Ok(resp.json().await?)
483 + json_response(resp, "update_item").await
515 484 }
516 485
517 486 /// Delete an item permanently.
@@ -525,13 +494,7 @@ impl MnwApiClient {
525 494 .send()
526 495 .await?;
527 496
528 - if !resp.status().is_success() {
529 - let status = resp.status();
530 - let body = resp.text().await.unwrap_or_default();
531 - anyhow::bail!("delete_item failed: HTTP {} — {}", status, body);
532 - }
533 -
534 - Ok(())
497 + empty_response(resp, "delete_item").await
535 498 }
536 499
537 500 /// Publish an item (set is_public=true).
@@ -552,13 +515,7 @@ impl MnwApiClient {
552 515 .send()
553 516 .await?;
554 517
555 - if !resp.status().is_success() {
556 - let status = resp.status();
557 - let body = resp.text().await.unwrap_or_default();
558 - anyhow::bail!("publish_item failed: HTTP {} — {}", status, body);
559 - }
560 -
561 - Ok(resp.json().await?)
518 + json_response(resp, "publish_item").await
562 519 }
563 520
564 521 /// Unpublish an item (set is_public=false).
@@ -579,13 +536,7 @@ impl MnwApiClient {
579 536 .send()
580 537 .await?;
581 538
582 - if !resp.status().is_success() {
583 - let status = resp.status();
584 - let body = resp.text().await.unwrap_or_default();
585 - anyhow::bail!("unpublish_item failed: HTTP {} — {}", status, body);
586 - }
587 -
588 - Ok(resp.json().await?)
539 + json_response(resp, "unpublish_item").await
589 540 }
590 541
591 542 /// Fetch versions for an item.
@@ -606,11 +557,7 @@ impl MnwApiClient {
606 557 .send()
607 558 .await?;
608 559
609 - if !resp.status().is_success() {
610 - anyhow::bail!("get_item_versions failed: HTTP {}", resp.status());
611 - }
612 -
613 - Ok(resp.json().await?)
560 + json_response(resp, "get_item_versions").await
614 561 }
615 562
616 563 /// Upload a file to S3 using a presigned URL.
@@ -661,11 +608,7 @@ impl MnwApiClient {
661 608 .send()
662 609 .await?;
663 610
664 - if !resp.status().is_success() {
665 - anyhow::bail!("list_blog_posts failed: HTTP {}", resp.status());
666 - }
667 -
668 - Ok(resp.json().await?)
611 + json_response(resp, "list_blog_posts").await
669 612 }
670 613
671 614 /// Create a blog post.
@@ -692,13 +635,7 @@ impl MnwApiClient {
692 635 .send()
693 636 .await?;
694 637
695 - if !resp.status().is_success() {
696 - let status = resp.status();
697 - let body = resp.text().await.unwrap_or_default();
698 - anyhow::bail!("create_blog_post failed: HTTP {} — {}", status, body);
699 - }
700 -
701 - Ok(resp.json().await?)
638 + json_response(resp, "create_blog_post").await
702 639 }
703 640
704 641 /// Delete a blog post.
@@ -712,11 +649,7 @@ impl MnwApiClient {
712 649 .send()
713 650 .await?;
714 651
715 - if !resp.status().is_success() {
716 - anyhow::bail!("delete_blog_post failed: HTTP {}", resp.status());
717 - }
718 -
719 - Ok(())
652 + empty_response(resp, "delete_blog_post").await
720 653 }
721 654
722 655 // ── Promo codes ──
@@ -732,11 +665,7 @@ impl MnwApiClient {
732 665 .send()
733 666 .await?;
734 667
735 - if !resp.status().is_success() {
736 - anyhow::bail!("list_promo_codes failed: HTTP {}", resp.status());
737 - }
738 -
739 - Ok(resp.json().await?)
668 + json_response(resp, "list_promo_codes").await
740 669 }
741 670
742 671 /// Create a promo code.
@@ -772,13 +701,7 @@ impl MnwApiClient {
772 701 .send()
773 702 .await?;
774 703
775 - if !resp.status().is_success() {
776 - let status = resp.status();
777 - let body = resp.text().await.unwrap_or_default();
778 - anyhow::bail!("create_promo_code failed: HTTP {} — {}", status, body);
779 - }
780 -
781 - Ok(resp.json().await?)
704 + json_response(resp, "create_promo_code").await
782 705 }
783 706
784 707 /// Delete a promo code.
@@ -795,11 +718,7 @@ impl MnwApiClient {
795 718 .send()
796 719 .await?;
797 720
798 - if !resp.status().is_success() {
799 - anyhow::bail!("delete_promo_code failed: HTTP {}", resp.status());
800 - }
801 -
802 - Ok(())
721 + empty_response(resp, "delete_promo_code").await
803 722 }
804 723
805 724 // ── License keys ──
@@ -822,11 +741,7 @@ impl MnwApiClient {
822 741 .send()
823 742 .await?;
824 743
825 - if !resp.status().is_success() {
826 - anyhow::bail!("list_license_keys failed: HTTP {}", resp.status());
827 - }
828 -
829 - Ok(resp.json().await?)
744 + json_response(resp, "list_license_keys").await
830 745 }
831 746
832 747 /// Generate a new license key for an item.
@@ -847,13 +762,7 @@ impl MnwApiClient {
847 762 .send()
848 763 .await?;
849 764
850 - if !resp.status().is_success() {
851 - let status = resp.status();
852 - let body = resp.text().await.unwrap_or_default();
853 - anyhow::bail!("generate_license_key failed: HTTP {} — {}", status, body);
854 - }
855 -
856 - Ok(resp.json().await?)
765 + json_response(resp, "generate_license_key").await
857 766 }
858 767
859 768 /// Revoke a license key.
@@ -874,11 +783,7 @@ impl MnwApiClient {
874 783 .send()
875 784 .await?;
876 785
877 - if !resp.status().is_success() {
878 - anyhow::bail!("revoke_license_key failed: HTTP {}", resp.status());
879 - }
880 -
881 - Ok(())
786 + empty_response(resp, "revoke_license_key").await
882 787 }
883 788
884 789 // ── Analytics ──
@@ -898,11 +803,7 @@ impl MnwApiClient {
898 803 .send()
899 804 .await?;
900 805
901 - if !resp.status().is_success() {
902 - anyhow::bail!("get_analytics failed: HTTP {}", resp.status());
903 - }
904 -
905 - Ok(resp.json().await?)
806 + json_response(resp, "get_analytics").await
906 807 }
907 808
908 809 /// Get recent seller transactions.
@@ -916,11 +817,7 @@ impl MnwApiClient {
916 817 .send()
917 818 .await?;
918 819
919 - if !resp.status().is_success() {
920 - anyhow::bail!("get_transactions failed: HTTP {}", resp.status());
921 - }
922 -
923 - Ok(resp.json().await?)
820 + json_response(resp, "get_transactions").await
924 821 }
925 822
926 823 /// Export sales as CSV string.
@@ -934,11 +831,7 @@ impl MnwApiClient {
934 831 .send()
935 832 .await?;
936 833
937 - if !resp.status().is_success() {
938 - anyhow::bail!("export_sales_csv failed: HTTP {}", resp.status());
939 - }
940 -
941 - Ok(resp.json().await?)
834 + json_response(resp, "export_sales_csv").await
942 835 }
943 836
944 837 // ── SSH keys ──
@@ -990,10 +883,6 @@ impl MnwApiClient {
990 883 .send()
991 884 .await?;
992 885
993 - if !resp.status().is_success() {
994 - anyhow::bail!("list_ssh_keys failed: HTTP {}", resp.status());
995 - }
996 -
997 - Ok(resp.json().await?)
886 + json_response(resp, "list_ssh_keys").await
998 887 }
999 888 }
M src/commands.rs +6 -13
@@ -5,6 +5,7 @@
5 5 //! return it for the SSH channel.
6 6
7 7 use crate::api::{MnwApiClient, UserInfo};
8 + use crate::format;
8 9
9 10 /// Execute a non-interactive command and return the output bytes.
10 11 pub async fn execute(
@@ -70,7 +71,7 @@ async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8
70 71 out.push_str("\r\n");
71 72 for p in &projects {
72 73 let status = if p.is_public { "public" } else { "draft" };
73 - let revenue = format_cents(p.revenue_cents);
74 + let revenue = format::format_cents(p.revenue_cents);
74 75 out.push_str(&format!(
75 76 "{:<30} {:<12} {:<8} {:<6} {:<10}\r\n",
76 77 truncate(&p.title, 29),
@@ -101,8 +102,8 @@ async fn cmd_analytics(
101 102 let mut out = String::new();
102 103 out.push_str(&format!("Analytics ({range})\r\n\r\n"));
103 104
104 - let rev = format_cents(data.current_revenue_cents);
105 - let prev_rev = format_cents(data.previous_revenue_cents);
105 + let rev = format::format_cents(data.current_revenue_cents);
106 + let prev_rev = format::format_cents(data.previous_revenue_cents);
106 107 out.push_str(&format!("Revenue: {rev} (prev: {prev_rev})\r\n"));
107 108 out.push_str(&format!(
108 109 "Sales: {} (prev: {})\r\n",
@@ -119,7 +120,7 @@ async fn cmd_analytics(
119 120 out.push_str(&format!(
120 121 " {:<30} {}\r\n",
121 122 truncate(&p.title, 29),
122 - format_cents(p.revenue_cents)
123 + format::format_cents(p.revenue_cents)
123 124 ));
124 125 }
125 126 }
@@ -147,7 +148,7 @@ async fn cmd_transactions(user: &UserInfo, api: &MnwApiClient, json: bool) -> Ve
147 148 out.push_str("\r\n");
148 149 for tx in &txs {
149 150 let title = tx.item_title.as_deref().unwrap_or("--");
150 - let amount = format_cents(tx.amount_cents as i64);
151 + let amount = format::format_cents(tx.amount_cents as i64);
151 152 let date = tx.created_at.get(..10).unwrap_or(&tx.created_at);
152 153 out.push_str(&format!(
153 154 "{:<30} {:<10} {:<12} {:<12}\r\n",
@@ -284,14 +285,6 @@ async fn cmd_blog_list(
284 285 }
285 286 }
286 287
287 - fn format_cents(cents: i64) -> String {
288 - if cents == 0 {
289 - "$0".to_string()
290 - } else {
291 - format!("${}.{:02}", cents / 100, cents.abs() % 100)
292 - }
293 - }
294 -
295 288 fn help_text() -> Vec<u8> {
296 289 b"Usage: ssh cli.makenot.work <command>\r\n\
297 290 \r\n\
@@ -0,0 +1,63 @@
1 + //! Shared formatting utilities used across TUI screens and commands.
2 +
3 + /// Format a revenue amount in cents. Returns "$0" for zero.
4 + pub fn format_cents(cents: i64) -> String {
5 + if cents == 0 {
6 + "$0".to_string()
7 + } else {
8 + format!("${}.{:02}", cents / 100, cents.abs() % 100)
9 + }
10 + }
11 +
12 + /// Format an item price in cents. Returns "Free" for zero.
13 + pub fn format_price(cents: i32) -> String {
14 + if cents == 0 {
15 + "Free".to_string()
16 + } else {
17 + format!("${}.{:02}", cents / 100, cents % 100)
18 + }
19 + }
20 +
21 + /// Human-readable creator tier label.
22 + pub fn format_tier(tier: &str) -> &str {
23 + match tier {
24 + "basic" => "Basic",
25 + "small_files" => "Small Files",
26 + "big_files" => "Big Files",
27 + "streaming" => "Streaming",
28 + _ => tier,
29 + }
30 + }
31 +
32 + /// Human-readable project type label.
33 + pub fn format_project_type(pt: &str) -> &str {
34 + match pt {
35 + "software" => "Software",
36 + "music" => "Music",
37 + "blog" => "Blog",
38 + "video" => "Video",
39 + "audio" => "Audio",
40 + "art" => "Art",
41 + "writing" => "Writing",
42 + "education" => "Education",
43 + other => other,
44 + }
45 + }
46 +
47 + /// Human-readable item type label.
48 + pub fn format_item_type(it: &str) -> &str {
49 + match it {
50 + "audio" => "Audio",
51 + "text" => "Text",
52 + "video" => "Video",
53 + "image" => "Image",
54 + "plugin" => "Plugin",
55 + "preset" => "Preset",
56 + "sample" => "Sample",
57 + "course" => "Course",
58 + "template" => "Template",
59 + "digital" => "Digital",
60 + "bundle" => "Bundle",
61 + other => other,
62 + }
63 + }
@@ -7,6 +7,7 @@
7 7 mod api;
8 8 mod commands;
9 9 mod config;
10 + mod format;
10 11 mod ssh;
11 12 mod staging;
12 13 mod tui;
@@ -4,9 +4,12 @@ use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 5 use ratatui::style::{Color, Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Row, Table};
7 + use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Row};
8 +
9 + use crate::format;
8 10
9 11 use super::App;
12 + use super::widgets;
10 13
11 14 pub fn render(frame: &mut Frame, app: &App) {
12 15 let area = frame.area();
@@ -103,7 +106,7 @@ fn render_stat_cards(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
103 106 let stats = [
104 107 (
105 108 "Revenue",
106 - format_cents(data.as_ref().map(|d| d.current_revenue_cents).unwrap_or(0)),
109 + format::format_cents(data.as_ref().map(|d| d.current_revenue_cents).unwrap_or(0)),
107 110 data.as_ref().map(|d| pct_change(d.current_revenue_cents, d.previous_revenue_cents)),
108 111 ),
109 112 (
@@ -176,7 +179,7 @@ fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout
176 179 .value(b.revenue_cents as u64)
177 180 .label(Line::from(b.label.clone()))
178 181 .text_value(if b.revenue_cents > 0 {
179 - format_cents(b.revenue_cents)
182 + format::format_cents(b.revenue_cents)
180 183 } else {
181 184 String::new()
182 185 })
@@ -222,22 +225,13 @@ fn render_chart_and_projects(frame: &mut Frame, app: &App, area: ratatui::layout
222 225 .map(|p| {
223 226 Row::new(vec![
224 227 format!(" {}", p.title),
225 - format_cents(p.revenue_cents),
228 + format::format_cents(p.revenue_cents),
226 229 ])
227 230 })
228 231 .collect();
229 232
230 - let header = Row::new(vec![" Project", "Revenue"])
231 - .style(
232 - Style::default()
233 - .fg(Color::DarkGray)
234 - .add_modifier(Modifier::BOLD),
235 - )
236 - .bottom_margin(0);
237 -
238 233 let widths = [Constraint::Min(20), Constraint::Length(12)];
239 - let table = Table::new(rows, widths).header(header);
240 - frame.render_widget(table, chunks[4]);
234 + widgets::render_table(frame, chunks[4], &[" Project", "Revenue"], &widths, rows);
241 235 }
242 236 }
243 237 }
@@ -265,23 +259,13 @@ fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
265 259 let empty = Paragraph::new(" No transactions.");
266 260 frame.render_widget(empty, chunks[1]);
267 261 } else {
268 - let selected = app.selected_index;
269 262 let rows: Vec<Row> = app
270 263 .transactions
271 264 .iter()
272 265 .enumerate()
273 266 .map(|(i, tx)| {
274 - let style = if i == selected {
275 - Style::default()
276 - .bg(Color::DarkGray)
277 - .fg(Color::White)
278 - .add_modifier(Modifier::BOLD)
279 - } else {
280 - Style::default()
281 - };
282 -
283 267 let title = tx.item_title.as_deref().unwrap_or("--");
284 - let amount = format_cents(tx.amount_cents as i64);
268 + let amount = format::format_cents(tx.amount_cents as i64);
285 269 let date = tx.created_at.get(..10).unwrap_or(&tx.created_at);
286 270
287 271 Row::new(vec![
@@ -290,18 +274,10 @@ fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
290 274 tx.status.clone(),
291 275 date.to_string(),
292 276 ])
293 - .style(style)
277 + .style(widgets::selected_style(i, Some(app.selected_index)))
294 278 })
295 279 .collect();
296 280
297 - let table_header = Row::new(vec![" Item", "Amount", "Status", "Date"])
298 - .style(
299 - Style::default()
300 - .fg(Color::DarkGray)
301 - .add_modifier(Modifier::BOLD),
302 - )
303 - .bottom_margin(0);
304 -
305 281 let widths = [
306 282 Constraint::Min(20),
307 283 Constraint::Length(12),
@@ -309,16 +285,7 @@ fn render_transactions(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
309 285 Constraint::Length(12),
310 286 ];
311 287
312 - let table = Table::new(rows, widths).header(table_header);
313 - frame.render_widget(table, chunks[1]);
314 - }
315 - }
316 -
317 - fn format_cents(cents: i64) -> String {
318 - if cents == 0 {
319 - "$0".to_string()
320 - } else {
321 - format!("${}.{:02}", cents / 100, cents.abs() % 100)
288 + widgets::render_table(frame, chunks[1], &[" Item", "Amount", "Status", "Date"], &widths, rows);
322 289 }
323 290 }
324 291
M src/tui/blog.rs +4 -22
@@ -4,9 +4,10 @@ use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 5 use ratatui::style::{Color, Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
9 9 use super::App;
10 + use super::widgets;
10 11
11 12 pub fn render(frame: &mut Frame, app: &App) {
12 13 let area = frame.area();
@@ -106,21 +107,11 @@ pub fn render(frame: &mut Frame, app: &App) {
106 107 }
107 108
108 109 fn render_post_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
109 - let selected = app.selected_index;
110 110 let rows: Vec<Row> = app
111 111 .blog_posts
112 112 .iter()
113 113 .enumerate()
114 114 .map(|(i, post)| {
115 - let style = if i == selected {
116 - Style::default()
117 - .bg(Color::DarkGray)
118 - .fg(Color::White)
119 - .add_modifier(Modifier::BOLD)
120 - } else {
121 - Style::default()
122 - };
123 -
124 115 let status = if post.is_published {
125 116 "published"
126 117 } else {
@@ -134,18 +125,10 @@ fn render_post_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
134 125 status.to_string(),
135 126 date.to_string(),
136 127 ])
137 - .style(style)
128 + .style(widgets::selected_style(i, Some(app.selected_index)))
138 129 })
139 130 .collect();
140 131
141 - let header = Row::new(vec![" Title", "Slug", "Status", "Created"])
142 - .style(
143 - Style::default()
144 - .fg(Color::DarkGray)
145 - .add_modifier(Modifier::BOLD),
146 - )
147 - .bottom_margin(0);
148 -
149 132 let widths = [
150 133 Constraint::Min(20),
151 134 Constraint::Length(20),
@@ -153,6 +136,5 @@ fn render_post_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
153 136 Constraint::Length(12),
154 137 ];
155 138
156 - let table = Table::new(rows, widths).header(header);
157 - frame.render_widget(table, area);
139 + widgets::render_table(frame, area, &[" Title", "Slug", "Status", "Created"], &widths, rows);
158 140 }
M src/tui/home.rs +10 -58
@@ -4,9 +4,12 @@ use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 5 use ratatui::style::{Color, Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 +
9 + use crate::format;
8 10
9 11 use super::App;
12 + use super::widgets;
10 13
11 14 pub fn render(frame: &mut Frame, app: &App) {
12 15 let area = frame.area();
@@ -15,7 +18,7 @@ pub fn render(frame: &mut Frame, app: &App) {
15 18 .user
16 19 .creator_tier
17 20 .as_deref()
18 - .map(format_tier)
21 + .map(format::format_tier)
19 22 .unwrap_or("No tier");
20 23
21 24 let title = Line::from(vec![
@@ -109,7 +112,7 @@ fn render_stats(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
109 112
110 113 let (revenue, sales, followers, items) = if let Some(ref s) = app.stats {
111 114 (
112 - format_cents(s.current_revenue_cents),
115 + format::format_cents(s.current_revenue_cents),
113 116 s.current_sales.to_string(),
114 117 s.current_followers.to_string(),
115 118 s.total_items.to_string(),
@@ -144,42 +147,24 @@ fn render_stats(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
144 147 }
145 148
146 149 fn render_project_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
147 - let selected = app.selected_index;
148 150 let rows: Vec<Row> = app
149 151 .projects
150 152 .iter()
151 153 .enumerate()
152 154 .map(|(i, p)| {
153 - let style = if i == selected {
154 - Style::default()
155 - .bg(Color::DarkGray)
156 - .fg(Color::White)
157 - .add_modifier(Modifier::BOLD)
158 - } else {
159 - Style::default()
160 - };
161 -
162 155 let visibility = if p.is_public { "public" } else { "draft" };
163 156
164 157 Row::new(vec![
165 158 format!(" {}", p.title),
166 - format_project_type(&p.project_type),
159 + format::format_project_type(&p.project_type).to_string(),
167 160 visibility.to_string(),
168 161 p.item_count.to_string(),
169 - format_cents(p.revenue_cents),
162 + format::format_cents(p.revenue_cents),
170 163 ])
171 - .style(style)
164 + .style(widgets::selected_style(i, Some(app.selected_index)))
172 165 })
173 166 .collect();
174 167
175 - let header = Row::new(vec![" Title", "Type", "Status", "Items", "Revenue"])
176 - .style(
177 - Style::default()
178 - .fg(Color::DarkGray)
179 - .add_modifier(Modifier::BOLD),
180 - )
181 - .bottom_margin(0);
182 -
183 168 let widths = [
184 169 Constraint::Min(20),
185 170 Constraint::Length(12),
@@ -188,39 +173,6 @@ fn render_project_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rec
188 173 Constraint::Length(12),
189 174 ];
190 175
191 - let table = Table::new(rows, widths).header(header);
192 - frame.render_widget(table, area);
176 + widgets::render_table(frame, area, &[" Title", "Type", "Status", "Items", "Revenue"], &widths, rows);
193 177 }
194 178
195 - fn format_cents(cents: i64) -> String {
196 - if cents == 0 {
197 - "$0".to_string()
198 - } else {
199 - format!("${}.{:02}", cents / 100, cents % 100)
200 - }
201 - }
202 -
203 - fn format_tier(tier: &str) -> &str {
204 - match tier {
205 - "basic" => "Basic",
206 - "small_files" => "Small Files",
207 - "big_files" => "Big Files",
208 - "streaming" => "Streaming",
209 - _ => tier,
210 - }
211 - }
212 -
213 - fn format_project_type(pt: &str) -> String {
214 - match pt {
215 - "software" => "Software",
216 - "music" => "Music",
217 - "blog" => "Blog",
218 - "video" => "Video",
219 - "audio" => "Audio",
220 - "art" => "Art",
221 - "writing" => "Writing",
222 - "education" => "Education",
223 - other => other,
224 - }
225 - .to_string()
226 - }
M src/tui/item.rs +7 -38
@@ -4,12 +4,14 @@ use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 5 use ratatui::style::{Color, Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
9 9 use crate::api::ItemDetail;
10 + use crate::format;
10 11 use crate::staging;
11 12
12 13 use super::App;
14 + use super::widgets;
13 15
14 16 pub fn render(frame: &mut Frame, app: &App) {
15 17 let area = frame.area();
@@ -29,7 +31,7 @@ pub fn render(frame: &mut Frame, app: &App) {
29 31 Span::styled(" Makenot.work ", Style::default().add_modifier(Modifier::BOLD)),
30 32 Span::raw(" -- "),
31 33 Span::styled(&item.title, Style::default().add_modifier(Modifier::BOLD)),
32 - Span::raw(format!(" -- {} -- {} ", format_item_type(&item.item_type), status_label)),
34 + Span::raw(format!(" -- {} -- {} ", format::format_item_type(&item.item_type), status_label)),
33 35 ]);
34 36
35 37 let block = Block::default()
@@ -138,7 +140,7 @@ fn render_item_info(
138 140 item: &ItemDetail,
139 141 area: ratatui::layout::Rect,
140 142 ) {
141 - let price = format_price(item.price_cents);
143 + let price = format::format_price(item.price_cents);
142 144 let desc_preview = item
143 145 .description
144 146 .as_deref()
@@ -197,7 +199,7 @@ fn render_item_info(
197 199 Line::from(vec![
198 200 Span::raw(" "),
199 201 Span::styled("Type: ", Style::default().fg(Color::DarkGray)),
200 - Span::raw(format_item_type(&item.item_type)),
202 + Span::raw(format::format_item_type(&item.item_type)),
201 203 Span::raw(" "),
202 204 Span::styled("Slug: ", Style::default().fg(Color::DarkGray)),
203 205 Span::raw(item.slug.clone()),
@@ -252,14 +254,6 @@ fn render_versions_table(frame: &mut Frame, app: &App, area: ratatui::layout::Re
252 254 })
253 255 .collect();
254 256
255 - let header = Row::new(vec![" Version", "File", "Size", "Downloads", "Date"])
256 - .style(
257 - Style::default()
258 - .fg(Color::DarkGray)
259 - .add_modifier(Modifier::BOLD),
260 - )
261 - .bottom_margin(0);
262 -
263 257 let widths = [
264 258 Constraint::Length(12),
265 259 Constraint::Min(16),
@@ -268,8 +262,7 @@ fn render_versions_table(frame: &mut Frame, app: &App, area: ratatui::layout::Re
268 262 Constraint::Length(12),
269 263 ];
270 264
271 - let table = Table::new(rows, widths).header(header);
272 - frame.render_widget(table, area);
265 + widgets::render_table(frame, area, &[" Version", "File", "Size", "Downloads", "Date"], &widths, rows);
273 266 }
274 267
275 268 /// Which field is being edited on the item detail screen.
@@ -280,27 +273,3 @@ pub enum ItemEditField {
280 273 Price,
281 274 }
282 275
283 - fn format_price(cents: i32) -> String {
284 - if cents == 0 {
285 - "Free".to_string()
286 - } else {
287 - format!("${}.{:02}", cents / 100, cents % 100)
288 - }
289 - }
290 -
291 - fn format_item_type(it: &str) -> &str {
292 - match it {
293 - "audio" => "Audio",
294 - "text" => "Text",
295 - "video" => "Video",
296 - "image" => "Image",
297 - "plugin" => "Plugin",
298 - "preset" => "Preset",
299 - "sample" => "Sample",
300 - "course" => "Course",
301 - "template" => "Template",
302 - "digital" => "Digital",
303 - "bundle" => "Bundle",
304 - other => other,
305 - }
306 - }
M src/tui/keys.rs +4 -22
@@ -4,9 +4,10 @@ use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 5 use ratatui::style::{Color, Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
9 9 use super::App;
10 + use super::widgets;
10 11
11 12 pub fn render(frame: &mut Frame, app: &App) {
12 13 let area = frame.area();
@@ -106,21 +107,11 @@ pub fn render(frame: &mut Frame, app: &App) {
106 107 }
107 108
108 109 fn render_key_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
109 - let selected = app.selected_index;
110 110 let rows: Vec<Row> = app
111 111 .license_keys
112 112 .iter()
113 113 .enumerate()
114 114 .map(|(i, key)| {
115 - let style = if i == selected {
116 - Style::default()
117 - .bg(Color::DarkGray)
118 - .fg(Color::White)
119 - .add_modifier(Modifier::BOLD)
120 - } else {
121 - Style::default()
122 - };
123 -
124 115 let status = if key.is_revoked {
125 116 "Revoked"
126 117 } else {
@@ -138,18 +129,10 @@ fn render_key_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
138 129 activations,
139 130 date.to_string(),
140 131 ])
141 - .style(style)
132 + .style(widgets::selected_style(i, Some(app.selected_index)))
142 133 })
143 134 .collect();
144 135
145 - let header = Row::new(vec![" Key", "Status", "Activations", "Created"])
146 - .style(
147 - Style::default()
148 - .fg(Color::DarkGray)
149 - .add_modifier(Modifier::BOLD),
150 - )
151 - .bottom_margin(0);
152 -
153 136 let widths = [
154 137 Constraint::Min(24),
155 138 Constraint::Length(10),
@@ -157,6 +140,5 @@ fn render_key_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
157 140 Constraint::Length(12),
158 141 ];
159 142
160 - let table = Table::new(rows, widths).header(header);
161 - frame.render_widget(table, area);
143 + widgets::render_table(frame, area, &[" Key", "Status", "Activations", "Created"], &widths, rows);
162 144 }
@@ -9,6 +9,7 @@ pub mod project;
9 9 pub mod promo;
10 10 pub mod settings;
11 11 pub mod upload;
12 + pub mod widgets;
12 13
13 14 use std::path::PathBuf;
14 15
@@ -4,11 +4,13 @@ use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 5 use ratatui::style::{Color, Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
9 9 use crate::api::Project;
10 + use crate::format;
10 11
11 12 use super::App;
13 + use super::widgets;
12 14
13 15 pub fn render(frame: &mut Frame, app: &App, project: &Project) {
14 16 let area = frame.area();
@@ -48,14 +50,11 @@ pub fn render(frame: &mut Frame, app: &App, project: &Project) {
48 50 Span::raw(" "),
49 51 Span::styled(&project.slug, Style::default().fg(Color::DarkGray)),
50 52 Span::raw(" "),
51 - Span::raw(format_project_type(&project.project_type)),
53 + Span::raw(format::format_project_type(&project.project_type)),
52 54 Span::raw(" "),
53 55 Span::raw(visibility),
54 56 Span::raw(" "),
55 - Span::raw(format!(
56 - "{}",
57 - format_cents(project.revenue_cents)
58 - )),
57 + Span::raw(format::format_cents(project.revenue_cents)),
59 58 Span::styled(" revenue", Style::default().fg(Color::DarkGray)),
60 59 ]));
61 60 frame.render_widget(info, chunks[1]);
@@ -116,41 +115,23 @@ pub fn render(frame: &mut Frame, app: &App, project: &Project) {
116 115 }
117 116
118 117 fn render_item_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
119 - let selected = app.selected_index;
120 118 let rows: Vec<Row> = app
121 119 .items
122 120 .iter()
123 121 .enumerate()
124 122 .map(|(i, item)| {
125 - let style = if i == selected {
126 - Style::default()
127 - .bg(Color::DarkGray)
128 - .fg(Color::White)
129 - .add_modifier(Modifier::BOLD)
130 - } else {
131 - Style::default()
132 - };
133 -
134 123 let visibility = if item.is_public { "public" } else { "draft" };
135 124
136 125 Row::new(vec![
137 126 format!(" {}", item.title),
138 - format_item_type(&item.item_type),
139 - format_cents(item.price_cents as i64),
127 + format::format_item_type(&item.item_type).to_string(),
128 + format::format_price(item.price_cents),
140 129 visibility.to_string(),
141 130 ])
142 - .style(style)
131 + .style(widgets::selected_style(i, Some(app.selected_index)))
143 132 })
144 133 .collect();
145 134
146 - let header = Row::new(vec![" Title", "Type", "Price", "Status"])
147 - .style(
148 - Style::default()
149 - .fg(Color::DarkGray)
150 - .add_modifier(Modifier::BOLD),
151 - )
152 - .bottom_margin(0);
153 -
154 135 let widths = [
155 136 Constraint::Min(20),
156 137 Constraint::Length(12),
@@ -158,46 +139,6 @@ fn render_item_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
158 139 Constraint::Length(8),
159 140 ];
160 141
161 - let table = Table::new(rows, widths).header(header);
162 - frame.render_widget(table, area);
163 - }
164 -
165 - fn format_cents(cents: i64) -> String {
166 - if cents == 0 {
167 - "Free".to_string()
168 - } else {
169 - format!("${}.{:02}", cents / 100, cents % 100)
170 - }
142 + widgets::render_table(frame, area, &[" Title", "Type", "Price", "Status"], &widths, rows);
171 143 }
172 144
173 - fn format_project_type(pt: &str) -> &str {
174 - match pt {
175 - "software" => "Software",
176 - "music" => "Music",
177 - "blog" => "Blog",
178 - "video" => "Video",
179 - "audio" => "Audio",
180 - "art" => "Art",
181 - "writing" => "Writing",
182 - "education" => "Education",
183 - other => other,
184 - }
185 - }
186 -
187 - fn format_item_type(it: &str) -> String {
188 - match it {
189 - "audio" => "Audio",
190 - "text" => "Text",
191 - "video" => "Video",
192 - "image" => "Image",
193 - "plugin" => "Plugin",
194 - "preset" => "Preset",
195 - "sample" => "Sample",
196 - "course" => "Course",
197 - "template" => "Template",
198 - "digital" => "Digital",
199 - "bundle" => "Bundle",
200 - other => other,
201 - }
202 - .to_string()
203 - }
M src/tui/promo.rs +4 -22
@@ -4,9 +4,10 @@ use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 5 use ratatui::style::{Color, Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
9 9 use super::App;
10 + use super::widgets;
10 11
11 12 pub fn render(frame: &mut Frame, app: &App) {
12 13 let area = frame.area();
@@ -110,21 +111,11 @@ pub fn render(frame: &mut Frame, app: &App) {
110 111 }
111 112
112 113 fn render_code_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
113 - let selected = app.selected_index;
114 114 let rows: Vec<Row> = app
115 115 .promo_codes
116 116 .iter()
117 117 .enumerate()
118 118 .map(|(i, code)| {
119 - let style = if i == selected {
120 - Style::default()
121 - .bg(Color::DarkGray)
122 - .fg(Color::White)
123 - .add_modifier(Modifier::BOLD)
124 - } else {
125 - Style::default()
126 - };
127 -
128 119 let discount = format_discount(code.discount_type.as_deref(), code.discount_value);
129 120 let scope = code
130 121 .item_title
@@ -142,18 +133,10 @@ fn render_code_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
142 133 scope.to_string(),
143 134 uses,
144 135 ])
145 - .style(style)
136 + .style(widgets::selected_style(i, Some(app.selected_index)))
146 137 })
147 138 .collect();
148 139
149 - let header = Row::new(vec![" Code", "Discount", "Scope", "Uses"])
150 - .style(
151 - Style::default()
152 - .fg(Color::DarkGray)
153 - .add_modifier(Modifier::BOLD),
154 - )
155 - .bottom_margin(0);
156 -
157 140 let widths = [
158 141 Constraint::Min(16),
159 142 Constraint::Length(12),
@@ -161,8 +144,7 @@ fn render_code_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
161 144 Constraint::Length(10),
162 145 ];
163 146
164 - let table = Table::new(rows, widths).header(header);
165 - frame.render_widget(table, area);
147 + widgets::render_table(frame, area, &[" Code", "Discount", "Scope", "Uses"], &widths, rows);
166 148 }
167 149
168 150 fn format_discount(dtype: Option<&str>, value: Option<i32>) -> String {
@@ -4,9 +4,13 @@ use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 5 use ratatui::style::{Color, Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Row, Table};
7 + use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Row};
8 +
9 + use crate::format;
10 + use crate::staging;
8 11
9 12 use super::App;
13 + use super::widgets;
10 14
11 15 pub fn render(frame: &mut Frame, app: &App) {
12 16 let area = frame.area();
@@ -53,7 +57,7 @@ pub fn render(frame: &mut Frame, app: &App) {
53 57 .user
54 58 .creator_tier
55 59 .as_deref()
56 - .map(format_tier)
60 + .map(format::format_tier)
57 61 .unwrap_or("No tier");
58 62
59 63 let display_name = app
@@ -98,8 +102,8 @@ pub fn render(frame: &mut Frame, app: &App) {
98 102 };
99 103 let label = format!(
100 104 " {} / {} ({}%)",
101 - format_bytes(info.storage_used_bytes),
102 - format_bytes(info.max_storage_bytes),
105 + staging::format_bytes(info.storage_used_bytes as u64),
106 + staging::format_bytes(info.max_storage_bytes as u64),
103 107 pct,
104 108 );
105 109
@@ -142,21 +146,11 @@ pub fn render(frame: &mut Frame, app: &App) {
142 146 let empty = Paragraph::new(" No SSH keys registered.");
143 147 frame.render_widget(empty, chunks[8]);
144 148 } else {
145 - let selected = app.selected_index;
146 149 let rows: Vec<Row> = app
147 150 .ssh_keys
148 151 .iter()
149 152 .enumerate()
150 153 .map(|(i, key)| {
151 - let style = if i == selected {
152 - Style::default()
153 - .bg(Color::DarkGray)
154 - .fg(Color::White)
155 - .add_modifier(Modifier::BOLD)
156 - } else {
157 - Style::default()
158 - };
159 -
160 154 let fp = key.fingerprint.get(..20).unwrap_or(&key.fingerprint);
161 155 let date = key.created_at.get(..10).unwrap_or(&key.created_at);
162 156
@@ -165,26 +159,17 @@ pub fn render(frame: &mut Frame, app: &App) {
165 159 format!("{}...", fp),
166 160 date.to_string(),
167 161 ])
168 - .style(style)
162 + .style(widgets::selected_style(i, Some(app.selected_index)))
169 163 })
170 164 .collect();
171 165
172 - let header = Row::new(vec![" Label", "Fingerprint", "Added"])
173 - .style(
174 - Style::default()
175 - .fg(Color::DarkGray)
176 - .add_modifier(Modifier::BOLD),
177 - )
178 - .bottom_margin(0);
179 -
180 166 let widths = [
181 167 Constraint::Min(20),
182 168 Constraint::Length(24),
183 169 Constraint::Length(12),
184 170 ];
185 171
186 - let table = Table::new(rows, widths).header(header);
187 - frame.render_widget(table, chunks[8]);
172 + widgets::render_table(frame, chunks[8], &[" Label", "Fingerprint", "Added"], &widths, rows);
188 173 }
189 174
190 175 // Status line
@@ -217,24 +202,3 @@ pub fn render(frame: &mut Frame, app: &App) {
217 202 frame.render_widget(keys_bar, chunks[10]);
218 203 }
219 204
220 - fn format_tier(tier: &str) -> &str {
221 - match tier {
222 - "basic" => "Basic",
223 - "small_files" => "Small Files",
224 - "big_files" => "Big Files",
225 - "streaming" => "Streaming",
226 - _ => tier,
227 - }
228 - }
229 -
230 - fn format_bytes(bytes: i64) -> String {
231 - if bytes < 1024 {
232 - format!("{bytes} B")
233 - } else if bytes < 1024 * 1024 {
234 - format!("{:.1} KB", bytes as f64 / 1024.0)
235 - } else if bytes < 1024 * 1024 * 1024 {
236 - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
237 - } else {
238 - format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
239 - }
240 - }
@@ -4,11 +4,13 @@ use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 5 use ratatui::style::{Color, Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 - use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
7 + use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
9 + use crate::format;
9 10 use crate::staging;
10 11
11 12 use super::App;
13 + use super::widgets;
12 14
13 15 pub fn render(frame: &mut Frame, app: &App) {
14 16 let area = frame.area();
@@ -133,7 +135,7 @@ fn render_storage_line(frame: &mut Frame, app: &App, area: ratatui::layout::Rect
133 135 .user
134 136 .creator_tier
135 137 .as_deref()
136 - .map(format_tier)
138 + .map(format::format_tier)
137 139 .unwrap_or("No tier");
138 140
139 141 if info.allows_file_uploads {
@@ -155,15 +157,6 @@ fn render_file_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
155 157 .iter()
156 158 .enumerate()
157 159 .map(|(i, sf)| {
158 - let style = if i == selected {
159 - Style::default()
160 - .bg(Color::DarkGray)
161 - .fg(Color::White)
162 - .add_modifier(Modifier::BOLD)
163 - } else {
164 - Style::default()
165 - };
166 -
167 160 let file_type = sf
168 161 .classification
169 162 .map(|c| c.item_type)
@@ -178,7 +171,7 @@ fn render_file_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
178 171 .and_then(|m| m.project_name.as_deref())
179 172 .unwrap_or("[none]");
180 173 let price = meta
181 - .map(|m| format_price(m.price_cents))
174 + .map(|m| format::format_price(m.price_cents))
182 175 .unwrap_or_else(|| "Free".to_string());
183 176
184 177 // Show edit indicator for editing field
@@ -196,18 +189,10 @@ fn render_file_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
196 189 project.to_string(),
197 190 price,
198 191 ])
199 - .style(style)
192 + .style(widgets::selected_style(i, Some(app.selected_index)))
200 193 })
201 194 .collect();
202 195
203 - let header = Row::new(vec![" File", "Size", "Type", "Title", "Project", "Price"])
204 - .style(
205 - Style::default()
206 - .fg(Color::DarkGray)
207 - .add_modifier(Modifier::BOLD),
208 - )
209 - .bottom_margin(0);
210 -
211 196 let widths = [
212 197 Constraint::Min(16),
213 198 Constraint::Length(10),
@@ -217,24 +202,6 @@ fn render_file_table(frame: &mut Frame, app: &App, area: ratatui::layout::Rect)
217 202 Constraint::Length(8),
218 203 ];
219 204
220 - let table = Table::new(rows, widths).header(header);
221 - frame.render_widget(table, area);
222 - }
223 -
224 - fn format_tier(tier: &str) -> &str {
225 - match tier {
226 - "basic" => "Basic",
227 - "small_files" => "Small Files",
228 - "big_files" => "Big Files",
229 - "streaming" => "Streaming",
230 - _ => tier,
231 - }
205 + widgets::render_table(frame, area, &[" File", "Size", "Type", "Title", "Project", "Price"], &widths, rows);
232 206 }
233 207
234 - fn format_price(cents: i32) -> String {
235 - if cents == 0 {
236 - "Free".to_string()
237 - } else {
238 - format!("${}.{:02}", cents / 100, cents % 100)
239 - }
240 - }
@@ -0,0 +1,43 @@
1 + //! Shared TUI widget helpers to reduce table-rendering boilerplate.
2 +
3 + use ratatui::Frame;
4 + use ratatui::layout::Constraint;
5 + use ratatui::style::{Color, Modifier, Style};
6 + use ratatui::widgets::{Row, Table};
7 +
8 + /// Style for the currently selected row (DarkGray bg, White fg, Bold).
9 + /// Returns default style for non-selected rows.
10 + pub fn selected_style(i: usize, selected: Option<usize>) -> Style {
11 + if selected == Some(i) {
12 + Style::default()
13 + .bg(Color::DarkGray)
14 + .fg(Color::White)
15 + .add_modifier(Modifier::BOLD)
16 + } else {
17 + Style::default()
18 + }
19 + }
20 +
21 + /// Render a table with a styled header row.
22 + ///
23 + /// Callers build their own `Vec<Row>` (using [`selected_style`] for
24 + /// row highlighting) and pass column headers + widths. This function
25 + /// assembles the header, constructs the `Table`, and renders it.
26 + pub fn render_table(
27 + frame: &mut Frame,
28 + area: ratatui::layout::Rect,
29 + headers: &[&str],
30 + widths: &[Constraint],
31 + rows: Vec<Row>,
32 + ) {
33 + let header = Row::new(headers.iter().map(|h| h.to_string()).collect::<Vec<_>>())
34 + .style(
35 + Style::default()
36 + .fg(Color::DarkGray)
37 + .add_modifier(Modifier::BOLD),
38 + )
39 + .bottom_margin(0);
40 +
41 + let table = Table::new(rows, widths).header(header);
42 + frame.render_widget(table, area);
43 + }