Skip to main content

max / makenotwork

Describe the export portal, and take its six exports through export_act /dashboard/export becomes src/quasi/export_portal.rs, replacing templates/dashboards/dashboard-export.html, dashboard::forms::export_portal, ExportPortalTemplate and static/dashboard-export-inline.js. Mounted through document_mounts, so it answers the address a reader types behind the same session gate /feed uses. The five direct exports were the sixth hand-written export control that project_overview's header names. They now go through export_act, which gains control(label, route, filename) so the portal's cards can read "Download" while the sentence stays stated once. This fixes what those buttons did. Posting with htmx meant is_htmx_request held and the API answered with a data: URI built from the whole body, which routes::api::exports truncates ("Export truncated. Enable JavaScript to download the full file."). A data-saves control reissues the call as a plain fetch carrying no HX-Request, so the reader gets the streamed file. The five sites already described made this trade; these were the last that had not. The filenames named here are the ones the endpoints set, asserted by test, so one export cannot arrive under two names. Content Files keeps a region: it queues a background job and mails a link rather than answering with a file, so its act targets CONTENT_STATUS. Export All is not carried over. window.exportAll selected '.export-card button.secondary' while every button rendered btn-secondary, so it returned at its first guard and did nothing at all. Filed as a problem rather than described, since reviving it is a product call about what "all" means when one of the six is asynchronous. Both at-the-flip instructions land on this screen: the body class is body_class(Wide, &["export-page"]) rather than a literal (2790e5c4), and the 18 spinner spellings the template carried go with it (736f45a5).
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_013vDpLixQiknHhfHiGxFWo7
Author: Max Johnson <me@maxj.phd> · 2026-08-31 16:41 UTC
Signed with PGP, not checked
Commit: 22661a66c446df0888d4d203a4ccbfc13531ec84
Parent: 701d957
9 files changed, +401 insertions, -226 deletions
@@ -39,6 +39,7 @@
39 39 //! Askama on the element around it, where they were always a fact about that
40 40 //! page's layout rather than about the control.
41 41
42 + use quasi_router::screen::Act;
42 43 use quasi_router::{Action, Node};
43 44
44 45 /// The route each export posts to, and the name the file is kept under.
@@ -54,10 +55,23 @@
54 55 /// measurement rather than a stand-in for one.
55 56 #[must_use]
56 57 pub fn act(route: &str, filename: &str) -> Node {
57 - Node::act(
58 - "Export CSV",
59 - Action::post(route).saving(filename).awaiting(),
60 - )
58 + Node::Act(control("Export CSV", route, filename))
59 + }
60 +
61 + /// The same sentence under a caller's own label.
62 + ///
63 + /// `Export CSV` is right for a control standing alone among others, which is
64 + /// every site [`act`] serves. It is wrong on the export portal, where six cards
65 + /// each name their own subject and the format is already in the card's meta
66 + /// line, so the control there reads `Download`. The label is the only thing
67 + /// that varies: what a caller must not restate is the sentence, which is here
68 + /// once and is what this module exists for.
69 + ///
70 + /// Returns the [`Act`] rather than a [`Node`] because the portal's controls sit
71 + /// in a [`quasi_router::Row`], which holds acts rather than nodes.
72 + #[must_use]
73 + pub fn control(label: &str, route: &str, filename: &str) -> Act {
74 + Act::new(label, Action::post(route).saving(filename).awaiting())
61 75 }
62 76
63 77 /// The same act as a fragment, for a template to drop in place.
@@ -45,6 +45,7 @@
45 45 pub mod discover_typeahead;
46 46 pub mod embeds;
47 47 pub mod export_act;
48 + pub mod export_portal;
48 49 pub mod feeds;
49 50 pub mod forum_memberships;
50 51 pub mod item_files;
@@ -302,7 +303,7 @@
302 303 ///
303 304 /// The CSRF probe reads [`PATHS`] as its skip list, and a document screen
304 305 /// registers no mutating route, so it has nothing to skip here either.
305 - pub const DOCUMENT_PATHS: &[&str] = &[feeds::PATH];
306 + pub const DOCUMENT_PATHS: &[&str] = &[feeds::PATH, export_portal::PATH];
306 307
307 308 /// Every screen's switch name, in the same order as [`PATHS`].
308 309 ///
@@ -396,10 +397,21 @@
396 397 ///
397 398 /// See [`DOCUMENT_PATHS`] for why these are not in [`mounts`].
398 399 pub fn document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
399 - vec![(
400 - feeds::PATH,
401 - document_mount(app, feeds::PATH, feeds::screen, feeds::renderer),
402 - )]
400 + vec![
401 + (
402 + feeds::PATH,
403 + document_mount(app, feeds::PATH, feeds::screen, feeds::renderer),
404 + ),
405 + (
406 + export_portal::PATH,
407 + document_mount(
408 + app,
409 + export_portal::PATH,
410 + export_portal::screen,
411 + export_portal::renderer,
412 + ),
413 + ),
414 + ]
403 415 }
404 416
405 417 /// A described screen a reader NAVIGATES to, rather than a panel htmx fetches.
@@ -255,18 +255,6 @@
255 255
256 256 // Export & Account Management
257 257
258 - /// Data export portal for the no-lock-in guarantee.
259 - #[derive(Template)]
260 - #[template(path = "dashboards/dashboard-export.html")]
261 - pub struct ExportPortalTemplate {
262 - pub csrf_token: CsrfTokenOption,
263 - pub session_user: Option<SessionUser>,
264 - /// Whether the user has any exportable content (projects, items, files).
265 - pub has_content: bool,
266 - /// Human-readable total size of exportable data (e.g. "12.3 MB").
267 - pub content_size: String,
268 - }
269 -
270 258 /// Data import portal for migrating from other platforms.
271 259 #[derive(Template)]
272 260 #[template(path = "dashboards/dashboard-import.html")]
@@ -209,7 +209,6 @@
209 209 AdminMetricsTemplate,
210 210 AdminCompCodesTemplate,
211 211 // Export, import & account management
212 - ExportPortalTemplate,
213 212 ImportPortalTemplate,
214 213 DeleteAccountTemplate,
215 214 BlogEditorTemplate,
@@ -12,8 +12,7 @@
12 12 error::{AppError, Result},
13 13 helpers::get_csrf_token,
14 14 templates::{
15 - BlogEditorTemplate, DeleteAccountTemplate, ExportPortalTemplate, ImportPortalTemplate,
16 - ItemEditRowTemplate,
15 + BlogEditorTemplate, DeleteAccountTemplate, ImportPortalTemplate, ItemEditRowTemplate,
17 16 },
18 17 types::ContentItem,
19 18 };
@@ -45,44 +44,6 @@
45 44 Ok(ItemEditRowTemplate { item })
46 45 }
47 46
48 - /// Render the data export portal page.
49 - #[tracing::instrument(skip_all, name = "dashboard_forms::export_portal")]
50 - pub(super) async fn export_portal(
51 - State(db): State<PgPool>,
52 - session: Session,
53 - AuthUser(session_user): AuthUser,
54 - ) -> Result<impl IntoResponse> {
55 - let csrf_token = get_csrf_token(&session).await;
56 -
57 - // Check if user has any S3 content (items, versions, insertions)
58 - let items = db::items::get_items_by_user(&db, session_user.id).await?;
59 - let has_item_content = items.iter().any(crate::db::DbItem::has_s3_content);
60 -
61 - // Sum known file sizes from versions and insertions
62 - let known_size = db::creator_tiers::get_user_content_size(&db, session_user.id).await?;
63 - let has_content = has_item_content || known_size > 0;
64 -
65 - let content_size = if !has_content {
66 - "No files".to_string()
67 - } else if has_item_content && known_size > 0 {
68 - format!(
69 - "{} + audio/cover files",
70 - crate::helpers::format_file_size(known_size)
71 - )
72 - } else if known_size > 0 {
73 - crate::helpers::format_file_size(known_size)
74 - } else {
75 - "Audio/cover files".to_string()
76 - };
77 -
78 - Ok(ExportPortalTemplate {
79 - csrf_token,
80 - session_user: Some(session_user),
81 - has_content,
82 - content_size,
83 - })
84 - }
85 -
86 47 /// Render the data import portal page.
87 48 #[tracing::instrument(skip_all, name = "dashboard_forms::import_portal")]
88 49 pub(super) async fn import_portal(
@@ -193,7 +193,6 @@
193 193 "/dashboard/project/{slug}/blog/new",
194 194 get(forms::blog_editor),
195 195 )
196 - .route_get("/dashboard/export", get(forms::export_portal))
197 196 .route_get("/dashboard/import", get(forms::import_portal))
198 197 .route_get("/dashboard/delete-account", get(forms::delete_account_page))
199 198 .route(
@@ -1,0 +1,365 @@
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.user.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(Some(&viewer.user), Some(&viewer.csrf)),
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 + }
@@ -1,25 +1,0 @@
1 - function exportAll() {
2 - var btn = document.getElementById('export-all-btn');
3 - var buttons = document.querySelectorAll('.export-card button.secondary');
4 - if (buttons.length === 0) return;
5 -
6 - btn.disabled = true;
7 - btn.textContent = 'Exporting...';
8 - var i = 0;
9 -
10 - function next() {
11 - if (i >= buttons.length) {
12 - btn.textContent = 'Done';
13 - // A temporary label going back to the real one: `Intent::Revert`,
14 - // the same duration a copy button flashes for.
15 - setTimeout(function() { btn.textContent = 'Export All'; btn.disabled = false; }, window.timing.revertMs());
16 - return;
17 - }
18 - buttons[i].click();
19 - i++;
20 - // Not a timing intent: this paces one click after another so the
21 - // browser is not handed a dozen downloads at once.
22 - setTimeout(next, 1500);
23 - }
24 - next();
25 - }
@@ -1,138 +1,0 @@
1 - {% extends "base.html" %}
2 -
3 - {% block title %}Export Your Data - Makenotwork{% endblock %}
4 - {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }} export-page"{% endblock %}
5 -
6 - {% block head %}
7 - {% endblock %}
8 -
9 - {% block content %}
10 - {% include "partials/site_header.html" %}
11 -
12 - <div class="container">
13 - <a href="/dashboard" class="back-link">&larr; Back to Dashboard</a>
14 -
15 - <header class="dashboard-export-header">
16 - <div>
17 - <h1 class="page-title">Export Your Data</h1>
18 - <p class="subtitle">Download your content, projects, and transaction history.</p>
19 - </div>
20 - <button class="btn-primary export-all-btn" id="export-all-btn" data-action="exportAll">
21 - Export All
22 - </button>
23 - </header>
24 -
25 - <div class="export-cards">
26 - <div class="export-card">
27 - <div class="export-card-info">
28 - <div class="export-card-title">Projects &amp; Items</div>
29 - <div class="export-card-desc">All your project and item metadata including titles, descriptions, prices, and tags.</div>
30 - <div class="export-card-meta">JSON format</div>
31 - <div class="export-status" id="projects-status"></div>
32 - </div>
33 - <button class="btn-secondary"
34 - hx-post="/api/export/projects"
35 - hx-target="#projects-status"
36 - hx-swap="innerHTML"
37 - hx-indicator="#projects-spinner">
38 - Download
39 - <span id="projects-spinner" class="htmx-indicator"> ...</span>
40 - </button>
41 - </div>
42 -
43 - <div class="export-card">
44 - <div class="export-card-info">
45 - <div class="export-card-title">Sales History</div>
46 - <div class="export-card-desc">Record of all sales you've made, including dates, amounts, and item titles.</div>
47 - <div class="export-card-meta">CSV format</div>
48 - <div class="export-status" id="sales-status"></div>
49 - </div>
50 - <button class="btn-secondary"
51 - hx-post="/api/export/sales"
52 - hx-target="#sales-status"
53 - hx-swap="innerHTML"
54 - hx-indicator="#sales-spinner">
55 - Download
56 - <span id="sales-spinner" class="htmx-indicator"> ...</span>
57 - </button>
58 - </div>
59 -
60 - <div class="export-card">
61 - <div class="export-card-info">
62 - <div class="export-card-title">Collaborator Payouts</div>
63 - <div class="export-card-desc">Record of all revenue shared with collaborators on your projects, both incoming and outgoing.</div>
64 - <div class="export-card-meta">CSV format</div>
65 - <div class="export-status" id="splits-status"></div>
66 - </div>
67 - <button class="btn-secondary"
68 - hx-post="/api/export/splits"
69 - hx-target="#splits-status"
70 - hx-swap="innerHTML"
71 - hx-indicator="#splits-spinner">
72 - Download
73 - <span id="splits-spinner" class="htmx-indicator"> ...</span>
74 - </button>
75 - </div>
76 -
77 - <div class="export-card">
78 - <div class="export-card-info">
79 - <div class="export-card-title">Purchase History</div>
80 - <div class="export-card-desc">Record of all items you've purchased, for your personal records.</div>
81 - <div class="export-card-meta">CSV format</div>
82 - <div class="export-status" id="purchases-status"></div>
83 - </div>
84 - <button class="btn-secondary"
85 - hx-post="/api/export/purchases"
86 - hx-target="#purchases-status"
87 - hx-swap="innerHTML"
88 - hx-indicator="#purchases-spinner">
89 - Download
90 - <span id="purchases-spinner" class="htmx-indicator"> ...</span>
91 - </button>
92 - </div>
93 -
94 - <div class="export-card">
95 - <div class="export-card-info">
96 - <div class="export-card-title">Followers &amp; Members</div>
97 - <div class="export-card-desc">List of users who follow you or have memberships to your projects.</div>
98 - <div class="export-card-meta">CSV format</div>
99 - <div class="export-status" id="followers-status"></div>
100 - </div>
101 - <button class="btn-secondary"
102 - hx-post="/api/export/followers"
103 - hx-target="#followers-status"
104 - hx-swap="innerHTML"
105 - hx-indicator="#followers-spinner">
106 - Download
107 - <span id="followers-spinner" class="htmx-indicator"> ...</span>
108 - </button>
109 - </div>
110 -
111 - {% if has_content %}
112 - <div class="export-card">
113 - <div class="export-card-info">
114 - <div class="export-card-title">Content Files</div>
115 - <div class="export-card-desc">All your uploaded audio files, cover images, version downloads, and dynamic clips.</div>
116 - <div class="export-card-meta">ZIP archive ({{ content_size }})</div>
117 - <div class="export-status" id="content-status"></div>
118 - </div>
119 - <button class="btn-secondary"
120 - hx-post="/api/export/content"
121 - hx-target="#content-status"
122 - hx-swap="innerHTML"
123 - hx-indicator="#content-spinner">
124 - Download
125 - <span id="content-spinner" class="htmx-indicator"> ...</span>
126 - </button>
127 - </div>
128 - {% endif %}
129 - </div>
130 -
131 - <div class="export-note">
132 - <h3>About Your Data</h3>
133 - <p>Your data belongs to you. These exports contain everything we store about your account and content. If you're planning to delete your account, we recommend downloading your data first.</p>
134 - </div>
135 - </div>
136 -
137 - <script src="/static/dashboard-export-inline.js?v=0623" defer></script>
138 - {% endblock %}