Skip to main content

max / makenotwork

13.0 KB · 361 lines History Blame Raw
1 //! A reader's public collection at `/c/{username}/{slug}`, described.
2 //!
3 //! The sixth public document, and the first at a **parameterised** address. It
4 //! replaces `templates/pages/collection.html`, `CollectionTemplate` and
5 //! `content::collection_page`.
6 //!
7 //! # A described document takes captures the same way a panel does
8 //!
9 //! `quasi_router::Router` has always held path parameters -- `ssh_keys`
10 //! registers `/keys/{id}` inside its nest -- and a document mount registers the
11 //! whole address rather than a nest root, so `/c/{username}/{slug}` goes in as
12 //! written and `request.captures` carries both halves. Nothing new was needed;
13 //! this is written down because every remaining content page (`/u/{username}`,
14 //! `/p/{slug}`, `/i/{item_id}`) is parameterised and somebody will wonder.
15 //!
16 //! # The privacy rule is the reason this page cares who is asking
17 //!
18 //! A private collection is visible only to its owner, and a stranger gets a 404
19 //! rather than a refusal -- the shipped behaviour, kept exactly: a 403 on a
20 //! private collection would confirm the collection exists, which is the thing
21 //! being hidden.
22 //!
23 //! That check is the whole reason this is [`super::Audience::Anyone`] rather
24 //! than a public state built once: it needs the reader's identity, and a
25 //! visitor is an ordinary caller who simply is not the owner.
26 //!
27 //! # Copy link is described, not scripted
28 //!
29 //! `<a href="" data-copy-link>` was markup plus a handler. [`Act::copying`]
30 //! says it: the destination is [`Action::local`] because a copy asks no route,
31 //! and the design system draws the control and the confirmation.
32
33 use makeover_layout as layout;
34 use quasi_router::screen::{Act, Cell, Cells, Column};
35 use quasi_router::{
36 Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
37 };
38 use quasi_webview::Webview;
39
40 use crate::db;
41
42 /// The address, registered whole. See [`super::public_document_mount`].
43 pub const PATH: &str = "/c/{username}/{slug}";
44
45 /// The page's own region, and what the skip link points at.
46 pub const PAGE_REGION: &str = "collection";
47
48 const MEASURE: layout::Measure = layout::Measure::Wide;
49
50 /// Everything the screen draws, resolved before it is drawn.
51 struct Loaded {
52 title: String,
53 /// The collection's own slug, for the address the Copy link control hands
54 /// over. Read back off the row rather than off the request, so the copied
55 /// link is the canonical one rather than whatever spelling was typed.
56 slug: String,
57 /// The owner's display name when they have set one, else their username.
58 owner_shown: String,
59 owner_username: String,
60 description: Option<String>,
61 is_public: bool,
62 items: Vec<Item>,
63 }
64
65 /// One item in the collection.
66 ///
67 /// `item_type` reads as a repetition of the struct's name and is not one: it is
68 /// the item's *kind* -- track, video, document -- and `type` is a keyword.
69 /// Named here rather than allowed at the crate root, so the exception is beside
70 /// the thing it excuses.
71 #[allow(
72 clippy::struct_field_names,
73 reason = "`type` is a keyword; this is the kind"
74 )]
75 struct Item {
76 id: String,
77 title: String,
78 item_type: String,
79 creator: String,
80 project: String,
81 price: String,
82 }
83
84 /// The page.
85 pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
86 // Moved out because the handler signature is quasi's: the request is
87 // consumed here rather than borrowed from.
88 let captures = request.captures;
89 let username = captures
90 .get("username")
91 .ok_or_else(|| RouteError::not_found("no such collection"))?;
92 let slug = captures
93 .get("slug")
94 .ok_or_else(|| RouteError::not_found("no such collection"))?;
95
96 let loaded = load(viewer, username, slug)?;
97
98 Ok(page_screen(&loaded).into())
99 }
100
101 /// Resolve the owner, the collection and its items, refusing exactly as the
102 /// shipped handler did.
103 fn load(viewer: &super::Viewer, username: &str, slug: &str) -> Result<Loaded, RouteError> {
104 let missing = || RouteError::not_found("no such collection");
105
106 let username = db::Username::new(username).map_err(|_| missing())?;
107 let slug = db::Slug::new(slug).map_err(|_| missing())?;
108
109 let owner = viewer
110 .block_on(db::users::get_user_by_username(&viewer.app.db, &username))
111 .map_err(|_| RouteError::internal("that collection could not be read"))?
112 .ok_or_else(missing)?;
113
114 let collection = viewer
115 .block_on(db::collections::get_collection_by_user_and_slug(
116 &viewer.app.db,
117 owner.id,
118 &slug,
119 ))
120 .map_err(|_| RouteError::internal("that collection could not be read"))?
121 .ok_or_else(missing)?;
122
123 // A private collection is the owner's alone, and a stranger is told it does
124 // not exist rather than that they may not see it. Refusing would confirm it
125 // exists, which is what private means here.
126 let is_owner = viewer.user.as_ref().is_some_and(|u| u.id == owner.id);
127 if !collection.is_public && !is_owner {
128 return Err(missing());
129 }
130
131 let items = viewer
132 .block_on(db::collections::get_collection_items(
133 &viewer.app.db,
134 collection.id,
135 ))
136 .map_err(|_| RouteError::internal("that collection could not be read"))?;
137
138 Ok(Loaded {
139 title: collection.title.clone(),
140 slug: collection.slug.to_string(),
141 owner_shown: owner
142 .display_name
143 .clone()
144 .unwrap_or_else(|| owner.username.to_string()),
145 owner_username: owner.username.to_string(),
146 description: collection.description.clone(),
147 is_public: collection.is_public,
148 items: items
149 .iter()
150 .map(|item| {
151 let view = crate::types::CollectionItem::from(item);
152 Item {
153 id: view.item_id,
154 title: view.title,
155 item_type: view.item_type,
156 creator: view.username,
157 project: view.project_title,
158 price: view.price_display,
159 }
160 })
161 .collect(),
162 })
163 }
164
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()));
168
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 });
176
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 ));
188 }
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 }
221
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 Node::Table {
231 columns: vec![
232 Column::new("Item")
233 .width(layout::Width::Fill)
234 .priority(layout::Priority::Essential),
235 Column::new("Type").width(layout::Width::Content),
236 Column::new("Creator").width(layout::Width::Content),
237 Column::new("Project").width(layout::Width::Content),
238 Column::new("Price").width(layout::Width::Content),
239 ],
240 rows: items
241 .iter()
242 .map(|item| {
243 Cells::new([
244 Cell::new(item.title.clone()),
245 Cell::new(item.item_type.clone()),
246 Cell::new(item.creator.clone()),
247 Cell::new(item.project.clone()),
248 Cell::new(item.price.clone()),
249 ])
250 .activate(Action::get(format!("/i/{}", item.id)).navigating())
251 })
252 .collect(),
253 more: None,
254 }
255 }
256
257 /// The document this screen is drawn in.
258 #[must_use]
259 pub fn renderer(viewer: &super::Viewer) -> Webview {
260 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
261 "{}{}",
262 crate::shell::skip_link(PAGE_REGION),
263 crate::shell::site_header(viewer.user.as_ref()),
264 )))
265 }
266
267 #[cfg(test)]
268 mod tests {
269 use super::*;
270
271 fn loaded(items: usize, is_public: bool) -> Loaded {
272 Loaded {
273 title: "Field Recordings".into(),
274 slug: "field-recordings".into(),
275 owner_shown: "Ada".into(),
276 owner_username: "ada".into(),
277 description: Some("Things I taped outdoors.".into()),
278 is_public,
279 items: (0..items)
280 .map(|n| Item {
281 id: format!("item{n}"),
282 title: format!("Track {n}"),
283 item_type: "Audio".into(),
284 creator: "ada".into(),
285 project: "Tapes".into(),
286 price: "$3".into(),
287 })
288 .collect(),
289 }
290 }
291
292 fn html(loaded: &Loaded) -> String {
293 use quasi_axum::Serves as _;
294
295 Webview::new().screen(&page_screen(loaded))
296 }
297
298 /// `2790e5c4`. Both classes were on the body already, so this is a copy.
299 #[test]
300 fn the_document_carries_the_classes_the_template_carried() {
301 let screen = page_screen(&loaded(2, true));
302
303 assert_eq!(
304 screen.document.body_class.as_deref(),
305 Some("padded-page collection-page")
306 );
307 let rendered = html(&loaded(2, true));
308 assert!(
309 rendered.contains("class=\"padded-page collection-page\""),
310 "{rendered}"
311 );
312 }
313
314 /// The title is the collection's and the owner's, in that order, which is
315 /// what a shared link shows in a tab and a preview card.
316 #[test]
317 fn the_document_is_titled_for_the_collection_and_its_owner() {
318 let screen = page_screen(&loaded(1, true));
319
320 assert_eq!(screen.title, "Field Recordings - ada - Makenotwork");
321 }
322
323 /// Every item is a row, and the row goes to the item.
324 #[test]
325 fn every_item_is_a_row_that_opens_it() {
326 let html = html(&loaded(3, true));
327
328 for n in 0..3 {
329 assert!(html.contains(&format!("Track {n}")), "{html}");
330 assert!(html.contains(&format!("/i/item{n}")), "{html}");
331 }
332 }
333
334 /// An empty collection says so rather than rendering an empty table.
335 #[test]
336 fn an_empty_collection_says_so() {
337 let html = html(&loaded(0, true));
338
339 assert!(html.contains("This collection is empty"), "{html}");
340 assert!(!html.contains("Price"), "no table headings either: {html}");
341 }
342
343 /// The owner's own private collection tells them so when they look at it.
344 /// A stranger never reaches this function; `load` refuses first.
345 #[test]
346 fn a_private_collection_is_badged_for_the_owner() {
347 assert!(html(&loaded(1, false)).contains("Only you can see it"));
348 assert!(!html(&loaded(1, true)).contains("Only you can see it"));
349 }
350
351 /// `736f45a5`: this screen's markup carries none of the four spellings.
352 #[test]
353 fn the_page_spells_no_spinner() {
354 let html = html(&loaded(2, true));
355
356 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
357 assert!(!html.contains(spelling), "{spelling} survives in {html}");
358 }
359 }
360 }
361