//! The data-export portal at `/dashboard/export`, described.
//!
//! The no-lock-in guarantee's own screen: six exports, five of which hand back
//! a file and one of which is assembled in the background and mailed. It
//! replaces `templates/dashboards/dashboard-export.html`,
//! `dashboard::forms::export_portal`, `ExportPortalTemplate` and
//! `static/dashboard-export-inline.js`.
//!
//! # The five direct exports were a sixth hand-written export control
//!
//! [`super::export_act`] is the described control for "post this route and keep
//! the answer as a file", and five sites in four templates already use it.
//! `project_overview`'s module header calls this page's hand-written buttons the
//! sixth. They were: `hx-post` + `hx-target` + `hx-swap` + `hx-indicator`, plus
//! a ` ...` and an empty status `
`, per
//! card, six times over. All of it says what [`Action::saving`] says in one
//! word, so the cards go through `export_act::control` and the six spellings go
//! with them (`736f45a5`).
//!
//! **This changes what the reader gets, and the change is a fix.** The shipped
//! buttons posted with htmx, so `is_htmx_request` held and the API took its
//! htmx branch: a `data:` URI built from the whole body, which
//! `routes::api::exports` truncates with the line "Export truncated. Enable
//! JavaScript to download the full file." A `data-saves` control cancels the
//! htmx request and reissues a plain `fetch` (`htmx-glue.ts`), which carries no
//! `HX-Request`, so the API streams the real file instead. The five sites
//! already described made this same trade; these six are the last that had not.
//!
//! # Content Files is not one of them, and that is why it keeps a region
//!
//! `/api/export/content` does not answer with a file. It queues a background
//! job, uploads a ZIP to S3 and mails a link, answering the request with a
//! status panel ([`export_pending_html`](crate::routes::api::exports)). So it
//! is the one card whose act still targets a region: [`Action::replacing`],
//! pointed at [`CONTENT_STATUS`], which is the documented use for a route the
//! description layer does not serve.
//!
//! # Export All is not carried over
//!
//! `data-action="exportAll"` ran `window.exportAll`, which selected
//! `.export-card button.secondary` while every button rendered `btn-secondary`.
//! The selector matched nothing, so the handler returned at its first guard and
//! the button did nothing at all -- no label change, no disabled state, no
//! error. Measured 2026-08-31 and filed as a problem; it shipped that way
//! rather than drifting (`git show b9dd22a6`). Describing a control that has
//! never worked would be inventing a feature inside a conversion, and fixing it
//! is a product call about what "all" means when one of the six is asynchronous.
//! The problem holds that question.
use makeover_layout as layout;
use quasi_router::screen::Act;
use quasi_router::{
Action, Document, Node, RegionKind, Request, Response, RouteError, Row, Screen as Described,
Slot,
};
use quasi_webview::Webview;
/// The address, registered whole. See [`super::document_mount`].
pub const PATH: &str = "/dashboard/export";
/// The page's own region, and what the skip link points at.
pub const PAGE_REGION: &str = "export";
/// The card set.
const CARDS_REGION: &str = "export-cards";
/// Where the content export's status panel lands.
///
/// The id the shipped template gave the empty `
`, so
/// the panel the API already returns arrives where it always did.
pub const CONTENT_STATUS: &str = "content-status";
const MEASURE: layout::Measure = layout::Measure::Wide;
/// One export that hands back a file.
///
/// `filename` is what the reader's disk ends up with, and it is the name the
/// endpoint itself sets in `Content-Disposition` rather than a shorter one
/// invented here: a `data-saves` control renames the download, so the two
/// disagreeing means the same export arrives under two names depending on which
/// control started it.
struct Direct {
title: &'static str,
description: &'static str,
meta: &'static str,
route: &'static str,
filename: &'static str,
}
/// The five that answer with a file, in the order the template listed them.
const DIRECT: &[Direct] = &[
Direct {
title: "Projects & Items",
description: "All your project and item metadata including titles, descriptions, prices, and tags.",
meta: "JSON format",
route: "/api/export/projects",
filename: "makenot-work-projects.json",
},
Direct {
title: "Sales History",
description: "Record of all sales you've made, including dates, amounts, and item titles.",
meta: "CSV format",
route: "/api/export/sales",
filename: "makenot-work-sales.csv",
},
Direct {
title: "Collaborator Payouts",
description: "Record of all revenue shared with collaborators on your projects, both incoming and outgoing.",
meta: "CSV format",
route: "/api/export/splits",
filename: "makenot-work-splits.csv",
},
Direct {
title: "Purchase History",
description: "Record of all items you've purchased, for your personal records.",
meta: "CSV format",
route: "/api/export/purchases",
filename: "makenot-work-purchases.csv",
},
Direct {
title: "Followers & Members",
description: "List of users who follow you or have memberships to your projects.",
meta: "CSV format",
route: "/api/export/followers",
filename: "makenot-work-followers.csv",
},
];
/// What the screen needs from the database.
pub struct Page {
/// Whether the reader has any exportable files at all.
pub has_content: bool,
/// The size line the Content Files card carries, already formatted.
pub content_size: String,
}
/// The two facts the content card needs, read the way the Askama handler read
/// them so the card says the same thing it said before.
pub async fn load(db: &sqlx::PgPool, user: crate::db::UserId) -> crate::error::Result
{
let items = crate::db::items::get_items_by_user(db, user).await?;
let has_item_content = items.iter().any(crate::db::DbItem::has_s3_content);
let known_size = crate::db::creator_tiers::get_user_content_size(db, user).await?;
let has_content = has_item_content || known_size > 0;
let content_size = if !has_content {
"No files".to_string()
} else if has_item_content && known_size > 0 {
format!(
"{} + audio/cover files",
crate::helpers::format_file_size(known_size)
)
} else if known_size > 0 {
crate::helpers::format_file_size(known_size)
} else {
"Audio/cover files".to_string()
};
Ok(Page {
has_content,
content_size,
})
}
pub fn screen(viewer: &super::Viewer, _request: Request) -> Result {
let page = viewer
.block_on(load(&viewer.app.db, viewer.reader()?.id))
.map_err(|_| RouteError::internal("your exports could not be read"))?;
Ok(page_screen(&page).into())
}
fn page_screen(page: &Page) -> Described {
Described::single("Export Your Data - Makenotwork")
.measured(MEASURE)
// `padded-page export-page`, which is what
// `dashboards/dashboard-export.html:4` rendered. Composed rather than
// written out: `Document::classed` replaces, so a screen naming only
// its own token would drop its measure (`2790e5c4`).
.documented(
Document::default().classed(crate::shell::body_class(MEASURE, &["export-page"])),
)
.summarised("Download your content, projects, and transaction history.")
.with(
Slot::new(PAGE_REGION, RegionKind::Pane)
.with(Node::Link {
text: "Back to Dashboard".to_string(),
action: Action::get("/dashboard").navigating(),
})
.with(Node::page("Export Your Data"))
.with(Node::text(
"Download your content, projects, and transaction history.",
))
.with(Node::Region(cards(page)))
.with(Node::section("About Your Data"))
.with(Node::text(
"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.",
)),
)
}
/// The card set: five direct exports, then the content archive when there is one.
fn cards(page: &Page) -> Slot {
let mut group = Slot::new(CARDS_REGION, RegionKind::Group);
let mut rows: Vec = DIRECT
.iter()
.map(|export| {
Row::new(export.title)
.secondary(export.description)
.meta(export.meta)
.act(super::export_act::control(
"Download",
export.route,
export.filename,
))
})
.collect();
if page.has_content {
rows.push(content_row(&page.content_size));
}
group = group.with(Node::List { rows, more: None });
// The status panel's home. Empty until the export is asked for, which is
// what the shipped `` was.
if page.has_content {
group = group.with(Node::Region(Slot::new(CONTENT_STATUS, RegionKind::Group)));
}
group
}
/// The asynchronous one. See the module header for why it targets a region.
fn content_row(size: &str) -> Row {
Row::new("Content Files")
.secondary(
"All your uploaded audio files, cover images, version downloads, and dynamic clips.",
)
.meta(format!("ZIP archive ({size})"))
.act(Act::new(
"Download",
Action::post("/api/export/content")
.replacing(CONTENT_STATUS)
.awaiting(),
))
}
#[must_use]
pub fn renderer(viewer: &super::Viewer) -> Webview {
Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
"{}{}",
crate::shell::skip_link(PAGE_REGION),
crate::shell::site_header(viewer.user.as_ref()),
)))
}
#[cfg(test)]
mod tests {
use super::*;
fn page(has_content: bool) -> Page {
Page {
has_content,
content_size: "12.3 MB".to_string(),
}
}
fn html(has_content: bool) -> String {
use quasi_axum::Serves as _;
Webview::new().screen(&page_screen(&page(has_content)))
}
/// The done-condition of `2790e5c4` for this screen: the class the template
/// carried, composed from the measure rather than written out.
#[test]
fn the_document_carries_the_class_the_template_carried() {
let screen = page_screen(&page(true));
assert_eq!(
screen.document.body_class.as_deref(),
Some("padded-page export-page")
);
assert!(
html(true).contains("padded-page export-page"),
"{}",
html(true)
);
}
/// Each direct export says where it reads from and what the file is called,
/// and nothing says it twice.
#[test]
fn every_direct_export_names_its_route_and_its_filename() {
let html = html(true);
for export in DIRECT {
assert!(
html.contains(&format!(r#"hx-post="{}""#, export.route)),
"{} missing from {html}",
export.route
);
assert!(
html.contains(&format!(r#"data-saves="{}""#, export.filename)),
"{} missing from {html}",
export.filename
);
}
}
/// The filename a control renames the download to is the one the endpoint
/// already sets, so the same export cannot arrive under two names.
#[test]
fn the_saved_filenames_are_the_ones_the_endpoints_set() {
let api = include_str!("../routes/api/exports/mod.rs");
for export in DIRECT {
assert!(
api.contains(export.filename),
"{} is not a filename `routes::api::exports` sets",
export.filename
);
}
}
/// The content export is the one that does not hand back a file, so it is
/// the one that still targets a region.
#[test]
fn the_content_export_fills_a_region_rather_than_saving_a_file() {
let html = html(true);
assert!(html.contains(r#"hx-post="/api/export/content""#), "{html}");
assert!(html.contains(CONTENT_STATUS), "{html}");
assert!(
!html.contains(r#"data-saves="makenot-work-content"#),
"the content export is queued and mailed, so it saves nothing: {html}"
);
}
/// `has_content` is false for a reader with no files, and the card and its
/// status region both go with it.
#[test]
fn a_reader_with_no_files_is_offered_no_content_export() {
let html = html(false);
assert!(!html.contains("/api/export/content"), "{html}");
assert!(!html.contains("Content Files"), "{html}");
assert!(!html.contains(CONTENT_STATUS), "{html}");
}
/// `736f45a5`: the wait is said once, by the description, rather than spelled
/// as an indicator element per card.
#[test]
fn no_card_spells_a_spinner() {
let html = html(true);
assert!(html.contains("data-awaiting="), "{html}");
for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
assert!(!html.contains(spelling), "{spelling} survives in {html}");
}
}
}