max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
3 files changed,
+487 insertions,
-376 deletions
| @@ -37,10 +37,9 @@ | |||
| 37 | 37 | //! whether to apply is told how many creators are actually here. | |
| 38 | 38 | ||
| 39 | 39 | use makeover_layout as layout; | |
| 40 | - | use quasi_router::screen::{Cell, Cells, Column, Figure, Table}; | |
| 41 | - | use quasi_router::{ | |
| 42 | - | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 43 | - | }; | |
| 40 | + | use quasi_declare::declare; | |
| 41 | + | use quasi_router::screen::Figure; | |
| 42 | + | use quasi_router::{Document, Request, Response, RouteError}; | |
| 44 | 43 | use quasi_webview::Webview; | |
| 45 | 44 | ||
| 46 | 45 | use crate::db; | |
| @@ -56,14 +55,32 @@ | |||
| 56 | 55 | ||
| 57 | 56 | /// One row of the tier table. | |
| 58 | 57 | /// | |
| 59 | - | /// `price` and `storage` are read off [`TierPrices`] rather than written here, | |
| 60 | - | /// for the reason `/use-cases` gives: a formatted price in a table is a price | |
| 61 | - | /// that goes stale on its own. | |
| 58 | + | /// The two figures are read off [`TierPrices`] rather than written here, for | |
| 59 | + | /// the reason `/use-cases` gives: a formatted price in a table is a price that | |
| 60 | + | /// goes stale on its own. | |
| 61 | + | /// | |
| 62 | + | /// [`price`](Self::price) and [`storage`](Self::storage) are what the table | |
| 63 | + | /// reads, and the two fields behind them are what those read. A description | |
| 64 | + | /// names what it draws, and `(tier.price)(prices)` is a call through a field | |
| 65 | + | /// rather than a name -- the same ruling policy's `OTHER` got when it was a | |
| 66 | + | /// slice of tuples. | |
| 62 | 67 | struct Tier { | |
| 63 | 68 | name: &'static str, | |
| 64 | 69 | best_for: &'static str, | |
| 65 | - | price: fn(&TierPrices) -> i32, | |
| 66 | - | storage: fn(&TierPrices) -> String, | |
| 70 | + | monthly: fn(&TierPrices) -> i32, | |
| 71 | + | total: fn(&TierPrices) -> String, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | impl Tier { | |
| 75 | + | /// The monthly fee, formatted as the table shows it. | |
| 76 | + | fn price(&self, prices: &TierPrices) -> String { | |
| 77 | + | format!("${}", (self.monthly)(prices)) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /// The storage envelope this tier buys. | |
| 81 | + | fn storage(&self, prices: &TierPrices) -> String { | |
| 82 | + | (self.total)(prices) | |
| 83 | + | } | |
| 67 | 84 | } | |
| 68 | 85 | ||
| 69 | 86 | /// The four, in the order the shipped table listed them. | |
| @@ -71,26 +88,26 @@ | |||
| 71 | 88 | Tier { | |
| 72 | 89 | name: "Basic", | |
| 73 | 90 | best_for: "Text, blogs, newsletters", | |
| 74 | - | price: |p| p.basic_std, | |
| 75 | - | storage: |p| p.basic_total.clone(), | |
| 91 | + | monthly: |p| p.basic_std, | |
| 92 | + | total: |p| p.basic_total.clone(), | |
| 76 | 93 | }, | |
| 77 | 94 | Tier { | |
| 78 | 95 | name: "Small Files", | |
| 79 | 96 | best_for: "Audio, plugins, small software", | |
| 80 | - | price: |p| p.small_files_std, | |
| 81 | - | storage: |p| p.small_files_total.clone(), | |
| 97 | + | monthly: |p| p.small_files_std, | |
| 98 | + | total: |p| p.small_files_total.clone(), | |
| 82 | 99 | }, | |
| 83 | 100 | Tier { | |
| 84 | 101 | name: "Big Files", | |
| 85 | 102 | best_for: "Video, games, large software", | |
| 86 | - | price: |p| p.big_files_std, | |
| 87 | - | storage: |p| p.big_files_total.clone(), | |
| 103 | + | monthly: |p| p.big_files_std, | |
| 104 | + | total: |p| p.big_files_total.clone(), | |
| 88 | 105 | }, | |
| 89 | 106 | Tier { | |
| 90 | 107 | name: "Everything", | |
| 91 | 108 | best_for: "All features, current and future", | |
| 92 | - | price: |p| p.everything_std, | |
| 93 | - | storage: |p| p.everything_total.clone(), | |
| 109 | + | monthly: |p| p.everything_std, | |
| 110 | + | total: |p| p.everything_total.clone(), | |
| 94 | 111 | }, | |
| 95 | 112 | ]; | |
| 96 | 113 | ||
| @@ -104,6 +121,27 @@ | |||
| 104 | 121 | Creator, | |
| 105 | 122 | } | |
| 106 | 123 | ||
| 124 | + | impl Standing { | |
| 125 | + | /// Nobody signed in. | |
| 126 | + | /// | |
| 127 | + | /// Three predicates rather than a pattern, because the form reaches a value | |
| 128 | + | /// through a name and not through a match arm, and each of the three offers | |
| 129 | + | /// a different number of things afterwards. | |
| 130 | + | const fn is_visitor(&self) -> bool { | |
| 131 | + | matches!(self, Self::Visitor) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// Signed in, not yet a creator. | |
| 135 | + | const fn is_reader(&self) -> bool { | |
| 136 | + | matches!(self, Self::Reader) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// Already has creator access. | |
| 140 | + | const fn is_creator(&self) -> bool { | |
| 141 | + | matches!(self, Self::Creator) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 107 | 145 | /// The page. | |
| 108 | 146 | pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 109 | 147 | use axum::extract::FromRef as _; | |
| @@ -123,122 +161,134 @@ | |||
| 123 | 161 | Ok(page_screen(&standing, total_creators, &billing.tier_prices).into()) | |
| 124 | 162 | } | |
| 125 | 163 | ||
| 126 | - | /// The whole document: the title, the measure, the body. | |
| 127 | - | fn page_screen(standing: &Standing, total_creators: i64, prices: &TierPrices) -> Described { | |
| 128 | - | let page = Slot::new(PAGE_REGION, RegionKind::Pane) | |
| 129 | - | .with(Node::page("Become a Creator")) | |
| 130 | - | .with(Node::text( | |
| 131 | - | "Anyone can sign up to browse and buy. To create projects and sell your work, apply \ | |
| 132 | - | for creator access. Most applications are approved within a few days. Makenotwork is \ | |
| 133 | - | in private alpha; we're approving applications one cohort at a time.", | |
| 134 | - | )) | |
| 135 | - | .with(Node::section("How It Works")) | |
| 136 | - | .with(super::own_prose( | |
| 137 | - | "1. **Sign up** and verify your email\n\ | |
| 138 | - | 2. **Apply** from your dashboard: tell us what you make and which tier fits\n\ | |
| 139 | - | 3. **Get approved**: we review applications individually, usually within a few days\n\ | |
| 140 | - | \n\ | |
| 141 | - | We review applications to make sure applicants are here to share and sell creative \ | |
| 142 | - | work. If you make something and want to sell it, you'll likely get in. Link to your \ | |
| 143 | - | existing work (a portfolio, channel, or profile elsewhere) to speed things up.\n\ | |
| 144 | - | \n\ | |
| 145 | - | **Important:** You sell in the currency your Stripe account settles in, and \ | |
| 146 | - | receiving payouts requires a [Stripe](https://stripe.com/global) account in a \ | |
| 147 | - | supported country that settles in one of the six we support: **USD, CAD, GBP, AUD, \ | |
| 148 | - | NZD or EUR**. Check both with Stripe before applying.", | |
| 149 | - | )) | |
| 150 | - | .with(Node::stats([Figure::new( | |
| 151 | - | total_creators.to_string(), | |
| 152 | - | "Active Creators", | |
| 153 | - | )])) | |
| 154 | - | .with(Node::section("Pricing")) | |
| 155 | - | .with(Node::text( | |
| 156 | - | "Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments is \ | |
| 157 | - | the payment processor's fee (~3%).", | |
| 158 | - | )) | |
| 159 | - | .with(tier_table(prices)) | |
| 160 | - | .with(super::own_prose( | |
| 161 | - | "Every tier is the complete platform: `/u/username` profile, project and item pages, \ | |
| 162 | - | project forum, Discover listing, memberships, pay-what-you-want, promo codes, RSS, \ | |
| 163 | - | analytics, full data export, 2FA/passkeys. The tier picks the file-size envelope, \ | |
| 164 | - | not the feature set. You sell in your Stripe account's currency (USD, CAD, GBP, AUD, \ | |
| 165 | - | NZD or EUR); receiving payouts requires [Stripe](https://stripe.com/global) in a \ | |
| 166 | - | supported country. [Full tier details](/docs/tiers) | \ | |
| 167 | - | [Pricing models](/docs/pricing)", | |
| 168 | - | )) | |
| 169 | - | .with(super::own_prose( | |
| 170 | - | "**Not ready to commit?** Request a **free trial** (2-6 weeks, no credit card) when \ | |
| 171 | - | you apply. Or [try sandbox mode](/sandbox) to explore the dashboard without signing \ | |
| 172 | - | up.", | |
| 173 | - | )) | |
| 174 | - | .with(Node::section("Who Runs This")) | |
| 175 | - | .with(super::own_prose( | |
| 176 | - | "Makenotwork is built and operated by one person. No investors, no board, no outside \ | |
| 177 | - | pressure. Decisions are fast and aligned with creators, but there's no large team \ | |
| 178 | - | behind the scenes. Read the full picture in our \ | |
| 179 | - | [continuity guarantee](/docs/guarantees#continuity) and \ | |
| 180 | - | [platform economics](/docs/economics).", | |
| 181 | - | )); | |
| 164 | + | declare! { | |
| 165 | + | /// The whole document: the title, the measure, the body. | |
| 166 | + | shape page_screen(standing: &Standing, total_creators: i64, prices: &TierPrices) -> Screen; | |
| 182 | 167 | ||
| 183 | - | let page = call_to_action(page, standing); | |
| 168 | + | screen single "Creators - Makenotwork" { | |
| 169 | + | measured MEASURE; | |
| 170 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &["creators-page"])); | |
| 171 | + | summarised "Apply for creator access: a flat monthly fee, no cut of your revenue, and four \ | |
| 172 | + | tiers that pick a file-size envelope rather than a feature set."; | |
| 184 | 173 | ||
| 185 | - | Described::single("Creators - Makenotwork") | |
| 186 | - | .measured(MEASURE) | |
| 187 | - | .documented( | |
| 188 | - | Document::default().classed(crate::shell::body_class(MEASURE, &["creators-page"])), | |
| 189 | - | ) | |
| 190 | - | .summarised( | |
| 191 | - | "Apply for creator access: a flat monthly fee, no cut of your revenue, and four \ | |
| 192 | - | tiers that pick a file-size envelope rather than a feature set.", | |
| 193 | - | ) | |
| 194 | - | .with(page) | |
| 195 | - | } | |
| 174 | + | region PAGE_REGION as Pane { | |
| 175 | + | page "Become a Creator"; | |
| 176 | + | text "Anyone can sign up to browse and buy. To create projects and sell your work, \ | |
| 177 | + | apply for creator access. Most applications are approved within a few days. \ | |
| 178 | + | Makenotwork is in private alpha; we're approving applications one cohort at a \ | |
| 179 | + | time."; | |
| 196 | 180 | ||
| 197 | - | /// The four tiers, priced from the live figures. | |
| 198 | - | fn tier_table(prices: &TierPrices) -> Node { | |
| 199 | - | // Four columns and four cells, written together, with no branch between | |
| 200 | - | // them: every tier is a full row, so position is checkable by eye here and | |
| 201 | - | // naming the columns would be ceremony. | |
| 202 | - | Table::new([ | |
| 203 | - | Column::new("Tier") | |
| 204 | - | .width(layout::Width::Content) | |
| 205 | - | .priority(layout::Priority::Essential), | |
| 206 | - | Column::new("Monthly").width(layout::Width::Content), | |
| 207 | - | Column::new("Best For").width(layout::Width::Fill), | |
| 208 | - | Column::new("Storage").width(layout::Width::Content), | |
| 209 | - | ]) | |
| 210 | - | .rows(TIERS.iter().map(|tier| { | |
| 211 | - | Cells::new([ | |
| 212 | - | Cell::new(tier.name), | |
| 213 | - | Cell::new(format!("${}", (tier.price)(prices))), | |
| 214 | - | Cell::new(tier.best_for), | |
| 215 | - | Cell::new((tier.storage)(prices)), | |
| 216 | - | ]) | |
| 217 | - | })) | |
| 218 | - | .into() | |
| 219 | - | } | |
| 181 | + | section "How It Works"; | |
| 182 | + | include super::own_prose( | |
| 183 | + | "1. **Sign up** and verify your email\n\ | |
| 184 | + | 2. **Apply** from your dashboard: tell us what you make and which tier fits\n\ | |
| 185 | + | 3. **Get approved**: we review applications individually, usually within a few days\n\ | |
| 186 | + | \n\ | |
| 187 | + | We review applications to make sure applicants are here to share and sell creative \ | |
| 188 | + | work. If you make something and want to sell it, you'll likely get in. Link to your \ | |
| 189 | + | existing work (a portfolio, channel, or profile elsewhere) to speed things up.\n\ | |
| 190 | + | \n\ | |
| 191 | + | **Important:** You sell in the currency your Stripe account settles in, and \ | |
| 192 | + | receiving payouts requires a [Stripe](https://stripe.com/global) account in a \ | |
| 193 | + | supported country that settles in one of the six we support: **USD, CAD, GBP, AUD, \ | |
| 194 | + | NZD or EUR**. Check both with Stripe before applying." | |
| 195 | + | ); | |
| 220 | 196 | ||
| 221 | - | /// What the page asks of this reader, which is the only thing on it that | |
| 222 | - | /// differs by who is asking. | |
| 223 | - | fn call_to_action(page: Slot, standing: &Standing) -> Slot { | |
| 224 | - | match standing { | |
| 225 | - | Standing::Creator => page | |
| 226 | - | .with(Node::text("You have creator access.")) | |
| 227 | - | .with(Node::act( | |
| 228 | - | "Go to Dashboard", | |
| 229 | - | Action::get("/dashboard").navigating(), | |
| 230 | - | )), | |
| 231 | - | Standing::Reader => page.with(Node::text("Ready to create?")).with(Node::act( | |
| 232 | - | "Apply from Dashboard", | |
| 233 | - | Action::get("/dashboard?tab=settings§ion=creator").navigating(), | |
| 234 | - | )), | |
| 235 | - | Standing::Visitor => page | |
| 236 | - | .with(Node::text("Join to get started.")) | |
| 237 | - | .with(Node::act("Join", Action::get("/join").navigating())) | |
| 238 | - | .with(Node::act("Login", Action::get("/login").navigating())), | |
| 197 | + | stats [Figure::new(total_creators.to_string(), "Active Creators")]; | |
| 198 | + | ||
| 199 | + | section "Pricing"; | |
| 200 | + | text "Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments \ | |
| 201 | + | is the payment processor's fee (~3%)."; | |
| 202 | + | include tier_table(prices); | |
| 203 | + | include super::own_prose( | |
| 204 | + | "Every tier is the complete platform: `/u/username` profile, project and item pages, \ | |
| 205 | + | project forum, Discover listing, memberships, pay-what-you-want, promo codes, RSS, \ | |
| 206 | + | analytics, full data export, 2FA/passkeys. The tier picks the file-size envelope, \ | |
| 207 | + | not the feature set. You sell in your Stripe account's currency (USD, CAD, GBP, AUD, \ | |
| 208 | + | NZD or EUR); receiving payouts requires [Stripe](https://stripe.com/global) in a \ | |
| 209 | + | supported country. [Full tier details](/docs/tiers) | \ | |
| 210 | + | [Pricing models](/docs/pricing)" | |
| 211 | + | ); | |
| 212 | + | include super::own_prose( | |
| 213 | + | "**Not ready to commit?** Request a **free trial** (2-6 weeks, no credit card) when \ | |
| 214 | + | you apply. Or [try sandbox mode](/sandbox) to explore the dashboard without signing \ | |
| 215 | + | up." | |
| 216 | + | ); | |
| 217 | + | ||
| 218 | + | section "Who Runs This"; | |
| 219 | + | include super::own_prose( | |
| 220 | + | "Makenotwork is built and operated by one person. No investors, no board, no outside \ | |
| 221 | + | pressure. Decisions are fast and aligned with creators, but there's no large team \ | |
| 222 | + | behind the scenes. Read the full picture in our \ | |
| 223 | + | [continuity guarantee](/docs/guarantees#continuity) and \ | |
| 224 | + | [platform economics](/docs/economics)." | |
| 225 | + | ); | |
| 226 | + | ||
| 227 | + | // The one part of the page that differs by who is asking, spread | |
| 228 | + | // where it was appended. `feeds` does the same with its body. | |
| 229 | + | for node in call_to_action(standing) { | |
| 230 | + | include node; | |
| 231 | + | } | |
| 232 | + | } | |
| 239 | 233 | } | |
| 240 | 234 | } | |
| 241 | 235 | ||
| 236 | + | declare! { | |
| 237 | + | /// The four tiers, priced from the live figures. | |
| 238 | + | /// | |
| 239 | + | /// Four columns and four cells, written together, with no branch between | |
| 240 | + | /// them: every tier is a full row, so position is checkable by eye here and | |
| 241 | + | /// naming the columns would be ceremony. | |
| 242 | + | shape tier_table(prices: &TierPrices) -> Node; | |
| 243 | + | ||
| 244 | + | table { | |
| 245 | + | column "Tier" { | |
| 246 | + | width Content; | |
| 247 | + | priority Essential; | |
| 248 | + | } | |
| 249 | + | column "Monthly" { | |
| 250 | + | width Content; | |
| 251 | + | } | |
| 252 | + | column "Best For" { | |
| 253 | + | width Fill; | |
| 254 | + | } | |
| 255 | + | column "Storage" { | |
| 256 | + | width Content; | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | for tier in TIERS { | |
| 260 | + | cells { | |
| 261 | + | cell tier.name; | |
| 262 | + | cell tier.price(prices); | |
| 263 | + | cell tier.best_for; | |
| 264 | + | cell tier.storage(prices); | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | declare! { | |
| 271 | + | /// What the page asks of this reader, which is the only thing on it that | |
| 272 | + | /// differs by who is asking. | |
| 273 | + | /// | |
| 274 | + | /// A panel rather than a `Slot` taken and handed back: each standing offers | |
| 275 | + | /// a different number of things -- a visitor two controls, the other two one | |
| 276 | + | /// each -- so the answer is a run of members and not one node. The three | |
| 277 | + | /// guards are exhaustive and disjoint by construction. | |
| 278 | + | shape call_to_action(standing: &Standing) -> Vec<Node>; | |
| 279 | + | ||
| 280 | + | text "You have creator access." when standing.is_creator(); | |
| 281 | + | act "Go to Dashboard" to get "/dashboard" navigating when standing.is_creator(); | |
| 282 | + | ||
| 283 | + | text "Ready to create?" when standing.is_reader(); | |
| 284 | + | act "Apply from Dashboard" to get "/dashboard?tab=settings§ion=creator" navigating | |
| 285 | + | when standing.is_reader(); | |
| 286 | + | ||
| 287 | + | text "Join to get started." when standing.is_visitor(); | |
| 288 | + | act "Join" to get "/join" navigating when standing.is_visitor(); | |
| 289 | + | act "Login" to get "/login" navigating when standing.is_visitor(); | |
| 290 | + | } | |
| 291 | + | ||
| 242 | 292 | /// The document this screen is drawn in. | |
| 243 | 293 | #[must_use] | |
| 244 | 294 | pub fn renderer(viewer: &super::Viewer) -> Webview { |
| @@ -44,10 +44,9 @@ | |||
| 44 | 44 | //! is a host contradicting the description rather than adding to it. The | |
| 45 | 45 | //! description now says the shape and the host adds only its own furniture. | |
| 46 | 46 | ||
| 47 | - | use makeover_layout as layout; | |
| 48 | 47 | use quasi_axum::Serves as _; | |
| 49 | - | use quasi_router::screen::{Act, Image, Row}; | |
| 50 | - | use quasi_router::{Action, Chrome, Document, Node, RegionKind, Screen, Slot}; | |
| 48 | + | use quasi_declare::declare; | |
| 49 | + | use quasi_router::{Chrome, Document, RegionKind, Screen}; | |
| 51 | 50 | use quasi_webview::{Shell, Webview}; | |
| 52 | 51 | ||
| 53 | 52 | use crate::templates::{EMBED_GEOMETRY_CSS, EMBED_TYPOGRAPHY_CSS, embed_theme_css}; | |
| @@ -145,20 +144,22 @@ | |||
| 145 | 144 | ) | |
| 146 | 145 | } | |
| 147 | 146 | ||
| 148 | - | /// A screen with one region, holding one node. | |
| 149 | - | fn one(title: &str, node: Node) -> Screen { | |
| 150 | - | Screen::single(title).with(Slot::new(REGION, RegionKind::Pane).with(node)) | |
| 151 | - | } | |
| 147 | + | declare! { | |
| 148 | + | /// A cover picture, cropped to its box. | |
| 149 | + | /// | |
| 150 | + | /// `Fit::Cover` because a cover is a fixed square here and the art it holds | |
| 151 | + | /// is any shape: the alternative is letterboxing inside a 40-pixel box, | |
| 152 | + | /// which is the art unreadable and the box the wrong colour. | |
| 153 | + | /// | |
| 154 | + | /// The alt text is empty on purpose. A cover repeats the title beside it, so | |
| 155 | + | /// a reader who cannot see the bytes is told nothing by a second copy of the | |
| 156 | + | /// name -- which is what [`Image::speaks`](quasi_router::Image::speaks) is | |
| 157 | + | /// for. | |
| 158 | + | shape cover(url: &str) -> Node; | |
| 152 | 159 | ||
| 153 | - | /// A cover picture, cropped to its box. | |
| 154 | - | /// | |
| 155 | - | /// `Fit::Cover` because a cover is a fixed square here and the art it holds is | |
| 156 | - | /// any shape: the alternative is letterboxing inside a 40-pixel box, which is | |
| 157 | - | /// the art unreadable and the box the wrong colour. | |
| 158 | - | fn cover(url: &str) -> Node { | |
| 159 | - | let mut picture = Image::new(url, ""); | |
| 160 | - | picture.fit = layout::Fit::Cover; | |
| 161 | - | Node::Image(picture) | |
| 160 | + | picture url "" { | |
| 161 | + | fit Cover; | |
| 162 | + | } | |
| 162 | 163 | } | |
| 163 | 164 | ||
| 164 | 165 | /// What an item embed is drawn from. | |
| @@ -184,92 +185,118 @@ | |||
| 184 | 185 | pub description_excerpt: String, | |
| 185 | 186 | } | |
| 186 | 187 | ||
| 187 | - | /// The buy control: a link out to makenot.work. | |
| 188 | - | /// | |
| 189 | - | /// [`Destination::External`](quasi_router::Destination::External), which is what | |
| 190 | - | /// makes it an anchor with `rel="noopener noreferrer"` in the webview rather | |
| 191 | - | /// than a button that asks a route. An embed's every control is one of these. | |
| 192 | - | fn buy(view: &ItemView) -> Act { | |
| 193 | - | Act::new(&view.button_text, Action::external(&view.purchase_url)) | |
| 188 | + | impl ItemView { | |
| 189 | + | /// Whether there is cover art to draw. | |
| 190 | + | /// | |
| 191 | + | /// A predicate and a reader rather than an `Option` the description reaches | |
| 192 | + | /// into: a guard asks one question and the picture is built either way, so | |
| 193 | + | /// the absent case hands [`cover`] an empty source and nothing places it. | |
| 194 | + | fn has_cover(&self) -> bool { | |
| 195 | + | self.cover_image_url.is_some() | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// The cover art's address, or nothing. | |
| 199 | + | fn cover_url(&self) -> &str { | |
| 200 | + | self.cover_image_url.as_deref().unwrap_or_default() | |
| 201 | + | } | |
| 194 | 202 | } | |
| 195 | 203 | ||
| 196 | - | /// The buy button: cover, title, price, and the control, on one line. | |
| 197 | - | /// | |
| 198 | - | /// The one embed that is genuinely a [`Row`]: a compact strip where the cover | |
| 199 | - | /// is a thumbnail beside the title rather than the card's own picture. Drawn | |
| 200 | - | /// with the `embed-button` body class, which is what sizes that thumbnail. | |
| 201 | - | #[must_use] | |
| 202 | - | pub fn item_button(view: &ItemView) -> Screen { | |
| 203 | - | let mut row = Row::new(""); | |
| 204 | - | if let Some(url) = &view.cover_image_url { | |
| 205 | - | row = row.part(layout::RowPart::Primary, cover(url)); | |
| 206 | - | } | |
| 207 | - | let row = row | |
| 208 | - | .part(layout::RowPart::Primary, Node::text(&view.title)) | |
| 209 | - | .meta(&view.price) | |
| 210 | - | .part(layout::RowPart::Actions, Node::Act(buy(view))); | |
| 211 | - | one(&view.title, Node::list([row])) | |
| 204 | + | declare! { | |
| 205 | + | /// The buy control: a link out to makenot.work. | |
| 206 | + | /// | |
| 207 | + | /// [`Destination::External`](quasi_router::Destination::External), which is | |
| 208 | + | /// what makes it an anchor with `rel="noopener noreferrer"` in the webview | |
| 209 | + | /// rather than a button that asks a route. An embed's every control is one | |
| 210 | + | /// of these. | |
| 211 | + | shape buy(view: &ItemView) -> Act; | |
| 212 | + | ||
| 213 | + | act &view.button_text to external &view.purchase_url; | |
| 212 | 214 | } | |
| 213 | 215 | ||
| 214 | - | /// The product card: the cover, what it is, who made it, and the control. | |
| 215 | - | /// | |
| 216 | - | /// Blocks rather than one row, which is the difference between a card and a | |
| 217 | - | /// button. A [`Row`] is an inline run and its parts share a line by role, so a | |
| 218 | - | /// card said as a row would read "coverTitle" with the excerpt and the price | |
| 219 | - | /// crushed in beside it. What a card actually is — a picture, a heading, a line | |
| 220 | - | /// about who made it, a paragraph, a price and a control, each on its own line — | |
| 221 | - | /// is a region holding six nodes, every one of which the vocabulary already | |
| 222 | - | /// names. | |
| 223 | - | #[must_use] | |
| 224 | - | pub fn item_card(view: &ItemView) -> Screen { | |
| 225 | - | let mut slot = Slot::new(REGION, RegionKind::Pane); | |
| 226 | - | if let Some(url) = &view.cover_image_url { | |
| 227 | - | slot = slot.with(cover(url)); | |
| 216 | + | declare! { | |
| 217 | + | /// The buy button: cover, title, price, and the control, on one line. | |
| 218 | + | /// | |
| 219 | + | /// The one embed that is genuinely a [`Row`](quasi_router::Row): a compact | |
| 220 | + | /// strip where the cover is a thumbnail beside the title rather than the | |
| 221 | + | /// card's own picture. Drawn with the `embed-button` body class, which is | |
| 222 | + | /// what sizes that thumbnail. | |
| 223 | + | /// | |
| 224 | + | /// The price is a setting and the other three are parts, which is the | |
| 225 | + | /// difference between a short trailing fact and something placed by role. | |
| 226 | + | #[must_use] | |
| 227 | + | pub shape item_button(view: &ItemView) -> Screen; | |
| 228 | + | ||
| 229 | + | screen single &view.title { | |
| 230 | + | region REGION as Pane { | |
| 231 | + | list { | |
| 232 | + | row "" { | |
| 233 | + | beside Primary include cover(view.cover_url()) when view.has_cover(); | |
| 234 | + | beside Primary text &view.title; | |
| 235 | + | meta &view.price; | |
| 236 | + | beside Actions include buy(view); | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | } | |
| 228 | 240 | } | |
| 229 | - | slot = slot.with(Node::section(&view.title)).with(Node::Link { | |
| 230 | - | text: format!("by {}", view.creator_display_name), | |
| 231 | - | action: Action::external(&view.profile_url), | |
| 232 | - | }); | |
| 233 | - | if !view.description_excerpt.is_empty() { | |
| 234 | - | slot = slot.with(Node::text(&view.description_excerpt)); | |
| 235 | - | } | |
| 236 | - | let slot = slot | |
| 237 | - | .with(Node::text(&view.price)) | |
| 238 | - | .with(Node::Act(buy(view))); | |
| 239 | - | Screen::single(&view.title).with(slot) | |
| 240 | 241 | } | |
| 241 | 242 | ||
| 242 | - | /// The audio player: the card, with the transport in a bespoke region. | |
| 243 | - | /// | |
| 244 | - | /// **A handover for now, a widget eventually.** A play button, a scrub bar and an | |
| 245 | - | /// elapsed readout are a media transport, and the vocabulary names none of the | |
| 246 | - | /// three on purpose — describing playback would | |
| 247 | - | /// put scrub, rate and chapters into a core two of the three renderers could only | |
| 248 | - | /// degrade. So this screen describes the chrome around the player and leaves the | |
| 249 | - | /// player alone, which is exactly what a [`RegionKind::Handover`] is for: the | |
| 250 | - | /// fill is owed, and a renderer without one should say so rather than draw an | |
| 251 | - | /// empty box where the transport goes. | |
| 252 | - | /// | |
| 253 | - | /// The markup and the script that fills it are [`player_markup`], unchanged from | |
| 254 | - | /// the template this replaces. | |
| 255 | - | #[must_use] | |
| 256 | - | pub fn item_player(view: &ItemView) -> Screen { | |
| 257 | - | let mut slot = Slot::new(REGION, RegionKind::Pane); | |
| 258 | - | if let Some(url) = &view.cover_image_url { | |
| 259 | - | slot = slot.with(cover(url)); | |
| 260 | - | } | |
| 261 | - | let slot = slot | |
| 262 | - | .with(Node::section(&view.title)) | |
| 263 | - | .with(Node::text(format!("by {}", view.creator_display_name))) | |
| 264 | - | .with(Node::text(&view.price)) | |
| 265 | - | .with(Node::Act(buy(view))); | |
| 243 | + | declare! { | |
| 244 | + | /// The product card: the cover, what it is, who made it, and the control. | |
| 245 | + | /// | |
| 246 | + | /// Blocks rather than one row, which is the difference between a card and a | |
| 247 | + | /// button. A row is an inline run and its parts share a line by role, so a | |
| 248 | + | /// card said as a row would read "coverTitle" with the excerpt and the price | |
| 249 | + | /// crushed in beside it. What a card actually is -- a picture, a heading, a | |
| 250 | + | /// line about who made it, a paragraph, a price and a control, each on its | |
| 251 | + | /// own line -- is a region holding six nodes, every one of which the | |
| 252 | + | /// vocabulary already names. | |
| 253 | + | #[must_use] | |
| 254 | + | pub shape item_card(view: &ItemView) -> Screen; | |
| 266 | 255 | ||
| 267 | - | Screen::single(&view.title).with(slot).with(Slot::new( | |
| 268 | - | PLAYER_REGION, | |
| 269 | - | RegionKind::Handover { | |
| 270 | - | name: "media-transport".into(), | |
| 271 | - | }, | |
| 272 | - | )) | |
| 256 | + | screen single &view.title { | |
| 257 | + | region REGION as Pane { | |
| 258 | + | include cover(view.cover_url()) when view.has_cover(); | |
| 259 | + | section &view.title; | |
| 260 | + | link "by {view.creator_display_name}" to external &view.profile_url; | |
| 261 | + | text &view.description_excerpt unless view.description_excerpt.is_empty(); | |
| 262 | + | text &view.price; | |
| 263 | + | include buy(view); | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | declare! { | |
| 269 | + | /// The audio player: the card, with the transport in a bespoke region. | |
| 270 | + | /// | |
| 271 | + | /// **A handover for now, a widget eventually.** A play button, a scrub bar | |
| 272 | + | /// and an elapsed readout are a media transport, and the vocabulary names | |
| 273 | + | /// none of the three on purpose -- describing playback would put scrub, rate | |
| 274 | + | /// and chapters into a core two of the three renderers could only degrade. | |
| 275 | + | /// So this screen describes the chrome around the player and leaves the | |
| 276 | + | /// player alone, which is exactly what a handover region is for: the fill is | |
| 277 | + | /// owed, and a renderer without one should say so rather than draw an empty | |
| 278 | + | /// box where the transport goes. | |
| 279 | + | /// | |
| 280 | + | /// The markup and the script that fills it are [`player_markup`], unchanged | |
| 281 | + | /// from the template this replaces. | |
| 282 | + | /// | |
| 283 | + | /// Who made it is text here and a link on the card, which the template had | |
| 284 | + | /// too: the player's chrome is a caption over a control, not a place to send | |
| 285 | + | /// somebody else. | |
| 286 | + | #[must_use] | |
| 287 | + | pub shape item_player(view: &ItemView) -> Screen; | |
| 288 | + | ||
| 289 | + | screen single &view.title { | |
| 290 | + | region REGION as Pane { | |
| 291 | + | include cover(view.cover_url()) when view.has_cover(); | |
| 292 | + | section &view.title; | |
| 293 | + | text "by {view.creator_display_name}"; | |
| 294 | + | text &view.price; | |
| 295 | + | include buy(view); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | region PLAYER_REGION as RegionKind::handover("media-transport") {} | |
| 299 | + | } | |
| 273 | 300 | } | |
| 274 | 301 | ||
| 275 | 302 | /// The handover region the transport is mounted in. | |
| @@ -368,38 +395,45 @@ | |||
| 368 | 395 | pub category_label: String, | |
| 369 | 396 | } | |
| 370 | 397 | ||
| 371 | - | /// The project card: [`item_card`]'s shape, about a project. | |
| 372 | - | #[must_use] | |
| 373 | - | pub fn project_card(view: &ProjectView) -> Screen { | |
| 374 | - | let mut slot = Slot::new(REGION, RegionKind::Pane); | |
| 375 | - | if let Some(url) = &view.cover_image_url { | |
| 376 | - | slot = slot.with(cover(url)); | |
| 398 | + | impl ProjectView { | |
| 399 | + | /// Whether there is cover art to draw. See [`ItemView::has_cover`]. | |
| 400 | + | fn has_cover(&self) -> bool { | |
| 401 | + | self.cover_image_url.is_some() | |
| 377 | 402 | } | |
| 378 | - | slot = slot.with(Node::section(&view.title)).with(Node::Link { | |
| 379 | - | text: format!("by {}", view.creator_display_name), | |
| 380 | - | action: Action::external(&view.profile_url), | |
| 381 | - | }); | |
| 382 | - | if !view.description_excerpt.is_empty() { | |
| 383 | - | slot = slot.with(Node::text(&view.description_excerpt)); | |
| 403 | + | ||
| 404 | + | /// The cover art's address, or nothing. | |
| 405 | + | fn cover_url(&self) -> &str { | |
| 406 | + | self.cover_image_url.as_deref().unwrap_or_default() | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | /// "item" or "items", for the count line. | |
| 410 | + | fn items_word(&self) -> &'static str { | |
| 411 | + | if self.item_count == 1 { | |
| 412 | + | "item" | |
| 413 | + | } else { | |
| 414 | + | "items" | |
| 415 | + | } | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | declare! { | |
| 420 | + | /// The project card: [`item_card`]'s shape, about a project. | |
| 421 | + | /// | |
| 422 | + | /// The count and the kind read together and neither stands on its own, so | |
| 423 | + | /// they are one line rather than two nodes. | |
| 424 | + | #[must_use] | |
| 425 | + | pub shape project_card(view: &ProjectView) -> Screen; | |
| 426 | + | ||
| 427 | + | screen single &view.title { | |
| 428 | + | region REGION as Pane { | |
| 429 | + | include cover(view.cover_url()) when view.has_cover(); | |
| 430 | + | section &view.title; | |
| 431 | + | link "by {view.creator_display_name}" to external &view.profile_url; | |
| 432 | + | text &view.description_excerpt unless view.description_excerpt.is_empty(); | |
| 433 | + | text "{view.item_count} {view.items_word()} \u{b7} {view.category_label}"; | |
| 434 | + | act "View project" to external &view.project_url; | |
| 435 | + | } | |
| 384 | 436 | } | |
| 385 | - | // The count and the kind read together and neither stands on its own, so | |
| 386 | - | // they are one line rather than two nodes. | |
| 387 | - | let slot = slot | |
| 388 | - | .with(Node::text(format!( | |
| 389 | - | "{} {} \u{b7} {}", | |
| 390 | - | view.item_count, | |
| 391 | - | if view.item_count == 1 { | |
| 392 | - | "item" | |
| 393 | - | } else { | |
| 394 | - | "items" | |
| 395 | - | }, | |
| 396 | - | view.category_label | |
| 397 | - | ))) | |
| 398 | - | .with(Node::Act(Act::new( | |
| 399 | - | "View project", | |
| 400 | - | Action::external(&view.project_url), | |
| 401 | - | ))); | |
| 402 | - | Screen::single(&view.title).with(slot) | |
| 403 | 437 | } | |
| 404 | 438 | ||
| 405 | 439 | /// What a tip embed is drawn from. | |
| @@ -414,23 +448,37 @@ | |||
| 414 | 448 | pub avatar_url: Option<String>, | |
| 415 | 449 | } | |
| 416 | 450 | ||
| 417 | - | /// The tip button. | |
| 418 | - | #[must_use] | |
| 419 | - | pub fn tip_button(view: &TipView) -> Screen { | |
| 420 | - | let mut row = Row::new(""); | |
| 421 | - | if let Some(url) = &view.avatar_url { | |
| 422 | - | row = row.part(layout::RowPart::Primary, cover(url)); | |
| 451 | + | impl TipView { | |
| 452 | + | /// Whether there is an avatar to draw. See [`ItemView::has_cover`]. | |
| 453 | + | fn has_avatar(&self) -> bool { | |
| 454 | + | self.avatar_url.is_some() | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | /// The avatar's address, or nothing. | |
| 458 | + | fn avatar(&self) -> &str { | |
| 459 | + | self.avatar_url.as_deref().unwrap_or_default() | |
| 460 | + | } | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | declare! { | |
| 464 | + | /// The tip button. | |
| 465 | + | /// | |
| 466 | + | /// [`item_button`]'s strip with nothing between the label and the control: | |
| 467 | + | /// a tip has no price to trail. | |
| 468 | + | #[must_use] | |
| 469 | + | pub shape tip_button(view: &TipView) -> Screen; | |
| 470 | + | ||
| 471 | + | screen single "Support {view.display_name}" { | |
| 472 | + | region REGION as Pane { | |
| 473 | + | list { | |
| 474 | + | row "" { | |
| 475 | + | beside Primary include cover(view.avatar()) when view.has_avatar(); | |
| 476 | + | beside Primary text "Support @{view.username}"; | |
| 477 | + | beside Actions act "Support" to external &view.tip_url; | |
| 478 | + | } | |
| 479 | + | } | |
| 480 | + | } | |
| 423 | 481 | } | |
| 424 | - | let row = row | |
| 425 | - | .part( | |
| 426 | - | layout::RowPart::Primary, | |
| 427 | - | Node::text(format!("Support @{}", view.username)), | |
| 428 | - | ) | |
| 429 | - | .part( | |
| 430 | - | layout::RowPart::Actions, | |
| 431 | - | Node::Act(Act::new("Support", Action::external(&view.tip_url))), | |
| 432 | - | ); | |
| 433 | - | one(&format!("Support {}", view.display_name), Node::list([row])) | |
| 434 | 482 | } | |
| 435 | 483 | ||
| 436 | 484 | #[cfg(test)] |
| @@ -27,9 +27,8 @@ | |||
| 27 | 27 | //! question hanging over S4's safety argument, and picking a screen that renders | |
| 28 | 28 | //! its own data is the answer for this batch. | |
| 29 | 29 | ||
| 30 | - | use makeover_layout as layout; | |
| 31 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Table}; | |
| 32 | - | use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 30 | + | use quasi_declare::declare; | |
| 31 | + | use quasi_router::{Method, Request, Response, RouteError}; | |
| 33 | 32 | use quasi_webview::Webview; | |
| 34 | 33 | ||
| 35 | 34 | use super::Viewer; | |
| @@ -150,118 +149,132 @@ | |||
| 150 | 149 | screen(viewer, Request::get(PATH)) | |
| 151 | 150 | } | |
| 152 | 151 | ||
| 153 | - | /// Everything inside the tab pane. | |
| 154 | - | /// | |
| 155 | - | /// Split from the handler so a test can build it without a database, the same | |
| 156 | - | /// split `ssh_keys` uses and the reason a described screen is testable at all. | |
| 157 | - | fn pane(buyers: &[BuyerView], shared: &[SharedView]) -> Node { | |
| 158 | - | let mut slot = Slot::new(REGION, RegionKind::Pane); | |
| 152 | + | declare! { | |
| 153 | + | /// Everything inside the tab pane. | |
| 154 | + | /// | |
| 155 | + | /// Split from the handler so a test can build it without a database, the | |
| 156 | + | /// same split `ssh_keys` uses and the reason a described screen is testable | |
| 157 | + | /// at all. | |
| 158 | + | /// | |
| 159 | + | /// The Askama version says "both empty" in a third `{% if %}` over the same | |
| 160 | + | /// two conditions and returns early. Said as guards, the two halves each | |
| 161 | + | /// carry their own, and the both-empty case is the one where neither is | |
| 162 | + | /// placed and the empty line is. | |
| 163 | + | shape pane(buyers: &[BuyerView], shared: &[SharedView]) -> Node; | |
| 159 | 164 | ||
| 160 | - | // Both empty is its own screen and not two empty tables. The Askama version | |
| 161 | - | // says this in a third `{% if %}` over the same two conditions. | |
| 162 | - | if buyers.is_empty() && shared.is_empty() { | |
| 163 | - | return Node::Region(slot.with(Node::empty("No contacts yet."))); | |
| 165 | + | region REGION as Pane { | |
| 166 | + | empty "No contacts yet." when buyers.is_empty() and shared.is_empty(); | |
| 167 | + | ||
| 168 | + | section "Your Buyers ({buyers.len()})" unless buyers.is_empty(); | |
| 169 | + | text "Buyers who opted to share their email with you at purchase time." | |
| 170 | + | unless buyers.is_empty(); | |
| 171 | + | include buyers_table(buyers) unless buyers.is_empty(); | |
| 172 | + | ||
| 173 | + | section "Shared With" unless shared.is_empty(); | |
| 174 | + | text "You've shared your email with these creators. You can revoke sharing at any time." | |
| 175 | + | unless shared.is_empty(); | |
| 176 | + | include shared_table(shared) unless shared.is_empty(); | |
| 164 | 177 | } | |
| 165 | - | ||
| 166 | - | if !buyers.is_empty() { | |
| 167 | - | slot = slot | |
| 168 | - | .with(Node::section(format!("Your Buyers ({})", buyers.len()))) | |
| 169 | - | .with(Node::text( | |
| 170 | - | "Buyers who opted to share their email with you at purchase time.", | |
| 171 | - | )) | |
| 172 | - | .with(buyers_table(buyers)); | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | if !shared.is_empty() { | |
| 176 | - | slot = slot | |
| 177 | - | .with(Node::section("Shared With")) | |
| 178 | - | .with(Node::text( | |
| 179 | - | "You've shared your email with these creators. You can revoke sharing at any time.", | |
| 180 | - | )) | |
| 181 | - | .with(shared_table(shared)); | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | Node::Region(slot) | |
| 185 | 178 | } | |
| 186 | 179 | ||
| 187 | - | /// The buyers who shared an email. | |
| 188 | - | fn buyers_table(buyers: &[BuyerView]) -> Node { | |
| 189 | - | // Positional cells, deliberately: the headings and the row are the one | |
| 190 | - | // expression below and every buyer contributes the same five cells, so | |
| 191 | - | // naming each column would buy nothing a reader cannot already see. Nothing | |
| 192 | - | // is paged, so no `more`: this is a whole set the handler already counted. | |
| 193 | - | Table::new(vec![ | |
| 194 | - | Column::new("Username") | |
| 195 | - | .width(layout::Width::Content) | |
| 196 | - | .priority(layout::Priority::Essential), | |
| 197 | - | Column::new("Email") | |
| 198 | - | .width(layout::Width::Fill) | |
| 199 | - | .priority(layout::Priority::Essential), | |
| 200 | - | Column::new("Purchases").width(layout::Width::Content), | |
| 201 | - | Column::new("Total Spent").width(layout::Width::Content), | |
| 202 | - | Column::new("Last Purchase") | |
| 203 | - | .width(layout::Width::Content) | |
| 204 | - | .priority(layout::Priority::Optional), | |
| 205 | - | ]) | |
| 206 | - | .rows(buyers.iter().map(|buyer| { | |
| 207 | - | Cells::new([ | |
| 208 | - | // The two flavours of a linked value in one row. A profile | |
| 209 | - | // is a route this server answers, so it stays inside the | |
| 210 | - | // app; an address is not, so it leaves. | |
| 211 | - | // | |
| 212 | - | // Navigating (`00ee7af5`): a profile is a whole document, | |
| 213 | - | // not a region, so the anchor is the whole of it and the | |
| 214 | - | // swap is dropped. Without it htmx would morph a | |
| 215 | - | // `<!doctype html>` page over the cell it was clicked in. | |
| 216 | - | Cell::new(buyer.username.clone()) | |
| 217 | - | .activate(Action::get(format!("/u/{}", buyer.username)).navigating()), | |
| 218 | - | Cell::new(buyer.email.clone()) | |
| 219 | - | .activate(Action::external(format!("mailto:{}", buyer.email))), | |
| 220 | - | Cell::new(buyer.purchases.clone()), | |
| 221 | - | Cell::new(buyer.spent.clone()), | |
| 222 | - | Cell::new(buyer.last_purchase.clone()), | |
| 223 | - | ]) | |
| 224 | - | })) | |
| 225 | - | .into() | |
| 180 | + | declare! { | |
| 181 | + | /// The buyers who shared an email. | |
| 182 | + | /// | |
| 183 | + | /// Positional cells, deliberately: the headings and the row are one | |
| 184 | + | /// declaration and every buyer contributes the same five cells, so naming | |
| 185 | + | /// each column would buy nothing a reader cannot already see. Nothing is | |
| 186 | + | /// paged, so no `more`: this is a whole set the handler already counted. | |
| 187 | + | shape buyers_table(buyers: &[BuyerView]) -> Node; | |
| 188 | + | ||
| 189 | + | table { | |
| 190 | + | column "Username" { | |
| 191 | + | width Content; | |
| 192 | + | priority Essential; | |
| 193 | + | } | |
| 194 | + | column "Email" { | |
| 195 | + | width Fill; | |
| 196 | + | priority Essential; | |
| 197 | + | } | |
| 198 | + | column "Purchases" { | |
| 199 | + | width Content; | |
| 200 | + | } | |
| 201 | + | column "Total Spent" { | |
| 202 | + | width Content; | |
| 203 | + | } | |
| 204 | + | column "Last Purchase" { | |
| 205 | + | width Content; | |
| 206 | + | priority Optional; | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | for buyer in buyers.iter() { | |
| 210 | + | cells { | |
| 211 | + | // The two flavours of a linked value in one row. A profile is a | |
| 212 | + | // route this server answers, so it stays inside the app; an | |
| 213 | + | // address is not, so it leaves. | |
| 214 | + | // | |
| 215 | + | // Navigating (`00ee7af5`): a profile is a whole document, not a | |
| 216 | + | // region, so the anchor is the whole of it and the swap is | |
| 217 | + | // dropped. Without it htmx would morph a `<!doctype html>` page | |
| 218 | + | // over the cell it was clicked in. | |
| 219 | + | cell buyer.username.clone() { | |
| 220 | + | activate to get "/u/{buyer.username}" navigating; | |
| 221 | + | } | |
| 222 | + | cell buyer.email.clone() { | |
| 223 | + | activate to external "mailto:{buyer.email}"; | |
| 224 | + | } | |
| 225 | + | cell buyer.purchases.clone(); | |
| 226 | + | cell buyer.spent.clone(); | |
| 227 | + | cell buyer.last_purchase.clone(); | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | } | |
| 226 | 231 | } | |
| 227 | 232 | ||
| 228 | - | /// The creators this reader has shared an email with. | |
| 229 | - | fn shared_table(shared: &[SharedView]) -> Node { | |
| 230 | - | // Positional, and here it is the only sensible reading: the act column has | |
| 231 | - | // no heading to name, so a named cell would have to spell the empty string | |
| 232 | - | // and the pairing would be less obvious than the order already makes it. | |
| 233 | - | // Both cells are drawn for every creator. Nothing is paged, so no `more`: | |
| 234 | - | // this is a whole set the handler already counted. | |
| 235 | - | Table::new(vec![ | |
| 236 | - | Column::new("Creator") | |
| 237 | - | .width(layout::Width::Fill) | |
| 238 | - | .priority(layout::Priority::Essential), | |
| 239 | - | Column::new("") | |
| 240 | - | .width(layout::Width::Content) | |
| 241 | - | .priority(layout::Priority::Essential), | |
| 242 | - | ]) | |
| 243 | - | .rows(shared.iter().map(|creator| { | |
| 244 | - | Cells::new([ | |
| 245 | - | // The display name is what the row reads as and the username | |
| 246 | - | // is where it goes, which is the pairing the template made | |
| 247 | - | // with a nested `{% if let %}` inside the anchor. | |
| 248 | - | Cell::new(creator.name.clone()) | |
| 249 | - | .activate(Action::get(format!("/u/{}", creator.username)).navigating()), | |
| 250 | - | Cell::acts([Act::new( | |
| 251 | - | "Revoke", | |
| 252 | - | // This screen's own route, under its own nest. The API's | |
| 253 | - | // answers 204, which htmx never swaps, so the row stayed | |
| 254 | - | // after a successful revoke. See `revoke`. | |
| 255 | - | Action::delete(format!("{PATH}/revoke/{}", creator.seller_id)).awaiting(), | |
| 256 | - | ) | |
| 257 | - | // The template asked with hx-confirm. Said here, a | |
| 258 | - | // terminal host asks in its own way and no host can | |
| 259 | - | // forget to ask. | |
| 260 | - | .confirm(format!("Revoke contact sharing with {}?", creator.username)) | |
| 261 | - | .tone(layout::Tone::Danger)]), | |
| 262 | - | ]) | |
| 263 | - | })) | |
| 264 | - | .into() | |
| 233 | + | declare! { | |
| 234 | + | /// The creators this reader has shared an email with. | |
| 235 | + | /// | |
| 236 | + | /// Positional, and here it is the only sensible reading: the act column has | |
| 237 | + | /// no heading to name, so a named cell would have to spell the empty string | |
| 238 | + | /// and the pairing would be less obvious than the order already makes it. | |
| 239 | + | /// Both cells are drawn for every creator. Nothing is paged, so no `more`: | |
| 240 | + | /// this is a whole set the handler already counted. | |
| 241 | + | shape shared_table(shared: &[SharedView]) -> Node; | |
| 242 | + | ||
| 243 | + | table { | |
| 244 | + | column "Creator" { | |
| 245 | + | width Fill; | |
| 246 | + | priority Essential; | |
| 247 | + | } | |
| 248 | + | column "" { | |
| 249 | + | width Content; | |
| 250 | + | priority Essential; | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | for creator in shared.iter() { | |
| 254 | + | cells { | |
| 255 | + | // The display name is what the row reads as and the username is | |
| 256 | + | // where it goes, which is the pairing the template made with a | |
| 257 | + | // nested `{% if let %}` inside the anchor. | |
| 258 | + | cell creator.name.clone() { | |
| 259 | + | activate to get "/u/{creator.username}" navigating; | |
| 260 | + | } | |
| 261 | + | // An empty cell holding one control is the same value as | |
| 262 | + | // `Cell::acts`, which `Cell::new`'s own doc says. | |
| 263 | + | cell "" { | |
| 264 | + | // This screen's own route, under its own nest. The API | |
| 265 | + | // answers 204, which htmx never swaps, so the row stayed | |
| 266 | + | // after a successful revoke. See `revoke`. | |
| 267 | + | act "Revoke" to delete "{PATH}/revoke/{creator.seller_id}" awaiting { | |
| 268 | + | // The template asked with hx-confirm. Said here, a | |
| 269 | + | // terminal host asks in its own way and no host can | |
| 270 | + | // forget to ask. | |
| 271 | + | confirm "Revoke contact sharing with {creator.username}?"; | |
| 272 | + | tone Danger; | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | } | |
| 265 | 278 | } | |
| 266 | 279 | ||
| 267 | 280 | /// The renderer this screen is drawn with. | |
| @@ -292,7 +305,7 @@ | |||
| 292 | 305 | } | |
| 293 | 306 | } | |
| 294 | 307 | ||
| 295 | - | fn render(node: &Node) -> String { | |
| 308 | + | fn render(node: &quasi_router::Node) -> String { | |
| 296 | 309 | Webview::new().fragment(node) | |
| 297 | 310 | } | |
| 298 | 311 |