max / makenotwork
6 files changed,
+520 insertions,
-474 deletions
| @@ -48,11 +48,8 @@ | |||
| 48 | 48 | //! The problem holds that question. | |
| 49 | 49 | ||
| 50 | 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 | - | }; | |
| 51 | + | use quasi_declare::declare; | |
| 52 | + | use quasi_router::{Document, Request, Response, RouteError}; | |
| 56 | 53 | use quasi_webview::Webview; | |
| 57 | 54 | ||
| 58 | 55 | /// The address, registered whole. See [`super::document_mount`]. | |
| @@ -169,83 +166,70 @@ | |||
| 169 | 166 | Ok(page_screen(&page).into()) | |
| 170 | 167 | } | |
| 171 | 168 | ||
| 172 | - | fn page_screen(page: &Page) -> Described { | |
| 173 | - | Described::single("Export Your Data - Makenotwork") | |
| 174 | - | .measured(MEASURE) | |
| 169 | + | declare! { | |
| 170 | + | /// The whole document: the title, the measure, the body. | |
| 171 | + | shape page_screen(page: &Page) -> Screen; | |
| 172 | + | ||
| 173 | + | screen single "Export Your Data - Makenotwork" { | |
| 174 | + | measured MEASURE; | |
| 175 | 175 | // `padded-page export-page`, which is what | |
| 176 | 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 | - | ) | |
| 177 | + | // written out: `Document::classed` replaces, so a screen naming only its | |
| 178 | + | // own token would drop its measure (`2790e5c4`). | |
| 179 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &["export-page"])); | |
| 180 | + | summarised "Download your content, projects, and transaction history."; | |
| 181 | + | ||
| 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 | + | } | |
| 192 | + | } | |
| 201 | 193 | } | |
| 202 | 194 | ||
| 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); | |
| 195 | + | declare! { | |
| 196 | + | /// The card set: five direct exports, then the content archive when there is | |
| 197 | + | /// one. | |
| 198 | + | /// | |
| 199 | + | /// The five go through [`super::export_act::control`], which is this | |
| 200 | + | /// server's one sentence about posting a route and keeping the answer as a | |
| 201 | + | /// file. What a caller must not restate is that sentence, so the control | |
| 202 | + | /// arrives built and the row places it. | |
| 203 | + | shape cards(page: &Page) -> Slot; | |
| 206 | 204 | ||
| 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(); | |
| 205 | + | region CARDS_REGION as Group { | |
| 206 | + | list { | |
| 207 | + | for export in DIRECT { | |
| 208 | + | row export.title { | |
| 209 | + | secondary export.description; | |
| 210 | + | meta export.meta; | |
| 211 | + | include super::export_act::control("Download", export.route, export.filename); | |
| 212 | + | } | |
| 213 | + | } | |
| 220 | 214 | ||
| 221 | - | if page.has_content { | |
| 222 | - | rows.push(content_row(&page.content_size)); | |
| 215 | + | include content_row(&page.content_size) when page.has_content; | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // The status panel's home. Empty until the export is asked for, which is | |
| 219 | + | // what the shipped `<div class="export-status" id="content-status">` was. | |
| 220 | + | region CONTENT_STATUS as Group when page.has_content {} | |
| 223 | 221 | } | |
| 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 | 222 | } | |
| 235 | 223 | ||
| 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 | - | )) | |
| 224 | + | declare! { | |
| 225 | + | /// The asynchronous one. See the module header for why it targets a region. | |
| 226 | + | shape content_row(size: &str) -> Row; | |
| 227 | + | ||
| 228 | + | row "Content Files" { | |
| 229 | + | secondary "All your uploaded audio files, cover images, version downloads, and dynamic clips."; | |
| 230 | + | meta "ZIP archive ({size})"; | |
| 231 | + | act "Download" to post "/api/export/content" replacing CONTENT_STATUS awaiting; | |
| 232 | + | } | |
| 249 | 233 | } | |
| 250 | 234 | ||
| 251 | 235 | #[must_use] |
| @@ -52,10 +52,9 @@ | |||
| 52 | 52 | //! separate from its richness one (quasicoherent `24a3b1df`, quasi 0.94). | |
| 53 | 53 | ||
| 54 | 54 | use makeover_layout as layout; | |
| 55 | - | use quasi_router::screen::{Figure, Row}; | |
| 56 | - | use quasi_router::{ | |
| 57 | - | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 58 | - | }; | |
| 55 | + | use quasi_declare::declare; | |
| 56 | + | use quasi_router::screen::Figure; | |
| 57 | + | use quasi_router::{Document, Request, Response, RouteError}; | |
| 59 | 58 | use quasi_webview::Webview; | |
| 60 | 59 | ||
| 61 | 60 | use crate::db; | |
| @@ -76,25 +75,56 @@ | |||
| 76 | 75 | /// What the membership costs, in whole dollars per month. | |
| 77 | 76 | const PRICE: &str = "$8"; | |
| 78 | 77 | ||
| 78 | + | /// One thing a membership buys. | |
| 79 | + | /// | |
| 80 | + | /// Named members rather than a tuple, for `policy`'s reason: a description | |
| 81 | + | /// names what it draws, and `.1` is not a name. | |
| 82 | + | struct Benefit { | |
| 83 | + | name: &'static str, | |
| 84 | + | detail: &'static str, | |
| 85 | + | } | |
| 86 | + | ||
| 79 | 87 | /// What a member gets. | |
| 80 | 88 | /// | |
| 81 | 89 | /// **Keep this list and the Fan+ card on the landing page identical, and keep | |
| 82 | 90 | /// both to what the code grants.** Audited 2026-08-05, carried over from the | |
| 83 | 91 | /// template's comment because it is a rule rather than a note: a benefit listed | |
| 84 | 92 | /// here and not granted by the code is a promise nobody implemented. | |
| 85 | - | const BENEFITS: &[(&str, &str)] = &[ | |
| 86 | - | ( | |
| 87 | - | "$5 monthly credit", | |
| 88 | - | "A promo code delivered by email each billing cycle, usable toward any purchase on the platform", | |
| 89 | - | ), | |
| 90 | - | ("+ badge", "Displayed next to your name on your forum posts"), | |
| 91 | - | ( | |
| 92 | - | "Forum signatures", | |
| 93 | - | "A signature block rendered under everything you post", | |
| 94 | - | ), | |
| 95 | - | ("Image embeds", "Post images in forum threads"), | |
| 93 | + | const BENEFITS: &[Benefit] = &[ | |
| 94 | + | Benefit { | |
| 95 | + | name: "$5 monthly credit", | |
| 96 | + | detail: "A promo code delivered by email each billing cycle, usable toward any purchase on the platform", | |
| 97 | + | }, | |
| 98 | + | Benefit { | |
| 99 | + | name: "+ badge", | |
| 100 | + | detail: "Displayed next to your name on your forum posts", | |
| 101 | + | }, | |
| 102 | + | Benefit { | |
| 103 | + | name: "Forum signatures", | |
| 104 | + | detail: "A signature block rendered under everything you post", | |
| 105 | + | }, | |
| 106 | + | Benefit { | |
| 107 | + | name: "Image embeds", | |
| 108 | + | detail: "Post images in forum threads", | |
| 109 | + | }, | |
| 96 | 110 | ]; | |
| 97 | 111 | ||
| 112 | + | /// The region a member's own state is drawn in. | |
| 113 | + | const MEMBERSHIP: &str = "fan-plus-membership"; | |
| 114 | + | ||
| 115 | + | /// The region the pitch is drawn in. | |
| 116 | + | const PITCH: &str = "fan-plus-pitch"; | |
| 117 | + | ||
| 118 | + | /// What a visitor is offered instead of a control they cannot use. | |
| 119 | + | /// | |
| 120 | + | /// One sentence with two inline links, not two buttons. The sentence says which | |
| 121 | + | /// link is for whom and a pair of buttons does not, and that distinction is the | |
| 122 | + | /// whole point of `loose-wire g1-23`: Fan+ needs an account, so without the | |
| 123 | + | /// second link this page is a dead end for exactly the visitor it is written | |
| 124 | + | /// for. It is prose, so it is `own_prose`, on `/policy`'s rule. | |
| 125 | + | const VISITOR: &str = | |
| 126 | + | "[Create an account](/join) to join, or [log in](/login) if you already have one."; | |
| 127 | + | ||
| 98 | 128 | /// What this request knows about the reader's membership. | |
| 99 | 129 | enum Standing { | |
| 100 | 130 | /// Nobody is signed in. The page is a pitch plus a way to get an account. | |
| @@ -106,6 +136,40 @@ | |||
| 106 | 136 | Member { period_end: Option<String> }, | |
| 107 | 137 | } | |
| 108 | 138 | ||
| 139 | + | /// The three questions the page asks about the reader, spelled once. | |
| 140 | + | /// | |
| 141 | + | /// Predicates rather than a dispatch: `Member` carries a value, and the form | |
| 142 | + | /// has no binding pattern to reach it through an arm. | |
| 143 | + | impl Standing { | |
| 144 | + | /// Signed in and paying. | |
| 145 | + | const fn is_member(&self) -> bool { | |
| 146 | + | matches!(self, Self::Member { .. }) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Signed in, not paying: the one reader who can be sold to directly. | |
| 150 | + | const fn is_unsubscribed(&self) -> bool { | |
| 151 | + | matches!(self, Self::Unsubscribed) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// Nobody is signed in. | |
| 155 | + | const fn is_visitor(&self) -> bool { | |
| 156 | + | matches!(self, Self::Visitor) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// When the current billing period ends, or nothing. | |
| 160 | + | /// | |
| 161 | + | /// Empty rather than `None`, so the description asks one question and reads | |
| 162 | + | /// one answer instead of matching an `Option` it cannot spell a pattern for. | |
| 163 | + | fn period_end(&self) -> &str { | |
| 164 | + | match self { | |
| 165 | + | Self::Member { | |
| 166 | + | period_end: Some(end), | |
| 167 | + | } => end, | |
| 168 | + | _ => "", | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 109 | 173 | /// The page. | |
| 110 | 174 | pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> { | |
| 111 | 175 | // Moved out of the request rather than borrowed: the signature is quasi's, | |
| @@ -140,81 +204,66 @@ | |||
| 140 | 204 | }) | |
| 141 | 205 | } | |
| 142 | 206 | ||
| 143 | - | /// The whole document: the title, the measure, the body. | |
| 144 | - | fn page_screen(standing: &Standing, just_subscribed: bool) -> Described { | |
| 145 | - | let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page("Fan+")); | |
| 207 | + | declare! { | |
| 208 | + | /// The whole document: the title, the measure, the body. | |
| 209 | + | /// | |
| 210 | + | /// Three readers at one address, which is what the optional user bought: | |
| 211 | + | /// a visitor gets the pitch and both ways to get an account, a signed-in | |
| 212 | + | /// reader gets the pitch and the control that subscribes, and a member gets | |
| 213 | + | /// their membership's state and nothing to buy. | |
| 214 | + | shape page_screen(standing: &Standing, just_subscribed: bool) -> Screen; | |
| 146 | 215 | ||
| 147 | - | if just_subscribed { | |
| 148 | - | page = page.with(Node::banner( | |
| 149 | - | layout::Tone::Success, | |
| 150 | - | "You're now a Fan+ member. Welcome.", | |
| 151 | - | )); | |
| 216 | + | screen single "Fan+ - Makenotwork" { | |
| 217 | + | measured MEASURE; | |
| 218 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &["fan-plus-page"])); | |
| 219 | + | summarised "Support the platform and get $5 of credit back every month, plus forum badges, \ | |
| 220 | + | signatures and image embeds."; | |
| 221 | + | ||
| 222 | + | region PAGE_REGION as Pane { | |
| 223 | + | page "Fan+"; | |
| 224 | + | banner layout::Tone::Success "You're now a Fan+ member. Welcome." when just_subscribed; | |
| 225 | + | ||
| 226 | + | include membership(standing) when standing.is_member(); | |
| 227 | + | include pitch() unless standing.is_member(); | |
| 228 | + | ||
| 229 | + | act "Join Fan+" to post SUBSCRIBE awaiting when standing.is_unsubscribed(); | |
| 230 | + | include super::own_prose(VISITOR) when standing.is_visitor(); | |
| 231 | + | } | |
| 152 | 232 | } | |
| 153 | - | ||
| 154 | - | page = match standing { | |
| 155 | - | Standing::Member { period_end } => membership(page, period_end.as_deref()), | |
| 156 | - | Standing::Unsubscribed => { | |
| 157 | - | pitch(page).with(Node::act("Join Fan+", Action::post(SUBSCRIBE).awaiting())) | |
| 158 | - | } | |
| 159 | - | Standing::Visitor => { | |
| 160 | - | // Fan+ needs an account, so a visitor gets both paths: the one for | |
| 161 | - | // people who already have one and the one for people who do not. | |
| 162 | - | // Without the second this page is a dead end for exactly the | |
| 163 | - | // visitor it is written for (`loose-wire g1-23`, and | |
| 164 | - | // `fan_plus_page_renders_for_anonymous` is the seal). | |
| 165 | - | // | |
| 166 | - | // One sentence with two inline links, not two buttons. The sentence | |
| 167 | - | // says which link is for whom and a pair of buttons does not, and | |
| 168 | - | // that distinction is the whole point of the finding above. It is | |
| 169 | - | // prose, so it is `Node::rich`, on `/policy`'s rule. | |
| 170 | - | pitch(page).with(super::own_prose( | |
| 171 | - | "[Create an account](/join) to join, or [log in](/login) if you already have one.", | |
| 172 | - | )) | |
| 173 | - | } | |
| 174 | - | }; | |
| 175 | - | ||
| 176 | - | Described::single("Fan+ - Makenotwork") | |
| 177 | - | .measured(MEASURE) | |
| 178 | - | .documented( | |
| 179 | - | Document::default().classed(crate::shell::body_class(MEASURE, &["fan-plus-page"])), | |
| 180 | - | ) | |
| 181 | - | .summarised( | |
| 182 | - | "Support the platform and get $5 of credit back every month, plus forum badges, \ | |
| 183 | - | signatures and image embeds.", | |
| 184 | - | ) | |
| 185 | - | .with(page) | |
| 186 | 233 | } | |
| 187 | 234 | ||
| 188 | - | /// What a member is shown: the state of the thing they are paying for. | |
| 189 | - | fn membership(page: Slot, period_end: Option<&str>) -> Slot { | |
| 190 | - | let mut page = page.with(Node::text("Your Fan+ membership is active.")); | |
| 235 | + | declare! { | |
| 236 | + | /// What a member is shown: the state of the thing they are paying for. | |
| 237 | + | shape membership(standing: &Standing) -> Slot; | |
| 191 | 238 | ||
| 192 | - | if let Some(end) = period_end { | |
| 193 | - | page = page.with(Node::text(format!("Current period ends: {end}"))); | |
| 239 | + | region MEMBERSHIP as Group { | |
| 240 | + | text "Your Fan+ membership is active."; | |
| 241 | + | text "Current period ends: {standing.period_end()}" | |
| 242 | + | unless standing.period_end().is_empty(); | |
| 243 | + | text "You'll receive a $5 credit code each billing cycle via email."; | |
| 194 | 244 | } | |
| 195 | - | ||
| 196 | - | page.with(Node::text( | |
| 197 | - | "You'll receive a $5 credit code each billing cycle via email.", | |
| 198 | - | )) | |
| 199 | 245 | } | |
| 200 | 246 | ||
| 201 | - | /// What somebody who is not a member is shown, whether or not they have an | |
| 202 | - | /// account. The two branches differ only in what they are offered afterwards. | |
| 203 | - | fn pitch(page: Slot) -> Slot { | |
| 204 | - | page.with(Node::text( | |
| 205 | - | "Support the platform and get something back every month.", | |
| 206 | - | )) | |
| 207 | - | .with(Node::section("What you get")) | |
| 208 | - | .with(Node::list( | |
| 209 | - | BENEFITS | |
| 210 | - | .iter() | |
| 211 | - | .map(|(name, detail)| Row::new(*name).secondary(*detail)), | |
| 212 | - | )) | |
| 213 | - | .with(Node::stats([Figure::new(PRICE, "per month")])) | |
| 214 | - | .with(Node::text( | |
| 215 | - | "Makenotwork is built on 0% platform fees. Fan+ is how you directly support the \ | |
| 216 | - | platform's development and operations, while getting real value back each month.", | |
| 217 | - | )) | |
| 247 | + | declare! { | |
| 248 | + | /// What somebody who is not a member is shown, whether or not they have an | |
| 249 | + | /// account. The two branches differ only in what they are offered | |
| 250 | + | /// afterwards. | |
| 251 | + | shape pitch() -> Slot; | |
| 252 | + | ||
| 253 | + | region PITCH as Group { | |
| 254 | + | text "Support the platform and get something back every month."; | |
| 255 | + | section "What you get"; | |
| 256 | + | list { | |
| 257 | + | for benefit in BENEFITS { | |
| 258 | + | row benefit.name { | |
| 259 | + | secondary benefit.detail; | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | stats [Figure::new(PRICE, "per month")]; | |
| 264 | + | text "Makenotwork is built on 0% platform fees. Fan+ is how you directly support the \ | |
| 265 | + | platform's development and operations, while getting real value back each month."; | |
| 266 | + | } | |
| 218 | 267 | } | |
| 219 | 268 | ||
| 220 | 269 | /// The document this screen is drawn in. | |
| @@ -350,8 +399,8 @@ | |||
| 350 | 399 | let html = html(&Standing::Unsubscribed, false); | |
| 351 | 400 | ||
| 352 | 401 | assert_eq!(BENEFITS.len(), 4); | |
| 353 | - | for (name, _) in BENEFITS { | |
| 354 | - | assert!(html.contains(name), "{name} missing"); | |
| 402 | + | for benefit in BENEFITS { | |
| 403 | + | assert!(html.contains(benefit.name), "{} missing", benefit.name); | |
| 355 | 404 | } | |
| 356 | 405 | } | |
| 357 | 406 |
| @@ -29,10 +29,9 @@ | |||
| 29 | 29 | //! here, because the push instructions are not theirs to act on. | |
| 30 | 30 | ||
| 31 | 31 | use makeover_layout as layout; | |
| 32 | - | use quasi_router::screen::{Cell, Cells, Column, Table, Tag}; | |
| 33 | - | use quasi_router::{ | |
| 34 | - | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 35 | - | }; | |
| 32 | + | use quasi_declare::declare; | |
| 33 | + | use quasi_router::screen::Tag; | |
| 34 | + | use quasi_router::{Document, Request, Response, RouteError}; | |
| 36 | 35 | use quasi_webview::Webview; | |
| 37 | 36 | ||
| 38 | 37 | use crate::db; | |
| @@ -45,6 +44,9 @@ | |||
| 45 | 44 | ||
| 46 | 45 | const MEASURE: layout::Measure = layout::Measure::Wide; | |
| 47 | 46 | ||
| 47 | + | /// The region an account with nothing published is drawn in. | |
| 48 | + | const EMPTY: &str = "git-repos-empty"; | |
| 49 | + | ||
| 48 | 50 | /// Everything the screen draws, resolved before it is drawn. | |
| 49 | 51 | struct Loaded { | |
| 50 | 52 | owner: String, | |
| @@ -61,6 +63,16 @@ | |||
| 61 | 63 | visibility: Option<String>, | |
| 62 | 64 | } | |
| 63 | 65 | ||
| 66 | + | impl Repo { | |
| 67 | + | /// What the visibility cell says, or the empty string. | |
| 68 | + | /// | |
| 69 | + | /// The cell is guarded on the `Option` and this reads it, because a | |
| 70 | + | /// description reads a value and does not bind one out of a pattern. | |
| 71 | + | fn visibility_label(&self) -> &str { | |
| 72 | + | self.visibility.as_deref().unwrap_or_default() | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 64 | 76 | /// The page. | |
| 65 | 77 | pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> { | |
| 66 | 78 | // Moved out because the handler signature is quasi's: the request is | |
| @@ -115,88 +127,87 @@ | |||
| 115 | 127 | }) | |
| 116 | 128 | } | |
| 117 | 129 | ||
| 118 | - | /// The whole document: the title, the measure, the body. | |
| 119 | - | fn page_screen(loaded: &Loaded) -> Described { | |
| 120 | - | let heading = format!("{}'s Repositories", loaded.owner); | |
| 130 | + | declare! { | |
| 131 | + | /// The whole document: the title, the measure, the body. | |
| 132 | + | shape page_screen(loaded: &Loaded) -> Screen; | |
| 121 | 133 | ||
| 122 | - | let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page(heading.clone())); | |
| 134 | + | let heading = "{loaded.owner}'s Repositories"; | |
| 123 | 135 | ||
| 124 | - | page = if loaded.repos.is_empty() { | |
| 125 | - | empty(page, loaded.is_owner, &loaded.owner) | |
| 126 | - | } else { | |
| 127 | - | page.with(listing(loaded)) | |
| 128 | - | }; | |
| 136 | + | screen single "{heading} - Git - Makenotwork" { | |
| 137 | + | measured MEASURE; | |
| 138 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 129 | 139 | ||
| 130 | - | Described::single(format!("{heading} - Git - Makenotwork")) | |
| 131 | - | .measured(MEASURE) | |
| 132 | - | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) | |
| 133 | - | .with(page) | |
| 140 | + | region PAGE_REGION as Pane { | |
| 141 | + | page heading.clone(); | |
| 142 | + | include empty(loaded.is_owner, &loaded.owner) when loaded.repos.is_empty(); | |
| 143 | + | include listing(loaded) unless loaded.repos.is_empty(); | |
| 144 | + | } | |
| 145 | + | } | |
| 134 | 146 | } | |
| 135 | 147 | ||
| 136 | - | /// An account with nothing published. | |
| 137 | - | /// | |
| 138 | - | /// The owner gets the two commands that fix it; a visitor gets the fact. The | |
| 139 | - | /// shipped template made the same split and it is worth keeping: `git remote | |
| 140 | - | /// add` is not advice a stranger can take. | |
| 141 | - | fn empty(page: Slot, is_owner: bool, owner: &str) -> Slot { | |
| 142 | - | let page = page.with(Node::empty("No repositories yet.")); | |
| 148 | + | declare! { | |
| 149 | + | /// An account with nothing published. | |
| 150 | + | /// | |
| 151 | + | /// The owner gets the two commands that fix it; a visitor gets the fact. | |
| 152 | + | /// The shipped template made the same split and it is worth keeping: `git | |
| 153 | + | /// remote add` is not advice a stranger can take. | |
| 154 | + | /// | |
| 155 | + | /// `own_prose` rather than `rich`, and the interpolation is the reason to | |
| 156 | + | /// say why: `owner` is a `Username`, which `validate_username` restricts to | |
| 157 | + | /// letters, digits and underscores, so it cannot carry markup into a source | |
| 158 | + | /// the renderer no longer hardens. A value that could would want `rich` | |
| 159 | + | /// instead, whatever else is in the string. | |
| 160 | + | shape empty(is_owner: bool, owner: &str) -> Slot; | |
| 143 | 161 | ||
| 144 | - | if !is_owner { | |
| 145 | - | return page; | |
| 162 | + | region EMPTY as Group { | |
| 163 | + | empty "No repositories yet."; | |
| 164 | + | include super::own_prose( | |
| 165 | + | "Push a new repository:\n\n```\ngit remote add origin \ | |
| 166 | + | https://makenot.work/git/{owner}/my-repo.git\ngit push -u origin main\n```" | |
| 167 | + | ) when is_owner; | |
| 146 | 168 | } | |
| 147 | - | ||
| 148 | - | // `own_prose` rather than `Node::rich`, and the interpolation is the reason | |
| 149 | - | // to say why: `owner` is a `Username`, which `validate_username` restricts | |
| 150 | - | // to letters, digits and underscores, so it cannot carry markup into a | |
| 151 | - | // source the renderer no longer hardens. A value that could would want | |
| 152 | - | // `Node::rich` instead, whatever else is in the string. | |
| 153 | - | page.with(super::own_prose(format!( | |
| 154 | - | "Push a new repository:\n\ | |
| 155 | - | \n\ | |
| 156 | - | ```\n\ | |
| 157 | - | git remote add origin https://makenot.work/git/{owner}/my-repo.git\n\ | |
| 158 | - | git push -u origin main\n\ | |
| 159 | - | ```" | |
| 160 | - | ))) | |
| 161 | 169 | } | |
| 162 | 170 | ||
| 163 | - | /// The repositories, as a table. | |
| 164 | - | /// | |
| 165 | - | /// The template drew a `<ul>` of two-line entries. As a table the description | |
| 166 | - | /// says which column carries the identity and which can be dropped on a narrow | |
| 167 | - | /// viewport, rather than leaving a stack of divs to wrap however it wraps. | |
| 168 | - | fn listing(loaded: &Loaded) -> Node { | |
| 169 | - | let mut columns = vec![ | |
| 170 | - | Column::new("Repository") | |
| 171 | - | .width(layout::Width::Content) | |
| 172 | - | .priority(layout::Priority::Essential), | |
| 173 | - | Column::new("Description").width(layout::Width::Fill), | |
| 174 | - | ]; | |
| 175 | - | // Nobody but the owner is shown a visibility column, because for everybody | |
| 176 | - | // else every row in it would say the same word. | |
| 177 | - | if loaded.is_owner { | |
| 178 | - | columns.push(Column::new("Visibility").width(layout::Width::Content)); | |
| 179 | - | } | |
| 171 | + | declare! { | |
| 172 | + | /// The repositories, as a table. | |
| 173 | + | /// | |
| 174 | + | /// The template drew a `<ul>` of two-line entries. As a table the | |
| 175 | + | /// description says which column carries the identity and which can be | |
| 176 | + | /// dropped on a narrow viewport, rather than leaving a stack of divs to wrap | |
| 177 | + | /// however it wraps. | |
| 178 | + | /// | |
| 179 | + | /// Nobody but the owner is shown a visibility column, because for everybody | |
| 180 | + | /// else every row in it would say the same word. The visibility cell names | |
| 181 | + | /// its column rather than counting to it, so the guard above is the only | |
| 182 | + | /// place ownership is decided: built by position, this row and that column | |
| 183 | + | /// list were two conditionals that had to agree, and nothing checked that | |
| 184 | + | /// they did. | |
| 185 | + | shape listing(loaded: &Loaded) -> Node; | |
| 180 | 186 | ||
| 181 | - | // The visibility cell names its column rather than counting to it, so the | |
| 182 | - | // conditional above is the only place ownership is decided. Built by | |
| 183 | - | // position, this row and that column list were two conditionals that had to | |
| 184 | - | // agree, and nothing checked that they did. | |
| 185 | - | Table::new(columns) | |
| 186 | - | .rows(loaded.repos.iter().map(|repo| { | |
| 187 | - | let visibility = repo | |
| 188 | - | .visibility | |
| 189 | - | .as_deref() | |
| 190 | - | .map(|visibility| Cell::new(String::new()).token(Tag::badge(visibility))); | |
| 191 | - | let mut row = Cells::default() | |
| 192 | - | .at("Repository", Cell::new(repo.name.clone())) | |
| 193 | - | .at("Description", Cell::new(repo.description.clone())); | |
| 194 | - | if let Some(cell) = visibility { | |
| 195 | - | row = row.at("Visibility", cell); | |
| 187 | + | table { | |
| 188 | + | column "Repository" { | |
| 189 | + | width Content; | |
| 190 | + | priority Essential; | |
| 191 | + | } | |
| 192 | + | column "Description" { | |
| 193 | + | width Fill; | |
| 194 | + | } | |
| 195 | + | column "Visibility" when loaded.is_owner { | |
| 196 | + | width Content; | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | for repo in loaded.repos.iter() { | |
| 200 | + | cells { | |
| 201 | + | cell at "Repository" repo.name.clone(); | |
| 202 | + | cell at "Description" repo.description.clone(); | |
| 203 | + | cell at "Visibility" "" when repo.visibility.is_some() { | |
| 204 | + | token Tag::badge(repo.visibility_label()); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | activate to get "/git/{loaded.owner}/{repo.name}" navigating; | |
| 196 | 208 | } | |
| 197 | - | row.activate(Action::get(format!("/git/{}/{}", loaded.owner, repo.name)).navigating()) | |
| 198 | - | })) | |
| 199 | - | .into() | |
| 209 | + | } | |
| 210 | + | } | |
| 200 | 211 | } | |
| 201 | 212 | ||
| 202 | 213 | /// The document this screen is drawn in. |
| @@ -169,8 +169,9 @@ | |||
| 169 | 169 | //! [`Action::replacing_enclosing`]: quasi_router::Action::replacing_enclosing | |
| 170 | 170 | ||
| 171 | 171 | use makeover_layout as layout; | |
| 172 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Table, Tag}; | |
| 173 | - | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 172 | + | use quasi_declare::declare; | |
| 173 | + | use quasi_router::RegionKind; | |
| 174 | + | use quasi_router::screen::Tag; | |
| 174 | 175 | use quasi_webview::Webview; | |
| 175 | 176 | ||
| 176 | 177 | use crate::types::Version; | |
| @@ -202,50 +203,61 @@ | |||
| 202 | 203 | .fragment(&pane(item_id, versions)) | |
| 203 | 204 | } | |
| 204 | 205 | ||
| 205 | - | /// The panel in its region. | |
| 206 | - | fn pane(item_id: &str, versions: &[Version]) -> Node { | |
| 207 | - | let mut slot = Slot::new(REGION, RegionKind::Pane).with(Node::section("Files & Versions")); | |
| 206 | + | declare! { | |
| 207 | + | /// The panel in its region. | |
| 208 | + | /// | |
| 209 | + | /// The upload machine sits below the table on an empty panel too, which is | |
| 210 | + | /// what "create your first version below" points at. | |
| 211 | + | shape pane(item_id: &str, versions: &[Version]) -> Node; | |
| 208 | 212 | ||
| 209 | - | if versions.is_empty() { | |
| 210 | - | slot = slot.with(Node::empty( | |
| 211 | - | "No versions uploaded yet. Create your first version below.", | |
| 212 | - | )); | |
| 213 | - | } else { | |
| 214 | - | slot = slot.with(table(item_id, versions)); | |
| 213 | + | region REGION as Pane { | |
| 214 | + | section "Files & Versions"; | |
| 215 | + | empty "No versions uploaded yet. Create your first version below." | |
| 216 | + | when versions.is_empty(); | |
| 217 | + | include table(item_id, versions) unless versions.is_empty(); | |
| 218 | + | ||
| 219 | + | region UPLOADER_SLOT as RegionKind::handover("version-upload") {} | |
| 215 | 220 | } | |
| 216 | - | ||
| 217 | - | // The upload machine, whatever state it is in. Below the table on an empty | |
| 218 | - | // panel too, which is what "create your first version below" points at. | |
| 219 | - | slot = slot.with(Node::Region(Slot::handover( | |
| 220 | - | UPLOADER_SLOT, | |
| 221 | - | "version-upload", | |
| 222 | - | ))); | |
| 223 | - | ||
| 224 | - | Node::Region(slot) | |
| 225 | 221 | } | |
| 226 | 222 | ||
| 227 | - | /// The versions that exist. | |
| 228 | - | /// | |
| 229 | - | /// The column list lives here and the cells live in [`row`], a function down, | |
| 230 | - | /// so the cells name their columns rather than counting to them: position is | |
| 231 | - | /// only safe while both halves are in front of you at once, and these two are | |
| 232 | - | /// not. The heading strings below are the whole of what [`row`] has to match. | |
| 233 | - | /// | |
| 234 | - | /// No `.more(..)`: every version of an item arrives in one query, so nothing is | |
| 235 | - | /// held back and there is no rest to ask for. | |
| 236 | - | fn table(item_id: &str, versions: &[Version]) -> Node { | |
| 237 | - | Table::new([ | |
| 238 | - | Column::new(COL_VERSION).width(layout::Width::Content), | |
| 239 | - | Column::new(COL_LABEL).width(layout::Width::Fill), | |
| 240 | - | Column::new(COL_FILE) | |
| 241 | - | .width(layout::Width::Fill) | |
| 242 | - | .priority(layout::Priority::Essential), | |
| 243 | - | Column::new(COL_SIZE).width(layout::Width::Content), | |
| 244 | - | Column::new(COL_DOWNLOADS).width(layout::Width::Content), | |
| 245 | - | Column::new(COL_ACTS).width(layout::Width::Content), | |
| 246 | - | ]) | |
| 247 | - | .rows(versions.iter().map(|version| row(item_id, version))) | |
| 248 | - | .into() | |
| 223 | + | declare! { | |
| 224 | + | /// The versions that exist. | |
| 225 | + | /// | |
| 226 | + | /// The column list lives here and the cells live in [`row`], a shape down, | |
| 227 | + | /// so the cells name their columns rather than counting to them: position is | |
| 228 | + | /// only safe while both halves are in front of you at once, and these two | |
| 229 | + | /// are not. The heading strings below are the whole of what [`row`] has to | |
| 230 | + | /// match. | |
| 231 | + | /// | |
| 232 | + | /// Nothing held back: every version of an item arrives in one query, so | |
| 233 | + | /// there is no rest to ask for. | |
| 234 | + | shape table(item_id: &str, versions: &[Version]) -> Node; | |
| 235 | + | ||
| 236 | + | table { | |
| 237 | + | column COL_VERSION { | |
| 238 | + | width Content; | |
| 239 | + | } | |
| 240 | + | column COL_LABEL { | |
| 241 | + | width Fill; | |
| 242 | + | } | |
| 243 | + | column COL_FILE { | |
| 244 | + | width Fill; | |
| 245 | + | priority Essential; | |
| 246 | + | } | |
| 247 | + | column COL_SIZE { | |
| 248 | + | width Content; | |
| 249 | + | } | |
| 250 | + | column COL_DOWNLOADS { | |
| 251 | + | width Content; | |
| 252 | + | } | |
| 253 | + | column COL_ACTS { | |
| 254 | + | width Content; | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | for version in versions.iter() { | |
| 258 | + | include row(item_id, version); | |
| 259 | + | } | |
| 260 | + | } | |
| 249 | 261 | } | |
| 250 | 262 | ||
| 251 | 263 | /// The headings, written once. | |
| @@ -262,46 +274,52 @@ | |||
| 262 | 274 | const COL_DOWNLOADS: &str = "Downloads"; | |
| 263 | 275 | const COL_ACTS: &str = ""; | |
| 264 | 276 | ||
| 265 | - | /// One version. | |
| 266 | - | fn row(item_id: &str, version: &Version) -> Cells { | |
| 267 | - | let mut number = Tag::badge(format!("v{}", version.number)); | |
| 277 | + | /// The current version's badge is the one that reads as a state rather than a | |
| 278 | + | /// number. | |
| 279 | + | fn number_tone(version: &Version) -> layout::Tone { | |
| 268 | 280 | if version.is_current { | |
| 269 | - | number.tone = layout::Tone::Success; | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | // What the template said in a three-way `{% if %}`/`{% match %}`: the | |
| 273 | - | // file's name when it has one, "1 file" when it has a file the name of | |
| 274 | - | // which was not recorded, and "No file" when it has none. | |
| 275 | - | let file = if version.has_file { | |
| 276 | - | version | |
| 277 | - | .file_name | |
| 278 | - | .clone() | |
| 279 | - | .unwrap_or_else(|| "1 file".to_string()) | |
| 281 | + | layout::Tone::Success | |
| 280 | 282 | } else { | |
| 281 | - | "No file".to_string() | |
| 282 | - | }; | |
| 283 | - | ||
| 284 | - | let mut acts = Vec::new(); | |
| 285 | - | if version.has_file { | |
| 286 | - | // `9dbe9206`. The route answers 303 to the presigned URL, so going | |
| 287 | - | // there is the download and no host code has to know that. | |
| 288 | - | acts.push(Act::new( | |
| 289 | - | "Download", | |
| 290 | - | Action::get(format!("/api/versions/{}/download", version.id)), | |
| 291 | - | )); | |
| 283 | + | layout::Tone::Neutral | |
| 292 | 284 | } | |
| 293 | - | acts.push(super::version_delete_act::act(item_id, &version.id)); | |
| 285 | + | } | |
| 294 | 286 | ||
| 295 | - | Cells::default() | |
| 296 | - | .at(COL_VERSION, Cell::new(String::new()).token(number)) | |
| 297 | - | .at( | |
| 298 | - | COL_LABEL, | |
| 299 | - | Cell::new(version.label.clone().unwrap_or_default()), | |
| 300 | - | ) | |
| 301 | - | .at(COL_FILE, Cell::new(file)) | |
| 302 | - | .at(COL_SIZE, Cell::new(version.size.clone())) | |
| 303 | - | .at(COL_DOWNLOADS, Cell::new(version.downloads.to_string())) | |
| 304 | - | .at(COL_ACTS, Cell::acts(acts)) | |
| 287 | + | /// What the file cell says. | |
| 288 | + | /// | |
| 289 | + | /// The template's three-way `{% if %}`/`{% match %}`: the file's name when it | |
| 290 | + | /// has one, "1 file" when it has a file the name of which was not recorded, and | |
| 291 | + | /// "No file" when it has none. A supplier because the middle case is a fallback | |
| 292 | + | /// computed from an `Option`, which the form has no expression to hold. | |
| 293 | + | fn file(version: &Version) -> String { | |
| 294 | + | if !version.has_file { | |
| 295 | + | return "No file".to_string(); | |
| 296 | + | } | |
| 297 | + | version | |
| 298 | + | .file_name | |
| 299 | + | .clone() | |
| 300 | + | .unwrap_or_else(|| "1 file".to_string()) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | declare! { | |
| 304 | + | /// One version. | |
| 305 | + | shape row(item_id: &str, version: &Version) -> Cells; | |
| 306 | + | ||
| 307 | + | cells { | |
| 308 | + | cell at COL_VERSION "" { | |
| 309 | + | token Tag::badge("v{version.number}").tone(number_tone(version)); | |
| 310 | + | } | |
| 311 | + | cell at COL_LABEL version.label.clone().unwrap_or_default(); | |
| 312 | + | cell at COL_FILE file(version); | |
| 313 | + | cell at COL_SIZE version.size.clone(); | |
| 314 | + | cell at COL_DOWNLOADS version.downloads.to_string(); | |
| 315 | + | cell at COL_ACTS "" { | |
| 316 | + | // `9dbe9206`. The route answers 303 to the presigned URL, so going | |
| 317 | + | // there is the download and no host code has to know that. | |
| 318 | + | act "Download" to get "/api/versions/{version.id}/download" | |
| 319 | + | when version.has_file; | |
| 320 | + | include super::version_delete_act::act(item_id, &version.id); | |
| 321 | + | } | |
| 322 | + | } | |
| 305 | 323 | } | |
| 306 | 324 | ||
| 307 | 325 | #[cfg(test)] |
| @@ -37,9 +37,9 @@ | |||
| 37 | 37 | //! source rather than a described list: a terminal renders the same three lines | |
| 38 | 38 | //! without being handed `<ul>`. | |
| 39 | 39 | ||
| 40 | - | use makeover_layout as layout; | |
| 41 | - | use quasi_router::screen::{Choice, Field}; | |
| 42 | - | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 40 | + | use quasi_declare::declare; | |
| 41 | + | use quasi_router::screen::Choice; | |
| 42 | + | use quasi_router::{Node, RegionKind, Slot}; | |
| 43 | 43 | use quasi_webview::Webview; | |
| 44 | 44 | ||
| 45 | 45 | /// The region the answer replaces, keeping the id the page already used. | |
| @@ -52,6 +52,17 @@ | |||
| 52 | 52 | /// empty until something is submitted. | |
| 53 | 53 | const RESULT: &str = "support-result"; | |
| 54 | 54 | ||
| 55 | + | /// How long an answer takes, and the way round the queue for a security issue. | |
| 56 | + | /// | |
| 57 | + | /// Prose. It is three durations and a mailto, not a set of things with | |
| 58 | + | /// addresses, so it is markdown carrying the source rather than a described | |
| 59 | + | /// list: a terminal renders the same three lines without being handed a `<ul>`. | |
| 60 | + | const RESPONSE_TIMES: &str = "Response times:\n\n\ | |
| 61 | + | - Security issues: same day\n\ | |
| 62 | + | - Billing and account access: 24 hours\n\ | |
| 63 | + | - Everything else: 1-2 business days\n\n\ | |
| 64 | + | For urgent security issues, email <security@makenot.work> directly."; | |
| 65 | + | ||
| 55 | 66 | /// The panel as the route answers it: the region, carrying its own id. | |
| 56 | 67 | #[must_use] | |
| 57 | 68 | pub fn fragment(email: &str) -> String { | |
| @@ -76,99 +87,74 @@ | |||
| 76 | 87 | out | |
| 77 | 88 | } | |
| 78 | 89 | ||
| 79 | - | /// The panel's contents, in order. | |
| 80 | - | fn body(email: &str) -> Vec<Node> { | |
| 81 | - | vec![ | |
| 82 | - | Node::section("Support"), | |
| 83 | - | Node::text( | |
| 84 | - | "Every response is from a real person. We will never use AI to handle your questions.", | |
| 85 | - | ), | |
| 86 | - | Node::text(format!( | |
| 87 | - | "We monitor the platform proactively and may have already opened a ticket for your \ | |
| 88 | - | issue. Check your email at {email} before submitting." | |
| 89 | - | )), | |
| 90 | - | ticket_form(), | |
| 91 | - | // Where the answer lands. Named by the form's own action, and empty | |
| 92 | - | // until something is submitted. | |
| 93 | - | Node::Region(Slot::new(RESULT, RegionKind::Pane)), | |
| 94 | - | response_times(), | |
| 95 | - | ] | |
| 90 | + | declare! { | |
| 91 | + | /// The panel's contents, in order. | |
| 92 | + | /// | |
| 93 | + | /// A list of members and not a region: the dashboard strip draws the frame | |
| 94 | + | /// and its `id`, so [`fill`] must add no second one, and [`fragment`] wraps | |
| 95 | + | /// the same members itself for the tab that answers over htmx. | |
| 96 | + | shape body(email: &str) -> Vec<Node>; | |
| 97 | + | ||
| 98 | + | section "Support"; | |
| 99 | + | text "Every response is from a real person. We will never use AI to handle your questions."; | |
| 100 | + | text "We monitor the platform proactively and may have already opened a ticket for your \ | |
| 101 | + | issue. Check your email at {email} before submitting."; | |
| 102 | + | ||
| 103 | + | include ticket_form(); | |
| 104 | + | ||
| 105 | + | // Where the answer lands. Named by the form's own action, and empty until | |
| 106 | + | // something is submitted. | |
| 107 | + | region RESULT as Pane {} | |
| 108 | + | ||
| 109 | + | include super::own_prose(RESPONSE_TIMES); | |
| 96 | 110 | } | |
| 97 | 111 | ||
| 98 | - | /// The ticket form. | |
| 99 | - | fn ticket_form() -> Node { | |
| 100 | - | Node::Form { | |
| 101 | - | // `awaiting` is the whole of what `hx-indicator="#support-spinner"` and | |
| 102 | - | // the span it pointed at were doing. See the module header. | |
| 103 | - | action: Action::post(TICKET).awaiting(), | |
| 104 | - | submit: "Submit".into(), | |
| 105 | - | fields: vec![ | |
| 106 | - | Field::select("category", "Category", categories()).required(), | |
| 107 | - | length( | |
| 108 | - | placeholder( | |
| 109 | - | Field::new(layout::FieldKind::Text, "subject", "Subject").required(), | |
| 110 | - | "Brief description of the issue", | |
| 111 | - | ), | |
| 112 | - | 200, | |
| 113 | - | ), | |
| 114 | - | length( | |
| 115 | - | placeholder( | |
| 116 | - | Field::new(layout::FieldKind::Textarea, "message", "Message").required(), | |
| 117 | - | "What happened? What did you expect? Include any relevant details.", | |
| 118 | - | ), | |
| 119 | - | 5000, | |
| 120 | - | ), | |
| 121 | - | ], | |
| 112 | + | declare! { | |
| 113 | + | /// The ticket form. | |
| 114 | + | /// | |
| 115 | + | /// `awaiting` is the whole of what `hx-indicator="#support-spinner"` and the | |
| 116 | + | /// span it pointed at were doing. See the module header. | |
| 117 | + | /// | |
| 118 | + | /// The category select opened with `<option value="" disabled selected>`, a | |
| 119 | + | /// markup idiom for "nothing chosen yet" that a description says by the | |
| 120 | + | /// field simply having no value. Six real options remain, and a renderer | |
| 121 | + | /// that is not a browser no longer has to know that one of the seven was a | |
| 122 | + | /// placeholder. | |
| 123 | + | /// | |
| 124 | + | /// The two lengths are the same limits the API validates against, and a | |
| 125 | + | /// form that lets a reader type 6000 characters before refusing them is | |
| 126 | + | /// worse than one that stops at 5000. | |
| 127 | + | shape ticket_form() -> Node; | |
| 128 | + | ||
| 129 | + | form post TICKET awaiting { | |
| 130 | + | submit "Submit"; | |
| 131 | + | ||
| 132 | + | field Select "category" "Category" { | |
| 133 | + | options [ | |
| 134 | + | Choice::new("bug", "Bug report"), | |
| 135 | + | Choice::new("billing", "Billing or payments"), | |
| 136 | + | Choice::new("account", "Account access"), | |
| 137 | + | Choice::new("content", "Content or uploads"), | |
| 138 | + | Choice::new("security", "Security concern"), | |
| 139 | + | Choice::new("other", "Other"), | |
| 140 | + | ]; | |
| 141 | + | required; | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | field Text "subject" "Subject" { | |
| 145 | + | required; | |
| 146 | + | placeholder "Brief description of the issue"; | |
| 147 | + | limited_to 200; | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | field Textarea "message" "Message" { | |
| 151 | + | required; | |
| 152 | + | placeholder "What happened? What did you expect? Include any relevant details."; | |
| 153 | + | limited_to 5000; | |
| 154 | + | } | |
| 122 | 155 | } | |
| 123 | 156 | } | |
| 124 | 157 | ||
| 125 | - | /// What a ticket can be about. | |
| 126 | - | /// | |
| 127 | - | /// The template's seventh option was `value=""`, disabled and selected: the | |
| 128 | - | /// markup spelling of "nothing chosen yet". A field with no value says it, so | |
| 129 | - | /// only the six real categories are here. | |
| 130 | - | fn categories() -> Vec<Choice> { | |
| 131 | - | [ | |
| 132 | - | ("bug", "Bug report"), | |
| 133 | - | ("billing", "Billing or payments"), | |
| 134 | - | ("account", "Account access"), | |
| 135 | - | ("content", "Content or uploads"), | |
| 136 | - | ("security", "Security concern"), | |
| 137 | - | ("other", "Other"), | |
| 138 | - | ] | |
| 139 | - | .into_iter() | |
| 140 | - | .map(|(value, label)| Choice::new(value, label)) | |
| 141 | - | .collect() | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | /// How long an answer takes, and the way round the queue for a security issue. | |
| 145 | - | fn response_times() -> Node { | |
| 146 | - | super::own_prose( | |
| 147 | - | "Response times:\n\n\ | |
| 148 | - | - Security issues: same day\n\ | |
| 149 | - | - Billing and account access: 24 hours\n\ | |
| 150 | - | - Everything else: 1-2 business days\n\n\ | |
| 151 | - | For urgent security issues, email <security@makenot.work> directly.", | |
| 152 | - | ) | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | /// Set a field's placeholder. A plain field assignment, since `Field` has no | |
| 156 | - | /// builder for it and the field is public. | |
| 157 | - | fn placeholder(mut field: Field, text: &str) -> Field { | |
| 158 | - | field.placeholder = Some(text.to_owned()); | |
| 159 | - | field | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | /// Cap what a field accepts, matching the template's `maxlength`. | |
| 163 | - | /// | |
| 164 | - | /// Kept rather than dropped: it is the same limit the API validates against, and | |
| 165 | - | /// a form that lets a reader type 6000 characters before refusing them is worse | |
| 166 | - | /// than one that stops at 5000. | |
| 167 | - | fn length(mut field: Field, max: u32) -> Field { | |
| 168 | - | field.max_length = Some(max); | |
| 169 | - | field | |
| 170 | - | } | |
| 171 | - | ||
| 172 | 158 | #[cfg(test)] | |
| 173 | 159 | mod tests { | |
| 174 | 160 | use super::*; |
| @@ -28,9 +28,8 @@ | |||
| 28 | 28 | //! reading, and the shipped rule set `font-family: var(--font-mono)` on it for | |
| 29 | 29 | //! the same reason without being able to say so. | |
| 30 | 30 | ||
| 31 | - | use makeover_layout as layout; | |
| 32 | - | use quasi_router::screen::Lexeme; | |
| 33 | - | use quasi_router::{Node, RegionKind, Run, Slot}; | |
| 31 | + | use quasi_declare::declare; | |
| 32 | + | use quasi_router::RegionKind; | |
| 34 | 33 | use quasi_webview::Webview; | |
| 35 | 34 | ||
| 36 | 35 | use crate::routes::git::notes_view::CommitNote; | |
| @@ -61,22 +60,20 @@ | |||
| 61 | 60 | .collect() | |
| 62 | 61 | } | |
| 63 | 62 | ||
| 64 | - | /// The panel, for a screen that owns its whole document. | |
| 65 | - | /// | |
| 66 | - | /// `None` when there is nothing to show. The shipped partial wrapped itself in | |
| 67 | - | /// `{% if !notes.is_empty() %}`, and an empty panel here would be a bordered | |
| 68 | - | /// box with nothing in it rather than the absence the page wants. | |
| 69 | - | #[must_use] | |
| 70 | - | pub fn region(notes: &[CommitNote]) -> Option<Node> { | |
| 71 | - | if notes.is_empty() { | |
| 72 | - | return None; | |
| 73 | - | } | |
| 63 | + | declare! { | |
| 64 | + | /// The panel, for a screen that owns its whole document. | |
| 65 | + | /// | |
| 66 | + | /// `None` when there is nothing to show. The shipped partial wrapped itself | |
| 67 | + | /// in `{{% if !notes.is_empty() %}}`, and an empty panel here would be a | |
| 68 | + | /// bordered box with nothing in it rather than the absence the page wants. | |
| 69 | + | #[must_use] | |
| 70 | + | pub shape region(notes: &[CommitNote]) -> Option<Node>; | |
| 74 | 71 | ||
| 75 | - | let mut panel = Slot::new(REGION, RegionKind::Group); | |
| 76 | - | for note in notes { | |
| 77 | - | panel = panel.with(Node::Region(one(note))); | |
| 72 | + | region REGION as Group unless notes.is_empty() { | |
| 73 | + | for note in notes { | |
| 74 | + | include one(note); | |
| 75 | + | } | |
| 78 | 76 | } | |
| 79 | - | Some(Node::Region(panel)) | |
| 80 | 77 | } | |
| 81 | 78 | ||
| 82 | 79 | /// The markup, for an Askama template to drop in. | |
| @@ -120,65 +117,50 @@ | |||
| 120 | 117 | webview | |
| 121 | 118 | } | |
| 122 | 119 | ||
| 123 | - | /// One note: what it is, where it came from, and what it says. | |
| 124 | - | fn one(note: &CommitNote) -> Slot { | |
| 125 | - | // The header is one line: a name on the left and a provenance on the | |
| 126 | - | // right. Wrapping keeps both when there is no room for one line, which | |
| 127 | - | // is what the shipped rule's `flex-wrap` did. | |
| 128 | - | let mut header = Run::new(layout::Fallback::Wrap).beside( | |
| 129 | - | Node::Code { | |
| 130 | - | runs: vec![Lexeme::plain(¬e.namespace)], | |
| 131 | - | language: None, | |
| 132 | - | inline: true, | |
| 133 | - | }, | |
| 134 | - | layout::Priority::Essential, | |
| 135 | - | ); | |
| 136 | - | ||
| 137 | - | if note.read_only { | |
| 138 | - | // Says where it came from, so a note nobody on the repo wrote does not | |
| 139 | - | // read as one of theirs they have forgotten. | |
| 140 | - | header = header.beside( | |
| 141 | - | Node::Text { | |
| 142 | - | text: "written by makenot.work".to_owned(), | |
| 143 | - | tone: layout::Tone::Neutral, | |
| 144 | - | }, | |
| 145 | - | layout::Priority::Secondary, | |
| 146 | - | ); | |
| 120 | + | /// Who last edited the note, or the bound the walk stopped at. | |
| 121 | + | /// | |
| 122 | + | /// A supplier because the line is decided by `exact`, which is a field inside | |
| 123 | + | /// an `Option` the form has no binding pattern to reach. Empty when there is no | |
| 124 | + | /// attribution, which is what R9 asks of a supplier: the value is built whether | |
| 125 | + | /// or not the guard places it. | |
| 126 | + | /// | |
| 127 | + | /// `exact` is the load-bearing field. When the walk hit its budget the commit | |
| 128 | + | /// it stopped at is not the one that made the edit, so the line names a bound | |
| 129 | + | /// and never a person. | |
| 130 | + | fn attribution(note: &CommitNote) -> String { | |
| 131 | + | let Some(attribution) = ¬e.attribution else { | |
| 132 | + | return String::new(); | |
| 133 | + | }; | |
| 134 | + | if attribution.exact { | |
| 135 | + | format!("{} - {}", attribution.by, attribution.when) | |
| 136 | + | } else { | |
| 137 | + | format!( | |
| 138 | + | "edited since {} - {}", | |
| 139 | + | attribution.short_commit, attribution.when | |
| 140 | + | ) | |
| 147 | 141 | } | |
| 142 | + | } | |
| 148 | 143 | ||
| 149 | - | if let Some(attribution) = ¬e.attribution { | |
| 150 | - | // `exact` is the load-bearing field. When the walk hit its budget the | |
| 151 | - | // commit it stopped at is not the one that made the edit, so the line | |
| 152 | - | // names a bound and never a person. | |
| 153 | - | let text = if attribution.exact { | |
| 154 | - | format!("{} - {}", attribution.by, attribution.when) | |
| 155 | - | } else { | |
| 156 | - | format!( | |
| 157 | - | "edited since {} - {}", | |
| 158 | - | attribution.short_commit, attribution.when | |
| 159 | - | ) | |
| 160 | - | }; | |
| 161 | - | header = header.beside( | |
| 162 | - | Node::Text { | |
| 163 | - | text, | |
| 164 | - | tone: layout::Tone::Neutral, | |
| 165 | - | }, | |
| 166 | - | layout::Priority::Secondary, | |
| 167 | - | ); | |
| 144 | + | declare! { | |
| 145 | + | /// One note: what it is, where it came from, and what it says. | |
| 146 | + | /// | |
| 147 | + | /// The header is one line: a name on the left and a provenance on the | |
| 148 | + | /// right. Wrapping keeps both when there is no room for one line, which is | |
| 149 | + | /// what the shipped rule's `flex-wrap` did. A note nobody on the repo wrote | |
| 150 | + | /// says so, so it does not read as one of theirs they have forgotten. | |
| 151 | + | shape one(note: &CommitNote) -> Slot; | |
| 152 | + | ||
| 153 | + | region "note-{slug(¬e.namespace)}" as Group { | |
| 154 | + | region "note-header-{slug(¬e.namespace)}" as Group { | |
| 155 | + | across Wrap { | |
| 156 | + | beside Essential literal ¬e.namespace; | |
| 157 | + | beside Secondary text "written by makenot.work" when note.read_only; | |
| 158 | + | beside Secondary text attribution(note) when note.attribution.is_some(); | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | region body_region(¬e.namespace) as RegionKind::handover("a rendered note") {} | |
| 168 | 163 | } | |
| 169 | - | ||
| 170 | - | Slot::new(format!("note-{}", slug(¬e.namespace)), RegionKind::Group) | |
| 171 | - | .with(Node::Region( | |
| 172 | - | Slot::new( | |
| 173 | - | format!("note-header-{}", slug(¬e.namespace)), | |
| 174 | - | RegionKind::Group, | |
| 175 | - | ) | |
| 176 | - | .across(header), | |
| 177 | - | )) | |
| 178 | - | .with(Node::Region(Slot::handover( | |
| 179 | - | body_region(¬e.namespace), | |
| 180 | - | "a rendered note", | |
| 181 | - | ))) | |
| 182 | 164 | } | |
| 183 | 165 | ||
| 184 | 166 | #[cfg(test)] | |
| @@ -254,6 +236,22 @@ | |||
| 254 | 236 | assert!(!inexact.contains("ada"), "{inexact}"); | |
| 255 | 237 | } | |
| 256 | 238 | ||
| 239 | + | /// Each note is its own region, under the id the panel builds from the | |
| 240 | + | /// namespace. Two notes on one page are two boxes, and the header is a box | |
| 241 | + | /// inside each of them. | |
| 242 | + | #[test] | |
| 243 | + | fn every_note_is_its_own_region() { | |
| 244 | + | let html = html(&[ | |
| 245 | + | note("commits", false, None), | |
| 246 | + | note("mnw/builds", false, None), | |
| 247 | + | ]); | |
| 248 | + | ||
| 249 | + | assert!(html.contains("id=\"note-commits\""), "{html}"); | |
| 250 | + | assert!(html.contains("id=\"note-header-commits\""), "{html}"); | |
| 251 | + | assert!(html.contains("id=\"note-mnw-builds\""), "{html}"); | |
| 252 | + | assert!(html.contains("id=\"note-header-mnw-builds\""), "{html}"); | |
| 253 | + | } | |
| 254 | + | ||
| 257 | 255 | /// The namespace is machine text in a line of reading, which is what the | |
| 258 | 256 | /// inline code member is for. | |
| 259 | 257 | #[test] |