Skip to main content

max / makenotwork

9.3 KB · 272 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 /// Query parameters for analytics time range selection.
23 #[derive(Deserialize)]
24 pub(super) struct AnalyticsQuery {
25 pub range: Option<String>,
26 }
27
28 /// Convert time-series buckets into chart bar view models.
29 ///
30 /// `pub(crate)` since `crate::quasi::user_analytics` draws the same chart: the
31 /// description layer has no word for one, so the described screen emits the
32 /// same markup from the same bars rather than computing its own.
33 pub(crate) fn build_chart_bars(
34 buckets: &[db::analytics::TimeBucket],
35 currency: crate::currency::SettlementCurrency,
36 ) -> Vec<ChartBar> {
37 let max_revenue = buckets
38 .iter()
39 .map(|b| b.revenue_cents)
40 .max()
41 .unwrap_or(Cents::new(1))
42 .max(Cents::new(1));
43 buckets
44 .iter()
45 .map(|b| ChartBar {
46 label: b.label.clone(),
47 height_pct: b.revenue_cents.as_f64() / max_revenue.as_f64() * 100.0,
48 value: crate::formatting::format_revenue(*b.revenue_cents, currency),
49 count: b.sales_count,
50 })
51 .collect()
52 }
53
54 /// Register dashboard page routes.
55 pub(crate) fn dashboard_routes(screens: &crate::config::QuasiScreens) -> CsrfRouter<AppState> {
56 let read_rate_limit = crate::helpers::rate_limiter_ms(
57 constants::DASHBOARD_READ_RATE_LIMIT_MS,
58 constants::DASHBOARD_READ_RATE_LIMIT_BURST,
59 );
60
61 // Tab endpoints, rate limited to prevent rapid polling
62 let tab_routes = CsrfRouter::new()
63 .route_get("/dashboard/tabs/details", get(tabs::dashboard_tab_details))
64 .route_get(
65 "/dashboard/tabs/settings",
66 get(tabs::dashboard_tab_settings),
67 )
68 .route_get("/dashboard/tabs/profile", get(tabs::dashboard_tab_profile))
69 .route_get("/dashboard/tabs/account", get(tabs::dashboard_tab_account))
70 .route_get(
71 "/dashboard/tabs/payments",
72 get(tabs::dashboard_tab_payments),
73 )
74 .route_get(
75 "/dashboard/tabs/projects",
76 get(tabs::dashboard_tab_projects),
77 )
78 .route_get("/dashboard/tabs/creator", get(tabs::dashboard_tab_creator))
79 .route_get("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit))
80 .route_get("/dashboard/tabs/media", get(tabs::dashboard_tab_media))
81 .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support))
82 // The SSH-keys tab is registered below rather than here: when its
83 // screen is switched on, `crate::quasi` serves this address instead and
84 // axum panics on two routes claiming one path.
85 .route_get(
86 "/dashboard/tabs/payout-summary",
87 get(tabs::dashboard_tab_payout_summary),
88 )
89 .route_get("/dashboard/transactions", get(tabs::dashboard_transactions))
90 .route_get(
91 "/dashboard/project/{slug}/tabs/overview",
92 get(project_tabs::project_tab_overview),
93 )
94 .route_get(
95 "/dashboard/project/{slug}/tabs/content",
96 get(project_tabs::project_tab_content),
97 )
98 .route_get(
99 "/dashboard/project/{slug}/tabs/analytics",
100 get(project_tabs::project_tab_analytics),
101 )
102 .route_get(
103 "/dashboard/project/{slug}/tabs/code",
104 get(project_tabs::project_tab_code),
105 )
106 .route_get(
107 "/dashboard/project/{slug}/tabs/settings",
108 get(project_tabs::project_tab_settings),
109 )
110 .route_get(
111 "/dashboard/project/{slug}/tabs/blog",
112 get(project_tabs::project_tab_blog),
113 )
114 .route_get(
115 "/dashboard/project/{slug}/tabs/monetization",
116 get(project_tabs::project_tab_monetization),
117 )
118 .route_get(
119 "/dashboard/project/{slug}/tabs/promotions",
120 get(project_tabs::project_tab_promotions),
121 )
122 .route_get(
123 "/dashboard/project/{slug}/tabs/subscriptions",
124 get(project_tabs::project_tab_subscriptions),
125 )
126 .route_get(
127 "/dashboard/project/{slug}/tabs/members",
128 get(project_tabs::project_tab_members),
129 )
130 .route_get(
131 "/dashboard/project/{slug}/tabs/synckit",
132 get(project_tabs::project_tab_synckit),
133 )
134 .route_get(
135 "/dashboard/item/{id}/tabs/overview",
136 get(tabs::item_tab_overview),
137 )
138 .route_get(
139 "/dashboard/item/{id}/tabs/details",
140 get(tabs::item_tab_details),
141 )
142 .route_get(
143 "/dashboard/item/{id}/tabs/pricing",
144 get(tabs::item_tab_pricing),
145 )
146 .route_get("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files))
147 .route_get("/dashboard/item/{id}/tabs/sales", get(tabs::item_tab_sales))
148 .route_get("/dashboard/item/{id}/tabs/embed", get(tabs::item_tab_embed))
149 .route_get(
150 "/dashboard/item/{id}/analytics",
151 get(main::dashboard_item_analytics),
152 )
153 .route_layer(GovernorLayer::new(read_rate_limit));
154
155 // The screens the description layer serves, when they are switched on. Each
156 // Askama handler stays registered and reachable in every other deployment,
157 // which is what makes a conversion revert by editing an env var.
158 let tab_routes = if screens.enabled(crate::quasi::ssh_keys::SCREEN) {
159 tab_routes
160 } else {
161 tab_routes.route_get(
162 crate::quasi::ssh_keys::PATH,
163 get(tabs::dashboard_tab_ssh_keys),
164 )
165 };
166 let tab_routes = if screens.enabled(crate::quasi::buyer_contacts::SCREEN) {
167 tab_routes
168 } else {
169 tab_routes.route_get(
170 crate::quasi::buyer_contacts::PATH,
171 get(tabs::dashboard_tab_contacts),
172 )
173 };
174 let tab_routes = if screens.enabled(crate::quasi::user_analytics::SCREEN) {
175 tab_routes
176 } else {
177 tab_routes.route_get(
178 crate::quasi::user_analytics::PATH,
179 get(tabs::dashboard_tab_analytics),
180 )
181 };
182 let tab_routes = if screens.enabled(crate::quasi::forum_memberships::SETTINGS_SCREEN) {
183 tab_routes
184 } else {
185 tab_routes.route_get(
186 crate::quasi::forum_memberships::SETTINGS_PATH,
187 get(tabs::dashboard_tab_forums),
188 )
189 };
190
191 CsrfRouter::new()
192 .merge(wizards::wizard_routes())
193 .route_get("/dashboard", get(main::dashboard))
194 .route_get("/dashboard/project/{slug}", get(main::dashboard_project))
195 .route_get("/dashboard/item/{id}", get(main::dashboard_item))
196 .merge(tab_routes)
197 .route_get("/dashboard/item/{id}/edit-row", get(forms::item_edit_row))
198 .route_get(
199 "/dashboard/project/{slug}/blog/new",
200 get(forms::blog_editor),
201 )
202 .route_get("/dashboard/export", get(forms::export_portal))
203 .route_get("/dashboard/import", get(forms::import_portal))
204 .route_get("/dashboard/delete-account", get(forms::delete_account_page))
205 .route(
206 "/dashboard/onboarding/dismiss",
207 post_csrf(main::dismiss_onboarding),
208 )
209 .route(
210 "/dashboard/onboarding/restore",
211 post_csrf(main::restore_onboarding),
212 )
213 .route(
214 "/dashboard/feed/regenerate",
215 post_csrf(tabs::regenerate_feed_url),
216 )
217 // Custom-page editors (profile + per project).
218 .route_get("/dashboard/custom-page", get(custom_page::user_editor))
219 .route("/dashboard/custom-page", post_csrf(custom_page::user_save))
220 .route(
221 "/dashboard/custom-page/draft",
222 post_csrf(custom_page::user_autosave),
223 )
224 .route(
225 "/dashboard/custom-page/reset",
226 post_csrf(custom_page::user_reset),
227 )
228 .route_get(
229 "/dashboard/project/{slug}/custom-page",
230 get(custom_page::project_editor),
231 )
232 .route(
233 "/dashboard/project/{slug}/custom-page",
234 post_csrf(custom_page::project_save),
235 )
236 .route(
237 "/dashboard/project/{slug}/custom-page/draft",
238 post_csrf(custom_page::project_autosave),
239 )
240 .route(
241 "/dashboard/project/{slug}/custom-page/reset",
242 post_csrf(custom_page::project_reset),
243 )
244 }
245
246 /// Query parameters for filtering dashboard transactions.
247 #[derive(Debug, Deserialize)]
248 #[allow(dead_code)] // Fields populated by query string deserialization
249 pub(super) struct TransactionQuery {
250 pub r#type: Option<String>,
251 pub period: Option<String>,
252 }
253
254 /// Collect and sort transactions from incoming and outgoing lists.
255 pub(super) fn collect_transactions(
256 incoming_txs: &[db::DbTransaction],
257 outgoing_txs: &[db::DbTransaction],
258 ) -> Vec<Transaction> {
259 let mut transactions: Vec<Transaction> = Vec::new();
260
261 for tx in incoming_txs {
262 transactions.push(Transaction::from_sale(tx));
263 }
264
265 for tx in outgoing_txs {
266 transactions.push(Transaction::from_purchase(tx));
267 }
268
269 transactions.sort_by(|a, b| b.date.cmp(&a.date));
270 transactions
271 }
272