Skip to main content

max / makenotwork

13.6 KB · 366 lines History Blame Raw
1 //! The data-export portal at `/dashboard/export`, described.
2 //!
3 //! The no-lock-in guarantee's own screen: six exports, five of which hand back
4 //! a file and one of which is assembled in the background and mailed. It
5 //! replaces `templates/dashboards/dashboard-export.html`,
6 //! `dashboard::forms::export_portal`, `ExportPortalTemplate` and
7 //! `static/dashboard-export-inline.js`.
8 //!
9 //! # The five direct exports were a sixth hand-written export control
10 //!
11 //! [`super::export_act`] is the described control for "post this route and keep
12 //! the answer as a file", and five sites in four templates already use it.
13 //! `project_overview`'s module header calls this page's hand-written buttons the
14 //! sixth. They were: `hx-post` + `hx-target` + `hx-swap` + `hx-indicator`, plus
15 //! a `<span class="htmx-indicator"> ...</span>` and an empty status `<div>`, per
16 //! card, six times over. All of it says what [`Action::saving`] says in one
17 //! word, so the cards go through `export_act::control` and the six spellings go
18 //! with them (`736f45a5`).
19 //!
20 //! **This changes what the reader gets, and the change is a fix.** The shipped
21 //! buttons posted with htmx, so `is_htmx_request` held and the API took its
22 //! htmx branch: a `data:` URI built from the whole body, which
23 //! `routes::api::exports` truncates with the line "Export truncated. Enable
24 //! JavaScript to download the full file." A `data-saves` control cancels the
25 //! htmx request and reissues a plain `fetch` (`htmx-glue.ts`), which carries no
26 //! `HX-Request`, so the API streams the real file instead. The five sites
27 //! already described made this same trade; these six are the last that had not.
28 //!
29 //! # Content Files is not one of them, and that is why it keeps a region
30 //!
31 //! `/api/export/content` does not answer with a file. It queues a background
32 //! job, uploads a ZIP to S3 and mails a link, answering the request with a
33 //! status panel ([`export_pending_html`](crate::routes::api::exports)). So it
34 //! is the one card whose act still targets a region: [`Action::replacing`],
35 //! pointed at [`CONTENT_STATUS`], which is the documented use for a route the
36 //! description layer does not serve.
37 //!
38 //! # Export All is not carried over
39 //!
40 //! `data-action="exportAll"` ran `window.exportAll`, which selected
41 //! `.export-card button.secondary` while every button rendered `btn-secondary`.
42 //! The selector matched nothing, so the handler returned at its first guard and
43 //! the button did nothing at all -- no label change, no disabled state, no
44 //! error. Measured 2026-08-31 and filed as a problem; it shipped that way
45 //! rather than drifting (`git show b9dd22a6`). Describing a control that has
46 //! never worked would be inventing a feature inside a conversion, and fixing it
47 //! is a product call about what "all" means when one of the six is asynchronous.
48 //! The problem holds that question.
49
50 use makeover_layout as layout;
51 use quasi_router::screen::Act;
52 use quasi_router::{
53 Action, Document, Node, RegionKind, Request, Response, RouteError, Row, Screen as Described,
54 Slot,
55 };
56 use quasi_webview::Webview;
57
58 /// The address, registered whole. See [`super::document_mount`].
59 pub const PATH: &str = "/dashboard/export";
60
61 /// The page's own region, and what the skip link points at.
62 pub const PAGE_REGION: &str = "export";
63
64 /// The card set.
65 const CARDS_REGION: &str = "export-cards";
66
67 /// Where the content export's status panel lands.
68 ///
69 /// The id the shipped template gave the empty `<div class="export-status">`, so
70 /// the panel the API already returns arrives where it always did.
71 pub const CONTENT_STATUS: &str = "content-status";
72
73 const MEASURE: layout::Measure = layout::Measure::Wide;
74
75 /// One export that hands back a file.
76 ///
77 /// `filename` is what the reader's disk ends up with, and it is the name the
78 /// endpoint itself sets in `Content-Disposition` rather than a shorter one
79 /// invented here: a `data-saves` control renames the download, so the two
80 /// disagreeing means the same export arrives under two names depending on which
81 /// control started it.
82 struct Direct {
83 title: &'static str,
84 description: &'static str,
85 meta: &'static str,
86 route: &'static str,
87 filename: &'static str,
88 }
89
90 /// The five that answer with a file, in the order the template listed them.
91 const DIRECT: &[Direct] = &[
92 Direct {
93 title: "Projects & Items",
94 description: "All your project and item metadata including titles, descriptions, prices, and tags.",
95 meta: "JSON format",
96 route: "/api/export/projects",
97 filename: "makenot-work-projects.json",
98 },
99 Direct {
100 title: "Sales History",
101 description: "Record of all sales you've made, including dates, amounts, and item titles.",
102 meta: "CSV format",
103 route: "/api/export/sales",
104 filename: "makenot-work-sales.csv",
105 },
106 Direct {
107 title: "Collaborator Payouts",
108 description: "Record of all revenue shared with collaborators on your projects, both incoming and outgoing.",
109 meta: "CSV format",
110 route: "/api/export/splits",
111 filename: "makenot-work-splits.csv",
112 },
113 Direct {
114 title: "Purchase History",
115 description: "Record of all items you've purchased, for your personal records.",
116 meta: "CSV format",
117 route: "/api/export/purchases",
118 filename: "makenot-work-purchases.csv",
119 },
120 Direct {
121 title: "Followers & Members",
122 description: "List of users who follow you or have memberships to your projects.",
123 meta: "CSV format",
124 route: "/api/export/followers",
125 filename: "makenot-work-followers.csv",
126 },
127 ];
128
129 /// What the screen needs from the database.
130 pub struct Page {
131 /// Whether the reader has any exportable files at all.
132 pub has_content: bool,
133 /// The size line the Content Files card carries, already formatted.
134 pub content_size: String,
135 }
136
137 /// The two facts the content card needs, read the way the Askama handler read
138 /// them so the card says the same thing it said before.
139 pub async fn load(db: &sqlx::PgPool, user: crate::db::UserId) -> crate::error::Result<Page> {
140 let items = crate::db::items::get_items_by_user(db, user).await?;
141 let has_item_content = items.iter().any(crate::db::DbItem::has_s3_content);
142
143 let known_size = crate::db::creator_tiers::get_user_content_size(db, user).await?;
144 let has_content = has_item_content || known_size > 0;
145
146 let content_size = if !has_content {
147 "No files".to_string()
148 } else if has_item_content && known_size > 0 {
149 format!(
150 "{} + audio/cover files",
151 crate::helpers::format_file_size(known_size)
152 )
153 } else if known_size > 0 {
154 crate::helpers::format_file_size(known_size)
155 } else {
156 "Audio/cover files".to_string()
157 };
158
159 Ok(Page {
160 has_content,
161 content_size,
162 })
163 }
164
165 pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
166 let page = viewer
167 .block_on(load(&viewer.app.db, viewer.reader()?.id))
168 .map_err(|_| RouteError::internal("your exports could not be read"))?;
169 Ok(page_screen(&page).into())
170 }
171
172 fn page_screen(page: &Page) -> Described {
173 Described::single("Export Your Data - Makenotwork")
174 .measured(MEASURE)
175 // `padded-page export-page`, which is what
176 // `dashboards/dashboard-export.html:4` rendered. Composed rather than
177 // written out: `Document::classed` replaces, so a screen naming only
178 // its own token would drop its measure (`2790e5c4`).
179 .documented(
180 Document::default().classed(crate::shell::body_class(MEASURE, &["export-page"])),
181 )
182 .summarised("Download your content, projects, and transaction history.")
183 .with(
184 Slot::new(PAGE_REGION, RegionKind::Pane)
185 .with(Node::Link {
186 text: "Back to Dashboard".to_string(),
187 action: Action::get("/dashboard").navigating(),
188 })
189 .with(Node::page("Export Your Data"))
190 .with(Node::text(
191 "Download your content, projects, and transaction history.",
192 ))
193 .with(Node::Region(cards(page)))
194 .with(Node::section("About Your Data"))
195 .with(Node::text(
196 "Your data belongs to you. These exports contain everything we store \
197 about your account and content. If you're planning to delete your \
198 account, we recommend downloading your data first.",
199 )),
200 )
201 }
202
203 /// The card set: five direct exports, then the content archive when there is one.
204 fn cards(page: &Page) -> Slot {
205 let mut group = Slot::new(CARDS_REGION, RegionKind::Group);
206
207 let mut rows: Vec<Row> = DIRECT
208 .iter()
209 .map(|export| {
210 Row::new(export.title)
211 .secondary(export.description)
212 .meta(export.meta)
213 .act(super::export_act::control(
214 "Download",
215 export.route,
216 export.filename,
217 ))
218 })
219 .collect();
220
221 if page.has_content {
222 rows.push(content_row(&page.content_size));
223 }
224
225 group = group.with(Node::List { rows, more: None });
226
227 // The status panel's home. Empty until the export is asked for, which is
228 // what the shipped `<div class="export-status" id="content-status">` was.
229 if page.has_content {
230 group = group.with(Node::Region(Slot::new(CONTENT_STATUS, RegionKind::Group)));
231 }
232
233 group
234 }
235
236 /// The asynchronous one. See the module header for why it targets a region.
237 fn content_row(size: &str) -> Row {
238 Row::new("Content Files")
239 .secondary(
240 "All your uploaded audio files, cover images, version downloads, and dynamic clips.",
241 )
242 .meta(format!("ZIP archive ({size})"))
243 .act(Act::new(
244 "Download",
245 Action::post("/api/export/content")
246 .replacing(CONTENT_STATUS)
247 .awaiting(),
248 ))
249 }
250
251 #[must_use]
252 pub fn renderer(viewer: &super::Viewer) -> Webview {
253 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
254 "{}{}",
255 crate::shell::skip_link(PAGE_REGION),
256 crate::shell::site_header(viewer.user.as_ref()),
257 )))
258 }
259
260 #[cfg(test)]
261 mod tests {
262 use super::*;
263
264 fn page(has_content: bool) -> Page {
265 Page {
266 has_content,
267 content_size: "12.3 MB".to_string(),
268 }
269 }
270
271 fn html(has_content: bool) -> String {
272 use quasi_axum::Serves as _;
273
274 Webview::new().screen(&page_screen(&page(has_content)))
275 }
276
277 /// The done-condition of `2790e5c4` for this screen: the class the template
278 /// carried, composed from the measure rather than written out.
279 #[test]
280 fn the_document_carries_the_class_the_template_carried() {
281 let screen = page_screen(&page(true));
282
283 assert_eq!(
284 screen.document.body_class.as_deref(),
285 Some("padded-page export-page")
286 );
287 assert!(
288 html(true).contains("padded-page export-page"),
289 "{}",
290 html(true)
291 );
292 }
293
294 /// Each direct export says where it reads from and what the file is called,
295 /// and nothing says it twice.
296 #[test]
297 fn every_direct_export_names_its_route_and_its_filename() {
298 let html = html(true);
299
300 for export in DIRECT {
301 assert!(
302 html.contains(&format!(r#"hx-post="{}""#, export.route)),
303 "{} missing from {html}",
304 export.route
305 );
306 assert!(
307 html.contains(&format!(r#"data-saves="{}""#, export.filename)),
308 "{} missing from {html}",
309 export.filename
310 );
311 }
312 }
313
314 /// The filename a control renames the download to is the one the endpoint
315 /// already sets, so the same export cannot arrive under two names.
316 #[test]
317 fn the_saved_filenames_are_the_ones_the_endpoints_set() {
318 let api = include_str!("../routes/api/exports/mod.rs");
319
320 for export in DIRECT {
321 assert!(
322 api.contains(export.filename),
323 "{} is not a filename `routes::api::exports` sets",
324 export.filename
325 );
326 }
327 }
328
329 /// The content export is the one that does not hand back a file, so it is
330 /// the one that still targets a region.
331 #[test]
332 fn the_content_export_fills_a_region_rather_than_saving_a_file() {
333 let html = html(true);
334
335 assert!(html.contains(r#"hx-post="/api/export/content""#), "{html}");
336 assert!(html.contains(CONTENT_STATUS), "{html}");
337 assert!(
338 !html.contains(r#"data-saves="makenot-work-content"#),
339 "the content export is queued and mailed, so it saves nothing: {html}"
340 );
341 }
342
343 /// `has_content` is false for a reader with no files, and the card and its
344 /// status region both go with it.
345 #[test]
346 fn a_reader_with_no_files_is_offered_no_content_export() {
347 let html = html(false);
348
349 assert!(!html.contains("/api/export/content"), "{html}");
350 assert!(!html.contains("Content Files"), "{html}");
351 assert!(!html.contains(CONTENT_STATUS), "{html}");
352 }
353
354 /// `736f45a5`: the wait is said once, by the description, rather than spelled
355 /// as an indicator element per card.
356 #[test]
357 fn no_card_spells_a_spinner() {
358 let html = html(true);
359
360 assert!(html.contains("data-awaiting="), "{html}");
361 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
362 assert!(!html.contains(spelling), "{spelling} survives in {html}");
363 }
364 }
365 }
366