//! A reader's public collection at `/c/{username}/{slug}`, described. //! //! The sixth public document, and the first at a **parameterised** address. It //! replaces `templates/pages/collection.html`, `CollectionTemplate` and //! `content::collection_page`. //! //! # A described document takes captures the same way a panel does //! //! `quasi_router::Router` has always held path parameters -- `ssh_keys` //! registers `/keys/{id}` inside its nest -- and a document mount registers the //! whole address rather than a nest root, so `/c/{username}/{slug}` goes in as //! written and `request.captures` carries both halves. Nothing new was needed; //! this is written down because every remaining content page (`/u/{username}`, //! `/p/{slug}`, `/i/{item_id}`) is parameterised and somebody will wonder. //! //! # The privacy rule is the reason this page cares who is asking //! //! A private collection is visible only to its owner, and a stranger gets a 404 //! rather than a refusal -- the shipped behaviour, kept exactly: a 403 on a //! private collection would confirm the collection exists, which is the thing //! being hidden. //! //! That check is the whole reason this is [`super::Audience::Anyone`] rather //! than a public state built once: it needs the reader's identity, and a //! visitor is an ordinary caller who simply is not the owner. //! //! # Copy link is described, not scripted //! //! `` was markup plus a handler. [`Act::copying`] //! says it: the destination is [`Action::local`] because a copy asks no route, //! and the design system draws the control and the confirmation. use makeover_layout as layout; use quasi_declare::declare; use quasi_router::{Document, Request, Response, RouteError}; use quasi_webview::Webview; use crate::db; /// The address, registered whole. See [`super::public_document_mount`]. pub const PATH: &str = "/c/{username}/{slug}"; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "collection"; const MEASURE: layout::Measure = layout::Measure::Wide; /// Everything the screen draws, resolved before it is drawn. pub(crate) struct Loaded { title: String, /// The collection's own slug, for the address the Copy link control hands /// over. Read back off the row rather than off the request, so the copied /// link is the canonical one rather than whatever spelling was typed. slug: String, /// The owner's display name when they have set one, else their username. owner_shown: String, owner_username: String, description: Option, is_public: bool, items: Vec, } impl Loaded { /// What the collection says about itself, or nothing. /// /// Empty rather than `None`, so the description asks one question and reads /// one answer instead of matching an `Option` it cannot spell a pattern for. fn description(&self) -> &str { self.description.as_deref().unwrap_or_default() } } /// One item in the collection. /// /// `item_type` reads as a repetition of the struct's name and is not one: it is /// the item's *kind* -- track, video, document -- and `type` is a keyword. /// Named here rather than allowed at the crate root, so the exception is beside /// the thing it excuses. #[allow( clippy::struct_field_names, reason = "`type` is a keyword; this is the kind" )] pub(crate) struct Item { id: String, title: String, item_type: String, creator: String, project: String, price: String, } /// The page. pub fn screen(viewer: &super::Viewer, request: Request) -> Result { // Moved out because the handler signature is quasi's: the request is // consumed here rather than borrowed from. let captures = request.captures; let username = captures .get("username") .ok_or_else(|| RouteError::not_found("no such collection"))?; let slug = captures .get("slug") .ok_or_else(|| RouteError::not_found("no such collection"))?; let loaded = load(viewer, username, slug)?; Ok(page_screen(&loaded).into()) } /// The one read this page makes, for the mount that serves it from a residual. /// /// One read, stating the document and filling the holes. Read twice, the two /// could disagree between them and the page would title itself for one /// collection and list another's items. pub(crate) fn reading( viewer: &super::Viewer, carried: &super::Carried, ) -> Result { load( viewer, carried.capture("username")?, carried.capture("slug")?, ) } /// Resolve the owner, the collection and its items, refusing exactly as the /// shipped handler did. fn load(viewer: &super::Viewer, username: &str, slug: &str) -> Result { let missing = || RouteError::not_found("no such collection"); let username = db::Username::new(username).map_err(|_| missing())?; let slug = db::Slug::new(slug).map_err(|_| missing())?; let owner = viewer .block_on(db::users::get_user_by_username(&viewer.app.db, &username)) .map_err(|_| RouteError::internal("that collection could not be read"))? .ok_or_else(missing)?; let collection = viewer .block_on(db::collections::get_collection_by_user_and_slug( &viewer.app.db, owner.id, &slug, )) .map_err(|_| RouteError::internal("that collection could not be read"))? .ok_or_else(missing)?; // A private collection is the owner's alone, and a stranger is told it does // not exist rather than that they may not see it. Refusing would confirm it // exists, which is what private means here. let is_owner = viewer.user.as_ref().is_some_and(|u| u.id == owner.id); if !collection.is_public && !is_owner { return Err(missing()); } let items = viewer .block_on(db::collections::get_collection_items( &viewer.app.db, collection.id, )) .map_err(|_| RouteError::internal("that collection could not be read"))?; Ok(Loaded { title: collection.title.clone(), slug: collection.slug.to_string(), owner_shown: owner .display_name .clone() .unwrap_or_else(|| owner.username.to_string()), owner_username: owner.username.to_string(), description: collection.description.clone(), is_public: collection.is_public, items: items .iter() .map(|item| { let view = crate::types::CollectionItem::from(item); Item { id: view.item_id, title: view.title, item_type: view.item_type, creator: view.username, project: view.project_title, price: view.price_display, } }) .collect(), }) } declare! { /// The whole document: the title, the measure, the body. pub(crate) shape page_screen(loaded: &Loaded) -> Screen; screen single "{loaded.title} - {loaded.owner_username} - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &["collection-page"])); include page_region(loaded); } } declare! { /// The page's one region, split out so it can be staged. /// /// Holes and branches over one loop: the collection's own words are read /// per request, the private banner and the empty state are guards, and the /// table is a loop whose body is compiled once and walked per item. #[staged] pub(crate) shape page_region(loaded: &Loaded) -> Slot; region PAGE_REGION as Pane { page loaded.title.clone(); // "by ", with the owner's name as a link rather than a // button: it is a name that goes somewhere, which is exactly what a // link is for and what an act would draw a bevel around. link "by {loaded.owner_shown}" to get "/u/{loaded.owner_username}" navigating; // The template put a `Private` badge beside the owner's name. A // `Tag` is not a `Node` -- tokens belong to rows and cells -- and // rather than invent a row to hold one, this says it as a banner. // That is the better reading anyway: the only person who sees it is // the owner, and what they need to know is that nobody else can open // the link they are looking at, which a small badge next to a name // says quietly and a banner says once. banner layout::Tone::Info "This collection is private. Only you can see it." unless loaded.is_public; text loaded.description() unless loaded.description().is_empty(); text "{loaded.items.len()} items"; empty "This collection is empty." when loaded.items.is_empty(); include items_table(&loaded.items) unless loaded.items.is_empty(); act "View profile" to get "/u/{loaded.owner_username}" navigating; // `copying` is a setting on the control and not a modifier of the // action: it sets the destination to `local` itself, because a copy // asks no route and the two facts are one sentence. act "Copy link" to local { copying "/c/{loaded.owner_username}/{loaded.slug}"; } } } declare! { /// The items, as a table. /// /// The template drew three stacked `
`s per item -- title, a /// middot-joined meta line, and a price pushed to the right -- which is a /// table written by hand. Saying it as one lets the design system decide /// what a narrow viewport drops, and the meta line's three facts become /// three columns that can be dropped independently rather than a string that /// wraps. /// /// The cells are positional, and stay so because the columns are five lines /// above them in one declaration and every item fills all five. Naming buys /// nothing a reader cannot already see; it earns its keep where a cell is /// conditional or the headings live in another shape. #[staged] shape items_table(items: &[Item]) -> Node; table { column "Item" { width Fill; priority Essential; } column "Type" { width Content; } column "Creator" { width Content; } column "Project" { width Content; } column "Price" { width Content; } for item in items.iter() { cells { cell item.title.clone(); cell item.item_type.clone(); cell item.creator.clone(); cell item.project.clone(); cell item.price.clone(); activate to get "/i/{item.id}" navigating; } } } } /// The document this screen is drawn in. #[must_use] pub fn renderer(viewer: &super::Viewer) -> Webview { Webview::new().with_shell(viewer.document_shell().with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(viewer.user.as_ref()), ))) } /// A collection as the tests draw it, with `items` items and that visibility. /// /// Module-level rather than inside `mod tests` because `quasi::residuals` needs /// one too: a screen that branches has to be filled under every branch to be /// checked at all, and `Loaded` is this module's own type. Test-only, so it /// costs the shipped binary nothing. #[cfg(test)] pub(crate) fn sample(items: usize, is_public: bool) -> Loaded { Loaded { title: "Field Recordings".into(), slug: "field-recordings".into(), owner_shown: "Ada".into(), owner_username: "ada".into(), description: Some("Things I taped outdoors.".into()), is_public, items: (0..items) .map(|n| Item { id: format!("item{n}"), title: format!("Track {n}"), item_type: "Audio".into(), creator: "ada".into(), project: "Tapes".into(), price: "$3".into(), }) .collect(), } } #[cfg(test)] mod tests { use super::*; use super::sample as loaded; fn html(loaded: &Loaded) -> String { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(loaded)) } /// `2790e5c4`. Both classes were on the body already, so this is a copy. #[test] fn the_document_carries_the_classes_the_template_carried() { let screen = page_screen(&loaded(2, true)); assert_eq!( screen.document.body_class.as_deref(), Some("padded-page collection-page") ); let rendered = html(&loaded(2, true)); assert!( rendered.contains("class=\"padded-page collection-page\""), "{rendered}" ); } /// The title is the collection's and the owner's, in that order, which is /// what a shared link shows in a tab and a preview card. #[test] fn the_document_is_titled_for_the_collection_and_its_owner() { let screen = page_screen(&loaded(1, true)); assert_eq!(screen.title, "Field Recordings - ada - Makenotwork"); } /// Every item is a row, and the row goes to the item. #[test] fn every_item_is_a_row_that_opens_it() { let html = html(&loaded(3, true)); for n in 0..3 { assert!(html.contains(&format!("Track {n}")), "{html}"); assert!(html.contains(&format!("/i/item{n}")), "{html}"); } } /// An empty collection says so rather than rendering an empty table. #[test] fn an_empty_collection_says_so() { let html = html(&loaded(0, true)); assert!(html.contains("This collection is empty"), "{html}"); assert!(!html.contains("Price"), "no table headings either: {html}"); } /// The owner's own private collection tells them so when they look at it. /// A stranger never reaches this function; `load` refuses first. #[test] fn a_private_collection_is_badged_for_the_owner() { assert!(html(&loaded(1, false)).contains("Only you can see it")); assert!(!html(&loaded(1, true)).contains("Only you can see it")); } /// `736f45a5`: this screen's markup carries none of the four spellings. #[test] fn the_page_spells_no_spinner() { let html = html(&loaded(2, true)); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!(!html.contains(spelling), "{spelling} survives in {html}"); } } }