//! Authenticated creator dashboard and HTMX tab partials. mod custom_page; mod forms; mod main; mod project_tabs; mod tabs; pub(crate) mod wizards; use axum::routing::get; use serde::Deserialize; use tower_governor::GovernorLayer; use crate::{ AppState, constants, csrf::{CsrfRouter, post_csrf}, db, db::Cents, types::{ChartBar, Transaction}, }; /// Which item dashboard tab the strip opens on. /// /// Replaces the `#tab-files` hash `core/tabs.ts` used to restore by clicking /// after load. `6b24f2df`. #[derive(Deserialize)] pub(super) struct ItemTabQuery { pub tab: Option, } /// Which user-dashboard tab the strip opens on, and which section within it. /// /// `?tab=settings§ion=creator`: the tab half is read by /// `crate::quasi::user_tabs`, the section half by /// `crate::quasi::settings_tabs`. `6b24f2df` step 5, nested by `3a7de032`. #[derive(Deserialize)] pub(super) struct UserTabQuery { pub tab: Option, pub section: Option, } /// Which settings section the settings sub-nav opens on. /// /// The second level of the dashboard deep link (`3a7de032`): /// `?tab=settings§ion=creator` is [`ItemTabQuery::tab`] choosing the tab and /// this choosing within it. Named separately because the settings fragment route /// takes only this half. #[derive(Deserialize)] pub(super) struct SectionQuery { pub section: Option, } /// Query parameters for analytics time range selection. #[derive(Deserialize)] pub(super) struct AnalyticsQuery { pub range: Option, } /// Convert time-series buckets into chart bar view models. /// /// `pub(crate)` since `crate::quasi::user_analytics` draws the same chart: the /// description layer has no word for one, so the described screen emits the /// same markup from the same bars rather than computing its own. pub(crate) fn build_chart_bars( buckets: &[db::analytics::TimeBucket], currency: crate::currency::SettlementCurrency, ) -> Vec { let max_revenue = buckets .iter() .map(|b| b.revenue_cents) .max() .unwrap_or(Cents::new(1)) .max(Cents::new(1)); buckets .iter() .map(|b| ChartBar { label: b.label.clone(), height_pct: b.revenue_cents.as_f64() / max_revenue.as_f64() * 100.0, value: crate::formatting::format_revenue(*b.revenue_cents, currency), count: b.sales_count, }) .collect() } /// Register dashboard page routes. pub(crate) fn dashboard_routes(screens: &crate::config::QuasiScreens) -> CsrfRouter { let read_rate_limit = crate::helpers::rate_limiter_ms( constants::DASHBOARD_READ_RATE_LIMIT_MS, constants::DASHBOARD_READ_RATE_LIMIT_BURST, ); // Tab endpoints, rate limited to prevent rapid polling let tab_routes = CsrfRouter::new() .route_get("/dashboard/tabs/details", get(tabs::dashboard_tab_details)) .route_get( "/dashboard/tabs/settings", get(tabs::dashboard_tab_settings), ) .route_get("/dashboard/tabs/profile", get(tabs::dashboard_tab_profile)) .route_get("/dashboard/tabs/account", get(tabs::dashboard_tab_account)) .route_get( "/dashboard/tabs/payments", get(tabs::dashboard_tab_payments), ) .route_get( "/dashboard/tabs/projects", get(tabs::dashboard_tab_projects), ) .route_get("/dashboard/tabs/creator", get(tabs::dashboard_tab_creator)) .route_get("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit)) .route_get("/dashboard/tabs/media", get(tabs::dashboard_tab_media)) .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support)) // The SSH-keys tab is registered below rather than here: when its // screen is switched on, `crate::quasi` serves this address instead and // axum panics on two routes claiming one path. .route_get( "/dashboard/tabs/payout-summary", get(tabs::dashboard_tab_payout_summary), ) .route_get("/dashboard/transactions", get(tabs::dashboard_transactions)) .route_get( "/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview), ) .route_get( "/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content), ) .route_get( "/dashboard/project/{slug}/tabs/analytics", get(project_tabs::project_tab_analytics), ) .route_get( "/dashboard/project/{slug}/tabs/code", get(project_tabs::project_tab_code), ) .route_get( "/dashboard/project/{slug}/tabs/settings", get(project_tabs::project_tab_settings), ) .route_get( "/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog), ) .route_get( "/dashboard/project/{slug}/tabs/monetization", get(project_tabs::project_tab_monetization), ) .route_get( "/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions), ) .route_get( "/dashboard/project/{slug}/tabs/subscriptions", get(project_tabs::project_tab_subscriptions), ) .route_get( "/dashboard/project/{slug}/tabs/members", get(project_tabs::project_tab_members), ) .route_get( "/dashboard/project/{slug}/tabs/synckit", get(project_tabs::project_tab_synckit), ) .route_get( "/dashboard/item/{id}/tabs/overview", get(tabs::item_tab_overview), ) .route_get( "/dashboard/item/{id}/tabs/details", get(tabs::item_tab_details), ) .route_get( "/dashboard/item/{id}/tabs/pricing", get(tabs::item_tab_pricing), ) .route_get("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files)) .route_get("/dashboard/item/{id}/tabs/sales", get(tabs::item_tab_sales)) .route_get("/dashboard/item/{id}/tabs/embed", get(tabs::item_tab_embed)) .route_get( "/dashboard/item/{id}/analytics", get(main::dashboard_item_analytics), ) .route_layer(GovernorLayer::new(read_rate_limit)); // The screens the description layer serves, when they are switched on. Each // Askama handler stays registered and reachable in every other deployment, // which is what makes a conversion revert by editing an env var. let tab_routes = if screens.enabled(crate::quasi::ssh_keys::SCREEN) { tab_routes } else { tab_routes.route_get( crate::quasi::ssh_keys::PATH, get(tabs::dashboard_tab_ssh_keys), ) }; let tab_routes = if screens.enabled(crate::quasi::buyer_contacts::SCREEN) { tab_routes } else { tab_routes.route_get( crate::quasi::buyer_contacts::PATH, get(tabs::dashboard_tab_contacts), ) }; let tab_routes = if screens.enabled(crate::quasi::user_analytics::SCREEN) { tab_routes } else { tab_routes.route_get( crate::quasi::user_analytics::PATH, get(tabs::dashboard_tab_analytics), ) }; let tab_routes = if screens.enabled(crate::quasi::forum_memberships::SETTINGS_SCREEN) { tab_routes } else { tab_routes.route_get( crate::quasi::forum_memberships::SETTINGS_PATH, get(tabs::dashboard_tab_forums), ) }; let routes = CsrfRouter::new() .merge(wizards::wizard_routes()) .route_get("/dashboard", get(main::dashboard)) .route_get("/dashboard/project/{slug}", get(main::dashboard_project)) .route_get("/dashboard/item/{id}", get(main::dashboard_item)) .merge(tab_routes) .route_get("/dashboard/item/{id}/edit-row", get(forms::item_edit_row)) .route_get( "/dashboard/project/{slug}/blog/new", get(forms::blog_editor), ) .route_get("/dashboard/export", get(forms::export_portal)) .route_get("/dashboard/import", get(forms::import_portal)) .route_get("/dashboard/delete-account", get(forms::delete_account_page)) .route( "/dashboard/onboarding/dismiss", post_csrf(main::dismiss_onboarding), ) .route( "/dashboard/onboarding/restore", post_csrf(main::restore_onboarding), ) .route( "/dashboard/feed/regenerate", post_csrf(tabs::regenerate_feed_url), ) // Custom-page editors (profile + per project). .route_get("/dashboard/custom-page", get(custom_page::user_editor)) .route("/dashboard/custom-page", post_csrf(custom_page::user_save)) .route( "/dashboard/custom-page/draft", post_csrf(custom_page::user_autosave), ) .route( "/dashboard/custom-page/reset", post_csrf(custom_page::user_reset), ) .route_get( "/dashboard/project/{slug}/custom-page", get(custom_page::project_editor), ) .route( "/dashboard/project/{slug}/custom-page", post_csrf(custom_page::project_save), ) .route( "/dashboard/project/{slug}/custom-page/draft", post_csrf(custom_page::project_autosave), ) .route( "/dashboard/project/{slug}/custom-page/reset", post_csrf(custom_page::project_reset), ); // The described Content panel's own writes, registered only where the panel // is what serves. A described write answers with the region it changed // (decision 7), which is a different response from the API bulk routes' // toast and is why these exist beside them rather than instead of them. // Off the read rate limiter above: these are presses, not polls. if !screens.enabled(crate::quasi::project_content::SCREEN) { return routes; } routes .route( "/dashboard/project/{slug}/tabs/content/bulk/{verb}", post_csrf(project_tabs::content_bulk), ) .route( "/dashboard/project/{slug}/tabs/content/move/{id}", post_csrf(project_tabs::content_move), ) .route( "/dashboard/project/{slug}/tabs/content/publish/{id}", post_csrf(project_tabs::content_publish), ) .route( "/dashboard/project/{slug}/tabs/content/rename/{id}", post_csrf(project_tabs::content_rename), ) .route( "/dashboard/project/{slug}/tabs/content/restore/{id}", post_csrf(project_tabs::content_restore), ) .route( "/dashboard/project/{slug}/tabs/content/blog/{id}/delete", post_csrf(project_tabs::content_blog_delete), ) } /// Query parameters for filtering dashboard transactions. #[derive(Debug, Deserialize)] #[allow(dead_code)] // Fields populated by query string deserialization pub(super) struct TransactionQuery { pub r#type: Option, pub period: Option, } /// Collect and sort transactions from incoming and outgoing lists. pub(super) fn collect_transactions( incoming_txs: &[db::DbTransaction], outgoing_txs: &[db::DbTransaction], ) -> Vec { let mut transactions: Vec = Vec::new(); for tx in incoming_txs { transactions.push(Transaction::from_sale(tx)); } for tx in outgoing_txs { transactions.push(Transaction::from_purchase(tx)); } transactions.sort_by(|a, b| b.date.cmp(&a.date)); transactions }