|
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 |
+ |
struct Item {
|
|
67 |
+ |
id: String,
|
|
68 |
+ |
title: String,
|
|
69 |
+ |
item_type: String,
|
|
70 |
+ |
creator: String,
|
|
71 |
+ |
project: String,
|
|
72 |
+ |
price: String,
|
|
73 |
+ |
}
|
|
74 |
+ |
|
|
75 |
+ |
/// The page.
|
|
76 |
+ |
pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
|
|
77 |
+ |
// Moved out because the handler signature is quasi's: the request is
|
|
78 |
+ |
// consumed here rather than borrowed from.
|
|
79 |
+ |
let captures = request.captures;
|
|
80 |
+ |
let username = captures
|
|
81 |
+ |
.get("username")
|
|
82 |
+ |
.ok_or_else(|| RouteError::not_found("no such collection"))?;
|
|
83 |
+ |
let slug = captures
|
|
84 |
+ |
.get("slug")
|
|
85 |
+ |
.ok_or_else(|| RouteError::not_found("no such collection"))?;
|
|
86 |
+ |
|
|
87 |
+ |
let loaded = load(viewer, username, slug)?;
|
|
88 |
+ |
|
|
89 |
+ |
Ok(page_screen(&loaded).into())
|
|
90 |
+ |
}
|
|
91 |
+ |
|
|
92 |
+ |
/// Resolve the owner, the collection and its items, refusing exactly as the
|
|
93 |
+ |
/// shipped handler did.
|
|
94 |
+ |
fn load(viewer: &super::Viewer, username: &str, slug: &str) -> Result<Loaded, RouteError> {
|
|
95 |
+ |
let missing = || RouteError::not_found("no such collection");
|
|
96 |
+ |
|
|
97 |
+ |
let username = db::Username::new(username).map_err(|_| missing())?;
|
|
98 |
+ |
let slug = db::Slug::new(slug).map_err(|_| missing())?;
|
|
99 |
+ |
|
|
100 |
+ |
let owner = viewer
|
|
101 |
+ |
.block_on(db::users::get_user_by_username(&viewer.app.db, &username))
|
|
102 |
+ |
.map_err(|_| RouteError::internal("that collection could not be read"))?
|
|
103 |
+ |
.ok_or_else(missing)?;
|
|
104 |
+ |
|
|
105 |
+ |
let collection = viewer
|
|
106 |
+ |
.block_on(db::collections::get_collection_by_user_and_slug(
|
|
107 |
+ |
&viewer.app.db,
|
|
108 |
+ |
owner.id,
|
|
109 |
+ |
&slug,
|
|
110 |
+ |
))
|
|
111 |
+ |
.map_err(|_| RouteError::internal("that collection could not be read"))?
|
|
112 |
+ |
.ok_or_else(missing)?;
|
|
113 |
+ |
|
|
114 |
+ |
// A private collection is the owner's alone, and a stranger is told it does
|
|
115 |
+ |
// not exist rather than that they may not see it. Refusing would confirm it
|
|
116 |
+ |
// exists, which is what private means here.
|
|
117 |
+ |
let is_owner = viewer.user.as_ref().is_some_and(|u| u.id == owner.id);
|
|
118 |
+ |
if !collection.is_public && !is_owner {
|
|
119 |
+ |
return Err(missing());
|
|
120 |
+ |
}
|
|
121 |
+ |
|
|
122 |
+ |
let items = viewer
|
|
123 |
+ |
.block_on(db::collections::get_collection_items(
|
|
124 |
+ |
&viewer.app.db,
|
|
125 |
+ |
collection.id,
|
|
126 |
+ |
))
|
|
127 |
+ |
.map_err(|_| RouteError::internal("that collection could not be read"))?;
|
|
128 |
+ |
|
|
129 |
+ |
Ok(Loaded {
|
|
130 |
+ |
title: collection.title.clone(),
|
|
131 |
+ |
slug: collection.slug.to_string(),
|
|
132 |
+ |
owner_shown: owner
|
|
133 |
+ |
.display_name
|
|
134 |
+ |
.clone()
|
|
135 |
+ |
.unwrap_or_else(|| owner.username.to_string()),
|
|
136 |
+ |
owner_username: owner.username.to_string(),
|
|
137 |
+ |
description: collection.description.clone(),
|
|
138 |
+ |
is_public: collection.is_public,
|
|
139 |
+ |
items: items
|
|
140 |
+ |
.iter()
|
|
141 |
+ |
.map(|item| {
|
|
142 |
+ |
let view = crate::types::CollectionItem::from(item);
|
|
143 |
+ |
Item {
|
|
144 |
+ |
id: view.item_id,
|
|
145 |
+ |
title: view.title,
|
|
146 |
+ |
item_type: view.item_type,
|
|
147 |
+ |
creator: view.username,
|
|
148 |
+ |
project: view.project_title,
|
|
149 |
+ |
price: view.price_display,
|
|
150 |
+ |
}
|
|
151 |
+ |
})
|
|
152 |
+ |
.collect(),
|
|
153 |
+ |
})
|
|
154 |
+ |
}
|
|
155 |
+ |
|
|
156 |
+ |
/// The whole document: the title, the measure, the body.
|
|
157 |
+ |
fn page_screen(loaded: &Loaded) -> Described {
|
|
158 |
+ |
let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page(loaded.title.clone()));
|
|
159 |
+ |
|
|
160 |
+ |
// "by <owner>", with the owner's name as a link rather than a button: it is
|
|
161 |
+ |
// a name that goes somewhere, which is exactly what `Node::Link` is for and
|
|
162 |
+ |
// what `Node::act` would draw a bevel around.
|
|
163 |
+ |
page = page.with(Node::Link {
|
|
164 |
+ |
text: format!("by {}", loaded.owner_shown),
|
|
165 |
+ |
action: Action::get(format!("/u/{}", loaded.owner_username)).navigating(),
|
|
166 |
+ |
});
|
|
167 |
+ |
|
|
168 |
+ |
// The template put a `Private` badge beside the owner's name. A `Tag` is
|
|
169 |
+ |
// not a `Node` -- tokens belong to rows and cells -- and rather than invent
|
|
170 |
+ |
// a row to hold one, this says it as a banner. That is the better reading
|
|
171 |
+ |
// anyway: the only person who sees it is the owner, and what they need to
|
|
172 |
+ |
// know is that nobody else can open the link they are looking at, which a
|
|
173 |
+ |
// small badge next to a name says quietly and a banner says once.
|
|
174 |
+ |
if !loaded.is_public {
|
|
175 |
+ |
page = page.with(Node::banner(
|
|
176 |
+ |
layout::Tone::Info,
|
|
177 |
+ |
"This collection is private. Only you can see it.",
|
|
178 |
+ |
));
|
|
179 |
+ |
}
|
|
180 |
+ |
|
|
181 |
+ |
if let Some(description) = loaded.description.as_deref() {
|
|
182 |
+ |
page = page.with(Node::text(description));
|
|
183 |
+ |
}
|
|
184 |
+ |
|
|
185 |
+ |
page = page.with(Node::text(format!("{} items", loaded.items.len())));
|
|
186 |
+ |
|
|
187 |
+ |
page = if loaded.items.is_empty() {
|
|
188 |
+ |
page.with(Node::empty("This collection is empty."))
|
|
189 |
+ |
} else {
|
|
190 |
+ |
page.with(items_table(&loaded.items))
|
|
191 |
+ |
};
|
|
192 |
+ |
|
|
193 |
+ |
page = page
|
|
194 |
+ |
.with(Node::act(
|
|
195 |
+ |
"View profile",
|
|
196 |
+ |
Action::get(format!("/u/{}", loaded.owner_username)).navigating(),
|
|
197 |
+ |
))
|
|
198 |
+ |
.with(Node::Act(Act::new("Copy link", Action::local()).copying(
|
|
199 |
+ |
format!("/c/{}/{}", loaded.owner_username, loaded.slug),
|
|
200 |
+ |
)));
|
|
201 |
+ |
|
|
202 |
+ |
Described::single(format!(
|
|
203 |
+ |
"{} - {} - Makenotwork",
|
|
204 |
+ |
loaded.title, loaded.owner_username
|
|
205 |
+ |
))
|
|
206 |
+ |
.measured(MEASURE)
|
|
207 |
+ |
.documented(
|
|
208 |
+ |
Document::default().classed(crate::shell::body_class(MEASURE, &["collection-page"])),
|
|
209 |
+ |
)
|
|
210 |
+ |
.with(page)
|
|
211 |
+ |
}
|
|
212 |
+ |
|
|
213 |
+ |
/// The items, as a table.
|
|
214 |
+ |
///
|
|
215 |
+ |
/// The template drew three stacked `<div>`s per item -- title, a middot-joined
|
|
216 |
+ |
/// meta line, and a price pushed to the right -- which is a table written by
|
|
217 |
+ |
/// hand. Saying it as one lets the design system decide what a narrow viewport
|
|
218 |
+ |
/// drops, and the meta line's three facts become three columns that can be
|
|
219 |
+ |
/// dropped independently rather than a string that wraps.
|
|
220 |
+ |
fn items_table(items: &[Item]) -> Node {
|
|
221 |
+ |
Node::Table {
|
|
222 |
+ |
columns: vec![
|
|
223 |
+ |
Column::new("Item")
|
|
224 |
+ |
.width(layout::Width::Fill)
|
|
225 |
+ |
.priority(layout::Priority::Essential),
|
|
226 |
+ |
Column::new("Type").width(layout::Width::Content),
|
|
227 |
+ |
Column::new("Creator").width(layout::Width::Content),
|
|
228 |
+ |
Column::new("Project").width(layout::Width::Content),
|
|
229 |
+ |
Column::new("Price").width(layout::Width::Content),
|
|
230 |
+ |
],
|
|
231 |
+ |
rows: items
|
|
232 |
+ |
.iter()
|
|
233 |
+ |
.map(|item| {
|
|
234 |
+ |
Cells::new([
|
|
235 |
+ |
Cell::new(item.title.clone()),
|
|
236 |
+ |
Cell::new(item.item_type.clone()),
|
|
237 |
+ |
Cell::new(item.creator.clone()),
|
|
238 |
+ |
Cell::new(item.project.clone()),
|
|
239 |
+ |
Cell::new(item.price.clone()),
|
|
240 |
+ |
])
|
|
241 |
+ |
.activate(Action::get(format!("/i/{}", item.id)).navigating())
|
|
242 |
+ |
})
|
|
243 |
+ |
.collect(),
|
|
244 |
+ |
more: None,
|
|
245 |
+ |
}
|
|
246 |
+ |
}
|
|
247 |
+ |
|
|
248 |
+ |
/// The document this screen is drawn in.
|
|
249 |
+ |
#[must_use]
|
|
250 |
+ |
pub fn renderer(viewer: &super::Viewer) -> Webview {
|
|
251 |
+ |
Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
|
|
252 |
+ |
"{}{}",
|
|
253 |
+ |
crate::shell::skip_link(PAGE_REGION),
|
|
254 |
+ |
crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
|
|
255 |
+ |
)))
|
|
256 |
+ |
}
|
|
257 |
+ |
|
|
258 |
+ |
#[cfg(test)]
|
|
259 |
+ |
mod tests {
|
|
260 |
+ |
use super::*;
|
|
261 |
+ |
|
|
262 |
+ |
fn loaded(items: usize, is_public: bool) -> Loaded {
|
|
263 |
+ |
Loaded {
|
|
264 |
+ |
title: "Field Recordings".into(),
|
|
265 |
+ |
slug: "field-recordings".into(),
|
|
266 |
+ |
owner_shown: "Ada".into(),
|
|
267 |
+ |
owner_username: "ada".into(),
|
|
268 |
+ |
description: Some("Things I taped outdoors.".into()),
|
|
269 |
+ |
is_public,
|
|
270 |
+ |
items: (0..items)
|
|
271 |
+ |
.map(|n| Item {
|
|
272 |
+ |
id: format!("item{n}"),
|
|
273 |
+ |
title: format!("Track {n}"),
|
|
274 |
+ |
item_type: "Audio".into(),
|
|
275 |
+ |
creator: "ada".into(),
|
|
276 |
+ |
project: "Tapes".into(),
|
|
277 |
+ |
price: "$3".into(),
|
|
278 |
+ |
})
|
|
279 |
+ |
.collect(),
|
|
280 |
+ |
}
|
|
281 |
+ |
}
|
|
282 |
+ |
|
|
283 |
+ |
fn html(loaded: &Loaded) -> String {
|
|
284 |
+ |
use quasi_axum::Serves as _;
|
|
285 |
+ |
|
|
286 |
+ |
Webview::new().screen(&page_screen(loaded))
|
|
287 |
+ |
}
|
|
288 |
+ |
|
|
289 |
+ |
/// `2790e5c4`. Both classes were on the body already, so this is a copy.
|
|
290 |
+ |
#[test]
|
|
291 |
+ |
fn the_document_carries_the_classes_the_template_carried() {
|
|
292 |
+ |
let screen = page_screen(&loaded(2, true));
|
|
293 |
+ |
|
|
294 |
+ |
assert_eq!(
|
|
295 |
+ |
screen.document.body_class.as_deref(),
|
|
296 |
+ |
Some("padded-page collection-page")
|
|
297 |
+ |
);
|
|
298 |
+ |
}
|
|
299 |
+ |
|
|
300 |
+ |
/// The title is the collection's and the owner's, in that order, which is
|
|
301 |
+ |
/// what a shared link shows in a tab and a preview card.
|
|
302 |
+ |
#[test]
|
|
303 |
+ |
fn the_document_is_titled_for_the_collection_and_its_owner() {
|
|
304 |
+ |
let screen = page_screen(&loaded(1, true));
|
|
305 |
+ |
|
|
306 |
+ |
assert_eq!(screen.title, "Field Recordings - ada - Makenotwork");
|
|
307 |
+ |
}
|
|
308 |
+ |
|
|
309 |
+ |
/// Every item is a row, and the row goes to the item.
|
|
310 |
+ |
#[test]
|
|
311 |
+ |
fn every_item_is_a_row_that_opens_it() {
|
|
312 |
+ |
let html = html(&loaded(3, true));
|
|
313 |
+ |
|
|
314 |
+ |
for n in 0..3 {
|
|
315 |
+ |
assert!(html.contains(&format!("Track {n}")), "{html}");
|
|
316 |
+ |
assert!(html.contains(&format!("/i/item{n}")), "{html}");
|
|
317 |
+ |
}
|
|
318 |
+ |
}
|
|
319 |
+ |
|
|
320 |
+ |
/// An empty collection says so rather than rendering an empty table.
|
|
321 |
+ |
#[test]
|
|
322 |
+ |
fn an_empty_collection_says_so() {
|
|
323 |
+ |
let html = html(&loaded(0, true));
|
|
324 |
+ |
|
|
325 |
+ |
assert!(html.contains("This collection is empty"), "{html}");
|
|
326 |
+ |
assert!(!html.contains("Price"), "no table headings either: {html}");
|
|
327 |
+ |
}
|
|
328 |
+ |
|
|
329 |
+ |
/// The owner's own private collection tells them so when they look at it.
|
|
330 |
+ |
/// A stranger never reaches this function; `load` refuses first.
|
|
331 |
+ |
#[test]
|
|
332 |
+ |
fn a_private_collection_is_badged_for_the_owner() {
|
|
333 |
+ |
assert!(html(&loaded(1, false)).contains("Only you can see it"));
|
|
334 |
+ |
assert!(!html(&loaded(1, true)).contains("Only you can see it"));
|
|
335 |
+ |
}
|
|
336 |
+ |
|
|
337 |
+ |
/// `736f45a5`: this screen's markup carries none of the four spellings.
|
|
338 |
+ |
#[test]
|
|
339 |
+ |
fn the_page_spells_no_spinner() {
|
|
340 |
+ |
let html = html(&loaded(2, true));
|
|
341 |
+ |
|
|
342 |
+ |
for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
|
|
343 |
+ |
assert!(!html.contains(spelling), "{spelling} survives in {html}");
|
|
344 |
+ |
}
|
|
345 |
+ |
}
|
|
346 |
+ |
}
|