Skip to main content

max / makenotwork

Dashboard restructure: split Account tab, remove labels, rename jargon Dashboard: - Split monolithic Account tab into Profile, Account, and Plan tabs - Creators land on Projects tab; fans land on Payments - Progressive disclosure: gate seller sections, custom domain, creator notifications behind creator access - Move SyncKit to project-level tab (renamed "Cloud Sync") - Simplify Stripe status to single intent-driven display Labels: - Remove labels feature entirely (AI disclosure covers trust signals) - Drop routes, DB module, admin panel, discover filters, templates, tests - Migration 094 drops labels and project_labels tables Jargon renames: - Insertions → Clips / Dynamic Clips - Revenue Splits → Collaborator Payouts - SyncKit tab → Cloud Sync Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-05 17:52 UTC
Commit: 6e6eb06069944d6ad044d410ce073c266b1a7934
Parent: 55b1271
46 files changed, +1108 insertions, -1483 deletions
@@ -3,7 +3,7 @@
3 3 ## Status
4 4 Done: All pre-beta phases, UX audit remediation, creator trust audit remediation. Active: Creator setup (Stripe), manual testing. Next: Soft launch.
5 5
6 - v0.4.10 deployed 2026-05-04. Audit grade A (Run 18, 2026-05-01). Code fuzz Run 19 complete (2026-05-03, 17 bugs fixed). ~1,220 unit + ~679 integration = ~1,930 tests (all passing). CI on astra operational. Mutation kill rate 99.4%. Property-based testing active (proptest). `cargo test --features fast-tests` for fast runs.
6 + v0.4.10 deployed 2026-05-04. Audit grade A (Run 20, 2026-05-04). ~83K LOC, 1,214+ test annotations, 0 cargo warnings, 2 cold spots. CI on astra operational. Mutation kill rate 99.4%. Property-based testing active (proptest). `cargo test --features fast-tests` for fast runs.
7 7
8 8 Human tasks (manual testing, outreach, legal, infrastructure) moved to `human_todo.md`.
9 9 Completed items moved to `todo_done.md`.
@@ -12,55 +12,92 @@ Completed items moved to `todo_done.md`.
12 12
13 13 ## Remaining Audit Items
14 14
15 - ### Code Fuzz (Run 19, 2026-05-03) — DONE
16 - All high/serious/minor items fixed. Migration 090 deployed. Test failures fixed. CI fixed.
15 + ### Run 20 (2026-05-04) — Medium priority — DONE
17 16
18 - #### Low (previous)
19 - - [ ] Add README.md to server/
17 + ### Run 20 — Low priority — DONE
18 + Split checkout.rs (792 -> 434 + 308 helpers). yara-x bumped 1.14->1.15 (intaglio fix). aws-sdk-s3 bumped 1.119->1.131. rustls-webpki 0.101.7/0.103.10 remain upstream-blocked.
20 19
21 20 ### Deferred Code Quality
22 - - [ ] Extract inline SQL from route handlers to db/ (4 locations)
23 - - [ ] Remove `async-trait` in favor of Rust 2024 native async traits
24 - - [ ] Migrate inline `onclick` handlers to `addEventListener` for strict CSP
25 - - [ ] Monitor scheduler.rs (1249), git/mod.rs (624), license_keys.rs (684) for growth
26 -
27 - ### Dashboard Usability Audit (2026-05-03)
28 -
29 - Grade: B-. Complexity B, Completeness B-, Learnability C+, Discoverability C.
30 -
31 - #### Discoverability (Critical)
32 - - [x] Reorganize user dashboard tabs — Analytics and Creator moved to visible tab bar; Support moved to overflow. SyncKit, Media, SSH Keys, Forums remain in overflow
33 - - [ ] Add dashboard tab customization setting — let users choose which tabs are always visible in the tab bar and which go into the overflow menu. Store preference per user
34 - - [ ] Move SyncKit tab to project dashboard — SyncKit apps are tied to projects (Linked To column). Show as a project-level tab alongside Code, filtered to that project's apps. Keep a user-level summary view (or link) for creators managing apps across multiple projects
35 - - [x] Surface Stripe requirement on Project Overview — persistent banner with direct link, shown until Stripe is connected
36 - - [ ] Add Media Library access from content editors — "Insert Image" button in blog editor and item content editor that opens media library. Currently completely disconnected from where users need it
37 - - [x] Always show Blog tab with empty state — shows "Start writing to engage your audience" with context about RSS
38 - - [ ] Add content search/filter to project Content tab — search by title, filter by status (Draft/Published/Scheduled) and type. Table stakes for any content management interface
39 - - [ ] Add "Embed & Share" quick action on Item Overview — embed codes only discoverable by navigating to specific item's Embed tab
40 -
41 - #### Learnability (High)
42 - - [x] Make breadcrumbs clickable navigation links — link to `#tab-projects` for correct tab navigation
43 - - [ ] Add explanatory text to jargon terms: SyncKit ("Cloud sync for indie apps" subtitle), Insertions (rename to "Dynamic Clips" in storage display), Labels ("Platform-curated tags describing your project's commitments"), Revenue Splits (add setup instructions linking to Project Members tab)
44 - - [x] Improve onboarding checklist context — each step now has a brief explanation
45 - - [ ] Add empty state context to analytics — change "No revenue data yet" to "Once you publish items and make sales, revenue data will appear here" with link to publish
46 - - [x] Add AI Classification option descriptions in item_details dropdown
47 -
48 - #### Complexity (Medium)
49 - - [ ] Split Account Details tab into sub-sections — currently 13 sections in one scroll. Group into: Profile (name, bio, links, domain), Security (password, 2FA, passkeys, sessions), Notifications, Data & Privacy (export, import, deletion)
50 - - [x] Hide UUID from item dashboard header — replaced with "Copy ID" button
51 - - [x] Hide Stripe account ID behind disclosure toggle — collapsed behind `<details>` "Show account ID"
52 - - [ ] Simplify Stripe status display — replace raw onboarding states with user-intent language: "Ready to receive payments" (green) or "Action required: [task]" (red)
53 -
54 - #### Feature Completeness (Medium)
55 - - [ ] Add download count analytics per item — standard on Bandcamp, Gumroad; primary consumption metric for digital goods
56 - - [ ] Add cross-project item view — creator with 3 projects can't see all items in one place; no global search
57 - - [ ] Add refund initiation from dashboard — currently must go to Stripe dashboard to issue refunds
21 + - [ ] Remove `async-trait` in favor of Rust 2024 native async traits (chronic)
22 + - [ ] Add README.md to server/
23 + - [ ] Split oversized route files: exports.rs (737), license_keys.rs (741), health.rs (844), tabs/user.rs (707)
24 + - [ ] Monitor scheduler.rs (1249), git/mod.rs (624) for growth
25 + - [x] [rust-fuzz] Replace `.unwrap()` with `.expect("tier passed is_none guard")` in db/creator_tiers.rs:607
26 + - [x] [rust-fuzz] Change `Config.host_url` from `String` to `Arc<str>` — eliminates 30+ String clones across codebase
27 +
28 + ### Dashboard Restructure (2026-05-05)
29 +
30 + Redesign user dashboard around user intent rather than role. Split the monolithic Account tab into three purpose-driven tabs. Gate content by account state (fan → new creator → active creator).
31 +
32 + #### Tab Structure
33 +
34 + New user-level tabs: **Projects, Payments, Analytics, Profile, Account, Plan** + overflow (Media, SSH Keys, Forums, Support).
35 +
36 + Fan tab bar (no creator access): **Profile, Payments, Plan, Support** — 4 tabs, no overflow.
37 +
38 + | Tab | Contents | Mental model |
39 + |-----|----------|--------------|
40 + | Projects | Project list, create project | "My work" |
41 + | Payments | Purchases (fan), + sales/tips/splits (creator) | "My money" |
42 + | Analytics | Views, revenue, fans | "My numbers" |
43 + | Profile | Display name, bio, links, custom domain, personal feed | "How the world sees me" |
44 + | Account | Email/username, password, 2FA, passkeys, sessions, notifications, data export/import, account status, pause, delete | "My account's mechanics" |
45 + | Plan | Creator tier, storage, Stripe connect, invites, broadcast; or "Become a Creator" application for fans | "My relationship with the platform" |
46 +
47 + Overflow (creator only): Media (after first project), SSH Keys (when git enabled), Forums (when MT memberships exist), Support (always).
48 +
49 + #### Progressive Disclosure by Account State
50 +
51 + **Fan (no creator access):**
52 + - Profile: display name, bio, links, personal feed. No custom domain
53 + - Payments: purchases only. No seller sections
54 + - Plan: "Become a Creator" — explanation, tier comparison, application form
55 + - Account: full security (password, 2FA, passkeys, sessions), fan notification prefs (release alerts, login alerts), data export, delete
56 + - Hidden entirely: Projects, Analytics
57 +
58 + **New creator (approved, no projects/sales yet):**
59 + - All fan content, plus:
60 + - Projects: empty state → "Create your first project"
61 + - Analytics: empty state → "Publish an item to start tracking views and revenue"
62 + - Profile: + custom domain
63 + - Account: + pause creator, + creator notification prefs (sale, follower, issue alerts)
64 + - Payments: + empty seller section ("Once you make sales, they appear here")
65 + - Plan: current tier, storage, upgrade/downgrade, invites, broadcast
66 +
67 + **Active creator:**
68 + - Full content in all tabs
69 +
70 + #### Implementation Steps
71 +
72 + - [x] **Phase 1: Split templates** — Extract `user_details.html` into `user_profile.html`, `user_account.html`, `user_plan.html`
73 + - [x] **Phase 2: Route handlers** — Add `dashboard_tab_profile`, `dashboard_tab_account`; keep `dashboard_tab_creator` (served as "Plan" tab)
74 + - [x] **Phase 3: Tab bar** — Update `dashboard-user.html` with new tab layout. Fan vs creator logic for which tabs render
75 + - [x] **Phase 4: Progressive disclosure** — Gate sections within each tab by account state. Custom domain behind creator access. Seller payments behind creator access. Creator notifications behind creator access. Analytics empty state improved
76 + - [x] **Phase 5: Move SyncKit to project dashboard** — Project-level tab alongside Code, filtered to that project's apps. DB query `get_sync_apps_by_project` added. Removed from user-level overflow
77 + - [x] **Phase 6: Simplify Stripe status** — Single intent-driven status: "Ready to receive payments" (green check) or "Action required: [task]" (warning). Replaces 3-column grid + multiple warning banners
78 + - [ ] **Phase 7: Performance pass** — Deferred to a dedicated cross-site performance push. Pre-fetch tab data on hover, optimize perceived load times, aim for A+ responsiveness across every page
79 +
80 + #### Remaining Items (from previous audit, unchanged)
81 +
82 + Discoverability:
83 + - [ ] Add Media Library access from content editors — "Insert Image" button in blog/item editors
84 + - [ ] Add content search/filter to project Content tab — search by title, filter by status/type
85 + - [ ] Add "Embed & Share" quick action on Item Overview
86 +
87 + Learnability:
88 + - [x] Rename jargon: Insertions → "Clips"/"Dynamic Clips", Revenue Splits → "Collaborator Payouts", SyncKit tab → "Cloud Sync"
89 + - [x] Remove Labels feature — AI disclosure covers the trust signal use case. Deleted: db module, API routes, admin panel, discover filters, project settings, item publish confirmations, 12 tests. DB tables (labels, project_labels) still exist but are unused; drop in a future migration
90 + - [x] Add empty state context to analytics (done in Phase 4)
91 +
92 + Feature Completeness:
93 + - [ ] Add download count analytics per item
94 + - [ ] Add cross-project item view
95 + - [ ] Add refund initiation from dashboard
58 96 - [ ] Add "Export as CSV" button on item sales tables
59 97
60 - #### Discoverability (Lower)
61 - - [ ] Add bulk operations hint on Content tab — show "Select items for bulk actions" tip; show action bar in disabled state so feature is discoverable
62 - - [ ] Add contextual next-step suggestions after key actions — "Next: Set pricing" after creating item; "Next: Create item" after creating project
63 - - [ ] Show all conditional tabs (SyncKit, SSH Keys, Forums) always with empty states explaining prerequisites — instead of hiding them entirely
98 + Discoverability (Lower):
99 + - [ ] Add bulk operations hint on Content tab
100 + - [ ] Add contextual next-step suggestions after key actions
64 101
65 102 ### UX — Deferred (post-beta table stakes)
66 103 - [ ] Reviews/ratings system for items
@@ -0,0 +1,3 @@
1 + -- Drop the labels feature (replaced by per-item AI disclosure).
2 + DROP TABLE IF EXISTS project_labels;
3 + DROP TABLE IF EXISTS labels;
@@ -17,7 +17,6 @@ pub struct DiscoverFilters<'a> {
17 17 pub search: Option<&'a str>,
18 18 pub item_type: Option<ItemType>,
19 19 pub tag: Option<&'a str>,
20 - pub label: Option<&'a str>,
21 20 pub min_price: Option<i32>,
22 21 pub max_price: Option<i32>,
23 22 pub sort_by: Option<DiscoverSort>,
@@ -77,7 +76,7 @@ fn is_short_query(term: &str) -> bool {
77 76 }
78 77
79 78 /// Append discover-item filter clauses to a dynamic query.
80 - /// Parameter positions: $1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag, $6=label, $7=ai_tier.
79 + /// Parameter positions: $1=search, $2=item_type, $3=min_price, $4=max_price, $5=tag, $6=ai_tier.
81 80 ///
82 81 /// When `short_query` is `true`, the ILIKE-only clause is used instead of the
83 82 /// full trigram + ILIKE clause (trigram matching is unreliable for 1-2 char terms).
@@ -113,21 +112,12 @@ fn append_item_discover_filters(
113 112 )"#,
114 113 );
115 114 }
116 - if filters.label.is_some() {
117 - query.push_str(
118 - r#" AND EXISTS (
119 - SELECT 1 FROM project_labels pl
120 - JOIN labels l ON l.id = pl.label_id
121 - WHERE pl.project_id = i.project_id AND l.slug = $6
122 - )"#,
123 - );
124 - }
125 115 if filters.ai_tier.is_some() {
126 - query.push_str(" AND i.ai_tier = $7");
116 + query.push_str(" AND i.ai_tier = $6");
127 117 }
128 118 }
129 119
130 - /// Bind the 7 discover-filter parameters ($1-$7) to a sqlx query.
120 + /// Bind the 6 discover-filter parameters ($1-$6) to a sqlx query.
131 121 macro_rules! bind_item_discover_filters {
132 122 ($q:expr, $filters:expr, $search_term:expr) => {
133 123 $q.bind($search_term.unwrap_or(""))
@@ -135,7 +125,6 @@ macro_rules! bind_item_discover_filters {
135 125 .bind($filters.min_price.unwrap_or(0))
136 126 .bind($filters.max_price.unwrap_or(i32::MAX))
137 127 .bind($filters.tag.unwrap_or(""))
138 - .bind($filters.label.unwrap_or(""))
139 128 .bind($filters.ai_tier.map(|t| t.to_string()).unwrap_or_default())
140 129 };
141 130 }
@@ -262,7 +251,7 @@ pub async fn discover_items(
262 251 }
263 252 };
264 253
265 - query.push_str(&format!(" ORDER BY {} LIMIT $8 OFFSET $9", order));
254 + query.push_str(&format!(" ORDER BY {} LIMIT $7 OFFSET $8", order));
266 255
267 256 let items = bind_item_discover_filters!(
268 257 sqlx::query_as::<_, DbDiscoverItemRow>(&query),
@@ -320,7 +309,6 @@ pub async fn discover_projects(
320 309 pool: &PgPool,
321 310 search: Option<&str>,
322 311 category_slug: Option<&str>,
323 - label_slug: Option<&str>,
324 312 limit: i64,
325 313 offset: i64,
326 314 ) -> Result<Vec<DbDiscoverProjectRow>> {
@@ -409,16 +397,6 @@ pub async fn discover_projects(
409 397 query.push_str(" AND pc.slug = $2");
410 398 }
411 399
412 - if label_slug.is_some() {
413 - query.push_str(
414 - r#" AND EXISTS (
415 - SELECT 1 FROM project_labels pl
416 - JOIN labels l ON l.id = pl.label_id
417 - WHERE pl.project_id = p.id AND l.slug = $3
418 - )"#,
419 - );
420 - }
421 -
422 400 query.push_str(" GROUP BY p.id, u.username, pc.name, pc.slug");
423 401
424 402 let order = if has_search {
@@ -427,12 +405,11 @@ pub async fn discover_projects(
427 405 "p.created_at DESC"
428 406 };
429 407
430 - query.push_str(&format!(" ORDER BY {} LIMIT $4 OFFSET $5", order));
408 + query.push_str(&format!(" ORDER BY {} LIMIT $3 OFFSET $4", order));
431 409
432 410 let projects = sqlx::query_as::<_, DbDiscoverProjectRow>(&query)
433 411 .bind(search_term.as_deref().unwrap_or(""))
434 412 .bind(category_slug.unwrap_or(""))
435 - .bind(label_slug.unwrap_or(""))
436 413 .bind(limit)
437 414 .bind(offset)
438 415 .fetch_all(pool)
@@ -447,7 +424,6 @@ pub async fn count_discover_projects(
447 424 pool: &PgPool,
448 425 search: Option<&str>,
449 426 category_slug: Option<&str>,
450 - label_slug: Option<&str>,
451 427 ) -> Result<i64> {
452 428 let search_term = normalize_search(search);
453 429 let has_search = search_term.is_some();
@@ -476,20 +452,9 @@ pub async fn count_discover_projects(
476 452 );
477 453 }
478 454
479 - if label_slug.is_some() {
480 - query.push_str(
481 - r#" AND EXISTS (
482 - SELECT 1 FROM project_labels pl
483 - JOIN labels l ON l.id = pl.label_id
484 - WHERE pl.project_id = p.id AND l.slug = $3
485 - )"#,
486 - );
487 - }
488 -
489 455 let count: i64 = sqlx::query_scalar(&query)
490 456 .bind(search_term.as_deref().unwrap_or(""))
491 457 .bind(category_slug.unwrap_or(""))
492 - .bind(label_slug.unwrap_or(""))
493 458 .fetch_one(pool)
494 459 .await?;
495 460
@@ -165,7 +165,6 @@ define_pg_uuid_id!(
165 165 IssueId,
166 166 IssueCommentId,
167 167 IssueLabelId,
168 - LabelId,
169 168 ReportId,
170 169 FanPlusSubscriptionId,
171 170 CollectionId,
@@ -1,176 +0,0 @@
1 - //! Label queries: CRUD for platform-curated labels, project label management, and discover facets.
2 -
3 - use sqlx::PgPool;
4 -
5 - use super::id_types::*;
6 - use super::models::*;
7 - use crate::error::Result;
8 -
9 - /// Get all labels sorted by sort_order.
10 - #[tracing::instrument(skip_all)]
11 - pub async fn get_all_labels(pool: &PgPool) -> Result<Vec<DbLabel>> {
12 - let labels = sqlx::query_as::<_, DbLabel>(
13 - "SELECT id, slug, display_name, definition, examples, nonexamples, sort_order, created_at FROM labels ORDER BY sort_order",
14 - )
15 - .fetch_all(pool)
16 - .await?;
17 -
18 - Ok(labels)
19 - }
20 -
21 - /// Get a single label by ID.
22 - #[tracing::instrument(skip_all)]
23 - pub async fn get_label_by_id(pool: &PgPool, label_id: LabelId) -> Result<Option<DbLabel>> {
24 - let label = sqlx::query_as::<_, DbLabel>(
25 - "SELECT id, slug, display_name, definition, examples, nonexamples, sort_order, created_at FROM labels WHERE id = $1",
26 - )
27 - .bind(label_id)
28 - .fetch_optional(pool)
29 - .await?;
30 -
31 - Ok(label)
32 - }
33 -
34 - /// Get labels attached to a project.
35 - #[tracing::instrument(skip_all)]
36 - pub async fn get_labels_for_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbLabel>> {
37 - let labels = sqlx::query_as::<_, DbLabel>(
38 - r#"
39 - SELECT l.id, l.slug, l.display_name, l.definition, l.examples, l.nonexamples, l.sort_order, l.created_at
40 - FROM labels l
41 - JOIN project_labels pl ON pl.label_id = l.id
42 - WHERE pl.project_id = $1
43 - ORDER BY l.sort_order
44 - "#,
45 - )
46 - .bind(project_id)
47 - .fetch_all(pool)
48 - .await?;
49 -
50 - Ok(labels)
51 - }
52 -
53 - /// Add a label to a project (idempotent).
54 - #[tracing::instrument(skip_all)]
55 - pub async fn add_label_to_project(
56 - pool: &PgPool,
57 - project_id: ProjectId,
58 - label_id: LabelId,
59 - ) -> Result<()> {
60 - sqlx::query(
61 - "INSERT INTO project_labels (project_id, label_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
62 - )
63 - .bind(project_id)
64 - .bind(label_id)
65 - .execute(pool)
66 - .await?;
67 -
68 - Ok(())
69 - }
70 -
71 - /// Remove a label from a project.
72 - #[tracing::instrument(skip_all)]
73 - pub async fn remove_label_from_project(
74 - pool: &PgPool,
75 - project_id: ProjectId,
76 - label_id: LabelId,
77 - ) -> Result<()> {
78 - sqlx::query("DELETE FROM project_labels WHERE project_id = $1 AND label_id = $2")
79 - .bind(project_id)
80 - .bind(label_id)
81 - .execute(pool)
82 - .await?;
83 -
84 - Ok(())
85 - }
86 -
87 - /// Count public projects per label for discover sidebar facets.
88 - ///
89 - /// Only returns labels that have at least one matching public project.
90 - #[tracing::instrument(skip_all)]
91 - pub async fn get_label_counts(pool: &PgPool) -> Result<Vec<DbLabelCount>> {
92 - let counts = sqlx::query_as::<_, DbLabelCount>(
93 - r#"
94 - SELECT l.slug, l.display_name, COUNT(*) AS count
95 - FROM labels l
96 - JOIN project_labels pl ON pl.label_id = l.id
97 - JOIN projects p ON p.id = pl.project_id
98 - WHERE p.is_public = true
99 - GROUP BY l.id, l.slug, l.display_name
100 - ORDER BY count DESC
101 - "#,
102 - )
103 - .fetch_all(pool)
104 - .await?;
105 -
106 - Ok(counts)
107 - }
108 -
109 - /// Create a new label (admin).
110 - #[tracing::instrument(skip_all)]
111 - pub async fn create_label(
112 - pool: &PgPool,
113 - slug: &str,
114 - display_name: &str,
115 - definition: &str,
116 - examples: &str,
117 - nonexamples: &str,
118 - ) -> Result<DbLabel> {
119 - let label = sqlx::query_as::<_, DbLabel>(
120 - r#"
121 - INSERT INTO labels (slug, display_name, definition, examples, nonexamples)
122 - VALUES ($1, $2, $3, $4, $5)
123 - RETURNING id, slug, display_name, definition, examples, nonexamples, sort_order, created_at
124 - "#,
125 - )
126 - .bind(slug)
127 - .bind(display_name)
128 - .bind(definition)
129 - .bind(examples)
130 - .bind(nonexamples)
131 - .fetch_one(pool)
132 - .await?;
133 -
134 - Ok(label)
135 - }
136 -
137 - /// Update an existing label (admin).
138 - #[tracing::instrument(skip_all)]
139 - pub async fn update_label(
140 - pool: &PgPool,
141 - label_id: LabelId,
142 - slug: &str,
143 - display_name: &str,
144 - definition: &str,
145 - examples: &str,
146 - nonexamples: &str,
147 - ) -> Result<()> {
148 - sqlx::query(
149 - r#"
150 - UPDATE labels
151 - SET slug = $2, display_name = $3, definition = $4, examples = $5, nonexamples = $6
152 - WHERE id = $1
153 - "#,
154 - )
155 - .bind(label_id)
156 - .bind(slug)
157 - .bind(display_name)
158 - .bind(definition)
159 - .bind(examples)
160 - .bind(nonexamples)
161 - .execute(pool)
162 - .await?;
163 -
164 - Ok(())
165 - }
166 -
167 - /// Delete a label (admin). Cascades via project_labels FK.
168 - #[tracing::instrument(skip_all)]
169 - pub async fn delete_label(pool: &PgPool, label_id: LabelId) -> Result<()> {
170 - sqlx::query("DELETE FROM labels WHERE id = $1")
171 - .bind(label_id)
172 - .execute(pool)
173 - .await?;
174 -
175 - Ok(())
176 - }
@@ -28,7 +28,6 @@ pub(crate) mod follows;
28 28 pub(crate) mod subscriptions;
29 29 pub(crate) mod tags;
30 30 pub(crate) mod categories;
31 - pub(crate) mod labels;
32 31 pub(crate) mod sessions;
33 32 pub(crate) mod totp;
34 33 pub(crate) mod passkeys;
@@ -1,7 +1,6 @@
1 - //! Category, label, and tag models.
1 + //! Category and tag models.
2 2
3 3 use chrono::{DateTime, Utc};
4 - use serde::Serialize;
5 4 use sqlx::FromRow;
6 5
7 6 use super::super::id_types::*;
@@ -28,30 +27,6 @@ pub struct DbCategoryCount {
28 27 pub count: i64,
29 28 }
30 29
31 - // ── Label models ──
32 -
33 - /// A platform-curated label that creators opt into voluntarily.
34 - #[derive(Debug, Clone, FromRow, Serialize)]
35 - pub struct DbLabel {
36 - pub id: LabelId,
37 - pub slug: Slug,
38 - pub display_name: String,
39 - pub definition: String,
40 - pub examples: String,
41 - pub nonexamples: String,
42 - pub sort_order: i32,
43 - pub created_at: DateTime<Utc>,
44 - }
45 -
46 - /// A label with a count of public projects, for discover sidebar facets.
47 - #[derive(Debug, Clone, FromRow)]
48 - #[allow(dead_code)] // Fields populated by sqlx query, read during type conversion
49 - pub struct DbLabelCount {
50 - pub slug: Slug,
51 - pub display_name: String,
52 - pub count: i64,
53 - }
54 -
55 30 // ── Tag models ──
56 31
57 32 /// A tag in the hierarchical taxonomy.
@@ -118,6 +118,21 @@ pub async fn get_sync_apps_by_creator(
118 118 Ok(apps)
119 119 }
120 120
121 + /// Get all sync apps linked to a specific project.
122 + pub async fn get_sync_apps_by_project(
123 + pool: &PgPool,
124 + project_id: ProjectId,
125 + ) -> Result<Vec<DbSyncApp>> {
126 + let apps = sqlx::query_as::<_, DbSyncApp>(
127 + "SELECT * FROM sync_apps WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100",
128 + )
129 + .bind(project_id)
130 + .fetch_all(pool)
131 + .await?;
132 +
133 + Ok(apps)
134 + }
135 +
121 136 /// Regenerate an API key for a sync app. Stores the hashed key and prefix.
122 137 #[tracing::instrument(skip_all)]
123 138 pub async fn regenerate_sync_app_key(
@@ -1,148 +0,0 @@
1 - //! Admin label management: CRUD and project label removal.
2 -
3 - use axum::{
4 - extract::{Path, State},
5 - response::IntoResponse,
6 - Form,
7 - };
8 - use serde::Deserialize;
9 -
10 - use crate::{
11 - auth::AdminUser,
12 - db::{self, LabelId, ProjectId},
13 - error::{AppError, Result},
14 - helpers::get_csrf_token,
15 - templates::*,
16 - AppState,
17 - };
18 -
19 - /// Render the admin labels management page.
20 - #[tracing::instrument(skip_all, name = "admin::admin_labels")]
21 - pub(super) async fn admin_labels(
22 - State(state): State<AppState>,
23 - session: tower_sessions::Session,
24 - AdminUser(user): AdminUser,
25 - ) -> Result<impl IntoResponse> {
26 - let csrf_token = get_csrf_token(&session).await;
27 - let labels = db::labels::get_all_labels(&state.db).await?;
28 -
29 - Ok(AdminLabelsTemplate {
30 - csrf_token,
31 - session_user: Some(user),
32 - labels,
33 - admin_active_page: "labels",
34 - })
35 - }
36 -
37 - /// Return label entries as an HTMX partial.
38 - #[tracing::instrument(skip_all, name = "admin::admin_label_entries")]
39 - pub(super) async fn admin_label_entries(
40 - State(state): State<AppState>,
41 - AdminUser(_user): AdminUser,
42 - ) -> Result<impl IntoResponse> {
43 - let labels = db::labels::get_all_labels(&state.db).await?;
44 - Ok(AdminLabelEntriesTemplate { labels })
45 - }
46 -
47 - #[derive(Debug, Deserialize)]
48 - pub(super) struct LabelForm {
49 - pub slug: String,
50 - pub display_name: String,
51 - pub definition: String,
52 - #[serde(default)]
53 - pub examples: String,
54 - #[serde(default)]
55 - pub nonexamples: String,
56 - }
57 -
58 - /// Create a new label (admin).
59 - #[tracing::instrument(skip_all, name = "admin::admin_create_label")]
60 - pub(super) async fn admin_create_label(
61 - State(state): State<AppState>,
62 - AdminUser(_admin): AdminUser,
63 - Form(form): Form<LabelForm>,
64 - ) -> Result<impl IntoResponse> {
65 - let slug = form.slug.trim();
66 - let display_name = form.display_name.trim();
67 - let definition = form.definition.trim();
68 -
69 - if slug.is_empty() || display_name.is_empty() || definition.is_empty() {
70 - return Err(AppError::Validation("Slug, display name, and definition are required".to_string()));
71 - }
72 -
73 - db::labels::create_label(
74 - &state.db,
75 - slug,
76 - display_name,
77 - definition,
78 - form.examples.trim(),
79 - form.nonexamples.trim(),
80 - ).await?;
81 -
82 - tracing::info!(slug = %slug, "admin created label");
83 -
84 - let labels = db::labels::get_all_labels(&state.db).await?;
85 - Ok(AdminLabelEntriesTemplate { labels })
86 - }
87 -
88 - /// Update an existing label (admin).
89 - #[tracing::instrument(skip_all, name = "admin::admin_update_label")]
90 - pub(super) async fn admin_update_label(
91 - State(state): State<AppState>,
92 - AdminUser(_admin): AdminUser,
93 - Path(id): Path<LabelId>,
94 - Form(form): Form<LabelForm>,
95 - ) -> Result<impl IntoResponse> {
96 - let slug = form.slug.trim();
97 - let display_name = form.display_name.trim();
98 - let definition = form.definition.trim();
99 -
100 - if slug.is_empty() || display_name.is_empty() || definition.is_empty() {
101 - return Err(AppError::Validation("Slug, display name, and definition are required".to_string()));
102 - }
103 -
104 - db::labels::update_label(
105 - &state.db,
106 - id,
107 - slug,
108 - display_name,
109 - definition,
110 - form.examples.trim(),
111 - form.nonexamples.trim(),
112 - ).await?;
113 -
114 - tracing::info!(label_id = %id, slug = %slug, "admin updated label");
115 -
116 - let labels = db::labels::get_all_labels(&state.db).await?;
117 - Ok(AdminLabelEntriesTemplate { labels })
118 - }
119 -
120 - /// Delete a label (admin).
121 - #[tracing::instrument(skip_all, name = "admin::admin_delete_label")]
122 - pub(super) async fn admin_delete_label(
123 - State(state): State<AppState>,
124 - AdminUser(_admin): AdminUser,
125 - Path(id): Path<LabelId>,
126 - ) -> Result<impl IntoResponse> {
127 - db::labels::delete_label(&state.db, id).await?;
128 -
129 - tracing::info!(label_id = %id, "admin deleted label");
130 -
131 - let labels = db::labels::get_all_labels(&state.db).await?;
132 - Ok(AdminLabelEntriesTemplate { labels })
133 - }
134 -
135 - /// Moderation: force-remove a label from a project.
136 - #[tracing::instrument(skip_all, name = "admin::admin_remove_project_label")]
137 - pub(super) async fn admin_remove_project_label(
138 - State(state): State<AppState>,
139 - AdminUser(_admin): AdminUser,
140 - Path((project_id, label_id)): Path<(ProjectId, LabelId)>,
141 - ) -> Result<impl IntoResponse> {
142 - db::labels::remove_label_from_project(&state.db, project_id, label_id).await?;
143 - db::projects::bump_cache_generation(&state.db, project_id).await?;
144 -
145 - tracing::info!(project_id = %project_id, label_id = %label_id, "admin removed label from project");
146 -
147 - Ok(axum::http::StatusCode::NO_CONTENT)
148 - }
@@ -1,6 +1,5 @@
1 1 //! Admin routes for creator waitlist management, user moderation, and platform operations.
2 2
3 - mod labels;
4 3 mod moderation;
5 4 mod signups;
6 5 mod uploads;
@@ -10,7 +9,7 @@ mod waitlist;
10 9 use axum::{
11 10 extract::{Path, State},
12 11 response::{IntoResponse, Response},
13 - routing::{get, post, put},
12 + routing::{get, post},
14 13 Form, Router,
15 14 };
16 15 use serde::Deserialize;
@@ -49,12 +48,6 @@ pub fn admin_routes() -> Router<AppState> {
49 48 // Appeals
50 49 .route("/admin/appeals", get(moderation::admin_appeals))
51 50 .route("/api/admin/appeals/{user_id}/decide", post(moderation::admin_decide_appeal))
52 - // Labels
53 - .route("/admin/labels", get(labels::admin_labels))
54 - .route("/admin/labels/entries", get(labels::admin_label_entries))
55 - .route("/api/admin/labels", post(labels::admin_create_label))
56 - .route("/api/admin/labels/{id}", put(labels::admin_update_label).delete(labels::admin_delete_label))
57 - .route("/api/admin/projects/{project_id}/remove-label/{label_id}", post(labels::admin_remove_project_label))
58 51 // Email signups
59 52 .route("/admin/signups", get(signups::admin_signups))
60 53 // Reports
@@ -1,114 +0,0 @@
1 - //! Label API: attach/detach labels on projects, list available labels.
2 -
3 - use axum::{
4 - extract::{Path, State},
5 - response::IntoResponse,
6 - Json,
7 - };
8 - use serde::Deserialize;
9 -
10 - use crate::{
11 - auth::AuthUser,
12 - db::{self, LabelId, ProjectId},
13 - error::{AppError, Result},
14 - templates::*,
15 - AppState,
16 - };
17 -
18 - use super::verify_project_ownership;
19 -
20 - /// Filter all_labels to only those not already applied to the project.
21 - fn filter_unapplied(all: Vec<db::DbLabel>, applied: &[db::DbLabel]) -> Vec<db::DbLabel> {
22 - all.into_iter()
23 - .filter(|l| !applied.iter().any(|a| a.id == l.id))
24 - .collect()
25 - }
26 -
27 - /// Form input for adding a label to a project.
28 - #[derive(Debug, Deserialize)]
29 - pub struct AddLabelRequest {
30 - pub label_id: LabelId,
31 - }
32 -
33 - /// Add a label to a project. Returns the updated labels partial.
34 - #[tracing::instrument(skip_all, name = "labels::add_label_to_project")]
35 - pub(super) async fn add_label_to_project(
36 - State(state): State<AppState>,
37 - AuthUser(user): AuthUser,
38 - Path(project_id): Path<ProjectId>,
39 - axum::Form(req): axum::Form<AddLabelRequest>,
40 - ) -> Result<impl IntoResponse> {
41 - user.check_not_suspended()?;
42 - let project = verify_project_ownership(&state, project_id, user.id).await?;
43 -
44 - // Verify the label exists
45 - db::labels::get_label_by_id(&state.db, req.label_id)
46 - .await?
47 - .ok_or(AppError::NotFound)?;
48 -
49 - db::labels::add_label_to_project(&state.db, project_id, req.label_id).await?;
50 - db::projects::bump_cache_generation(&state.db, project_id).await?;
51 -
52 - let project_labels = db::labels::get_labels_for_project(&state.db, project_id).await?;
53 - let all_labels = db::labels::get_all_labels(&state.db).await?;
54 - let available_labels = filter_unapplied(all_labels, &project_labels);
55 -
56 - Ok(ProjectLabelsTemplate {
57 - project_id: project.id.to_string(),
58 - project_labels,
59 - available_labels,
60 - })
61 - }
62 -
63 - /// Remove a label from a project. Returns the updated labels partial.
64 - #[tracing::instrument(skip_all, name = "labels::remove_label_from_project")]
65 - pub(super) async fn remove_label_from_project(
66 - State(state): State<AppState>,
67 - AuthUser(user): AuthUser,
68 - Path((project_id, label_id)): Path<(ProjectId, LabelId)>,
69 - ) -> Result<impl IntoResponse> {
70 - user.check_not_suspended()?;
71 - let project = verify_project_ownership(&state, project_id, user.id).await?;
72 -
73 - db::labels::remove_label_from_project(&state.db, project_id, label_id).await?;
74 - db::projects::bump_cache_generation(&state.db, project_id).await?;
75 -
76 - let project_labels = db::labels::get_labels_for_project(&state.db, project_id).await?;
77 - let all_labels = db::labels::get_all_labels(&state.db).await?;
78 - let available_labels = filter_unapplied(all_labels, &project_labels);
79 -
80 - Ok(ProjectLabelsTemplate {
81 - project_id: project.id.to_string(),
82 - project_labels,
83 - available_labels,
84 - })
85 - }
86 -
87 - /// Get labels for a project (returns HTMX partial).
88 - #[tracing::instrument(skip_all, name = "labels::get_project_labels")]
89 - pub(super) async fn get_project_labels(
90 - State(state): State<AppState>,
91 - AuthUser(user): AuthUser,
92 - Path(project_id): Path<ProjectId>,
93 - ) -> Result<impl IntoResponse> {
94 - let project = verify_project_ownership(&state, project_id, user.id).await?;
95 -
96 - let project_labels = db::labels::get_labels_for_project(&state.db, project_id).await?;
97 - let all_labels = db::labels::get_all_labels(&state.db).await?;
98 - let available_labels = filter_unapplied(all_labels, &project_labels);
99 -
100 - Ok(ProjectLabelsTemplate {
101 - project_id: project.id.to_string(),
102 - project_labels,
103 - available_labels,
104 - })
105 - }
106 -
107 - /// List all available labels (JSON, for confirmation dialogs).
108 - #[tracing::instrument(skip_all, name = "labels::list_labels")]
109 - pub(super) async fn list_labels(
110 - State(state): State<AppState>,
111 - ) -> Result<impl IntoResponse> {
112 - let labels = db::labels::get_all_labels(&state.db).await?;
113 - Ok(Json(labels))
114 - }
@@ -28,7 +28,6 @@ mod links;
28 28 mod passkeys;
29 29 mod projects;
30 30 mod subscriptions;
31 - mod labels;
32 31 mod tags;
33 32 pub(crate) mod totp;
34 33 mod users;
@@ -141,7 +140,7 @@ struct PublicProject {
141 140 async fn public_projects(
142 141 State(state): State<AppState>,
143 142 ) -> Result<impl IntoResponse> {
144 - let rows = db::discover::discover_projects(&state.db, None, None, None, 50, 0).await?;
143 + let rows = db::discover::discover_projects(&state.db, None, None, 50, 0).await?;
145 144 let data: Vec<PublicProject> = rows
146 145 .into_iter()
147 146 .map(|r| PublicProject {
@@ -321,9 +320,6 @@ pub fn api_routes() -> Router<AppState> {
321 320 // SSH key management
322 321 .route("/api/users/me/ssh-keys", get(ssh_keys::list_keys).post(ssh_keys::add_key))
323 322 .route("/api/users/me/ssh-keys/{id}", delete(ssh_keys::delete_key))
324 - // Label management (creator)
325 - .route("/api/projects/{id}/labels", post(labels::add_label_to_project))
326 - .route("/api/projects/{id}/labels/{label_id}", delete(labels::remove_label_from_project))
327 323 // Reports
328 324 .route("/api/reports", post(reports::submit_report))
329 325 // Collections
@@ -400,9 +396,6 @@ pub fn api_routes() -> Router<AppState> {
400 396 // Content insertion list (HTMX partials for dashboard)
401 397 .route("/api/users/me/insertions", get(content_insertions::list_insertions))
402 398 .route("/api/items/{id}/insertions", get(content_insertions::list_placements))
403 - // Labels (read)
404 - .route("/api/labels", get(labels::list_labels))
405 - .route("/api/projects/{id}/labels", get(labels::get_project_labels))
406 399 // Collections (read)
407 400 .route("/api/collections/for-item/{item_id}", get(collections::collections_for_item))
408 401 // Custom domains (read)
@@ -47,6 +47,8 @@ pub fn dashboard_routes() -> Router<AppState> {
47 47 // Tab endpoints — rate limited to prevent rapid polling
48 48 let tab_routes = Router::new()
49 49 .route("/dashboard/tabs/details", get(tabs::dashboard_tab_details))
50 + .route("/dashboard/tabs/profile", get(tabs::dashboard_tab_profile))
51 + .route("/dashboard/tabs/account", get(tabs::dashboard_tab_account))
50 52 .route("/dashboard/tabs/payments", get(tabs::dashboard_tab_payments))
51 53 .route("/dashboard/tabs/projects", get(tabs::dashboard_tab_projects))
52 54 .route("/dashboard/tabs/creator", get(tabs::dashboard_tab_creator))
@@ -66,6 +68,7 @@ pub fn dashboard_routes() -> Router<AppState> {
66 68 .route("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions))
67 69 .route("/dashboard/project/{slug}/tabs/subscriptions", get(project_tabs::project_tab_subscriptions))
68 70 .route("/dashboard/project/{slug}/tabs/members", get(project_tabs::project_tab_members))
71 + .route("/dashboard/project/{slug}/tabs/synckit", get(project_tabs::project_tab_synckit))
69 72 .route("/dashboard/item/{id}/tabs/overview", get(tabs::item_tab_overview))
70 73 .route("/dashboard/item/{id}/tabs/details", get(tabs::item_tab_details))
71 74 .route("/dashboard/item/{id}/tabs/pricing", get(tabs::item_tab_pricing))
@@ -268,18 +268,12 @@ pub(super) async fn project_tab_settings(
268 268 .await?
269 269 .unwrap_or_default();
270 270
271 - // Fetch labels for this project and all available labels
272 - let project_labels = db::labels::get_labels_for_project(&state.db, db_project.id).await?;
273 - let all_labels = db::labels::get_all_labels(&state.db).await?;
274 - let available_labels: Vec<db::DbLabel> = all_labels.into_iter()
275 - .filter(|l| !project_labels.iter().any(|a| a.id == l.id))
276 - .collect();
277 271 let project_id = db_project.id.to_string();
278 272
279 273 let features = db_project.features.clone();
280 274 let project_features = db::ProjectFeature::all();
281 275
282 - Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate { project, category_name, project_labels, available_labels, project_id, features, project_features }))
276 + Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate { project, category_name, project_id, features, project_features }))
283 277 }
284 278
285 279 /// Render the HTMX partial for the project subscriptions tab (tier management).
@@ -466,3 +460,57 @@ pub(super) async fn project_tab_members(
466 460 owner_split,
467 461 }))
468 462 }
463 +
464 + /// Render the HTMX partial for SyncKit apps linked to a project.
465 + #[tracing::instrument(skip_all, name = "project_tabs::project_tab_synckit")]
466 + pub(super) async fn project_tab_synckit(
467 + State(state): State<AppState>,
468 + AuthUser(session_user): AuthUser,
469 + headers: HeaderMap,
470 + Path(slug): Path<String>,
471 + ) -> Result<axum::response::Response> {
472 + let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
473 + Ok(pair) => pair,
474 + Err(not_modified) => return Ok(not_modified),
475 + };
476 +
477 + let db_apps =
478 + db::synckit::get_sync_apps_by_project(&state.db, db_project.id).await?;
479 +
480 + let stats_batch =
481 + db::synckit::get_sync_app_stats_batch(&state.db, session_user.id).await?;
482 + let stats_map: std::collections::HashMap<_, _> = stats_batch
483 + .into_iter()
484 + .map(|(id, devices, logs)| (id, (devices, logs)))
485 + .collect();
486 +
487 + let mut apps = Vec::with_capacity(db_apps.len());
488 + for app in &db_apps {
489 + let (device_count, log_entry_count) = stats_map
490 + .get(&app.id)
491 + .copied()
492 + .unwrap_or((0, 0));
493 +
494 + let api_key_masked = format!("{}...", &app.api_key_prefix);
495 +
496 + apps.push(SyncAppRow {
497 + id: app.id.to_string(),
498 + name: app.name.clone(),
499 + api_key_masked,
500 + api_key_full: String::new(),
501 + is_active: app.is_active,
502 + device_count,
503 + log_entry_count,
504 + created_at: app.created_at.format("%b %d, %Y").to_string(),
505 + slug: app.slug.clone(),
506 + project_name: None,
507 + project_slug: None,
508 + item_title: None,
509 + });
510 + }
511 +
512 + Ok(helpers::with_etag(generation, ProjectSyncKitTabTemplate {
513 + apps,
514 + project_id: db_project.id.to_string(),
515 + }))
516 + }
@@ -185,22 +185,14 @@ pub(in crate::routes::pages::dashboard) async fn item_tab_settings(
185 185 AuthUser(session_user): AuthUser,
186 186 Path(id): Path<String>,
187 187 ) -> Result<impl IntoResponse> {
188 - let (db_item, db_project) =
188 + let (db_item, _) =
189 189 resolve_owned_item(&state, session_user.id, &id).await?;
190 190 let item_id = db_item.id;
191 191 let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
192 192 let item = build_item_view(&db_item, &item_tags);
193 193
194 - let project_labels: Vec<String> =
195 - db::labels::get_labels_for_project(&state.db, db_project.id)
196 - .await?
197 - .into_iter()
198 - .map(|l| l.display_name)
199 - .collect();
200 -
201 194 Ok(ItemSettingsTabTemplate {
202 195 item,
203 - project_labels,
204 196 })
205 197 }
206 198
@@ -8,8 +8,9 @@ pub(super) use item::{
8 8 item_tab_sales, item_tab_settings,
9 9 };
10 10 pub(super) use user::{
11 - dashboard_tab_analytics, dashboard_tab_creator, dashboard_tab_details,
12 - dashboard_tab_forums, dashboard_tab_media, dashboard_tab_payments,
13 - dashboard_tab_projects, dashboard_tab_ssh_keys, dashboard_tab_support,
14 - dashboard_tab_synckit, dashboard_transactions,
11 + dashboard_tab_account, dashboard_tab_analytics, dashboard_tab_creator,
12 + dashboard_tab_details, dashboard_tab_forums, dashboard_tab_media,
13 + dashboard_tab_payments, dashboard_tab_profile, dashboard_tab_projects,
14 + dashboard_tab_ssh_keys, dashboard_tab_support, dashboard_tab_synckit,
15 + dashboard_transactions,
15 16 };
@@ -118,6 +118,124 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_details(
118 118 })
119 119 }
120 120
121 + /// Render the HTMX partial for the dashboard profile tab.
122 + #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_profile")]
123 + pub(in crate::routes::pages::dashboard) async fn dashboard_tab_profile(
124 + State(state): State<AppState>,
125 + AuthUser(session_user): AuthUser,
126 + ) -> Result<impl IntoResponse> {
127 + let db_user = db::users::get_user_by_id(&state.db, session_user.id)
128 + .await?
129 + .ok_or(AppError::NotFound)?;
130 +
131 + let db_links =
132 + db::custom_links::get_custom_links_by_user(&state.db, session_user.id).await?;
133 +
134 + let user = User::from(&db_user);
135 +
136 + let custom_links: Vec<CustomLinkWithId> = db_links
137 + .into_iter()
138 + .map(|l| CustomLinkWithId {
139 + id: l.id.to_string(),
140 + url: l.url,
141 + title: l.title,
142 + })
143 + .collect();
144 +
145 + let feed_url = helpers::generate_feed_url(
146 + &state.config.host_url,
147 + session_user.id,
148 + &state.config.signing_secret,
149 + );
150 +
151 + let custom_domain =
152 + db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id)
153 + .await?
154 + .map(|d| {
155 + let instructions = if d.verified {
156 + String::new()
157 + } else {
158 + format!(
159 + "Add a DNS TXT record: _mnw-verify.{} with value {}",
160 + d.domain, d.verification_token
161 + )
162 + };
163 + crate::templates::CustomDomainInfo {
164 + id: d.id.to_string(),
165 + domain: d.domain,
166 + verified: d.verified,
167 + verification_token: d.verification_token,
168 + instructions,
169 + }
170 + });
171 +
172 + Ok(UserProfileTabTemplate {
173 + user,
174 + custom_links,
175 + feed_url,
176 + can_create_projects: session_user.can_create_projects,
177 + custom_domain,
178 + })
179 + }
180 +
181 + /// Render the HTMX partial for the dashboard account tab.
182 + #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_account")]
183 + pub(in crate::routes::pages::dashboard) async fn dashboard_tab_account(
184 + State(state): State<AppState>,
185 + session: Session,
186 + AuthUser(session_user): AuthUser,
187 + ) -> Result<impl IntoResponse> {
188 + let db_user = db::users::get_user_by_id(&state.db, session_user.id)
189 + .await?
190 + .ok_or(AppError::NotFound)?;
191 +
192 + let user = User::from(&db_user);
193 +
194 + let sessions =
195 + db::sessions::get_user_sessions(&state.db, session_user.id).await?;
196 + let current_session_id = session
197 + .get::<db::UserSessionId>(SESSION_TRACKING_KEY)
198 + .await
199 + .ok()
200 + .flatten();
201 +
202 + // Fetch moderation actions for "Account Status" section
203 + let active_actions = db::moderation::get_active_actions(&state.db, session_user.id).await?;
204 + let all_actions = db::moderation::get_history(&state.db, session_user.id).await?;
205 +
206 + let moderation_active: Vec<ModerationActionView> = active_actions
207 + .iter()
208 + .map(|a| ModerationActionView {
209 + action_label: a.action_type.label().to_string(),
210 + reason: a.reason.clone(),
211 + created_at: a.created_at.format("%b %-d, %Y").to_string(),
212 + resolved_at: None,
213 + })
214 + .collect();
215 +
216 + let moderation_history: Vec<ModerationActionView> = all_actions
217 + .iter()
218 + .filter(|a| a.resolved_at.is_some())
219 + .map(|a| ModerationActionView {
220 + action_label: a.action_type.label().to_string(),
221 + reason: a.reason.clone(),
222 + created_at: a.created_at.format("%b %-d, %Y").to_string(),
223 + resolved_at: a.resolved_at.map(|d| d.format("%b %-d, %Y").to_string()),
224 + })
225 + .collect();
226 +
227 + Ok(UserAccountTabTemplate {
228 + user,
229 + sessions,
230 + current_session_id,
231 + can_create_projects: session_user.can_create_projects,
232 + email_verified: db_user.email_verified,
233 + moderation_active,
234 + moderation_history,
235 + creator_paused: db_user.is_creator_paused(),
236 + })
237 + }
238 +
121 239 /// Render the HTMX partial for the dashboard payments tab.
122 240 #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_payments")]
123 241 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
@@ -193,6 +311,7 @@ pub(in crate::routes::pages::dashboard) async fn dashboard_tab_payments(
193 311 splits_incoming_total: helpers::format_revenue(splits_incoming_cents),
194 312 splits_incoming_count,
195 313 splits_outgoing_total: helpers::format_revenue(splits_outgoing_cents),
314 + can_create_projects: session_user.can_create_projects,
196 315 })
197 316 }
198 317
@@ -168,7 +168,6 @@ pub(crate) async fn render_project_page(
168 168 Vec::new()
169 169 };
170 170
171 - let project_labels = db::labels::get_labels_for_project(&state.db, db_project.id).await?;
172 171 let has_blog_posts = db::blog_posts::has_published_posts(&state.db, db_project.id).await?;
173 172
174 173 let community_url = if db_project.mt_community_id.is_some() {
@@ -194,7 +193,6 @@ pub(crate) async fn render_project_page(
194 193 has_subscription,
195 194 host_url: state.config.host_url.clone(),
196 195 git_repos,
197 - project_labels,
198 196 has_blog_posts,
199 197 community_url,
200 198 tips_enabled: db_user.tips_enabled && db_user.stripe_charges_enabled,
@@ -42,7 +42,6 @@ pub struct DiscoverQuery {
42 42 pub item_type: Option<String>,
43 43 pub tag: Option<String>,
44 44 pub category: Option<String>,
45 - pub label: Option<String>,
46 45 #[serde(default, deserialize_with = "empty_string_as_none")]
47 46 pub min_price: Option<i32>,
48 47 #[serde(default, deserialize_with = "empty_string_as_none")]
@@ -94,8 +93,6 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis
94 93
95 94 let category_filter = query.category.as_deref().filter(|s| !s.is_empty());
96 95
97 - let label_filter = query.label.as_deref().filter(|s| !s.is_empty());
98 -
99 96 let ai_tier_filter: Option<db::AiTier> = query.ai_tier.as_deref()
100 97 .filter(|s| !s.is_empty())
101 98 .and_then(|s| s.parse().ok());
@@ -105,7 +102,6 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis
105 102 pool,
106 103 search_filter,
107 104 category_filter,
108 - label_filter,
109 105 limit,
110 106 offset,
111 107 )
@@ -115,7 +111,6 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis
115 111 pool,
116 112 search_filter,
117 113 category_filter,
118 - label_filter,
119 114 )
120 115 .await?;
121 116
@@ -130,7 +125,6 @@ async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<Dis
130 125 search: search_filter,
131 126 item_type: item_type_filter,
132 127 tag: tag_filter,
133 - label: label_filter,
134 128 min_price: query.min_price,
135 129 max_price: query.max_price,
136 130 sort_by: sort_filter,
@@ -419,28 +413,6 @@ pub(super) async fn discover(
419 413 PriceFilter { label: "$100+".to_string(), count: price_counts.over_100 as u32 },
420 414 ];
421 415
422 - // Build label filters (both modes)
423 - let label_filter = query.label.as_deref().filter(|s| !s.is_empty());
424 - let label_counts = db::labels::get_label_counts(&state.db).await?;
425 - let mut label_filters: Vec<FilterCategory> = vec![FilterCategory {
426 - name: "All".to_string(),
427 - value: String::new(),
428 - count: data.total_count,
429 - active: label_filter.is_none(),
430 - id: String::new(),
431 - following: false,
432 - }];
433 - for lc in label_counts {
434 - label_filters.push(FilterCategory {
435 - name: lc.display_name,
436 - value: lc.slug.to_string(),
437 - count: lc.count as u32,
438 - active: label_filter == Some(lc.slug.as_str()),
439 - id: String::new(),
440 - following: false,
441 - });
442 - }
443 -
444 416 Ok(DiscoverTemplate {
445 417 csrf_token,
446 418 session_user: maybe_user,
@@ -450,7 +422,6 @@ pub(super) async fn discover(
450 422 type_filters,
451 423 tag_filters,
452 424 category_filters,
453 - label_filters,
454 425 price_filters,
455 426 total_items: data.total_count,
456 427 current_page: data.current_page,
@@ -460,7 +431,6 @@ pub(super) async fn discover(
460 431 current_type: query.item_type.unwrap_or_default(),
461 432 current_tag: query.tag.unwrap_or_default(),
462 433 current_category: query.category.unwrap_or_default(),
463 - current_label: query.label.unwrap_or_default(),
464 434 pagination_range: data.pagination_range,
465 435 showing_start: data.showing_start,
466 436 showing_end: data.showing_end,
@@ -486,6 +456,5 @@ pub(super) async fn discover_results(
486 456 showing_start: data.showing_start,
487 457 showing_end: data.showing_end,
488 458 current_category: query.category.unwrap_or_default(),
489 - current_label: query.label.unwrap_or_default(),
490 459 })
491 460 }
@@ -126,16 +126,6 @@ pub struct AdminAppealsTemplate {
126 126 pub admin_active_page: &'static str,
127 127 }
128 128
129 - /// Admin label management page.
130 - #[derive(Template)]
131 - #[template(path = "dashboards/admin-labels.html")]
132 - pub struct AdminLabelsTemplate {
133 - pub csrf_token: CsrfTokenOption,
134 - pub session_user: Option<SessionUser>,
135 - pub labels: Vec<crate::db::DbLabel>,
136 - pub admin_active_page: &'static str,
137 - }
138 -
139 129 /// Admin reports queue page.
140 130 #[derive(Template)]
141 131 #[template(path = "dashboards/admin-reports.html")]