Skip to main content

max / makenotwork

14.4 KB · 407 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_declare::declare;
35 use quasi_router::{Document, Request, Response, RouteError};
36 use quasi_webview::Webview;
37
38 use crate::db;
39
40 /// The address, registered whole. See [`super::public_document_mount`].
41 pub const PATH: &str = "/c/{username}/{slug}";
42
43 /// The page's own region, and what the skip link points at.
44 pub const PAGE_REGION: &str = "collection";
45
46 const MEASURE: layout::Measure = layout::Measure::Wide;
47
48 /// Everything the screen draws, resolved before it is drawn.
49 pub(crate) struct Loaded {
50 title: String,
51 /// The collection's own slug, for the address the Copy link control hands
52 /// over. Read back off the row rather than off the request, so the copied
53 /// link is the canonical one rather than whatever spelling was typed.
54 slug: String,
55 /// The owner's display name when they have set one, else their username.
56 owner_shown: String,
57 owner_username: String,
58 description: Option<String>,
59 is_public: bool,
60 items: Vec<Item>,
61 }
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
73 /// One item in the collection.
74 ///
75 /// `item_type` reads as a repetition of the struct's name and is not one: it is
76 /// the item's *kind* -- track, video, document -- and `type` is a keyword.
77 /// Named here rather than allowed at the crate root, so the exception is beside
78 /// the thing it excuses.
79 #[allow(
80 clippy::struct_field_names,
81 reason = "`type` is a keyword; this is the kind"
82 )]
83 pub(crate) struct Item {
84 id: String,
85 title: String,
86 item_type: String,
87 creator: String,
88 project: String,
89 price: String,
90 }
91
92 /// The page.
93 pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
94 // Moved out because the handler signature is quasi's: the request is
95 // consumed here rather than borrowed from.
96 let captures = request.captures;
97 let username = captures
98 .get("username")
99 .ok_or_else(|| RouteError::not_found("no such collection"))?;
100 let slug = captures
101 .get("slug")
102 .ok_or_else(|| RouteError::not_found("no such collection"))?;
103
104 let loaded = load(viewer, username, slug)?;
105
106 Ok(page_screen(&loaded).into())
107 }
108
109 /// The one read this page makes, for the mount that serves it from a residual.
110 ///
111 /// One read, stating the document and filling the holes. Read twice, the two
112 /// could disagree between them and the page would title itself for one
113 /// collection and list another's items.
114 pub(crate) fn reading(
115 viewer: &super::Viewer,
116 carried: &super::Carried,
117 ) -> Result<Loaded, RouteError> {
118 load(
119 viewer,
120 carried.capture("username")?,
121 carried.capture("slug")?,
122 )
123 }
124
125 /// Resolve the owner, the collection and its items, refusing exactly as the
126 /// shipped handler did.
127 fn load(viewer: &super::Viewer, username: &str, slug: &str) -> Result<Loaded, RouteError> {
128 let missing = || RouteError::not_found("no such collection");
129
130 let username = db::Username::new(username).map_err(|_| missing())?;
131 let slug = db::Slug::new(slug).map_err(|_| missing())?;
132
133 let owner = viewer
134 .block_on(db::users::get_user_by_username(&viewer.app.db, &username))
135 .map_err(|_| RouteError::internal("that collection could not be read"))?
136 .ok_or_else(missing)?;
137
138 let collection = viewer
139 .block_on(db::collections::get_collection_by_user_and_slug(
140 &viewer.app.db,
141 owner.id,
142 &slug,
143 ))
144 .map_err(|_| RouteError::internal("that collection could not be read"))?
145 .ok_or_else(missing)?;
146
147 // A private collection is the owner's alone, and a stranger is told it does
148 // not exist rather than that they may not see it. Refusing would confirm it
149 // exists, which is what private means here.
150 let is_owner = viewer.user.as_ref().is_some_and(|u| u.id == owner.id);
151 if !collection.is_public && !is_owner {
152 return Err(missing());
153 }
154
155 let items = viewer
156 .block_on(db::collections::get_collection_items(
157 &viewer.app.db,
158 collection.id,
159 ))
160 .map_err(|_| RouteError::internal("that collection could not be read"))?;
161
162 Ok(Loaded {
163 title: collection.title.clone(),
164 slug: collection.slug.to_string(),
165 owner_shown: owner
166 .display_name
167 .clone()
168 .unwrap_or_else(|| owner.username.to_string()),
169 owner_username: owner.username.to_string(),
170 description: collection.description.clone(),
171 is_public: collection.is_public,
172 items: items
173 .iter()
174 .map(|item| {
175 let view = crate::types::CollectionItem::from(item);
176 Item {
177 id: view.item_id,
178 title: view.title,
179 item_type: view.item_type,
180 creator: view.username,
181 project: view.project_title,
182 price: view.price_display,
183 }
184 })
185 .collect(),
186 })
187 }
188
189 declare! {
190 /// The whole document: the title, the measure, the body.
191 pub(crate) shape page_screen(loaded: &Loaded) -> Screen;
192
193 screen single "{loaded.title} - {loaded.owner_username} - Makenotwork" {
194 measured MEASURE;
195 documented Document::default().classed(crate::shell::body_class(MEASURE, &["collection-page"]));
196
197 include page_region(loaded);
198 }
199 }
200
201 declare! {
202 /// The page's one region, split out so it can be staged.
203 ///
204 /// Holes and branches over one loop: the collection's own words are read
205 /// per request, the private banner and the empty state are guards, and the
206 /// table is a loop whose body is compiled once and walked per item.
207 #[staged]
208 pub(crate) shape page_region(loaded: &Loaded) -> Slot;
209
210 region PAGE_REGION as Pane {
211 page loaded.title.clone();
212
213 // "by <owner>", with the owner's name as a link rather than a
214 // button: it is a name that goes somewhere, which is exactly what a
215 // link is for and what an act would draw a bevel around.
216 link "by {loaded.owner_shown}" to get "/u/{loaded.owner_username}" navigating;
217
218 // The template put a `Private` badge beside the owner's name. A
219 // `Tag` is not a `Node` -- tokens belong to rows and cells -- and
220 // rather than invent a row to hold one, this says it as a banner.
221 // That is the better reading anyway: the only person who sees it is
222 // the owner, and what they need to know is that nobody else can open
223 // the link they are looking at, which a small badge next to a name
224 // says quietly and a banner says once.
225 banner layout::Tone::Info "This collection is private. Only you can see it."
226 unless loaded.is_public;
227
228 text loaded.description() unless loaded.description().is_empty();
229 text "{loaded.items.len()} items";
230
231 empty "This collection is empty." when loaded.items.is_empty();
232 include items_table(&loaded.items) unless loaded.items.is_empty();
233
234 act "View profile" to get "/u/{loaded.owner_username}" navigating;
235
236 // `copying` is a setting on the control and not a modifier of the
237 // action: it sets the destination to `local` itself, because a copy
238 // asks no route and the two facts are one sentence.
239 act "Copy link" to local {
240 copying "/c/{loaded.owner_username}/{loaded.slug}";
241 }
242 }
243 }
244
245 declare! {
246 /// The items, as a table.
247 ///
248 /// The template drew three stacked `<div>`s per item -- title, a
249 /// middot-joined meta line, and a price pushed to the right -- which is a
250 /// table written by hand. Saying it as one lets the design system decide
251 /// what a narrow viewport drops, and the meta line's three facts become
252 /// three columns that can be dropped independently rather than a string that
253 /// wraps.
254 ///
255 /// The cells are positional, and stay so because the columns are five lines
256 /// above them in one declaration and every item fills all five. Naming buys
257 /// nothing a reader cannot already see; it earns its keep where a cell is
258 /// conditional or the headings live in another shape.
259 #[staged]
260 shape items_table(items: &[Item]) -> Node;
261
262 table {
263 column "Item" {
264 width Fill;
265 priority Essential;
266 }
267 column "Type" {
268 width Content;
269 }
270 column "Creator" {
271 width Content;
272 }
273 column "Project" {
274 width Content;
275 }
276 column "Price" {
277 width Content;
278 }
279
280 for item in items.iter() {
281 cells {
282 cell item.title.clone();
283 cell item.item_type.clone();
284 cell item.creator.clone();
285 cell item.project.clone();
286 cell item.price.clone();
287
288 activate to get "/i/{item.id}" navigating;
289 }
290 }
291 }
292 }
293
294 /// The document this screen is drawn in.
295 #[must_use]
296 pub fn renderer(viewer: &super::Viewer) -> Webview {
297 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
298 "{}{}",
299 crate::shell::skip_link(PAGE_REGION),
300 crate::shell::site_header(viewer.user.as_ref()),
301 )))
302 }
303
304 /// A collection as the tests draw it, with `items` items and that visibility.
305 ///
306 /// Module-level rather than inside `mod tests` because `quasi::residuals` needs
307 /// one too: a screen that branches has to be filled under every branch to be
308 /// checked at all, and `Loaded` is this module's own type. Test-only, so it
309 /// costs the shipped binary nothing.
310 #[cfg(test)]
311 pub(crate) fn sample(items: usize, is_public: bool) -> Loaded {
312 Loaded {
313 title: "Field Recordings".into(),
314 slug: "field-recordings".into(),
315 owner_shown: "Ada".into(),
316 owner_username: "ada".into(),
317 description: Some("Things I taped outdoors.".into()),
318 is_public,
319 items: (0..items)
320 .map(|n| Item {
321 id: format!("item{n}"),
322 title: format!("Track {n}"),
323 item_type: "Audio".into(),
324 creator: "ada".into(),
325 project: "Tapes".into(),
326 price: "$3".into(),
327 })
328 .collect(),
329 }
330 }
331
332 #[cfg(test)]
333 mod tests {
334 use super::*;
335
336 use super::sample as loaded;
337
338 fn html(loaded: &Loaded) -> String {
339 use quasi_axum::Serves as _;
340
341 Webview::new().screen(&page_screen(loaded))
342 }
343
344 /// `2790e5c4`. Both classes were on the body already, so this is a copy.
345 #[test]
346 fn the_document_carries_the_classes_the_template_carried() {
347 let screen = page_screen(&loaded(2, true));
348
349 assert_eq!(
350 screen.document.body_class.as_deref(),
351 Some("padded-page collection-page")
352 );
353 let rendered = html(&loaded(2, true));
354 assert!(
355 rendered.contains("class=\"padded-page collection-page\""),
356 "{rendered}"
357 );
358 }
359
360 /// The title is the collection's and the owner's, in that order, which is
361 /// what a shared link shows in a tab and a preview card.
362 #[test]
363 fn the_document_is_titled_for_the_collection_and_its_owner() {
364 let screen = page_screen(&loaded(1, true));
365
366 assert_eq!(screen.title, "Field Recordings - ada - Makenotwork");
367 }
368
369 /// Every item is a row, and the row goes to the item.
370 #[test]
371 fn every_item_is_a_row_that_opens_it() {
372 let html = html(&loaded(3, true));
373
374 for n in 0..3 {
375 assert!(html.contains(&format!("Track {n}")), "{html}");
376 assert!(html.contains(&format!("/i/item{n}")), "{html}");
377 }
378 }
379
380 /// An empty collection says so rather than rendering an empty table.
381 #[test]
382 fn an_empty_collection_says_so() {
383 let html = html(&loaded(0, true));
384
385 assert!(html.contains("This collection is empty"), "{html}");
386 assert!(!html.contains("Price"), "no table headings either: {html}");
387 }
388
389 /// The owner's own private collection tells them so when they look at it.
390 /// A stranger never reaches this function; `load` refuses first.
391 #[test]
392 fn a_private_collection_is_badged_for_the_owner() {
393 assert!(html(&loaded(1, false)).contains("Only you can see it"));
394 assert!(!html(&loaded(1, true)).contains("Only you can see it"));
395 }
396
397 /// `736f45a5`: this screen's markup carries none of the four spellings.
398 #[test]
399 fn the_page_spells_no_spinner() {
400 let html = html(&loaded(2, true));
401
402 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
403 assert!(!html.contains(spelling), "{spelling} survives in {html}");
404 }
405 }
406 }
407