Skip to main content

max / makenotwork

11.7 KB · 337 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(screens: &crate::config::QuasiScreens) -> 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(
117 "/dashboard/tabs/payout-summary",
118 get(tabs::dashboard_tab_payout_summary),
119 )
120 .route_get("/dashboard/transactions", get(tabs::dashboard_transactions))
121 .route_get(
122 "/dashboard/project/{slug}/tabs/overview",
123 get(project_tabs::project_tab_overview),
124 )
125 .route_get(
126 "/dashboard/project/{slug}/tabs/content",
127 get(project_tabs::project_tab_content),
128 )
129 .route_get(
130 "/dashboard/project/{slug}/tabs/analytics",
131 get(project_tabs::project_tab_analytics),
132 )
133 .route_get(
134 "/dashboard/project/{slug}/tabs/code",
135 get(project_tabs::project_tab_code),
136 )
137 .route_get(
138 "/dashboard/project/{slug}/tabs/settings",
139 get(project_tabs::project_tab_settings),
140 )
141 .route_get(
142 "/dashboard/project/{slug}/tabs/blog",
143 get(project_tabs::project_tab_blog),
144 )
145 .route_get(
146 "/dashboard/project/{slug}/tabs/monetization",
147 get(project_tabs::project_tab_monetization),
148 )
149 .route_get(
150 "/dashboard/project/{slug}/tabs/promotions",
151 get(project_tabs::project_tab_promotions),
152 )
153 .route_get(
154 "/dashboard/project/{slug}/tabs/subscriptions",
155 get(project_tabs::project_tab_subscriptions),
156 )
157 .route_get(
158 "/dashboard/project/{slug}/tabs/members",
159 get(project_tabs::project_tab_members),
160 )
161 .route_get(
162 "/dashboard/project/{slug}/tabs/synckit",
163 get(project_tabs::project_tab_synckit),
164 )
165 .route_get(
166 "/dashboard/item/{id}/tabs/overview",
167 get(tabs::item_tab_overview),
168 )
169 .route_get(
170 "/dashboard/item/{id}/tabs/details",
171 get(tabs::item_tab_details),
172 )
173 .route_get(
174 "/dashboard/item/{id}/tabs/pricing",
175 get(tabs::item_tab_pricing),
176 )
177 .route_get("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files))
178 .route_get("/dashboard/item/{id}/tabs/sales", get(tabs::item_tab_sales))
179 .route_get("/dashboard/item/{id}/tabs/embed", get(tabs::item_tab_embed))
180 .route_get(
181 "/dashboard/item/{id}/analytics",
182 get(main::dashboard_item_analytics),
183 )
184 .route_layer(GovernorLayer::new(read_rate_limit));
185
186 // The screens the description layer serves, when they are switched on. Each
187 // Askama handler stays registered and reachable in every other deployment,
188 // which is what makes a conversion revert by editing an env var.
189 let tab_routes = if screens.enabled(crate::quasi::ssh_keys::SCREEN) {
190 tab_routes
191 } else {
192 tab_routes.route_get(
193 crate::quasi::ssh_keys::PATH,
194 get(tabs::dashboard_tab_ssh_keys),
195 )
196 };
197 let tab_routes = if screens.enabled(crate::quasi::buyer_contacts::SCREEN) {
198 tab_routes
199 } else {
200 tab_routes.route_get(
201 crate::quasi::buyer_contacts::PATH,
202 get(tabs::dashboard_tab_contacts),
203 )
204 };
205 let tab_routes = if screens.enabled(crate::quasi::user_analytics::SCREEN) {
206 tab_routes
207 } else {
208 tab_routes.route_get(
209 crate::quasi::user_analytics::PATH,
210 get(tabs::dashboard_tab_analytics),
211 )
212 };
213 let tab_routes = if screens.enabled(crate::quasi::forum_memberships::SETTINGS_SCREEN) {
214 tab_routes
215 } else {
216 tab_routes.route_get(
217 crate::quasi::forum_memberships::SETTINGS_PATH,
218 get(tabs::dashboard_tab_forums),
219 )
220 };
221
222 let routes = CsrfRouter::new()
223 .merge(wizards::wizard_routes())
224 .route_get("/dashboard", get(main::dashboard))
225 .route_get("/dashboard/project/{slug}", get(main::dashboard_project))
226 .route_get("/dashboard/item/{id}", get(main::dashboard_item))
227 .merge(tab_routes)
228 .route_get("/dashboard/item/{id}/edit-row", get(forms::item_edit_row))
229 .route_get(
230 "/dashboard/project/{slug}/blog/new",
231 get(forms::blog_editor),
232 )
233 .route_get("/dashboard/export", get(forms::export_portal))
234 .route_get("/dashboard/import", get(forms::import_portal))
235 .route_get("/dashboard/delete-account", get(forms::delete_account_page))
236 .route(
237 "/dashboard/onboarding/dismiss",
238 post_csrf(main::dismiss_onboarding),
239 )
240 .route(
241 "/dashboard/onboarding/restore",
242 post_csrf(main::restore_onboarding),
243 )
244 .route(
245 "/dashboard/feed/regenerate",
246 post_csrf(tabs::regenerate_feed_url),
247 )
248 // Custom-page editors (profile + per project).
249 .route_get("/dashboard/custom-page", get(custom_page::user_editor))
250 .route("/dashboard/custom-page", post_csrf(custom_page::user_save))
251 .route(
252 "/dashboard/custom-page/draft",
253 post_csrf(custom_page::user_autosave),
254 )
255 .route(
256 "/dashboard/custom-page/reset",
257 post_csrf(custom_page::user_reset),
258 )
259 .route_get(
260 "/dashboard/project/{slug}/custom-page",
261 get(custom_page::project_editor),
262 )
263 .route(
264 "/dashboard/project/{slug}/custom-page",
265 post_csrf(custom_page::project_save),
266 )
267 .route(
268 "/dashboard/project/{slug}/custom-page/draft",
269 post_csrf(custom_page::project_autosave),
270 )
271 .route(
272 "/dashboard/project/{slug}/custom-page/reset",
273 post_csrf(custom_page::project_reset),
274 );
275
276 // The described Content panel's own writes, registered only where the panel
277 // is what serves. A described write answers with the region it changed
278 // (decision 7), which is a different response from the API bulk routes'
279 // toast and is why these exist beside them rather than instead of them.
280 // Off the read rate limiter above: these are presses, not polls.
281 if !screens.enabled(crate::quasi::project_content::SCREEN) {
282 return routes;
283 }
284 routes
285 .route(
286 "/dashboard/project/{slug}/tabs/content/bulk/{verb}",
287 post_csrf(project_tabs::content_bulk),
288 )
289 .route(
290 "/dashboard/project/{slug}/tabs/content/move/{id}",
291 post_csrf(project_tabs::content_move),
292 )
293 .route(
294 "/dashboard/project/{slug}/tabs/content/publish/{id}",
295 post_csrf(project_tabs::content_publish),
296 )
297 .route(
298 "/dashboard/project/{slug}/tabs/content/rename/{id}",
299 post_csrf(project_tabs::content_rename),
300 )
301 .route(
302 "/dashboard/project/{slug}/tabs/content/restore/{id}",
303 post_csrf(project_tabs::content_restore),
304 )
305 .route(
306 "/dashboard/project/{slug}/tabs/content/blog/{id}/delete",
307 post_csrf(project_tabs::content_blog_delete),
308 )
309 }
310
311 /// Query parameters for filtering dashboard transactions.
312 #[derive(Debug, Deserialize)]
313 #[allow(dead_code)] // Fields populated by query string deserialization
314 pub(super) struct TransactionQuery {
315 pub r#type: Option<String>,
316 pub period: Option<String>,
317 }
318
319 /// Collect and sort transactions from incoming and outgoing lists.
320 pub(super) fn collect_transactions(
321 incoming_txs: &[db::DbTransaction],
322 outgoing_txs: &[db::DbTransaction],
323 ) -> Vec<Transaction> {
324 let mut transactions: Vec<Transaction> = Vec::new();
325
326 for tx in incoming_txs {
327 transactions.push(Transaction::from_sale(tx));
328 }
329
330 for tx in outgoing_txs {
331 transactions.push(Transaction::from_purchase(tx));
332 }
333
334 transactions.sort_by(|a, b| b.date.cmp(&a.date));
335 transactions
336 }
337