max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
7 files changed,
+574 insertions,
-175 deletions
| @@ -46,7 +46,7 @@ | |||
| 46 | 46 | const MEASURE: layout::Measure = layout::Measure::Wide; | |
| 47 | 47 | ||
| 48 | 48 | /// Everything the screen draws, resolved before it is drawn. | |
| 49 | - | struct Loaded { | |
| 49 | + | pub(crate) struct Loaded { | |
| 50 | 50 | title: String, | |
| 51 | 51 | /// The collection's own slug, for the address the Copy link control hands | |
| 52 | 52 | /// over. Read back off the row rather than off the request, so the copied | |
| @@ -80,7 +80,7 @@ | |||
| 80 | 80 | clippy::struct_field_names, | |
| 81 | 81 | reason = "`type` is a keyword; this is the kind" | |
| 82 | 82 | )] | |
| 83 | - | struct Item { | |
| 83 | + | pub(crate) struct Item { | |
| 84 | 84 | id: String, | |
| 85 | 85 | title: String, | |
| 86 | 86 | item_type: String, | |
| @@ -106,6 +106,22 @@ | |||
| 106 | 106 | Ok(page_screen(&loaded).into()) | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | + | /// The one read this page makes, for the mount that serves it from a residual. | |
| 110 | + | /// | |
| 111 | + | /// One read, stating the document and filling the holes. Read twice, the two | |
| 112 | + | /// could disagree between them and the page would title itself for one | |
| 113 | + | /// collection and list another's items. | |
| 114 | + | pub(crate) fn reading( | |
| 115 | + | viewer: &super::Viewer, | |
| 116 | + | carried: &super::Carried, | |
| 117 | + | ) -> Result<Loaded, RouteError> { | |
| 118 | + | load( | |
| 119 | + | viewer, | |
| 120 | + | carried.capture("username")?, | |
| 121 | + | carried.capture("slug")?, | |
| 122 | + | ) | |
| 123 | + | } | |
| 124 | + | ||
| 109 | 125 | /// Resolve the owner, the collection and its items, refusing exactly as the | |
| 110 | 126 | /// shipped handler did. | |
| 111 | 127 | fn load(viewer: &super::Viewer, username: &str, slug: &str) -> Result<Loaded, RouteError> { | |
| @@ -172,44 +188,56 @@ | |||
| 172 | 188 | ||
| 173 | 189 | declare! { | |
| 174 | 190 | /// The whole document: the title, the measure, the body. | |
| 175 | - | shape page_screen(loaded: &Loaded) -> Screen; | |
| 191 | + | pub(crate) shape page_screen(loaded: &Loaded) -> Screen; | |
| 176 | 192 | ||
| 177 | 193 | screen single "{loaded.title} - {loaded.owner_username} - Makenotwork" { | |
| 178 | 194 | measured MEASURE; | |
| 179 | 195 | documented Document::default().classed(crate::shell::body_class(MEASURE, &["collection-page"])); | |
| 180 | 196 | ||
| 181 | - | region PAGE_REGION as Pane { | |
| 182 | - | page loaded.title.clone(); | |
| 197 | + | include page_region(loaded); | |
| 198 | + | } | |
| 199 | + | } | |
| 183 | 200 | ||
| 184 | - | // "by <owner>", with the owner's name as a link rather than a | |
| 185 | - | // button: it is a name that goes somewhere, which is exactly what a | |
| 186 | - | // link is for and what an act would draw a bevel around. | |
| 187 | - | link "by {loaded.owner_shown}" to get "/u/{loaded.owner_username}" navigating; | |
| 201 | + | declare! { | |
| 202 | + | /// The page's one region, split out so it can be staged. | |
| 203 | + | /// | |
| 204 | + | /// Holes and branches over one loop: the collection's own words are read | |
| 205 | + | /// per request, the private banner and the empty state are guards, and the | |
| 206 | + | /// table is a loop whose body is compiled once and walked per item. | |
| 207 | + | #[staged] | |
| 208 | + | pub(crate) shape page_region(loaded: &Loaded) -> Slot; | |
| 188 | 209 | ||
| 189 | - | // The template put a `Private` badge beside the owner's name. A | |
| 190 | - | // `Tag` is not a `Node` -- tokens belong to rows and cells -- and | |
| 191 | - | // rather than invent a row to hold one, this says it as a banner. | |
| 192 | - | // That is the better reading anyway: the only person who sees it is | |
| 193 | - | // the owner, and what they need to know is that nobody else can open | |
| 194 | - | // the link they are looking at, which a small badge next to a name | |
| 195 | - | // says quietly and a banner says once. | |
| 196 | - | banner layout::Tone::Info "This collection is private. Only you can see it." | |
| 197 | - | unless loaded.is_public; | |
| 210 | + | region PAGE_REGION as Pane { | |
| 211 | + | page loaded.title.clone(); | |
| 198 | 212 | ||
| 199 | - | text loaded.description() unless loaded.description().is_empty(); | |
| 200 | - | text "{loaded.items.len()} items"; | |
| 213 | + | // "by <owner>", with the owner's name as a link rather than a | |
| 214 | + | // button: it is a name that goes somewhere, which is exactly what a | |
| 215 | + | // link is for and what an act would draw a bevel around. | |
| 216 | + | link "by {loaded.owner_shown}" to get "/u/{loaded.owner_username}" navigating; | |
| 201 | 217 | ||
| 202 | - | empty "This collection is empty." when loaded.items.is_empty(); | |
| 203 | - | include items_table(&loaded.items) unless loaded.items.is_empty(); | |
| 218 | + | // The template put a `Private` badge beside the owner's name. A | |
| 219 | + | // `Tag` is not a `Node` -- tokens belong to rows and cells -- and | |
| 220 | + | // rather than invent a row to hold one, this says it as a banner. | |
| 221 | + | // That is the better reading anyway: the only person who sees it is | |
| 222 | + | // the owner, and what they need to know is that nobody else can open | |
| 223 | + | // the link they are looking at, which a small badge next to a name | |
| 224 | + | // says quietly and a banner says once. | |
| 225 | + | banner layout::Tone::Info "This collection is private. Only you can see it." | |
| 226 | + | unless loaded.is_public; | |
| 204 | 227 | ||
| 205 | - | act "View profile" to get "/u/{loaded.owner_username}" navigating; | |
| 228 | + | text loaded.description() unless loaded.description().is_empty(); | |
| 229 | + | text "{loaded.items.len()} items"; | |
| 206 | 230 | ||
| 207 | - | // `copying` is a setting on the control and not a modifier of the | |
| 208 | - | // action: it sets the destination to `local` itself, because a copy | |
| 209 | - | // asks no route and the two facts are one sentence. | |
| 210 | - | act "Copy link" to local { | |
| 211 | - | copying "/c/{loaded.owner_username}/{loaded.slug}"; | |
| 212 | - | } | |
| 231 | + | empty "This collection is empty." when loaded.items.is_empty(); | |
| 232 | + | include items_table(&loaded.items) unless loaded.items.is_empty(); | |
| 233 | + | ||
| 234 | + | act "View profile" to get "/u/{loaded.owner_username}" navigating; | |
| 235 | + | ||
| 236 | + | // `copying` is a setting on the control and not a modifier of the | |
| 237 | + | // action: it sets the destination to `local` itself, because a copy | |
| 238 | + | // asks no route and the two facts are one sentence. | |
| 239 | + | act "Copy link" to local { | |
| 240 | + | copying "/c/{loaded.owner_username}/{loaded.slug}"; | |
| 213 | 241 | } | |
| 214 | 242 | } | |
| 215 | 243 | } | |
| @@ -228,6 +256,7 @@ | |||
| 228 | 256 | /// above them in one declaration and every item fills all five. Naming buys | |
| 229 | 257 | /// nothing a reader cannot already see; it earns its keep where a cell is | |
| 230 | 258 | /// conditional or the headings live in another shape. | |
| 259 | + | #[staged] | |
| 231 | 260 | shape items_table(items: &[Item]) -> Node; | |
| 232 | 261 | ||
| 233 | 262 | table { | |
| @@ -272,30 +301,39 @@ | |||
| 272 | 301 | ))) | |
| 273 | 302 | } | |
| 274 | 303 | ||
| 304 | + | /// A collection as the tests draw it, with `items` items and that visibility. | |
| 305 | + | /// | |
| 306 | + | /// Module-level rather than inside `mod tests` because `quasi::residuals` needs | |
| 307 | + | /// one too: a screen that branches has to be filled under every branch to be | |
| 308 | + | /// checked at all, and `Loaded` is this module's own type. Test-only, so it | |
| 309 | + | /// costs the shipped binary nothing. | |
| 310 | + | #[cfg(test)] | |
| 311 | + | pub(crate) fn sample(items: usize, is_public: bool) -> Loaded { | |
| 312 | + | Loaded { | |
| 313 | + | title: "Field Recordings".into(), | |
| 314 | + | slug: "field-recordings".into(), | |
| 315 | + | owner_shown: "Ada".into(), | |
| 316 | + | owner_username: "ada".into(), | |
| 317 | + | description: Some("Things I taped outdoors.".into()), | |
| 318 | + | is_public, | |
| 319 | + | items: (0..items) | |
| 320 | + | .map(|n| Item { | |
| 321 | + | id: format!("item{n}"), | |
| 322 | + | title: format!("Track {n}"), | |
| 323 | + | item_type: "Audio".into(), | |
| 324 | + | creator: "ada".into(), | |
| 325 | + | project: "Tapes".into(), | |
| 326 | + | price: "$3".into(), | |
| 327 | + | }) | |
| 328 | + | .collect(), | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 275 | 332 | #[cfg(test)] | |
| 276 | 333 | mod tests { | |
| 277 | 334 | use super::*; | |
| 278 | 335 | ||
| 279 | - | fn loaded(items: usize, is_public: bool) -> Loaded { | |
| 280 | - | Loaded { | |
| 281 | - | title: "Field Recordings".into(), | |
| 282 | - | slug: "field-recordings".into(), | |
| 283 | - | owner_shown: "Ada".into(), | |
| 284 | - | owner_username: "ada".into(), | |
| 285 | - | description: Some("Things I taped outdoors.".into()), | |
| 286 | - | is_public, | |
| 287 | - | items: (0..items) | |
| 288 | - | .map(|n| Item { | |
| 289 | - | id: format!("item{n}"), | |
| 290 | - | title: format!("Track {n}"), | |
| 291 | - | item_type: "Audio".into(), | |
| 292 | - | creator: "ada".into(), | |
| 293 | - | project: "Tapes".into(), | |
| 294 | - | price: "$3".into(), | |
| 295 | - | }) | |
| 296 | - | .collect(), | |
| 297 | - | } | |
| 298 | - | } | |
| 336 | + | use super::sample as loaded; | |
| 299 | 337 | ||
| 300 | 338 | fn html(loaded: &Loaded) -> String { | |
| 301 | 339 | use quasi_axum::Serves as _; |
| @@ -72,6 +72,7 @@ | |||
| 72 | 72 | /// Returns the [`Act`] rather than a [`Node`] because the portal's controls sit | |
| 73 | 73 | /// in a [`quasi_router::Row`], which holds acts rather than nodes. | |
| 74 | 74 | #[must_use] | |
| 75 | + | #[constant] | |
| 75 | 76 | pub shape control(label: &str, route: &str, filename: &str) -> Act; | |
| 76 | 77 | ||
| 77 | 78 | act label to post route saving filename awaiting; |
| @@ -69,60 +69,6 @@ | |||
| 69 | 69 | ||
| 70 | 70 | const MEASURE: layout::Measure = layout::Measure::Wide; | |
| 71 | 71 | ||
| 72 | - | /// One export that hands back a file. | |
| 73 | - | /// | |
| 74 | - | /// `filename` is what the reader's disk ends up with, and it is the name the | |
| 75 | - | /// endpoint itself sets in `Content-Disposition` rather than a shorter one | |
| 76 | - | /// invented here: a `data-saves` control renames the download, so the two | |
| 77 | - | /// disagreeing means the same export arrives under two names depending on which | |
| 78 | - | /// control started it. | |
| 79 | - | struct Direct { | |
| 80 | - | title: &'static str, | |
| 81 | - | description: &'static str, | |
| 82 | - | meta: &'static str, | |
| 83 | - | route: &'static str, | |
| 84 | - | filename: &'static str, | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | /// The five that answer with a file, in the order the template listed them. | |
| 88 | - | const DIRECT: &[Direct] = &[ | |
| 89 | - | Direct { | |
| 90 | - | title: "Projects & Items", | |
| 91 | - | description: "All your project and item metadata including titles, descriptions, prices, and tags.", | |
| 92 | - | meta: "JSON format", | |
| 93 | - | route: "/api/export/projects", | |
| 94 | - | filename: "makenot-work-projects.json", | |
| 95 | - | }, | |
| 96 | - | Direct { | |
| 97 | - | title: "Sales History", | |
| 98 | - | description: "Record of all sales you've made, including dates, amounts, and item titles.", | |
| 99 | - | meta: "CSV format", | |
| 100 | - | route: "/api/export/sales", | |
| 101 | - | filename: "makenot-work-sales.csv", | |
| 102 | - | }, | |
| 103 | - | Direct { | |
| 104 | - | title: "Collaborator Payouts", | |
| 105 | - | description: "Record of all revenue shared with collaborators on your projects, both incoming and outgoing.", | |
| 106 | - | meta: "CSV format", | |
| 107 | - | route: "/api/export/splits", | |
| 108 | - | filename: "makenot-work-splits.csv", | |
| 109 | - | }, | |
| 110 | - | Direct { | |
| 111 | - | title: "Purchase History", | |
| 112 | - | description: "Record of all items you've purchased, for your personal records.", | |
| 113 | - | meta: "CSV format", | |
| 114 | - | route: "/api/export/purchases", | |
| 115 | - | filename: "makenot-work-purchases.csv", | |
| 116 | - | }, | |
| 117 | - | Direct { | |
| 118 | - | title: "Followers & Members", | |
| 119 | - | description: "List of users who follow you or have memberships to your projects.", | |
| 120 | - | meta: "CSV format", | |
| 121 | - | route: "/api/export/followers", | |
| 122 | - | filename: "makenot-work-followers.csv", | |
| 123 | - | }, | |
| 124 | - | ]; | |
| 125 | - | ||
| 126 | 72 | /// What the screen needs from the database. | |
| 127 | 73 | pub struct Page { | |
| 128 | 74 | /// Whether the reader has any exportable files at all. | |
| @@ -159,16 +105,20 @@ | |||
| 159 | 105 | }) | |
| 160 | 106 | } | |
| 161 | 107 | ||
| 162 | - | pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 163 | - | let page = viewer | |
| 108 | + | /// The one read this page makes, for the mount that serves it from a residual. | |
| 109 | + | pub(crate) fn reading(viewer: &super::Viewer) -> Result<Page, RouteError> { | |
| 110 | + | viewer | |
| 164 | 111 | .block_on(load(&viewer.app.db, viewer.reader()?.id)) | |
| 165 | - | .map_err(|_| RouteError::internal("your exports could not be read"))?; | |
| 166 | - | Ok(page_screen(&page).into()) | |
| 112 | + | .map_err(|_| RouteError::internal("your exports could not be read")) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 116 | + | Ok(page_screen(&reading(viewer)?).into()) | |
| 167 | 117 | } | |
| 168 | 118 | ||
| 169 | 119 | declare! { | |
| 170 | 120 | /// The whole document: the title, the measure, the body. | |
| 171 | - | shape page_screen(page: &Page) -> Screen; | |
| 121 | + | pub(crate) shape page_screen(page: &Page) -> Screen; | |
| 172 | 122 | ||
| 173 | 123 | screen single "Export Your Data - Makenotwork" { | |
| 174 | 124 | measured MEASURE; | |
| @@ -179,16 +129,27 @@ | |||
| 179 | 129 | documented Document::default().classed(crate::shell::body_class(MEASURE, &["export-page"])); | |
| 180 | 130 | summarised "Download your content, projects, and transaction history."; | |
| 181 | 131 | ||
| 182 | - | region PAGE_REGION as Pane { | |
| 183 | - | link "Back to Dashboard" to get "/dashboard" navigating; | |
| 184 | - | page "Export Your Data"; | |
| 185 | - | text "Download your content, projects, and transaction history."; | |
| 186 | - | include cards(page); | |
| 187 | - | section "About Your Data"; | |
| 188 | - | text "Your data belongs to you. These exports contain everything we store \ | |
| 189 | - | about your account and content. If you're planning to delete your \ | |
| 190 | - | account, we recommend downloading your data first."; | |
| 191 | - | } | |
| 132 | + | include page_region(page); | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | declare! { | |
| 137 | + | /// The page's one region, split out so it can be staged. | |
| 138 | + | /// | |
| 139 | + | /// Everything on it but the content card is words, so the residual is one | |
| 140 | + | /// literal around two branches: whether this reader has files at all. | |
| 141 | + | #[staged] | |
| 142 | + | pub(crate) shape page_region(page: &Page) -> Slot; | |
| 143 | + | ||
| 144 | + | region PAGE_REGION as Pane { | |
| 145 | + | link "Back to Dashboard" to get "/dashboard" navigating; | |
| 146 | + | page "Export Your Data"; | |
| 147 | + | text "Download your content, projects, and transaction history."; | |
| 148 | + | include cards(page); | |
| 149 | + | section "About Your Data"; | |
| 150 | + | text "Your data belongs to you. These exports contain everything we store \ | |
| 151 | + | about your account and content. If you're planning to delete your \ | |
| 152 | + | account, we recommend downloading your data first."; | |
| 192 | 153 | } | |
| 193 | 154 | } | |
| 194 | 155 | ||
| @@ -200,11 +161,12 @@ | |||
| 200 | 161 | /// server's one sentence about posting a route and keeping the answer as a | |
| 201 | 162 | /// file. What a caller must not restate is that sentence, so the control | |
| 202 | 163 | /// arrives built and the row places it. | |
| 164 | + | #[staged] | |
| 203 | 165 | shape cards(page: &Page) -> Slot; | |
| 204 | 166 | ||
| 205 | 167 | region CARDS_REGION as Group { | |
| 206 | 168 | list { | |
| 207 | - | for export in DIRECT { | |
| 169 | + | for export in copy "content/export-portal.toml" as direct { | |
| 208 | 170 | row export.title { | |
| 209 | 171 | secondary export.description; | |
| 210 | 172 | meta export.meta; | |
| @@ -223,6 +185,7 @@ | |||
| 223 | 185 | ||
| 224 | 186 | declare! { | |
| 225 | 187 | /// The asynchronous one. See the module header for why it targets a region. | |
| 188 | + | #[staged] | |
| 226 | 189 | shape content_row(size: &str) -> Row; | |
| 227 | 190 | ||
| 228 | 191 | row "Content Files" { | |
| @@ -275,22 +238,48 @@ | |||
| 275 | 238 | ); | |
| 276 | 239 | } | |
| 277 | 240 | ||
| 241 | + | /// The five direct exports, read the way the macro reads them. | |
| 242 | + | /// | |
| 243 | + | /// `policy`'s rule: the file is asked what the page should say rather than | |
| 244 | + | /// a second copy of it being kept here. It was a `const DIRECT` in this | |
| 245 | + | /// module and these tests iterated it, which checked the page against the | |
| 246 | + | /// same array the page was built from; now they check it against the file. | |
| 247 | + | fn direct() -> Vec<toml::Table> { | |
| 248 | + | let copy: toml::Table = include_str!("../../content/export-portal.toml") | |
| 249 | + | .parse() | |
| 250 | + | .expect("the export copy is TOML"); | |
| 251 | + | ||
| 252 | + | copy["direct"] | |
| 253 | + | .as_array() | |
| 254 | + | .expect("a list of direct exports") | |
| 255 | + | .iter() | |
| 256 | + | .map(|export| export.as_table().expect("a table").clone()) | |
| 257 | + | .collect() | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /// What a card says it is, out of the copy. | |
| 261 | + | fn says(export: &toml::Table, key: &str) -> String { | |
| 262 | + | export[key].as_str().expect("a string").to_owned() | |
| 263 | + | } | |
| 264 | + | ||
| 278 | 265 | /// Each direct export says where it reads from and what the file is called, | |
| 279 | 266 | /// and nothing says it twice. | |
| 280 | 267 | #[test] | |
| 281 | 268 | fn every_direct_export_names_its_route_and_its_filename() { | |
| 282 | 269 | let html = html(true); | |
| 283 | 270 | ||
| 284 | - | for export in DIRECT { | |
| 271 | + | assert_eq!(direct().len(), 5, "five exports answer with a file"); | |
| 272 | + | for export in direct() { | |
| 273 | + | let route = says(&export, "route"); | |
| 274 | + | let filename = says(&export, "filename"); | |
| 275 | + | ||
| 285 | 276 | assert!( | |
| 286 | - | html.contains(&format!(r#"hx-post="{}""#, export.route)), | |
| 287 | - | "{} missing from {html}", | |
| 288 | - | export.route | |
| 277 | + | html.contains(&format!(r#"hx-post="{route}""#)), | |
| 278 | + | "{route} missing from {html}" | |
| 289 | 279 | ); | |
| 290 | 280 | assert!( | |
| 291 | - | html.contains(&format!(r#"data-saves="{}""#, export.filename)), | |
| 292 | - | "{} missing from {html}", | |
| 293 | - | export.filename | |
| 281 | + | html.contains(&format!(r#"data-saves="{filename}""#)), | |
| 282 | + | "{filename} missing from {html}" | |
| 294 | 283 | ); | |
| 295 | 284 | } | |
| 296 | 285 | } | |
| @@ -301,11 +290,11 @@ | |||
| 301 | 290 | fn the_saved_filenames_are_the_ones_the_endpoints_set() { | |
| 302 | 291 | let api = include_str!("../routes/api/exports/mod.rs"); | |
| 303 | 292 | ||
| 304 | - | for export in DIRECT { | |
| 293 | + | for export in direct() { | |
| 294 | + | let filename = says(&export, "filename"); | |
| 305 | 295 | assert!( | |
| 306 | - | api.contains(export.filename), | |
| 307 | - | "{} is not a filename `routes::api::exports` sets", | |
| 308 | - | export.filename | |
| 296 | + | api.contains(&filename), | |
| 297 | + | "{filename} is not a filename `routes::api::exports` sets" | |
| 309 | 298 | ); | |
| 310 | 299 | } | |
| 311 | 300 | } |
| @@ -587,11 +587,18 @@ | |||
| 587 | 587 | ), | |
| 588 | 588 | ( | |
| 589 | 589 | export_portal::PATH, | |
| 590 | - | document_mount( | |
| 590 | + | served_gated_mount( | |
| 591 | 591 | app, | |
| 592 | 592 | export_portal::PATH, | |
| 593 | - | export_portal::screen, | |
| 594 | 593 | export_portal::renderer, | |
| 594 | + | |viewer, _| { | |
| 595 | + | let page = export_portal::reading(viewer)?; | |
| 596 | + | Ok(Served { | |
| 597 | + | screen: export_portal::page_screen(&page), | |
| 598 | + | markup: export_portal::page_region_serve(&residuals::EXPORT_PORTAL, &page) | |
| 599 | + | .into(), | |
| 600 | + | }) | |
| 601 | + | }, | |
| 595 | 602 | ), | |
| 596 | 603 | ), | |
| 597 | 604 | ] | |
| @@ -723,11 +730,21 @@ | |||
| 723 | 730 | ), | |
| 724 | 731 | ( | |
| 725 | 732 | collections::PATH, | |
| 726 | - | public_document_mount( | |
| 733 | + | served_document_mount( | |
| 727 | 734 | app, | |
| 728 | 735 | collections::PATH, | |
| 729 | - | collections::screen, | |
| 730 | 736 | collections::renderer, | |
| 737 | + | |viewer, carried| { | |
| 738 | + | // The address's captures and the row they resolve to, read | |
| 739 | + | // once. `Carried` carries them because a screen on the seam | |
| 740 | + | // never reaches the adapter and so has no `Request`. | |
| 741 | + | let loaded = collections::reading(viewer, carried)?; | |
| 742 | + | Ok(Served { | |
| 743 | + | screen: collections::page_screen(&loaded), | |
| 744 | + | markup: collections::page_region_serve(&residuals::COLLECTIONS, &loaded) | |
| 745 | + | .into(), | |
| 746 | + | }) | |
| 747 | + | }, | |
| 731 | 748 | ), | |
| 732 | 749 | ), | |
| 733 | 750 | // The git browse tree carries a per-IP cap on every read, and a | |
| @@ -857,24 +874,56 @@ | |||
| 857 | 874 | renderer: fn(&Viewer) -> quasi_webview::Webview, | |
| 858 | 875 | answer: fn(&Viewer, &Carried) -> Result<Served, quasi_router::RouteError>, | |
| 859 | 876 | ) -> axum::Router { | |
| 860 | - | let app = app.clone(); | |
| 861 | - | axum::Router::new().route( | |
| 877 | + | served(app, path, Audience::Anyone, renderer, answer) | |
| 878 | + | } | |
| 879 | + | ||
| 880 | + | /// A gated document whose regions are already written, mounted. | |
| 881 | + | /// | |
| 882 | + | /// [`served_document_mount`] with the two things [`public_document_mount`] | |
| 883 | + | /// removes put back: the factory is built on [`Audience::Reader`], so a | |
| 884 | + | /// signed-out request is refused rather than answered, and [`signed_in`] runs in | |
| 885 | + | /// front so that refusal is the branded 401 with its way back in rather than a | |
| 886 | + | /// bare 403. The relationship is exactly [`document_mount`]'s to | |
| 887 | + | /// [`public_document_mount`], and it is written twice for the same reason those | |
| 888 | + | /// are: what a mount refuses is what a test can press it for. | |
| 889 | + | fn served_gated_mount( | |
| 890 | + | app: &AppState, | |
| 891 | + | path: &'static str, | |
| 892 | + | renderer: fn(&Viewer) -> quasi_webview::Webview, | |
| 893 | + | answer: fn(&Viewer, &Carried) -> Result<Served, quasi_router::RouteError>, | |
| 894 | + | ) -> axum::Router { | |
| 895 | + | served(app, path, Audience::Reader, renderer, answer) | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | /// The seam's mount, under either audience. | |
| 899 | + | fn served( | |
| 900 | + | app: &AppState, | |
| 901 | + | path: &'static str, | |
| 902 | + | audience: Audience, | |
| 903 | + | renderer: fn(&Viewer) -> quasi_webview::Webview, | |
| 904 | + | answer: fn(&Viewer, &Carried) -> Result<Served, quasi_router::RouteError>, | |
| 905 | + | ) -> axum::Router { | |
| 906 | + | let gated = matches!(audience, Audience::Reader); | |
| 907 | + | let held = app.clone(); | |
| 908 | + | let router = axum::Router::new().route( | |
| 862 | 909 | path, | |
| 863 | - | axum::routing::get(move |parts: axum::http::request::Parts| { | |
| 864 | - | let app = app.clone(); | |
| 910 | + | axum::routing::get(move |mut parts: axum::http::request::Parts| { | |
| 911 | + | let app = held.clone(); | |
| 865 | 912 | async move { | |
| 866 | 913 | use axum::response::IntoResponse as _; | |
| 867 | 914 | ||
| 868 | - | // `Audience::Anyone` never refuses a reader for being signed | |
| 869 | - | // out, so the error arm here is the session layer or the CSRF | |
| 870 | - | // store failing, which is this server being broken rather than | |
| 871 | - | // this page being unreachable. Same answer the adapter gives | |
| 872 | - | // every other screen for the same failure. | |
| 873 | - | let Ok(viewer) = viewer_factory(app.clone(), Audience::Anyone)(&parts).await else { | |
| 915 | + | // Under `Audience::Anyone` a reader is never refused for being | |
| 916 | + | // signed out, so the error arm there is the session layer or | |
| 917 | + | // the CSRF store failing, which is this server being broken | |
| 918 | + | // rather than this page being unreachable. Under | |
| 919 | + | // `Audience::Reader` the gate in front has already refused a | |
| 920 | + | // visitor, so it is the same failure. Same answer the adapter | |
| 921 | + | // gives every other screen for it. | |
| 922 | + | let Ok(viewer) = viewer_factory(app.clone(), audience)(&parts).await else { | |
| 874 | 923 | return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); | |
| 875 | 924 | }; | |
| 876 | 925 | ||
| 877 | - | let carried = Carried::of(&parts); | |
| 926 | + | let carried = Carried::of(&mut parts).await; | |
| 878 | 927 | match answer(&viewer, &carried) { | |
| 879 | 928 | Ok(Served { screen, markup }) => { | |
| 880 | 929 | axum::response::Html(renderer(&viewer).served(&screen, &markup)) | |
| @@ -889,7 +938,13 @@ | |||
| 889 | 938 | } | |
| 890 | 939 | } | |
| 891 | 940 | }), | |
| 892 | - | ) | |
| 941 | + | ); | |
| 942 | + | ||
| 943 | + | if gated { | |
| 944 | + | router.layer(axum::middleware::from_fn_with_state(app.clone(), signed_in)) | |
| 945 | + | } else { | |
| 946 | + | router | |
| 947 | + | } | |
| 893 | 948 | } | |
| 894 | 949 | ||
| 895 | 950 | /// What a screen on the seam answers with: its document, and the markup to draw | |
| @@ -913,28 +968,81 @@ | |||
| 913 | 968 | /// there is no `Request` to read. This is that much of one: the query, parsed | |
| 914 | 969 | /// the same way, and nothing else. Kept to what a served document actually | |
| 915 | 970 | /// needs rather than growing into a second `Request` nobody asked for. | |
| 916 | - | pub struct Carried(std::collections::HashMap<String, String>); | |
| 971 | + | pub struct Carried { | |
| 972 | + | /// The query the reader arrived with. | |
| 973 | + | query: std::collections::HashMap<String, String>, | |
| 974 | + | /// What the address pattern caught, for a screen at a parameterised | |
| 975 | + | /// address. | |
| 976 | + | captures: std::collections::HashMap<String, String>, | |
| 977 | + | } | |
| 917 | 978 | ||
| 918 | 979 | impl Carried { | |
| 919 | - | /// The query the reader arrived with. | |
| 920 | - | fn of(parts: &axum::http::request::Parts) -> Self { | |
| 921 | - | Self( | |
| 922 | - | parts | |
| 923 | - | .uri | |
| 924 | - | .query() | |
| 925 | - | .map(|query| { | |
| 926 | - | url::form_urlencoded::parse(query.as_bytes()) | |
| 927 | - | .map(|(key, value)| (key.into_owned(), value.into_owned())) | |
| 928 | - | .collect() | |
| 929 | - | }) | |
| 930 | - | .unwrap_or_default(), | |
| 931 | - | ) | |
| 980 | + | /// What this request carried, out of the parts the adapter never sees. | |
| 981 | + | /// | |
| 982 | + | /// # The captures are axum's, and they are percent-decoded | |
| 983 | + | /// | |
| 984 | + | /// A screen on the seam answers no `Screen`, so its address is matched by | |
| 985 | + | /// the axum route this mount registers rather than by | |
| 986 | + | /// `quasi_router::Router`. The two agree about which requests match and | |
| 987 | + | /// differ in one thing: axum decodes `%2f`-style escapes in a capture and | |
| 988 | + | /// quasi hands the raw segment over. So `/c/ada/a%2Db` reaches the seam as | |
| 989 | + | /// slug `a-b` and reaches the described twin as `a%2Db`. | |
| 990 | + | /// | |
| 991 | + | /// Stated rather than reconciled. The difference is a widening toward the | |
| 992 | + | /// spelling RFC 3986 says the address means, it lands on the same row, and | |
| 993 | + | /// the alternative is a second path matcher in this crate. | |
| 994 | + | async fn of(parts: &mut axum::http::request::Parts) -> Self { | |
| 995 | + | use axum::extract::FromRequestParts as _; | |
| 996 | + | ||
| 997 | + | let query = parts | |
| 998 | + | .uri | |
| 999 | + | .query() | |
| 1000 | + | .map(|query| { | |
| 1001 | + | url::form_urlencoded::parse(query.as_bytes()) | |
| 1002 | + | .map(|(key, value)| (key.into_owned(), value.into_owned())) | |
| 1003 | + | .collect() | |
| 1004 | + | }) | |
| 1005 | + | .unwrap_or_default(); | |
| 1006 | + | ||
| 1007 | + | // An address with no captures in it has no `UrlParams` extension at | |
| 1008 | + | // all, which is a refusal here and an empty map rather than a failure: | |
| 1009 | + | // `/policy` carries none and asks for none. | |
| 1010 | + | let captures = axum::extract::RawPathParams::from_request_parts(parts, &()) | |
| 1011 | + | .await | |
| 1012 | + | .map(|params| { | |
| 1013 | + | params | |
| 1014 | + | .iter() | |
| 1015 | + | .map(|(key, value)| (key.to_owned(), value.to_owned())) | |
| 1016 | + | .collect() | |
| 1017 | + | }) | |
| 1018 | + | .unwrap_or_default(); | |
| 1019 | + | ||
| 1020 | + | Self { query, captures } | |
| 932 | 1021 | } | |
| 933 | 1022 | ||
| 934 | 1023 | /// Whether the address carried `key` set to exactly `value`. | |
| 935 | 1024 | #[must_use] | |
| 936 | 1025 | pub fn says(&self, key: &str, value: &str) -> bool { | |
| 937 | - | self.0.get(key).is_some_and(|held| held.trim() == value) | |
| 1026 | + | self.query.get(key).is_some_and(|held| held.trim() == value) | |
| 1027 | + | } | |
| 1028 | + | ||
| 1029 | + | /// What the query carried under `key`, if anything. | |
| 1030 | + | #[must_use] | |
| 1031 | + | pub fn asked(&self, key: &str) -> Option<&str> { | |
| 1032 | + | self.query.get(key).map(String::as_str) | |
| 1033 | + | } | |
| 1034 | + | ||
| 1035 | + | /// What the address pattern caught under `name`. | |
| 1036 | + | /// | |
| 1037 | + | /// Fallible with the refusal a missing capture deserves rather than an | |
| 1038 | + | /// `Option`: a capture the pattern declares is always present, so absence | |
| 1039 | + | /// is this mount being registered at an address that does not name it, and | |
| 1040 | + | /// every caller would write the same `ok_or_else`. | |
| 1041 | + | pub fn capture(&self, name: &str) -> Result<&str, quasi_router::RouteError> { | |
| 1042 | + | self.captures | |
| 1043 | + | .get(name) | |
| 1044 | + | .map(String::as_str) | |
| 1045 | + | .ok_or_else(|| quasi_router::RouteError::not_found("no such page")) | |
| 938 | 1046 | } | |
| 939 | 1047 | } | |
| 940 | 1048 | ||
| @@ -1193,8 +1301,16 @@ | |||
| 1193 | 1301 | .split_once("\n}") | |
| 1194 | 1302 | .expect("document_mounts ends") | |
| 1195 | 1303 | .0; | |
| 1304 | + | // Two mount styles, and both are gated documents, for the same | |
| 1305 | + | // reason `every_public_document_is_listed_in_public_document_paths` | |
| 1306 | + | // counts two: a screen on the residual seam has no `Screen` to answer | |
| 1307 | + | // with, so it takes [`served_gated_mount`] rather than the adapter. | |
| 1308 | + | // Counting only the first would let a screen leave `DOCUMENT_PATHS` | |
| 1309 | + | // unnoticed by moving onto the seam. | |
| 1310 | + | let adapted = mounted.matches("document_mount(").count(); | |
| 1311 | + | let served = mounted.matches("served_gated_mount(").count(); | |
| 1196 | 1312 | assert_eq!( | |
| 1197 | - | mounted.matches("document_mount(").count(), | |
| 1313 | + | adapted + served, | |
| 1198 | 1314 | DOCUMENT_PATHS.len(), | |
| 1199 | 1315 | "document_mounts and DOCUMENT_PATHS describe the same screens" | |
| 1200 | 1316 | ); |
| @@ -72,6 +72,12 @@ | |||
| 72 | 72 | ("FAN_PLUS", |plan| { | |
| 73 | 73 | Node::Region(super::fan_plus::page_region_staged(plan)) | |
| 74 | 74 | }), | |
| 75 | + | ("COLLECTIONS", |plan| { | |
| 76 | + | Node::Region(super::collections::page_region_staged(plan)) | |
| 77 | + | }), | |
| 78 | + | ("EXPORT_PORTAL", |plan| { | |
| 79 | + | Node::Region(super::export_portal::page_region_staged(plan)) | |
| 80 | + | }), | |
| 75 | 81 | ] | |
| 76 | 82 | } | |
| 77 | 83 | ||
| @@ -387,6 +393,133 @@ | |||
| 387 | 393 | } | |
| 388 | 394 | } | |
| 389 | 395 | ||
| 396 | + | /// A looping screen's residual fills to what the renderer builds. | |
| 397 | + | /// | |
| 398 | + | /// `/c/{username}/{slug}` is the first residual on the seam that carries a | |
| 399 | + | /// **loop**: its items table is one compiled row body walked once per item, | |
| 400 | + | /// rather than a row's markup rendered per item. Filled at three lengths so | |
| 401 | + | /// a residual that had baked one request's row count into its literals | |
| 402 | + | /// fails: nought exercises the empty state, one the body once, and three | |
| 403 | + | /// the body repeated. | |
| 404 | + | /// | |
| 405 | + | /// Both visibilities as well, because the private banner is a branch and an | |
| 406 | + | /// equality on the public page alone would pass against a residual that had | |
| 407 | + | /// lost it. | |
| 408 | + | #[test] | |
| 409 | + | fn the_collections_residual_fills_to_what_the_renderer_builds() { | |
| 410 | + | use quasi_axum::Serves as _; | |
| 411 | + | ||
| 412 | + | for items in [0, 1, 3] { | |
| 413 | + | for is_public in [true, false] { | |
| 414 | + | let loaded = crate::quasi::collections::sample(items, is_public); | |
| 415 | + | let filled = crate::quasi::collections::page_region_serve(&COLLECTIONS, &loaded); | |
| 416 | + | let rendered = Webview::new().fragment(&Node::Region( | |
| 417 | + | crate::quasi::collections::page_region(&loaded), | |
| 418 | + | )); | |
| 419 | + | ||
| 420 | + | assert_eq!(filled, rendered, "items={items} is_public={is_public}"); | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | /// The row body is compiled once, not once per item the derivation saw. | |
| 426 | + | /// | |
| 427 | + | /// What says the loop is a loop. A residual derived from a one-row render | |
| 428 | + | /// and stored flat would still fill correctly at one item and lose rows at | |
| 429 | + | /// three, which the equality above catches; this catches the other half, | |
| 430 | + | /// that the table's markup is in the residual at all rather than being | |
| 431 | + | /// rebuilt per request. | |
| 432 | + | #[test] | |
| 433 | + | fn the_collections_residual_holds_its_row_body_once() { | |
| 434 | + | use quasi_router::stage::Op; | |
| 435 | + | ||
| 436 | + | fn loops(ops: &[Op]) -> usize { | |
| 437 | + | ops.iter() | |
| 438 | + | .map(|op| match op { | |
| 439 | + | Op::Loop(body) => 1 + loops(body), | |
| 440 | + | Op::Branch(body) => loops(body), | |
| 441 | + | _ => 0, | |
| 442 | + | }) | |
| 443 | + | .sum() | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | assert_eq!(loops(COLLECTIONS.ops()), 1, "one loop, over the items"); | |
| 447 | + | assert!( | |
| 448 | + | format!("{:?}", COLLECTIONS.ops()).contains("Creator"), | |
| 449 | + | "the table's headings are compiled, not built per request", | |
| 450 | + | ); | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | /// The export portal's residual fills to what the renderer builds. | |
| 454 | + | /// | |
| 455 | + | /// The first gated document on the seam, and the one whose copy moved out | |
| 456 | + | /// of a `const` in the same pass: the five direct cards are read at macro | |
| 457 | + | /// time and fold into literals, and what is left varying is whether this | |
| 458 | + | /// reader has files at all. Both answers, and two size lines under the | |
| 459 | + | /// yes, since the size is a hole and filling with one value would pass | |
| 460 | + | /// against a residual that had baked it in. | |
| 461 | + | #[test] | |
| 462 | + | fn the_export_portal_residual_fills_to_what_the_renderer_builds() { | |
| 463 | + | use crate::quasi::export_portal::Page; | |
| 464 | + | use quasi_axum::Serves as _; | |
| 465 | + | ||
| 466 | + | let pages = [ | |
| 467 | + | Page { | |
| 468 | + | has_content: false, | |
| 469 | + | content_size: "No files".to_owned(), | |
| 470 | + | }, | |
| 471 | + | Page { | |
| 472 | + | has_content: true, | |
| 473 | + | content_size: "1.4 GB + audio/cover files".to_owned(), | |
| 474 | + | }, | |
| 475 | + | Page { | |
| 476 | + | has_content: true, | |
| 477 | + | content_size: "17 KB".to_owned(), | |
| 478 | + | }, | |
| 479 | + | ]; | |
| 480 | + | ||
| 481 | + | for page in &pages { | |
| 482 | + | let filled = crate::quasi::export_portal::page_region_serve(&EXPORT_PORTAL, page); | |
| 483 | + | let rendered = Webview::new().fragment(&Node::Region( | |
| 484 | + | crate::quasi::export_portal::page_region(page), | |
| 485 | + | )); | |
| 486 | + | ||
| 487 | + | assert_eq!(filled, rendered, "has_content={}", page.has_content); | |
| 488 | + | } | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | /// The five direct exports are compiled, not rebuilt per request. | |
| 492 | + | /// | |
| 493 | + | /// What says the copy move landed. Each card's route is in the residual's | |
| 494 | + | /// literals; a loop appearing here would mean the cards went back to being | |
| 495 | + | /// read through a path the macro cannot evaluate. | |
| 496 | + | #[test] | |
| 497 | + | fn the_export_portal_residual_holds_its_five_cards() { | |
| 498 | + | use quasi_router::stage::Op; | |
| 499 | + | ||
| 500 | + | let compiled = format!("{:?}", EXPORT_PORTAL.ops()); | |
| 501 | + | for route in [ | |
| 502 | + | "/api/export/projects", | |
| 503 | + | "/api/export/sales", | |
| 504 | + | "/api/export/splits", | |
| 505 | + | "/api/export/purchases", | |
| 506 | + | "/api/export/followers", | |
| 507 | + | ] { | |
| 508 | + | assert!(compiled.contains(route), "{route} is not in the residual"); | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | fn loops(ops: &[Op]) -> usize { | |
| 512 | + | ops.iter() | |
| 513 | + | .map(|op| match op { | |
| 514 | + | Op::Loop(body) => 1 + loops(body), | |
| 515 | + | Op::Branch(body) => loops(body), | |
| 516 | + | _ => 0, | |
| 517 | + | }) | |
| 518 | + | .sum() | |
| 519 | + | } | |
| 520 | + | assert_eq!(loops(EXPORT_PORTAL.ops()), 0, "the cards are unrolled"); | |
| 521 | + | } | |
| 522 | + | ||
| 390 | 523 | /// Every screen the roster names is checked above. | |
| 391 | 524 | /// | |
| 392 | 525 | /// The gap this closes is the one a table opens: a screen added to | |
| @@ -400,8 +533,9 @@ | |||
| 400 | 533 | /// the two counts have to be kept by hand or not at all. | |
| 401 | 534 | #[test] | |
| 402 | 535 | fn every_screen_on_the_seam_is_checked() { | |
| 403 | - | /// Screens with their own filling test: `/use-cases`, `/fan-plus`. | |
| 404 | - | const HOLED: usize = 2; | |
| 536 | + | /// Screens with their own filling test: `/use-cases`, `/fan-plus`, | |
| 537 | + | /// `/c/{username}/{slug}`, `/dashboard/export`. | |
| 538 | + | const HOLED: usize = 4; | |
| 405 | 539 | ||
| 406 | 540 | assert_eq!( | |
| 407 | 541 | roster().len(), |
| @@ -48,35 +48,107 @@ | |||
| 48 | 48 | ||
| 49 | 49 | pub static FAN_PLUS: ::quasi_router::stage::Residual = | |
| 50 | 50 | ::quasi_router::stage::Residual::compiled(&[ | |
| 51 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"fan-plus\" class=\"region pane\"><h1 class=\"heading\">Fan+</h1><")), | |
| 51 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"fan-plus\" class=\"region pane\"><h1 class=\"heading\">Fan+</h1>")), | |
| 52 | 52 | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 53 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("div class=\"banner\" data-tone=\"success\" role=\"status\" aria-live=\"polite\">You're now a Fan+ member. Welcome.</div><")), | |
| 53 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"banner\" data-tone=\"success\" role=\"status\" aria-live=\"polite\">You're now a Fan+ member. Welcome.</div>")), | |
| 54 | 54 | ||
| 55 | 55 | ])), | |
| 56 | 56 | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 57 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("section id=\"fan-plus-membership\" class=\"region group\"><p class=\"text\">Your Fan+ membership is active.</p><p class=\"text\">")), | |
| 57 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<section id=\"fan-plus-membership\" class=\"region group\"><p class=\"text\">Your Fan+ membership is active.</p>")), | |
| 58 | 58 | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 59 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("Current period ends: ")), | |
| 59 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">Current period ends: ")), | |
| 60 | 60 | ::quasi_router::stage::Op::Hole { scope: 1, id: 0 }, | |
| 61 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</p><p class=\"text\">")), | |
| 61 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</p>")), | |
| 62 | 62 | ||
| 63 | 63 | ])), | |
| 64 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("You'll receive a $5 credit code each billing cycle via email.</p><div class=\"anchored\" id=\"fan-plus-membership-anchored\" data-menu=\"anchored\" hidden></div></section><")), | |
| 64 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">You'll receive a $5 credit code each billing cycle via email.</p><div class=\"anchored\" id=\"fan-plus-membership-anchored\" data-menu=\"anchored\" hidden></div></section>")), | |
| 65 | 65 | ||
| 66 | 66 | ])), | |
| 67 | 67 | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 68 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("section id=\"fan-plus-pitch\" class=\"region group\"><p class=\"text\">Support the platform and get something back every month.</p><h2 class=\"heading\">What you get</h2><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">$5 monthly credit</span><span class=\"row-secondary\">A promo code delivered by email each billing cycle, usable toward any purchase on the platform</span></li><li class=\"row\" data-row><span class=\"row-primary\">+ badge</span><span class=\"row-secondary\">Displayed next to your name on your forum posts</span></li><li class=\"row\" data-row><span class=\"row-primary\">Forum signatures</span><span class=\"row-secondary\">A signature block rendered under everything you post</span></li><li class=\"row\" data-row><span class=\"row-primary\">Image embeds</span><span class=\"row-secondary\">Post images in forum threads</span></li></ul><div class=\"figures\"><div class=\"figure\" aria-label=\"per month: $8\"><span class=\"figure-value\" aria-hidden=\"true\">$8</span><span class=\"figure-caption\" aria-hidden=\"true\">per month</span></div></div><p class=\"text\">Makenotwork is built on 0% platform fees. Fan+ is how you directly support the platform's development and operations, while getting real value back each month.</p><div class=\"anchored\" id=\"fan-plus-pitch-anchored\" data-menu=\"anchored\" hidden></div></section><")), | |
| 68 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<section id=\"fan-plus-pitch\" class=\"region group\"><p class=\"text\">Support the platform and get something back every month.</p><h2 class=\"heading\">What you get</h2><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">$5 monthly credit</span><span class=\"row-secondary\">A promo code delivered by email each billing cycle, usable toward any purchase on the platform</span></li><li class=\"row\" data-row><span class=\"row-primary\">+ badge</span><span class=\"row-secondary\">Displayed next to your name on your forum posts</span></li><li class=\"row\" data-row><span class=\"row-primary\">Forum signatures</span><span class=\"row-secondary\">A signature block rendered under everything you post</span></li><li class=\"row\" data-row><span class=\"row-primary\">Image embeds</span><span class=\"row-secondary\">Post images in forum threads</span></li></ul><div class=\"figures\"><div class=\"figure\" aria-label=\"per month: $8\"><span class=\"figure-value\" aria-hidden=\"true\">$8</span><span class=\"figure-caption\" aria-hidden=\"true\">per month</span></div></div><p class=\"text\">Makenotwork is built on 0% platform fees. Fan+ is how you directly support the platform's development and operations, while getting real value back each month.</p><div class=\"anchored\" id=\"fan-plus-pitch-anchored\" data-menu=\"anchored\" hidden></div></section>")), | |
| 69 | 69 | ||
| 70 | 70 | ])), | |
| 71 | 71 | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 72 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("button type=\"button\" class=\"button\" data-act hx-post=\"/stripe/fan-plus\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Join Fan+</button><")), | |
| 72 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<button type=\"button\" class=\"button\" data-act hx-post=\"/stripe/fan-plus\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Join Fan+</button>")), | |
| 73 | 73 | ||
| 74 | 74 | ])), | |
| 75 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("div class=\"")), | |
| 76 | 75 | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 77 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("rich\" data-disable-scripting><p><a href=\"/join\" rel=\"noopener noreferrer\">Create an account</a> to join, or <a href=\"/login\" rel=\"noopener noreferrer\">log in</a> if you already have one.</p>\n</div><div class=\"")), | |
| 76 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"rich\" data-disable-scripting><p><a href=\"/join\" rel=\"noopener noreferrer\">Create an account</a> to join, or <a href=\"/login\" rel=\"noopener noreferrer\">log in</a> if you already have one.</p>\n</div>")), | |
| 78 | 77 | ||
| 79 | 78 | ])), | |
| 80 | - | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("anchored\" id=\"fan-plus-anchored\" data-menu=\"anchored\" hidden></div></div>")), | |
| 79 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"fan-plus-anchored\" data-menu=\"anchored\" hidden></div></div>")), | |
| 80 | + | ]); | |
| 81 | + | ||
| 82 | + | pub static COLLECTIONS: ::quasi_router::stage::Residual = | |
| 83 | + | ::quasi_router::stage::Residual::compiled(&[ | |
| 84 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"collection\" class=\"region pane\"><h1 class=\"heading\">")), | |
| 85 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 0 }, | |
| 86 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</h1><a class=\"link\" data-act href=\"/u/")), | |
| 87 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 2 }, | |
| 88 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\">by ")), | |
| 89 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 1 }, | |
| 90 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</a>")), | |
| 91 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 92 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"banner\" data-tone=\"info\" role=\"status\" aria-live=\"polite\">This collection is private. Only you can see it.</div>")), | |
| 93 | + | ||
| 94 | + | ])), | |
| 95 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 96 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">")), | |
| 97 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 3 }, | |
| 98 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</p>")), | |
| 99 | + | ||
| 100 | + | ])), | |
| 101 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">")), | |
| 102 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 4 }, | |
| 103 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed(" items</p>")), | |
| 104 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 105 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"placeholder\" data-state=\"empty\" role=\"status\" aria-live=\"polite\"><p class=\"placeholder-text\">This collection is empty.</p></div>")), | |
| 106 | + | ||
| 107 | + | ])), | |
| 108 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 109 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div role=\"table\" class=\"table\"><div role=\"row\" class=\"table-head\"><span role=\"columnheader\" class=\"table-heading col-Item cell-fill cell-keeps\">Item</span><span role=\"columnheader\" class=\"table-heading col-Type cell-content cell-drops-next\">Type</span><span role=\"columnheader\" class=\"table-heading col-Creator cell-content cell-drops-next\">Creator</span><span role=\"columnheader\" class=\"table-heading col-Project cell-content cell-drops-next\">Project</span><span role=\"columnheader\" class=\"table-heading col-Price cell-content cell-drops-next\">Price</span></div>")), | |
| 110 | + | ::quasi_router::stage::Op::Loop(::std::borrow::Cow::Borrowed(&[ | |
| 111 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div role=\"row\" class=\"table-row\" data-row href=\"/i/")), | |
| 112 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 5 }, | |
| 113 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\"><div class=\"cell col-Item cell-fill cell-keeps cell-value\">")), | |
| 114 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 0 }, | |
| 115 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Type cell-content cell-drops-next cell-value\">")), | |
| 116 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 1 }, | |
| 117 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Creator cell-content cell-drops-next cell-value\">")), | |
| 118 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 2 }, | |
| 119 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Project cell-content cell-drops-next cell-value\">")), | |
| 120 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 3 }, | |
| 121 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Price cell-content cell-drops-next cell-value\">")), | |
| 122 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 4 }, | |
| 123 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div></div>")), | |
| 124 | + | ||
| 125 | + | ])), | |
| 126 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div>")), | |
| 127 | + | ||
| 128 | + | ])), | |
| 129 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<a class=\"button\" data-act href=\"/u/")), | |
| 130 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 5 }, | |
| 131 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\">View profile</a><button type=\"button\" class=\"button\" data-act data-local data-copies=\"/c/")), | |
| 132 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 6 }, | |
| 133 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("/")), | |
| 134 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 7 }, | |
| 135 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\">Copy link</button><div class=\"anchored\" id=\"collection-anchored\" data-menu=\"anchored\" hidden></div></div>")), | |
| 136 | + | ]); | |
| 137 | + | ||
| 138 | + | pub static EXPORT_PORTAL: ::quasi_router::stage::Residual = | |
| 139 | + | ::quasi_router::stage::Residual::compiled(&[ | |
| 140 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"export\" class=\"region pane\"><a class=\"link\" data-act href=\"/dashboard\">Back to Dashboard</a><h1 class=\"heading\">Export Your Data</h1><p class=\"text\">Download your content, projects, and transaction history.</p><section id=\"export-cards\" class=\"region group\"><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">Projects & Items</span><span class=\"row-secondary\">All your project and item metadata including titles, descriptions, prices, and tags.</span><span class=\"row-meta\">JSON format</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act data-saves=\"makenot-work-projects.json\" hx-post=\"/api/export/projects\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li><li class=\"row\" data-row><span class=\"row-primary\">Sales History</span><span class=\"row-secondary\">Record of all sales you've made, including dates, amounts, and item titles.</span><span class=\"row-meta\">CSV format</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act data-saves=\"makenot-work-sales.csv\" hx-post=\"/api/export/sales\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li><li class=\"row\" data-row><span class=\"row-primary\">Collaborator Payouts</span><span class=\"row-secondary\">Record of all revenue shared with collaborators on your projects, both incoming and outgoing.</span><span class=\"row-meta\">CSV format</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act data-saves=\"makenot-work-splits.csv\" hx-post=\"/api/export/splits\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li><li class=\"row\" data-row><span class=\"row-primary\">Purchase History</span><span class=\"row-secondary\">Record of all items you've purchased, for your personal records.</span><span class=\"row-meta\">CSV format</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act data-saves=\"makenot-work-purchases.csv\" hx-post=\"/api/export/purchases\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li><li class=\"row\" data-row><span class=\"row-primary\">Followers & Members</span><span class=\"row-secondary\">List of users who follow you or have memberships to your projects.</span><span class=\"row-meta\">CSV format</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act data-saves=\"makenot-work-followers.csv\" hx-post=\"/api/export/followers\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li>")), | |
| 141 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 142 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<li class=\"row\" data-row><span class=\"row-primary\">Content Files</span><span class=\"row-secondary\">All your uploaded audio files, cover images, version downloads, and dynamic clips.</span><span class=\"row-meta\">ZIP archive (")), | |
| 143 | + | ::quasi_router::stage::Op::Hole { scope: 32, id: 0 }, | |
| 144 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed(")</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act hx-target=\"#content-status\" hx-post=\"/api/export/content\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li>")), | |
| 145 | + | ||
| 146 | + | ])), | |
| 147 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</ul>")), | |
| 148 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 149 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<section id=\"content-status\" class=\"region group\"><div class=\"anchored\" id=\"content-status-anchored\" data-menu=\"anchored\" hidden></div></section>")), | |
| 150 | + | ||
| 151 | + | ])), | |
| 152 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"export-cards-anchored\" data-menu=\"anchored\" hidden></div></section><h2 class=\"heading\">About Your Data</h2><p class=\"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.</p><div class=\"anchored\" id=\"export-anchored\" data-menu=\"anchored\" hidden></div></div>")), | |
| 81 | 153 | ]); | |
| 82 | 154 |
| @@ -1,0 +1,49 @@ | |||
| 1 | + | # The copy at `/dashboard/export`, which is words and routes and nothing else. | |
| 2 | + | # | |
| 3 | + | # Read at build time by the `declare!` in `src/quasi/export_portal.rs`. Editing | |
| 4 | + | # this file is editing the page. See `content/policy.toml` for the split this | |
| 5 | + | # follows. | |
| 6 | + | # | |
| 7 | + | # `filename` is what the reader's disk ends up with, and it is the name the | |
| 8 | + | # endpoint itself sets in `Content-Disposition` rather than a shorter one | |
| 9 | + | # invented here: a `data-saves` control renames the download, so the two | |
| 10 | + | # disagreeing means the same export arrives under two names depending on which | |
| 11 | + | # control started it. | |
| 12 | + | # | |
| 13 | + | # The order is the order the cards render in, which is the order the shipped | |
| 14 | + | # template listed them. | |
| 15 | + | ||
| 16 | + | [[direct]] | |
| 17 | + | title = "Projects & Items" | |
| 18 | + | description = "All your project and item metadata including titles, descriptions, prices, and tags." | |
| 19 | + | meta = "JSON format" | |
| 20 | + | route = "/api/export/projects" | |
| 21 | + | filename = "makenot-work-projects.json" | |
| 22 | + | ||
| 23 | + | [[direct]] | |
| 24 | + | title = "Sales History" | |
| 25 | + | description = "Record of all sales you've made, including dates, amounts, and item titles." | |
| 26 | + | meta = "CSV format" | |
| 27 | + | route = "/api/export/sales" | |
| 28 | + | filename = "makenot-work-sales.csv" | |
| 29 | + | ||
| 30 | + | [[direct]] | |
| 31 | + | title = "Collaborator Payouts" | |
| 32 | + | description = "Record of all revenue shared with collaborators on your projects, both incoming and outgoing." | |
| 33 | + | meta = "CSV format" | |
| 34 | + | route = "/api/export/splits" | |
| 35 | + | filename = "makenot-work-splits.csv" | |
| 36 | + | ||
| 37 | + | [[direct]] | |
| 38 | + | title = "Purchase History" | |
| 39 | + | description = "Record of all items you've purchased, for your personal records." | |
| 40 | + | meta = "CSV format" | |
| 41 | + | route = "/api/export/purchases" | |
| 42 | + | filename = "makenot-work-purchases.csv" | |
| 43 | + | ||
| 44 | + | [[direct]] | |
| 45 | + | title = "Followers & Members" | |
| 46 | + | description = "List of users who follow you or have memberships to your projects." | |
| 47 | + | meta = "CSV format" | |
| 48 | + | route = "/api/export/followers" | |
| 49 | + | filename = "makenot-work-followers.csv" |