max / makenotwork
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")] |