max / makenotwork
5 files changed,
+550 insertions,
-463 deletions
| @@ -31,10 +31,8 @@ | |||
| 31 | 31 | //! and the design system draws the control and the confirmation. | |
| 32 | 32 | ||
| 33 | 33 | use makeover_layout as layout; | |
| 34 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Table}; | |
| 35 | - | use quasi_router::{ | |
| 36 | - | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 37 | - | }; | |
| 34 | + | use quasi_declare::declare; | |
| 35 | + | use quasi_router::{Document, Request, Response, RouteError}; | |
| 38 | 36 | use quasi_webview::Webview; | |
| 39 | 37 | ||
| 40 | 38 | use crate::db; | |
| @@ -62,6 +60,16 @@ | |||
| 62 | 60 | items: Vec<Item>, | |
| 63 | 61 | } | |
| 64 | 62 | ||
| 63 | + | impl Loaded { | |
| 64 | + | /// What the collection says about itself, or nothing. | |
| 65 | + | /// | |
| 66 | + | /// Empty rather than `None`, so the description asks one question and reads | |
| 67 | + | /// one answer instead of matching an `Option` it cannot spell a pattern for. | |
| 68 | + | fn description(&self) -> &str { | |
| 69 | + | self.description.as_deref().unwrap_or_default() | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 65 | 73 | /// One item in the collection. | |
| 66 | 74 | /// | |
| 67 | 75 | /// `item_type` reads as a repetition of the struct's name and is not one: it is | |
| @@ -162,95 +170,96 @@ | |||
| 162 | 170 | }) | |
| 163 | 171 | } | |
| 164 | 172 | ||
| 165 | - | /// The whole document: the title, the measure, the body. | |
| 166 | - | fn page_screen(loaded: &Loaded) -> Described { | |
| 167 | - | let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page(loaded.title.clone())); | |
| 173 | + | declare! { | |
| 174 | + | /// The whole document: the title, the measure, the body. | |
| 175 | + | shape page_screen(loaded: &Loaded) -> Screen; | |
| 168 | 176 | ||
| 169 | - | // "by <owner>", with the owner's name as a link rather than a button: it is | |
| 170 | - | // a name that goes somewhere, which is exactly what `Node::Link` is for and | |
| 171 | - | // what `Node::act` would draw a bevel around. | |
| 172 | - | page = page.with(Node::Link { | |
| 173 | - | text: format!("by {}", loaded.owner_shown), | |
| 174 | - | action: Action::get(format!("/u/{}", loaded.owner_username)).navigating(), | |
| 175 | - | }); | |
| 177 | + | screen single "{loaded.title} - {loaded.owner_username} - Makenotwork" { | |
| 178 | + | measured MEASURE; | |
| 179 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &["collection-page"])); | |
| 176 | 180 | ||
| 177 | - | // The template put a `Private` badge beside the owner's name. A `Tag` is | |
| 178 | - | // not a `Node` -- tokens belong to rows and cells -- and rather than invent | |
| 179 | - | // a row to hold one, this says it as a banner. That is the better reading | |
| 180 | - | // anyway: the only person who sees it is the owner, and what they need to | |
| 181 | - | // know is that nobody else can open the link they are looking at, which a | |
| 182 | - | // small badge next to a name says quietly and a banner says once. | |
| 183 | - | if !loaded.is_public { | |
| 184 | - | page = page.with(Node::banner( | |
| 185 | - | layout::Tone::Info, | |
| 186 | - | "This collection is private. Only you can see it.", | |
| 187 | - | )); | |
| 181 | + | region PAGE_REGION as Pane { | |
| 182 | + | page loaded.title.clone(); | |
| 183 | + | ||
| 184 | + | // "by <owner>", with the owner's name as a link rather than a | |
| 185 | + | // button: it is a name that goes somewhere, which is exactly what a | |
| 186 | + | // link is for and what an act would draw a bevel around. | |
| 187 | + | link "by {loaded.owner_shown}" to get "/u/{loaded.owner_username}" navigating; | |
| 188 | + | ||
| 189 | + | // The template put a `Private` badge beside the owner's name. A | |
| 190 | + | // `Tag` is not a `Node` -- tokens belong to rows and cells -- and | |
| 191 | + | // rather than invent a row to hold one, this says it as a banner. | |
| 192 | + | // That is the better reading anyway: the only person who sees it is | |
| 193 | + | // the owner, and what they need to know is that nobody else can open | |
| 194 | + | // the link they are looking at, which a small badge next to a name | |
| 195 | + | // says quietly and a banner says once. | |
| 196 | + | banner layout::Tone::Info "This collection is private. Only you can see it." | |
| 197 | + | unless loaded.is_public; | |
| 198 | + | ||
| 199 | + | text loaded.description() unless loaded.description().is_empty(); | |
| 200 | + | text "{loaded.items.len()} items"; | |
| 201 | + | ||
| 202 | + | empty "This collection is empty." when loaded.items.is_empty(); | |
| 203 | + | include items_table(&loaded.items) unless loaded.items.is_empty(); | |
| 204 | + | ||
| 205 | + | act "View profile" to get "/u/{loaded.owner_username}" navigating; | |
| 206 | + | ||
| 207 | + | // `copying` is a setting on the control and not a modifier of the | |
| 208 | + | // action: it sets the destination to `local` itself, because a copy | |
| 209 | + | // asks no route and the two facts are one sentence. | |
| 210 | + | act "Copy link" to local { | |
| 211 | + | copying "/c/{loaded.owner_username}/{loaded.slug}"; | |
| 212 | + | } | |
| 213 | + | } | |
| 188 | 214 | } | |
| 189 | - | ||
| 190 | - | if let Some(description) = loaded.description.as_deref() { | |
| 191 | - | page = page.with(Node::text(description)); | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | page = page.with(Node::text(format!("{} items", loaded.items.len()))); | |
| 195 | - | ||
| 196 | - | page = if loaded.items.is_empty() { | |
| 197 | - | page.with(Node::empty("This collection is empty.")) | |
| 198 | - | } else { | |
| 199 | - | page.with(items_table(&loaded.items)) | |
| 200 | - | }; | |
| 201 | - | ||
| 202 | - | page = page | |
| 203 | - | .with(Node::act( | |
| 204 | - | "View profile", | |
| 205 | - | Action::get(format!("/u/{}", loaded.owner_username)).navigating(), | |
| 206 | - | )) | |
| 207 | - | .with(Node::Act(Act::new("Copy link", Action::local()).copying( | |
| 208 | - | format!("/c/{}/{}", loaded.owner_username, loaded.slug), | |
| 209 | - | ))); | |
| 210 | - | ||
| 211 | - | Described::single(format!( | |
| 212 | - | "{} - {} - Makenotwork", | |
| 213 | - | loaded.title, loaded.owner_username | |
| 214 | - | )) | |
| 215 | - | .measured(MEASURE) | |
| 216 | - | .documented( | |
| 217 | - | Document::default().classed(crate::shell::body_class(MEASURE, &["collection-page"])), | |
| 218 | - | ) | |
| 219 | - | .with(page) | |
| 220 | 215 | } | |
| 221 | 216 | ||
| 222 | - | /// The items, as a table. | |
| 223 | - | /// | |
| 224 | - | /// The template drew three stacked `<div>`s per item -- title, a middot-joined | |
| 225 | - | /// meta line, and a price pushed to the right -- which is a table written by | |
| 226 | - | /// hand. Saying it as one lets the design system decide what a narrow viewport | |
| 227 | - | /// drops, and the meta line's three facts become three columns that can be | |
| 228 | - | /// dropped independently rather than a string that wraps. | |
| 229 | - | fn items_table(items: &[Item]) -> Node { | |
| 230 | - | // The cells are positional, and stay so because the columns are three lines | |
| 231 | - | // above them in this one expression and every item fills all five. Naming | |
| 232 | - | // buys nothing a reader cannot already see; it earns its keep where a cell | |
| 233 | - | // is conditional or the headings live in another function. | |
| 234 | - | Table::new([ | |
| 235 | - | Column::new("Item") | |
| 236 | - | .width(layout::Width::Fill) | |
| 237 | - | .priority(layout::Priority::Essential), | |
| 238 | - | Column::new("Type").width(layout::Width::Content), | |
| 239 | - | Column::new("Creator").width(layout::Width::Content), | |
| 240 | - | Column::new("Project").width(layout::Width::Content), | |
| 241 | - | Column::new("Price").width(layout::Width::Content), | |
| 242 | - | ]) | |
| 243 | - | .rows(items.iter().map(|item| { | |
| 244 | - | Cells::new([ | |
| 245 | - | Cell::new(item.title.clone()), | |
| 246 | - | Cell::new(item.item_type.clone()), | |
| 247 | - | Cell::new(item.creator.clone()), | |
| 248 | - | Cell::new(item.project.clone()), | |
| 249 | - | Cell::new(item.price.clone()), | |
| 250 | - | ]) | |
| 251 | - | .activate(Action::get(format!("/i/{}", item.id)).navigating()) | |
| 252 | - | })) | |
| 253 | - | .into() | |
| 217 | + | declare! { | |
| 218 | + | /// The items, as a table. | |
| 219 | + | /// | |
| 220 | + | /// The template drew three stacked `<div>`s per item -- title, a | |
| 221 | + | /// middot-joined meta line, and a price pushed to the right -- which is a | |
| 222 | + | /// table written by hand. Saying it as one lets the design system decide | |
| 223 | + | /// what a narrow viewport drops, and the meta line's three facts become | |
| 224 | + | /// three columns that can be dropped independently rather than a string that | |
| 225 | + | /// wraps. | |
| 226 | + | /// | |
| 227 | + | /// The cells are positional, and stay so because the columns are five lines | |
| 228 | + | /// above them in one declaration and every item fills all five. Naming buys | |
| 229 | + | /// nothing a reader cannot already see; it earns its keep where a cell is | |
| 230 | + | /// conditional or the headings live in another shape. | |
| 231 | + | shape items_table(items: &[Item]) -> Node; | |
| 232 | + | ||
| 233 | + | table { | |
| 234 | + | column "Item" { | |
| 235 | + | width Fill; | |
| 236 | + | priority Essential; | |
| 237 | + | } | |
| 238 | + | column "Type" { | |
| 239 | + | width Content; | |
| 240 | + | } | |
| 241 | + | column "Creator" { | |
| 242 | + | width Content; | |
| 243 | + | } | |
| 244 | + | column "Project" { | |
| 245 | + | width Content; | |
| 246 | + | } | |
| 247 | + | column "Price" { | |
| 248 | + | width Content; | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | for item in items.iter() { | |
| 252 | + | cells { | |
| 253 | + | cell item.title.clone(); | |
| 254 | + | cell item.item_type.clone(); | |
| 255 | + | cell item.creator.clone(); | |
| 256 | + | cell item.project.clone(); | |
| 257 | + | cell item.price.clone(); | |
| 258 | + | ||
| 259 | + | activate to get "/i/{item.id}" navigating; | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | } | |
| 254 | 263 | } | |
| 255 | 264 | ||
| 256 | 265 | /// The document this screen is drawn in. |
| @@ -82,10 +82,9 @@ | |||
| 82 | 82 | //! [`Cell::part`]: quasi_router::screen::Cell::part | |
| 83 | 83 | ||
| 84 | 84 | use makeover_layout as layout; | |
| 85 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Rest, Table, Tag}; | |
| 86 | - | use quasi_router::{ | |
| 87 | - | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 88 | - | }; | |
| 85 | + | use quasi_declare::declare; | |
| 86 | + | use quasi_router::screen::{Rest, Tag}; | |
| 87 | + | use quasi_router::{Action, Document, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 89 | 88 | use quasi_webview::Webview; | |
| 90 | 89 | ||
| 91 | 90 | use crate::constants; | |
| @@ -255,20 +254,29 @@ | |||
| 255 | 254 | Ok(page_screen(&loaded.page()).into()) | |
| 256 | 255 | } | |
| 257 | 256 | ||
| 258 | - | /// The whole document: the title, the measure, the body. | |
| 259 | - | fn page_screen(page: &Page<'_>) -> Described { | |
| 260 | - | let mut pane = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page("Your Feed")); | |
| 261 | - | for node in body(page, Surface::Page) { | |
| 262 | - | pane = pane.with(node); | |
| 263 | - | } | |
| 264 | - | Described::single("Feed - Makenotwork") | |
| 265 | - | .measured(MEASURE) | |
| 257 | + | declare! { | |
| 258 | + | /// The whole document: the title, the measure, the body. | |
| 259 | + | /// | |
| 260 | + | /// The body arrives as a list of members and is spread into the pane one at | |
| 261 | + | /// a time, which is the same loop both callers wrote by hand: a fill has no | |
| 262 | + | /// region of its own, so nothing can place it whole. | |
| 263 | + | shape page_screen(page: &Page<'_>) -> Screen; | |
| 264 | + | ||
| 265 | + | screen single "Feed - Makenotwork" { | |
| 266 | + | measured MEASURE; | |
| 266 | 267 | // `padded-page feed-page`, which is what `pages/feed.html:4` rendered. | |
| 267 | 268 | // Composed rather than written out: `Document::classed` replaces, so a | |
| 268 | 269 | // screen naming only its own token would drop its measure (`2790e5c4`). | |
| 269 | - | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &["feed-page"]))) | |
| 270 | - | .summarised("Items from the users, projects and tags you follow.") | |
| 271 | - | .with(pane) | |
| 270 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &["feed-page"])); | |
| 271 | + | summarised "Items from the users, projects and tags you follow."; | |
| 272 | + | ||
| 273 | + | region PAGE_REGION as Pane { | |
| 274 | + | page "Your Feed"; | |
| 275 | + | for node in body(page, Surface::Page) { | |
| 276 | + | include node; | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | } | |
| 272 | 280 | } | |
| 273 | 281 | ||
| 274 | 282 | /// The document this screen is drawn in. | |
| @@ -311,98 +319,111 @@ | |||
| 311 | 319 | } | |
| 312 | 320 | } | |
| 313 | 321 | ||
| 314 | - | /// The surface's contents, in order. | |
| 315 | - | fn body(page: &Page<'_>, surface: Surface) -> Vec<Node> { | |
| 316 | - | if page.items.is_empty() { | |
| 317 | - | return vec![empty()]; | |
| 322 | + | declare! { | |
| 323 | + | /// The surface's contents, in order. | |
| 324 | + | shape body(page: &Page<'_>, surface: Surface) -> Vec<Node>; | |
| 325 | + | ||
| 326 | + | include empty() when page.items.is_empty(); | |
| 327 | + | ||
| 328 | + | text "Showing {page.showing_start}-{page.showing_end} of {page.total_items} items" | |
| 329 | + | unless page.items.is_empty(); | |
| 330 | + | include table(page, surface) unless page.items.is_empty(); | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | declare! { | |
| 334 | + | /// Nothing followed yet. | |
| 335 | + | /// | |
| 336 | + | /// The two templates spelled this differently -- the panel wrote its own | |
| 337 | + | /// three paragraphs and a button, the page called | |
| 338 | + | /// `ui::empty_state_with_action` -- and said the same thing with the same | |
| 339 | + | /// way out. One spelling now, which is one of the things converting both | |
| 340 | + | /// surfaces together buys. | |
| 341 | + | shape empty() -> Node; | |
| 342 | + | ||
| 343 | + | empty "Nothing here yet. Follow users, projects, or tags to see their items here." { | |
| 344 | + | offering "Browse Discover" to get "/discover" navigating; | |
| 318 | 345 | } | |
| 319 | - | ||
| 320 | - | vec![ | |
| 321 | - | Node::text(format!( | |
| 322 | - | "Showing {}-{} of {} items", | |
| 323 | - | page.showing_start, page.showing_end, page.total_items | |
| 324 | - | )), | |
| 325 | - | table(page, surface), | |
| 326 | - | ] | |
| 327 | 346 | } | |
| 328 | 347 | ||
| 329 | - | /// Nothing followed yet. | |
| 330 | - | /// | |
| 331 | - | /// The two templates spelled this differently -- the panel wrote its own three | |
| 332 | - | /// paragraphs and a button, the page called `ui::empty_state_with_action` -- and | |
| 333 | - | /// said the same thing with the same way out. One spelling now, which is one of | |
| 334 | - | /// the things converting both surfaces together buys. | |
| 335 | - | fn empty() -> Node { | |
| 336 | - | Node::empty("Nothing here yet. Follow users, projects, or tags to see their items here.") | |
| 337 | - | .offering(Act::new( | |
| 338 | - | "Browse Discover", | |
| 339 | - | Action::get("/discover").navigating(), | |
| 340 | - | )) | |
| 341 | - | } | |
| 348 | + | declare! { | |
| 349 | + | /// The five columns, and the rows under them. | |
| 350 | + | shape table(page: &Page<'_>, surface: Surface) -> Node; | |
| 342 | 351 | ||
| 343 | - | /// The five columns, and the rows under them. | |
| 344 | - | fn table(page: &Page<'_>, surface: Surface) -> Node { | |
| 345 | - | let mut table = Table::new([ | |
| 346 | - | Column::new("Type").width(layout::Width::Content), | |
| 347 | - | Column::new("Name") | |
| 348 | - | .width(layout::Width::Fill) | |
| 349 | - | .priority(layout::Priority::Essential), | |
| 350 | - | Column::new("Tag").width(layout::Width::Content), | |
| 351 | - | Column::new("Price").width(layout::Width::Content), | |
| 352 | - | Column::new("Date").width(layout::Width::Content), | |
| 353 | - | ]) | |
| 354 | - | .rows(page.items.iter().map(row)); | |
| 352 | + | table { | |
| 353 | + | column "Type" { | |
| 354 | + | width Content; | |
| 355 | + | } | |
| 356 | + | column "Name" { | |
| 357 | + | width Fill; | |
| 358 | + | priority Essential; | |
| 359 | + | } | |
| 360 | + | column "Tag" { | |
| 361 | + | width Content; | |
| 362 | + | } | |
| 363 | + | column "Price" { | |
| 364 | + | width Content; | |
| 365 | + | } | |
| 366 | + | column "Date" { | |
| 367 | + | width Content; | |
| 368 | + | } | |
| 355 | 369 | ||
| 356 | - | if let Some(rest) = rest(page, surface) { | |
| 357 | - | table = table.more(rest); | |
| 370 | + | for item in page.items.iter() { | |
| 371 | + | include row(item); | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | more rest(page, surface) when page.total_pages over 1; | |
| 358 | 375 | } | |
| 359 | - | ||
| 360 | - | table.into() | |
| 361 | 376 | } | |
| 362 | 377 | ||
| 363 | - | /// One item. | |
| 378 | + | /// What the price cell reads, which is nothing when the item is free. | |
| 364 | 379 | /// | |
| 365 | - | /// The cells name their columns rather than counting to them, because the | |
| 366 | - | /// headings are in [`table`] and this is a different function: position is only | |
| 367 | - | /// checkable when both halves are in front of you, and here they never are. The | |
| 368 | - | /// names must match [`table`]'s `Column::new` strings exactly, since a name no | |
| 369 | - | /// column carries is dropped rather than reported. | |
| 370 | - | fn row(item: &DiscoverItem) -> Cells { | |
| 371 | - | let price = if item.is_free { | |
| 372 | - | let mut free = Tag::badge("Free"); | |
| 373 | - | free.tone = layout::Tone::Success; | |
| 374 | - | Cell::new(String::new()).token(free) | |
| 380 | + | /// A free item says so with a token instead, and the two are one cell rather | |
| 381 | + | /// than two guarded ones: a cell that is sometimes a word and sometimes a badge | |
| 382 | + | /// is still one cell. | |
| 383 | + | fn price(item: &DiscoverItem) -> String { | |
| 384 | + | if item.is_free { | |
| 385 | + | String::new() | |
| 375 | 386 | } else { | |
| 376 | - | Cell::new(item.price.clone()) | |
| 377 | - | }; | |
| 387 | + | item.price.clone() | |
| 388 | + | } | |
| 389 | + | } | |
| 378 | 390 | ||
| 379 | - | Cells::default() | |
| 380 | - | .at( | |
| 381 | - | "Type", | |
| 382 | - | Cell::new(String::new()).token(Tag::badge(item.item_type.clone())), | |
| 383 | - | ) | |
| 391 | + | declare! { | |
| 392 | + | /// One item. | |
| 393 | + | /// | |
| 394 | + | /// The cells name their columns rather than counting to them, because the | |
| 395 | + | /// headings are in [`table`] and this is a different shape: position is only | |
| 396 | + | /// checkable when both halves are in front of you, and here they never are. | |
| 397 | + | /// The names must match [`table`]'s `column` strings exactly, since a name | |
| 398 | + | /// no column carries is dropped rather than reported. | |
| 399 | + | shape row(item: &DiscoverItem) -> Cells; | |
| 400 | + | ||
| 401 | + | cells { | |
| 402 | + | cell at "Type" "" { | |
| 403 | + | token Tag::badge(item.item_type.clone()); | |
| 404 | + | } | |
| 384 | 405 | // The name is the link text and the creator rides under it in the same | |
| 385 | 406 | // cell, which is what `Cell::activate` picks: the first text part. | |
| 386 | - | .at( | |
| 387 | - | "Name", | |
| 388 | - | Cell::new(item.name.clone()).part(Node::text(item.creator.clone())), | |
| 389 | - | ) | |
| 390 | - | .at("Tag", Cell::new(item.primary_tag.clone())) | |
| 391 | - | .at("Price", price) | |
| 392 | - | .at("Date", Cell::new(item.date.clone())) | |
| 393 | - | .activate(Action::get(format!("/i/{}", item.id)).navigating()) | |
| 407 | + | cell at "Name" item.name.clone() { | |
| 408 | + | text item.creator.clone(); | |
| 409 | + | } | |
| 410 | + | cell at "Tag" item.primary_tag.clone(); | |
| 411 | + | cell at "Price" price(item) { | |
| 412 | + | token Tag::badge("Free").tone(layout::Tone::Success) when item.is_free; | |
| 413 | + | } | |
| 414 | + | cell at "Date" item.date.clone(); | |
| 415 | + | ||
| 416 | + | activate to get "/i/{item.id}" navigating; | |
| 417 | + | } | |
| 394 | 418 | } | |
| 395 | 419 | ||
| 396 | 420 | /// What the reader has not been shown, and every way to ask for it. | |
| 397 | 421 | /// | |
| 398 | - | /// `None` on a single-page feed, which is what `{% if total_pages > 1 %}` said: | |
| 399 | - | /// a set that arrived whole has no rest, and a pager drawn over one would be | |
| 400 | - | /// two disabled buttons and the number 1. | |
| 401 | - | fn rest(page: &Page<'_>, surface: Surface) -> Option<Rest> { | |
| 402 | - | if page.total_pages <= 1 { | |
| 403 | - | return None; | |
| 404 | - | } | |
| 405 | - | ||
| 422 | + | /// The table asks for this only when there is more than one page, which is what | |
| 423 | + | /// `{% if total_pages > 1 %}` said: a set that arrived whole has no rest, and a | |
| 424 | + | /// pager drawn over one would be two disabled buttons and the number 1. R9 means | |
| 425 | + | /// this is still called on a single-page feed, and the answer is thrown away. | |
| 426 | + | fn rest(page: &Page<'_>, surface: Surface) -> Rest { | |
| 406 | 427 | let per = constants::FEED_PAGE_SIZE as usize; | |
| 407 | 428 | let from = (page.current_page as usize).saturating_sub(1) * per; | |
| 408 | 429 | let mut rest = Rest::page(from, per).of(page.total_items as usize); | |
| @@ -417,7 +438,7 @@ | |||
| 417 | 438 | rest = rest.jumping(*jump as usize, surface.address(*jump)); | |
| 418 | 439 | } | |
| 419 | 440 | ||
| 420 | - | Some(rest) | |
| 441 | + | rest | |
| 421 | 442 | } | |
| 422 | 443 | ||
| 423 | 444 | #[cfg(test)] |
| @@ -31,8 +31,7 @@ | |||
| 31 | 31 | ||
| 32 | 32 | use makeover_layout as layout; | |
| 33 | 33 | use quasi_declare::declare; | |
| 34 | - | use quasi_router::screen::{Cell, Cells, Column, Lexeme, Table}; | |
| 35 | - | use quasi_router::{Action, Document, Node, RegionKind, Screen, Slot}; | |
| 34 | + | use quasi_router::Document; | |
| 36 | 35 | use quasi_webview::Webview; | |
| 37 | 36 | ||
| 38 | 37 | use crate::git::{BlameLine, Breadcrumb, RefInfo}; | |
| @@ -72,28 +71,28 @@ | |||
| 72 | 71 | } | |
| 73 | 72 | } | |
| 74 | 73 | ||
| 75 | - | /// The whole document: the title, the measure, the body. | |
| 76 | - | #[must_use] | |
| 77 | - | pub fn screen(view: &View<'_>) -> Screen { | |
| 78 | - | let page = Slot::new(PAGE_REGION, RegionKind::Pane) | |
| 79 | - | .with(super::widgets::git_nav::heading(view.owner, view.repo)) | |
| 80 | - | .with(super::widgets::git_nav::region(&view.nav())) | |
| 81 | - | .with(super::widgets::git_nav::breadcrumb( | |
| 82 | - | view.owner, | |
| 83 | - | view.repo, | |
| 84 | - | view.current_ref, | |
| 85 | - | view.breadcrumbs, | |
| 86 | - | )) | |
| 87 | - | .with(header(view)) | |
| 88 | - | .with(table(view)); | |
| 74 | + | declare! { | |
| 75 | + | /// The whole document: the title, the measure, the body. | |
| 76 | + | #[must_use] | |
| 77 | + | pub shape screen(view: &View<'_>) -> Screen; | |
| 89 | 78 | ||
| 90 | - | Screen::single(format!( | |
| 91 | - | "Blame {} - {} - Git - Makenotwork", | |
| 92 | - | view.filename, view.repo | |
| 93 | - | )) | |
| 94 | - | .measured(MEASURE) | |
| 95 | - | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) | |
| 96 | - | .with(page) | |
| 79 | + | screen single "Blame {view.filename} - {view.repo} - Git - Makenotwork" { | |
| 80 | + | measured MEASURE; | |
| 81 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 82 | + | ||
| 83 | + | region PAGE_REGION as Pane { | |
| 84 | + | include super::widgets::git_nav::heading(view.owner, view.repo); | |
| 85 | + | include super::widgets::git_nav::region(&view.nav()); | |
| 86 | + | include super::widgets::git_nav::breadcrumb( | |
| 87 | + | view.owner, | |
| 88 | + | view.repo, | |
| 89 | + | view.current_ref, | |
| 90 | + | view.breadcrumbs | |
| 91 | + | ); | |
| 92 | + | include header(view); | |
| 93 | + | include table(view); | |
| 94 | + | } | |
| 95 | + | } | |
| 97 | 96 | } | |
| 98 | 97 | ||
| 99 | 98 | declare! { | |
| @@ -119,73 +118,84 @@ | |||
| 119 | 118 | } | |
| 120 | 119 | } | |
| 121 | 120 | ||
| 122 | - | /// One row per line: who last touched it, when, and what it says. | |
| 123 | - | fn table(view: &View<'_>) -> Node { | |
| 124 | - | let base = format!("/git/{}/{}", view.owner, view.repo); | |
| 121 | + | declare! { | |
| 122 | + | /// One row per line: who last touched it, when, and what it says. | |
| 123 | + | shape table(view: &View<'_>) -> Node; | |
| 125 | 124 | ||
| 126 | - | Table::new(vec![ | |
| 125 | + | let base = "/git/{view.owner}/{view.repo}"; | |
| 126 | + | ||
| 127 | + | table { | |
| 127 | 128 | // The commit is the identity of the row: everything else is a fact | |
| 128 | - | // about it, and a narrow viewport that dropped it would leave a | |
| 129 | - | // blame table that blames nobody. | |
| 130 | - | Column::new("Commit") | |
| 131 | - | .width(layout::Width::Content) | |
| 132 | - | .priority(layout::Priority::Essential), | |
| 133 | - | Column::new("Author") | |
| 134 | - | .width(layout::Width::Content) | |
| 135 | - | .priority(layout::Priority::Secondary), | |
| 136 | - | Column::new("Date") | |
| 137 | - | .width(layout::Width::Content) | |
| 138 | - | .priority(layout::Priority::Optional), | |
| 139 | - | Column::new("Line") | |
| 140 | - | .width(layout::Width::Content) | |
| 141 | - | .priority(layout::Priority::Secondary), | |
| 142 | - | Column::new("Code") | |
| 143 | - | .width(layout::Width::Fill) | |
| 144 | - | .priority(layout::Priority::Essential), | |
| 145 | - | ]) | |
| 146 | - | .rows(view.lines.iter().map(|line| row(view, &base, line))) | |
| 147 | - | .into() | |
| 129 | + | // about it, and a narrow viewport that dropped it would leave a blame | |
| 130 | + | // table that blames nobody. | |
| 131 | + | column "Commit" { | |
| 132 | + | width Content; | |
| 133 | + | priority Essential; | |
| 134 | + | } | |
| 135 | + | column "Author" { | |
| 136 | + | width Content; | |
| 137 | + | priority Secondary; | |
| 138 | + | } | |
| 139 | + | column "Date" { | |
| 140 | + | width Content; | |
| 141 | + | priority Optional; | |
| 142 | + | } | |
| 143 | + | column "Line" { | |
| 144 | + | width Content; | |
| 145 | + | priority Secondary; | |
| 146 | + | } | |
| 147 | + | column "Code" { | |
| 148 | + | width Fill; | |
| 149 | + | priority Essential; | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | for line in view.lines.iter() { | |
| 153 | + | include row(view, &base, line); | |
| 154 | + | } | |
| 155 | + | } | |
| 148 | 156 | } | |
| 149 | 157 | ||
| 150 | - | /// One blamed line. | |
| 158 | + | /// What the notes link on a blamed line reads. | |
| 151 | 159 | /// | |
| 152 | - | /// The cells name their columns because the column list is a function away: a | |
| 153 | - | /// row written by position here would be lined up against headings nobody | |
| 154 | - | /// reading this function can see. | |
| 155 | - | fn row(view: &View<'_>, base: &str, line: &BlameLine) -> Cells { | |
| 156 | - | let commit = format!("{base}/commit/{}", line.commit_oid); | |
| 157 | - | ||
| 158 | - | // The short oid opens the commit; the badge beside it opens the same commit | |
| 159 | - | // at its notes. Two addresses on one cell, which is what a cell holding a | |
| 160 | - | // run of leaves is for. | |
| 161 | - | let mut identity = Cell::new(String::new()).part(Node::Link { | |
| 162 | - | text: line.commit_short_oid.clone(), | |
| 163 | - | action: Action::get(commit.clone()).navigating(), | |
| 164 | - | }); | |
| 165 | - | if let Some(notes) = view.annotated.get(&line.commit_oid) { | |
| 166 | - | identity = identity.part(Node::Link { | |
| 167 | - | text: if *notes == 1 { | |
| 168 | - | "note".to_owned() | |
| 169 | - | } else { | |
| 170 | - | format!("{notes} notes") | |
| 171 | - | }, | |
| 172 | - | action: Action::get(format!("{commit}#notes")).navigating(), | |
| 173 | - | }); | |
| 160 | + | /// Empty when the commit carries no annotations, which is R9: the value is | |
| 161 | + | /// built whether or not the guard places it. A supplier because the count | |
| 162 | + | /// decides between a word and a number, inside an `Option` the form has no | |
| 163 | + | /// binding pattern to reach. | |
| 164 | + | fn notes_label(view: &View<'_>, line: &BlameLine) -> String { | |
| 165 | + | match view.annotated.get(&line.commit_oid) { | |
| 166 | + | Some(1) => "note".to_owned(), | |
| 167 | + | Some(notes) => format!("{notes} notes"), | |
| 168 | + | None => String::new(), | |
| 174 | 169 | } | |
| 170 | + | } | |
| 175 | 171 | ||
| 176 | - | Cells::default() | |
| 177 | - | .at("Commit", identity) | |
| 178 | - | .at("Author", Cell::new(line.author_name.clone())) | |
| 179 | - | .at("Date", Cell::new(line.time_formatted.clone())) | |
| 180 | - | .at("Line", Cell::new(line.lineno.to_string())) | |
| 181 | - | .at( | |
| 182 | - | "Code", | |
| 183 | - | Cell::default().part(Node::Code { | |
| 184 | - | runs: vec![Lexeme::plain(&line.content)], | |
| 185 | - | language: None, | |
| 186 | - | inline: true, | |
| 187 | - | }), | |
| 188 | - | ) | |
| 172 | + | declare! { | |
| 173 | + | /// One blamed line. | |
| 174 | + | /// | |
| 175 | + | /// The cells name their columns because the column list is a shape away: a | |
| 176 | + | /// row written by position here would be lined up against headings nobody | |
| 177 | + | /// reading this declaration can see. | |
| 178 | + | /// | |
| 179 | + | /// The short oid opens the commit; the link beside it opens the same commit | |
| 180 | + | /// at its notes. Two addresses on one cell, which is what a cell holding a | |
| 181 | + | /// run of leaves is for. | |
| 182 | + | shape row(view: &View<'_>, base: &str, line: &BlameLine) -> Cells; | |
| 183 | + | ||
| 184 | + | let commit = "{base}/commit/{line.commit_oid}"; | |
| 185 | + | ||
| 186 | + | cells { | |
| 187 | + | cell at "Commit" "" { | |
| 188 | + | link line.commit_short_oid.clone() to get commit.clone() navigating; | |
| 189 | + | link notes_label(view, line) to get "{commit}#notes" navigating | |
| 190 | + | when view.annotated.contains_key(&line.commit_oid); | |
| 191 | + | } | |
| 192 | + | cell at "Author" line.author_name.clone(); | |
| 193 | + | cell at "Date" line.time_formatted.clone(); | |
| 194 | + | cell at "Line" line.lineno.to_string(); | |
| 195 | + | cell at "Code" "" { | |
| 196 | + | literal &line.content; | |
| 197 | + | } | |
| 198 | + | } | |
| 189 | 199 | } | |
| 190 | 200 | ||
| 191 | 201 | /// The document this screen is drawn in. |
| @@ -42,7 +42,8 @@ | |||
| 42 | 42 | //! for no reason a reader could otherwise guess. | |
| 43 | 43 | ||
| 44 | 44 | use makeover_layout as layout; | |
| 45 | - | use quasi_router::screen::{Cell, Cells, Column, Figure, Table, Tag}; | |
| 45 | + | use quasi_declare::declare; | |
| 46 | + | use quasi_router::screen::{Figure, Tag}; | |
| 46 | 47 | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 47 | 48 | use quasi_webview::Webview; | |
| 48 | 49 | ||
| @@ -54,12 +55,33 @@ | |||
| 54 | 55 | /// The bespoke region the chart is drawn into. | |
| 55 | 56 | const CHART_SLOT: &str = "project-revenue-chart"; | |
| 56 | 57 | ||
| 58 | + | /// One window the panel offers. | |
| 59 | + | /// | |
| 60 | + | /// Named members rather than a tuple, for `policy`'s reason: a description names | |
| 61 | + | /// what it draws, and `.1` is not a name. | |
| 62 | + | struct Range { | |
| 63 | + | value: &'static str, | |
| 64 | + | label: &'static str, | |
| 65 | + | } | |
| 66 | + | ||
| 57 | 67 | /// The four windows the panel offers, and what each is called. | |
| 58 | - | const RANGES: &[(&str, &str)] = &[ | |
| 59 | - | ("7d", "Last 7 days"), | |
| 60 | - | ("30d", "Last 30 days"), | |
| 61 | - | ("90d", "Last 90 days"), | |
| 62 | - | ("all", "All time"), | |
| 68 | + | const RANGES: &[Range] = &[ | |
| 69 | + | Range { | |
| 70 | + | value: "7d", | |
| 71 | + | label: "Last 7 days", | |
| 72 | + | }, | |
| 73 | + | Range { | |
| 74 | + | value: "30d", | |
| 75 | + | label: "Last 30 days", | |
| 76 | + | }, | |
| 77 | + | Range { | |
| 78 | + | value: "90d", | |
| 79 | + | label: "Last 90 days", | |
| 80 | + | }, | |
| 81 | + | Range { | |
| 82 | + | value: "all", | |
| 83 | + | label: "All time", | |
| 84 | + | }, | |
| 63 | 85 | ]; | |
| 64 | 86 | ||
| 65 | 87 | /// The panel as the route answers it: the region, carrying its own id. | |
| @@ -92,129 +114,156 @@ | |||
| 92 | 114 | webview | |
| 93 | 115 | } | |
| 94 | 116 | ||
| 95 | - | /// The panel's contents, in order. | |
| 96 | - | fn body( | |
| 97 | - | slug: &str, | |
| 98 | - | range: &str, | |
| 99 | - | stats: &[StatCard], | |
| 100 | - | bars: &[ChartBar], | |
| 101 | - | items: &[ContentItem], | |
| 102 | - | ) -> Vec<Node> { | |
| 103 | - | let mut out = vec![ | |
| 104 | - | Node::Link { | |
| 105 | - | text: "Docs: Analytics".into(), | |
| 106 | - | action: Action::get("/docs/analytics").navigating(), | |
| 107 | - | }, | |
| 108 | - | Node::Link { | |
| 109 | - | text: "Export data".into(), | |
| 110 | - | action: Action::get("/dashboard/export").navigating(), | |
| 111 | - | }, | |
| 112 | - | Node::section(heading(range)), | |
| 113 | - | ]; | |
| 117 | + | declare! { | |
| 118 | + | /// The panel's contents, in order. | |
| 119 | + | /// | |
| 120 | + | /// The chart is a bespoke region: the description says only that there is | |
| 121 | + | /// one here and what it is called, and [`drawn`] puts the markup in it. | |
| 122 | + | shape body( | |
| 123 | + | slug: &str, | |
| 124 | + | range: &str, | |
| 125 | + | stats: &[StatCard], | |
| 126 | + | bars: &[ChartBar], | |
| 127 | + | items: &[ContentItem], | |
| 128 | + | ) -> Vec<Node>; | |
| 114 | 129 | ||
| 115 | - | out.extend(range_chips(slug, range)); | |
| 116 | - | out.push(figures(stats)); | |
| 117 | - | out.push(Node::section("Revenue Over Time")); | |
| 130 | + | link "Docs: Analytics" to get "/docs/analytics" navigating; | |
| 131 | + | link "Export data" to get "/dashboard/export" navigating; | |
| 132 | + | section heading(range); | |
| 118 | 133 | ||
| 119 | - | out.push(if bars.is_empty() { | |
| 120 | - | Node::empty( | |
| 121 | - | "No revenue data yet. Revenue will appear here after your first sale. \ | |
| 122 | - | Publish an item and share it to get started.", | |
| 123 | - | ) | |
| 124 | - | } else { | |
| 125 | - | // The description says only that there is a region here and what it is | |
| 126 | - | // called; `drawn` puts the markup in it. | |
| 127 | - | Node::Region(Slot::ceded(CHART_SLOT, "revenue-chart")) | |
| 128 | - | }); | |
| 134 | + | for chip in range_chips(slug, range) { | |
| 135 | + | include chip; | |
| 136 | + | } | |
| 129 | 137 | ||
| 130 | - | out.push(Node::section("Top Performing Items")); | |
| 131 | - | out.push(if items.is_empty() { | |
| 132 | - | Node::empty("No sales data yet. Publish and promote your items to see analytics here.") | |
| 133 | - | } else { | |
| 134 | - | top_items(items) | |
| 135 | - | }); | |
| 138 | + | include figures(stats); | |
| 136 | 139 | ||
| 137 | - | out | |
| 140 | + | section "Revenue Over Time"; | |
| 141 | + | empty "No revenue data yet. Revenue will appear here after your first sale. \ | |
| 142 | + | Publish an item and share it to get started." | |
| 143 | + | when bars.is_empty(); | |
| 144 | + | region CHART_SLOT as RegionKind::ceded("revenue-chart") unless bars.is_empty() {} | |
| 145 | + | ||
| 146 | + | section "Top Performing Items"; | |
| 147 | + | empty "No sales data yet. Publish and promote your items to see analytics here." | |
| 148 | + | when items.is_empty(); | |
| 149 | + | include top_items(items) unless items.is_empty(); | |
| 138 | 150 | } | |
| 139 | 151 | ||
| 140 | 152 | /// What the window is called, as the heading says it. | |
| 141 | 153 | fn heading(range: &str) -> &'static str { | |
| 142 | 154 | RANGES | |
| 143 | 155 | .iter() | |
| 144 | - | .find(|(value, _)| *value == range) | |
| 145 | - | .map_or("All time", |(_, label)| *label) | |
| 156 | + | .find(|window| window.value == range) | |
| 157 | + | .map_or("All time", |window| window.label) | |
| 146 | 158 | } | |
| 147 | 159 | ||
| 148 | - | /// The four range controls. | |
| 160 | + | /// Whether this window is the one being shown. | |
| 149 | 161 | /// | |
| 150 | - | /// Chips rather than acts, and latched rather than carrying an `is-selected` | |
| 151 | - | /// class: which window is showing is a fact about the control, so the renderer | |
| 152 | - | /// draws the pressed state from the description instead of the template | |
| 153 | - | /// composing a class name. | |
| 154 | - | fn range_chips(slug: &str, range: &str) -> Vec<Node> { | |
| 155 | - | RANGES | |
| 156 | - | .iter() | |
| 157 | - | .map(|(value, _)| { | |
| 158 | - | Node::Token( | |
| 159 | - | Tag::chip( | |
| 160 | - | *value, | |
| 161 | - | Action::get(format!("/dashboard/project/{slug}/tabs/analytics")) | |
| 162 | - | .carrying("range", *value), | |
| 163 | - | ) | |
| 164 | - | .latched(*value == range), | |
| 165 | - | ) | |
| 166 | - | }) | |
| 167 | - | .collect() | |
| 162 | + | /// A supplier because a comparison is an expression, and the form admits none | |
| 163 | + | /// in an argument. It hands back a `bool`, which is the smallest thing that | |
| 164 | + | /// works and keeps it out of the population. | |
| 165 | + | fn is_shown(window: &Range, range: &str) -> bool { | |
| 166 | + | window.value == range | |
| 168 | 167 | } | |
| 169 | 168 | ||
| 170 | - | /// The figures across the top. | |
| 169 | + | /// One range control. | |
| 171 | 170 | /// | |
| 172 | - | /// Toned the way `super::user_analytics::stats` is: the tone rides on the | |
| 173 | - | /// delta, so a card with nothing to report stays neutral rather than going | |
| 174 | - | /// green for having no news. | |
| 175 | - | fn figures(stats: &[StatCard]) -> Node { | |
| 176 | - | Node::Stats { | |
| 177 | - | figures: stats | |
| 178 | - | .iter() | |
| 179 | - | .map(|stat| { | |
| 180 | - | let mut figure = Figure::new(stat.value.clone(), stat.label.clone()); | |
| 181 | - | if let Some(change) = &stat.change { | |
| 182 | - | figure = figure.change(change.clone()).tone(if stat.is_positive { | |
| 183 | - | layout::Tone::Success | |
| 184 | - | } else { | |
| 185 | - | layout::Tone::Danger | |
| 186 | - | }); | |
| 187 | - | } | |
| 188 | - | (figure, None) | |
| 189 | - | }) | |
| 190 | - | .collect(), | |
| 171 | + | /// A supplier and not a member: `token` is a **setting** on a row and on a cell, | |
| 172 | + | /// and a body's idents are told from settings by the member list alone, so the | |
| 173 | + | /// name cannot mean both. `quasi_declare`'s `NODE_MEMBERS` records the ruling. | |
| 174 | + | fn chip(window: &Range, slug: &str, range: &str) -> Node { | |
| 175 | + | Node::token( | |
| 176 | + | Tag::chip( | |
| 177 | + | window.value, | |
| 178 | + | Action::get(format!("/dashboard/project/{slug}/tabs/analytics")) | |
| 179 | + | .carrying("range", window.value), | |
| 180 | + | ) | |
| 181 | + | .latched(is_shown(window, range)), | |
| 182 | + | ) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | declare! { | |
| 186 | + | /// The four range controls. | |
| 187 | + | /// | |
| 188 | + | /// Chips rather than acts, and latched rather than carrying an | |
| 189 | + | /// `is-selected` class: which window is showing is a fact about the control, | |
| 190 | + | /// so the renderer draws the pressed state from the description instead of | |
| 191 | + | /// the template composing a class name. | |
| 192 | + | shape range_chips(slug: &str, range: &str) -> Vec<Node>; | |
| 193 | + | ||
| 194 | + | for window in RANGES { | |
| 195 | + | include chip(window, slug, range); | |
| 191 | 196 | } | |
| 192 | 197 | } | |
| 193 | 198 | ||
| 194 | - | /// What sold. | |
| 195 | - | /// | |
| 196 | - | /// A table rather than the template's `<ul>` of two `<span>`s. The two columns | |
| 197 | - | /// were already a table pretending not to be, and a described one gets its | |
| 198 | - | /// header row back. | |
| 199 | - | /// | |
| 200 | - | /// The cells stay positional rather than naming their columns. Naming exists to | |
| 201 | - | /// stop a cell list drifting from a column list it cannot see, and here the two | |
| 202 | - | /// lists are the same expression: every row is the same two cells, nothing is | |
| 203 | - | /// conditional, and a reader checking the order reads eight lines to do it. | |
| 204 | - | fn top_items(items: &[ContentItem]) -> Node { | |
| 205 | - | Table::new([ | |
| 206 | - | Column::new("Item") | |
| 207 | - | .width(layout::Width::Fill) | |
| 208 | - | .priority(layout::Priority::Essential), | |
| 209 | - | Column::new("Revenue").width(layout::Width::Content), | |
| 210 | - | ]) | |
| 211 | - | .rows(items.iter().map(|item| { | |
| 212 | - | Cells::new([ | |
| 213 | - | Cell::new(item.title.clone()), | |
| 214 | - | Cell::new(item.revenue.clone()), | |
| 215 | - | ]) | |
| 216 | - | })) | |
| 217 | - | .into() | |
| 199 | + | /// What the delta reads, or nothing. | |
| 200 | + | fn change(stat: &StatCard) -> &str { | |
| 201 | + | stat.change.as_deref().unwrap_or_default() | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// The tone rides on the delta, so a card with nothing to report stays neutral | |
| 205 | + | /// rather than going green for having no news. | |
| 206 | + | fn delta_tone(stat: &StatCard) -> layout::Tone { | |
| 207 | + | if stat.is_positive { | |
| 208 | + | layout::Tone::Success | |
| 209 | + | } else { | |
| 210 | + | layout::Tone::Danger | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | declare! { | |
| 215 | + | /// The figures across the top. | |
| 216 | + | /// | |
| 217 | + | /// Toned the way `super::user_analytics::stats` is: see [`delta_tone`]. | |
| 218 | + | /// | |
| 219 | + | /// The empty list is what the figures accrete onto. `Node::stats` takes the | |
| 220 | + | /// whole list and this one is built a card at a time, so `Node::figure` is | |
| 221 | + | /// the accreting half, on the rule that closed `Table::column`, | |
| 222 | + | /// `Cells::cell` and `Field::options` before it. | |
| 223 | + | shape figures(stats: &[StatCard]) -> Node; | |
| 224 | + | ||
| 225 | + | stats [] { | |
| 226 | + | for stat in stats.iter() { | |
| 227 | + | figure Figure::new(stat.value.clone(), stat.label.clone()) | |
| 228 | + | when stat.change.is_none(); | |
| 229 | + | figure Figure::new(stat.value.clone(), stat.label.clone()) | |
| 230 | + | .change(change(stat)) | |
| 231 | + | .tone(delta_tone(stat)) | |
| 232 | + | unless stat.change.is_none(); | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | declare! { | |
| 238 | + | /// What sold. | |
| 239 | + | /// | |
| 240 | + | /// A table rather than the template's `<ul>` of two `<span>`s. The two | |
| 241 | + | /// columns were already a table pretending not to be, and a described one | |
| 242 | + | /// gets its header row back. | |
| 243 | + | /// | |
| 244 | + | /// The cells stay positional rather than naming their columns. Naming exists | |
| 245 | + | /// to stop a cell list drifting from a column list it cannot see, and here | |
| 246 | + | /// the two lists are one declaration: every row is the same two cells, | |
| 247 | + | /// nothing is conditional, and a reader checking the order reads eight lines | |
| 248 | + | /// to do it. | |
| 249 | + | shape top_items(items: &[ContentItem]) -> Node; | |
| 250 | + | ||
| 251 | + | table { | |
| 252 | + | column "Item" { | |
| 253 | + | width Fill; | |
| 254 | + | priority Essential; | |
| 255 | + | } | |
| 256 | + | column "Revenue" { | |
| 257 | + | width Content; | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | for item in items.iter() { | |
| 261 | + | cells { | |
| 262 | + | cell item.title.clone(); | |
| 263 | + | cell item.revenue.clone(); | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | } | |
| 218 | 267 | } | |
| 219 | 268 | ||
| 220 | 269 | #[cfg(test)] |
| @@ -53,8 +53,9 @@ | |||
| 53 | 53 | //! it sounds (see `super::mount`'s note on the two screens that did not). | |
| 54 | 54 | ||
| 55 | 55 | use makeover_layout as layout; | |
| 56 | - | use quasi_router::screen::{Act, Row, Tag}; | |
| 57 | - | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 56 | + | use quasi_declare::declare; | |
| 57 | + | use quasi_router::screen::Tag; | |
| 58 | + | use quasi_router::{Node, RegionKind, Slot}; | |
| 58 | 59 | use quasi_webview::Webview; | |
| 59 | 60 | ||
| 60 | 61 | use crate::types::ProjectCard; | |
| @@ -101,44 +102,50 @@ | |||
| 101 | 102 | out | |
| 102 | 103 | } | |
| 103 | 104 | ||
| 104 | - | /// The panel's contents, in order. | |
| 105 | - | fn body(projects: &[ProjectCard], can_create_projects: bool) -> Vec<Node> { | |
| 106 | - | let mut out = vec![ | |
| 107 | - | Node::Link { | |
| 108 | - | text: "Docs: Projects".into(), | |
| 109 | - | action: Action::get("/docs/projects").navigating(), | |
| 110 | - | }, | |
| 111 | - | Node::section("Your Projects"), | |
| 112 | - | start_act(can_create_projects), | |
| 113 | - | ]; | |
| 105 | + | declare! { | |
| 106 | + | /// The panel's contents, in order. | |
| 107 | + | /// | |
| 108 | + | /// A list of members and not a region: the strip draws the frame and its | |
| 109 | + | /// `id`, so [`fill`] must add no second one, and [`fragment`] wraps the same | |
| 110 | + | /// members itself. | |
| 111 | + | shape body(projects: &[ProjectCard], can_create_projects: bool) -> Vec<Node>; | |
| 114 | 112 | ||
| 115 | - | if projects.is_empty() { | |
| 116 | - | out.push(getting_started(can_create_projects)); | |
| 117 | - | return out; | |
| 118 | - | } | |
| 113 | + | link "Docs: Projects" to get "/docs/projects" navigating; | |
| 114 | + | section "Your Projects"; | |
| 115 | + | include start_act(can_create_projects); | |
| 119 | 116 | ||
| 120 | - | out.push(Node::list(projects.iter().map(card))); | |
| 121 | - | out | |
| 117 | + | include getting_started(can_create_projects) when projects.is_empty(); | |
| 118 | + | ||
| 119 | + | list { | |
| 120 | + | for project in projects.iter() { | |
| 121 | + | include card(project); | |
| 122 | + | } | |
| 123 | + | } unless projects.is_empty(); | |
| 122 | 124 | } | |
| 123 | 125 | ||
| 124 | - | /// The one control at the top, which is a different offer per reader. | |
| 126 | + | declare! { | |
| 127 | + | /// The one control at the top, which is a different offer per reader. | |
| 128 | + | /// | |
| 129 | + | /// Two spellings in the template and one idea: a creator starts a project, | |
| 130 | + | /// and everyone else asks to become one. | |
| 131 | + | /// | |
| 132 | + | /// `external` because it is a whole page rather than a fragment, so it | |
| 133 | + | /// leaves. `super::project_content`'s New Item says it the same way and for | |
| 134 | + | /// the same reason: an internal get would fetch the wizard into this panel. | |
| 135 | + | shape start_act(can_create_projects: bool) -> Node; | |
| 136 | + | ||
| 137 | + | given can_create_projects { | |
| 138 | + | true -> act "New Project" to external NEW_PROJECT; | |
| 139 | + | false -> act "Apply for Creator Access" to external APPLY; | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// The first meta line: what kind of project it is, when it was made, and when | |
| 144 | + | /// it was last touched. | |
| 125 | 145 | /// | |
| 126 | - | /// Two spellings in the template and one idea: a creator starts a project, and | |
| 127 | - | /// everyone else asks to become one. | |
| 128 | - | fn start_act(can_create_projects: bool) -> Node { | |
| 129 | - | if can_create_projects { | |
| 130 | - | // `external` because it is a whole page rather than a fragment, so it | |
| 131 | - | // leaves. `super::project_content`'s New Item says it the same way and | |
| 132 | - | // for the same reason: an internal `Action::get` would fetch the wizard | |
| 133 | - | // into this panel. | |
| 134 | - | Node::act("New Project", Action::external(NEW_PROJECT)) | |
| 135 | - | } else { | |
| 136 | - | Node::act("Apply for Creator Access", Action::external(APPLY)) | |
| 137 | - | } | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | /// One project. | |
| 141 | - | fn card(project: &ProjectCard) -> Row { | |
| 146 | + | /// A supplier because the last clause is optional and the three read as one | |
| 147 | + | /// sentence, which is a string the form has no expression to build. | |
| 148 | + | fn made(project: &ProjectCard) -> String { | |
| 142 | 149 | let mut meta = format!( | |
| 143 | 150 | "{} · Created {}", | |
| 144 | 151 | project.project_type, project.created_date | |
| @@ -147,37 +154,30 @@ | |||
| 147 | 154 | meta.push_str(" · Last updated "); | |
| 148 | 155 | meta.push_str(updated); | |
| 149 | 156 | } | |
| 157 | + | meta | |
| 158 | + | } | |
| 150 | 159 | ||
| 151 | - | let mut row = Row::new(project.title.clone()).meta(meta); | |
| 160 | + | declare! { | |
| 161 | + | /// One project. | |
| 162 | + | /// | |
| 163 | + | /// The template puts `stats` and the badge on one line separated by a | |
| 164 | + | /// middot, and hides the middot when stats is empty. Said as two parts, the | |
| 165 | + | /// renderer decides the separator and the empty case stops being a | |
| 166 | + | /// conditional in the markup. | |
| 167 | + | shape card(project: &ProjectCard) -> Row; | |
| 152 | 168 | ||
| 153 | - | // The template puts `stats` and the badge on one line separated by a | |
| 154 | - | // middot, and hides the middot when stats is empty. Said as two parts, the | |
| 155 | - | // renderer decides the separator and the empty case stops being a | |
| 156 | - | // conditional in the markup. | |
| 157 | - | if !project.stats.is_empty() { | |
| 158 | - | row = row.meta(project.stats.clone()); | |
| 169 | + | row project.title.clone() { | |
| 170 | + | meta made(project); | |
| 171 | + | meta project.stats.clone() unless project.stats.is_empty(); | |
| 172 | + | token Tag::badge(project.status.clone()).tone(tone(project.status_tone)); | |
| 173 | + | ||
| 174 | + | act "View" to external "/p/{project.slug}"; | |
| 175 | + | act "Edit" to external "/dashboard/project/{project.slug}"; | |
| 176 | + | act "Delete" to delete "/api/projects/{project.id}" { | |
| 177 | + | tone Danger; | |
| 178 | + | confirm "Delete this project? This cannot be undone."; | |
| 179 | + | } | |
| 159 | 180 | } | |
| 160 | - | ||
| 161 | - | let mut badge = Tag::badge(project.status.clone()); | |
| 162 | - | badge.tone = tone(project.status_tone); | |
| 163 | - | ||
| 164 | - | row.token(badge) | |
| 165 | - | .act(Act::new( | |
| 166 | - | "View", | |
| 167 | - | Action::external(format!("/p/{}", project.slug)), | |
| 168 | - | )) | |
| 169 | - | .act(Act::new( | |
| 170 | - | "Edit", | |
| 171 | - | Action::external(format!("/dashboard/project/{}", project.slug)), | |
| 172 | - | )) | |
| 173 | - | .act( | |
| 174 | - | Act::new( | |
| 175 | - | "Delete", | |
| 176 | - | Action::delete(format!("/api/projects/{}", project.id)), | |
| 177 | - | ) | |
| 178 | - | .tone(layout::Tone::Danger) | |
| 179 | - | .confirm("Delete this project? This cannot be undone."), | |
| 180 | - | ) | |
| 181 | 181 | } | |
| 182 | 182 | ||
| 183 | 183 | /// The badge tone, from the string `ProjectCard::from_db` picked. | |
| @@ -196,34 +196,32 @@ | |||
| 196 | 196 | } | |
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | - | /// What stands where the list would be, for an account with no projects. | |
| 200 | - | fn getting_started(can_create_projects: bool) -> Node { | |
| 201 | - | // The four steps are prose rather than a described list: they are one | |
| 202 | - | // explanation of what the product is, not a set of things with addresses. | |
| 203 | - | // `Node::Rich` carries the markdown source, which is what lets a terminal | |
| 204 | - | // render the same four steps without being handed markup. | |
| 205 | - | Node::Region( | |
| 206 | - | Slot::new("user-projects-getting-started", RegionKind::Pane) | |
| 207 | - | .with(Node::StandIn { | |
| 208 | - | state: layout::Readiness::Empty, | |
| 209 | - | message: "Welcome to Makenotwork. A project groups your work. Think of it as an \ | |
| 210 | - | album, podcast feed, or product line. Each project contains items: \ | |
| 211 | - | individual tracks, episodes, downloads, or posts." | |
| 212 | - | .into(), | |
| 213 | - | act: None, | |
| 214 | - | }) | |
| 215 | - | .with(super::own_prose( | |
| 216 | - | "1. Create a project\n\ | |
| 217 | - | 2. Add items: audio, video, text, or software\n\ | |
| 218 | - | 3. Set prices (or keep them free) and publish\n\ | |
| 219 | - | 4. Connect your payment account, 0% platform fee", | |
| 220 | - | )) | |
| 221 | - | .with(if can_create_projects { | |
| 222 | - | Node::act("Create Your First Project", Action::external(NEW_PROJECT)) | |
| 223 | - | } else { | |
| 224 | - | Node::act("Apply for Creator Access", Action::external(APPLY)) | |
| 225 | - | }), | |
| 226 | - | ) | |
| 199 | + | declare! { | |
| 200 | + | /// What stands where the list would be, for an account with no projects. | |
| 201 | + | /// | |
| 202 | + | /// The four steps are prose rather than a described list: they are one | |
| 203 | + | /// explanation of what the product is, not a set of things with addresses. | |
| 204 | + | /// `own_prose` carries the markdown source, which is what lets a terminal | |
| 205 | + | /// render the same four steps without being handed markup. | |
| 206 | + | shape getting_started(can_create_projects: bool) -> Node; | |
| 207 | + | ||
| 208 | + | region "user-projects-getting-started" as Pane { | |
| 209 | + | empty "Welcome to Makenotwork. A project groups your work. Think of it as an \ | |
| 210 | + | album, podcast feed, or product line. Each project contains items: \ | |
| 211 | + | individual tracks, episodes, downloads, or posts."; | |
| 212 | + | ||
| 213 | + | include super::own_prose( | |
| 214 | + | "1. Create a project\n\ | |
| 215 | + | 2. Add items: audio, video, text, or software\n\ | |
| 216 | + | 3. Set prices (or keep them free) and publish\n\ | |
| 217 | + | 4. Connect your payment account, 0% platform fee" | |
| 218 | + | ); | |
| 219 | + | ||
| 220 | + | given can_create_projects { | |
| 221 | + | true -> act "Create Your First Project" to external NEW_PROJECT; | |
| 222 | + | false -> act "Apply for Creator Access" to external APPLY; | |
| 223 | + | } | |
| 224 | + | } | |
| 227 | 225 | } | |
| 228 | 226 | ||
| 229 | 227 | #[cfg(test)] |