//! 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_router::screen::{Act, Cell, Cells, Column}; use quasi_router::{ Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, }; 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. 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, } /// 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" )] 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()) } /// 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(), }) } /// The whole document: the title, the measure, the body. fn page_screen(loaded: &Loaded) -> Described { let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::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 `Node::Link` is for and // what `Node::act` would draw a bevel around. page = page.with(Node::Link { text: format!("by {}", loaded.owner_shown), action: Action::get(format!("/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. if !loaded.is_public { page = page.with(Node::banner( layout::Tone::Info, "This collection is private. Only you can see it.", )); } if let Some(description) = loaded.description.as_deref() { page = page.with(Node::text(description)); } page = page.with(Node::text(format!("{} items", loaded.items.len()))); page = if loaded.items.is_empty() { page.with(Node::empty("This collection is empty.")) } else { page.with(items_table(&loaded.items)) }; page = page .with(Node::act( "View profile", Action::get(format!("/u/{}", loaded.owner_username)).navigating(), )) .with(Node::Act(Act::new("Copy link", Action::local()).copying( format!("/c/{}/{}", loaded.owner_username, loaded.slug), ))); Described::single(format!( "{} - {} - Makenotwork", loaded.title, loaded.owner_username )) .measured(MEASURE) .documented( Document::default().classed(crate::shell::body_class(MEASURE, &["collection-page"])), ) .with(page) } /// 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. fn items_table(items: &[Item]) -> Node { Node::Table { columns: vec![ Column::new("Item") .width(layout::Width::Fill) .priority(layout::Priority::Essential), Column::new("Type").width(layout::Width::Content), Column::new("Creator").width(layout::Width::Content), Column::new("Project").width(layout::Width::Content), Column::new("Price").width(layout::Width::Content), ], rows: items .iter() .map(|item| { Cells::new([ Cell::new(item.title.clone()), Cell::new(item.item_type.clone()), Cell::new(item.creator.clone()), Cell::new(item.project.clone()), Cell::new(item.price.clone()), ]) .activate(Action::get(format!("/i/{}", item.id)).navigating()) }) .collect(), more: None, } } /// 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()), ))) } #[cfg(test)] mod tests { use super::*; fn loaded(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(), } } 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}"); } } }