Skip to main content

max / makenotwork

10.9 KB · 300 lines History Blame Raw
1 //! Authenticated creator dashboard and HTMX tab partials.
2
3 mod custom_page;
4 mod forms;
5 mod main;
6 mod project_tabs;
7 mod tabs;
8 pub(crate) mod wizards;
9
10 use axum::routing::get;
11 use serde::Deserialize;
12 use tower_governor::GovernorLayer;
13
14 use crate::{
15 AppState, constants,
16 csrf::{CsrfRouter, post_csrf},
17 db,
18 db::Cents,
19 types::{ChartBar, Transaction},
20 };
21
22 /// Which item dashboard tab the strip opens on.
23 ///
24 /// Replaces the `#tab-files` hash `core/tabs.ts` used to restore by clicking
25 /// after load. `6b24f2df`.
26 #[derive(Deserialize)]
27 pub(super) struct ItemTabQuery {
28 pub tab: Option<String>,
29 }
30
31 /// Which user-dashboard tab the strip opens on, and which section within it.
32 ///
33 /// `?tab=settings&section=creator`: the tab half is read by
34 /// `crate::quasi::user_tabs`, the section half by
35 /// `crate::quasi::settings_tabs`. `6b24f2df` step 5, nested by `3a7de032`.
36 #[derive(Deserialize)]
37 pub(super) struct UserTabQuery {
38 pub tab: Option<String>,
39 pub section: Option<String>,
40 }
41
42 /// Which settings section the settings sub-nav opens on.
43 ///
44 /// The second level of the dashboard deep link (`3a7de032`):
45 /// `?tab=settings&section=creator` is [`ItemTabQuery::tab`] choosing the tab and
46 /// this choosing within it. Named separately because the settings fragment route
47 /// takes only this half.
48 #[derive(Deserialize)]
49 pub(super) struct SectionQuery {
50 pub section: Option<String>,
51 }
52
53 /// Query parameters for analytics time range selection.
54 #[derive(Deserialize)]
55 pub(super) struct AnalyticsQuery {
56 pub range: Option<String>,
57 }
58
59 /// Convert time-series buckets into chart bar view models.
60 ///
61 /// `pub(crate)` since `crate::quasi::user_analytics` draws the same chart: the
62 /// description layer has no word for one, so the described screen emits the
63 /// same markup from the same bars rather than computing its own.
64 pub(crate) fn build_chart_bars(
65 buckets: &[db::analytics::TimeBucket],
66 currency: crate::currency::SettlementCurrency,
67 ) -> Vec<ChartBar> {
68 let max_revenue = buckets
69 .iter()
70 .map(|b| b.revenue_cents)
71 .max()
72 .unwrap_or(Cents::new(1))
73 .max(Cents::new(1));
74 buckets
75 .iter()
76 .map(|b| ChartBar {
77 label: b.label.clone(),
78 height_pct: b.revenue_cents.as_f64() / max_revenue.as_f64() * 100.0,
79 value: crate::formatting::format_revenue(*b.revenue_cents, currency),
80 count: b.sales_count,
81 })
82 .collect()
83 }
84
85 /// Register dashboard page routes.
86 pub(crate) fn dashboard_routes() -> CsrfRouter<AppState> {
87 let read_rate_limit = crate::helpers::rate_limiter_ms(
88 constants::DASHBOARD_READ_RATE_LIMIT_MS,
89 constants::DASHBOARD_READ_RATE_LIMIT_BURST,
90 );
91
92 // Tab endpoints, rate limited to prevent rapid polling
93 let tab_routes = CsrfRouter::new()
94 .route_get("/dashboard/tabs/details", get(tabs::dashboard_tab_details))
95 .route_get(
96 "/dashboard/tabs/settings",
97 get(tabs::dashboard_tab_settings),
98 )
99 .route_get("/dashboard/tabs/profile", get(tabs::dashboard_tab_profile))
100 .route_get("/dashboard/tabs/account", get(tabs::dashboard_tab_account))
101 .route_get(
102 "/dashboard/tabs/payments",
103 get(tabs::dashboard_tab_payments),
104 )
105 .route_get(
106 "/dashboard/tabs/projects",
107 get(tabs::dashboard_tab_projects),
108 )
109 .route_get("/dashboard/tabs/creator", get(tabs::dashboard_tab_creator))
110 .route_get("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit))
111 .route_get("/dashboard/tabs/media", get(tabs::dashboard_tab_media))
112 .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support))
113 // The SSH-keys tab is registered below rather than here: when its
114 // screen is switched on, `crate::quasi` serves this address instead and
115 // axum panics on two routes claiming one path.
116 .route_get("/dashboard/transactions", get(tabs::dashboard_transactions))
117 .route_get(
118 "/dashboard/project/{slug}/tabs/overview",
119 get(project_tabs::project_tab_overview),
120 )
121 .route_get(
122 "/dashboard/project/{slug}/tabs/content",
123 get(project_tabs::project_tab_content),
124 )
125 .route_get(
126 "/dashboard/project/{slug}/tabs/analytics",
127 get(project_tabs::project_tab_analytics),
128 )
129 .route_get(
130 "/dashboard/project/{slug}/tabs/code",
131 get(project_tabs::project_tab_code),
132 )
133 .route_get(
134 "/dashboard/project/{slug}/tabs/settings",
135 get(project_tabs::project_tab_settings),
136 )
137 .route_get(
138 "/dashboard/project/{slug}/tabs/monetization",
139 get(project_tabs::project_tab_monetization),
140 )
141 // `/tabs/promotions` was registered here until 2026-08-26 and reached by
142 // nothing. Its partial is live -- `project_monetization.html` includes
143 // it -- but the composite carries the promo data itself, so the route
144 // that rendered the partial alone had no caller. Its two neighbours
145 // below are NOT the same case: both are real self-refresh targets.
146 .route_get(
147 "/dashboard/project/{slug}/tabs/subscriptions",
148 get(project_tabs::project_tab_subscriptions),
149 )
150 .route_get(
151 "/dashboard/project/{slug}/tabs/members",
152 get(project_tabs::project_tab_members),
153 )
154 .route_get(
155 "/dashboard/project/{slug}/tabs/synckit",
156 get(project_tabs::project_tab_synckit),
157 )
158 .route_get(
159 "/dashboard/item/{id}/tabs/overview",
160 get(tabs::item_tab_overview),
161 )
162 .route_get(
163 "/dashboard/item/{id}/tabs/details",
164 get(tabs::item_tab_details),
165 )
166 .route_get(
167 "/dashboard/item/{id}/tabs/pricing",
168 get(tabs::item_tab_pricing),
169 )
170 .route_get("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files))
171 .route_get("/dashboard/item/{id}/tabs/sales", get(tabs::item_tab_sales))
172 .route_get("/dashboard/item/{id}/tabs/embed", get(tabs::item_tab_embed))
173 .route_get(
174 "/dashboard/item/{id}/analytics",
175 get(main::dashboard_item_analytics),
176 )
177 .route_layer(GovernorLayer::new(read_rate_limit));
178
179 // The four tabs `crate::quasi` serves are deliberately absent here. They used
180 // to be registered behind `QUASI_SCREENS` being off, so a conversion reverted
181 // by editing an env var; the flag is gone (`64b33b26`) and the described
182 // rendering is the only one. Registering them here now would panic axum on
183 // two routes claiming one path.
184
185 let routes = CsrfRouter::new()
186 .merge(wizards::wizard_routes())
187 .route_get("/dashboard", get(main::dashboard))
188 .route_get("/dashboard/project/{slug}", get(main::dashboard_project))
189 .route_get("/dashboard/item/{id}", get(main::dashboard_item))
190 .merge(tab_routes)
191 .route_get("/dashboard/item/{id}/edit-row", get(forms::item_edit_row))
192 .route_get(
193 "/dashboard/project/{slug}/blog/new",
194 get(forms::blog_editor),
195 )
196 .route_get("/dashboard/import", get(forms::import_portal))
197 .route_get("/dashboard/delete-account", get(forms::delete_account_page))
198 .route(
199 "/dashboard/onboarding/dismiss",
200 post_csrf(main::dismiss_onboarding),
201 )
202 .route(
203 "/dashboard/onboarding/restore",
204 post_csrf(main::restore_onboarding),
205 )
206 .route(
207 "/dashboard/feed/regenerate",
208 post_csrf(tabs::regenerate_feed_url),
209 )
210 // Custom-page editors (profile + per project).
211 .route_get("/dashboard/custom-page", get(custom_page::user_editor))
212 .route("/dashboard/custom-page", post_csrf(custom_page::user_save))
213 .route(
214 "/dashboard/custom-page/draft",
215 post_csrf(custom_page::user_autosave),
216 )
217 .route(
218 "/dashboard/custom-page/reset",
219 post_csrf(custom_page::user_reset),
220 )
221 .route_get(
222 "/dashboard/project/{slug}/custom-page",
223 get(custom_page::project_editor),
224 )
225 .route(
226 "/dashboard/project/{slug}/custom-page",
227 post_csrf(custom_page::project_save),
228 )
229 .route(
230 "/dashboard/project/{slug}/custom-page/draft",
231 post_csrf(custom_page::project_autosave),
232 )
233 .route(
234 "/dashboard/project/{slug}/custom-page/reset",
235 post_csrf(custom_page::project_reset),
236 );
237
238 // The described Content panel's own writes, registered only where the panel
239 // is what serves. A described write answers with the region it changed
240 // (decision 7), which is a different response from the API bulk routes'
241 // toast and is why these exist beside them rather than instead of them.
242 // Off the read rate limiter above: these are presses, not polls.
243 //
244 // Unconditional since `64b33b26`. These were registered only where the
245 // described panel was what served, because the Askama panel had no use for
246 // them; that panel is gone and the description is the only rendering.
247 routes
248 .route(
249 "/dashboard/project/{slug}/tabs/content/bulk/{verb}",
250 post_csrf(project_tabs::content_bulk),
251 )
252 .route(
253 "/dashboard/project/{slug}/tabs/content/move/{id}",
254 post_csrf(project_tabs::content_move),
255 )
256 .route(
257 "/dashboard/project/{slug}/tabs/content/publish/{id}",
258 post_csrf(project_tabs::content_publish),
259 )
260 .route(
261 "/dashboard/project/{slug}/tabs/content/rename/{id}",
262 post_csrf(project_tabs::content_rename),
263 )
264 .route(
265 "/dashboard/project/{slug}/tabs/content/restore/{id}",
266 post_csrf(project_tabs::content_restore),
267 )
268 .route(
269 "/dashboard/project/{slug}/tabs/content/blog/{id}/delete",
270 post_csrf(project_tabs::content_blog_delete),
271 )
272 }
273
274 /// Query parameters for filtering dashboard transactions.
275 #[derive(Debug, Deserialize)]
276 #[allow(dead_code)] // Fields populated by query string deserialization
277 pub(super) struct TransactionQuery {
278 pub r#type: Option<String>,
279 pub period: Option<String>,
280 }
281
282 /// Collect and sort transactions from incoming and outgoing lists.
283 pub(super) fn collect_transactions(
284 incoming_txs: &[db::DbTransaction],
285 outgoing_txs: &[db::DbTransaction],
286 ) -> Vec<Transaction> {
287 let mut transactions: Vec<Transaction> = Vec::new();
288
289 for tx in incoming_txs {
290 transactions.push(Transaction::from_sale(tx));
291 }
292
293 for tx in outgoing_txs {
294 transactions.push(Transaction::from_purchase(tx));
295 }
296
297 transactions.sort_by(|a, b| b.date.cmp(&a.date));
298 transactions
299 }
300