Skip to main content

max / makenotwork

Describe the collection page, the first at a parameterised address The sixth public document. /c/{username}/{slug} goes into the document mount as written and request.captures carries both halves; nothing new was needed, but it is worth recording because every remaining content page is parameterised. The privacy rule is why this page needs the optional user: a private collection is its owner's alone, and a stranger is told it does not exist rather than that they may not see it, because refusing would confirm it exists. That behaviour already has integration coverage - public_collection_page_visible and private_collection_page_hidden in tests/workflows/collections.rs - and this is the first conversion those tests will press. The item rows were three stacked divs and a middot-joined meta line, which is a table written by hand. As a Node::Table the three facts become three columns that can be dropped independently rather than a string that wraps. Copy link stops being markup plus a handler and becomes Act::copying, whose destination is Action::local because a copy asks no route. The Private badge became a banner: a Tag is not a Node, and rather than invent a row to hold one, the owner is told once that nobody else can open the link. Deletes templates/pages/collection.html, CollectionTemplate, content::collection_page and fourteen .collection-page rules.
Author: Max Johnson <me@maxj.phd> · 2026-09-01 13:28 UTC
Signed with PGP, not checked
Commit: ce5599fe3baaf6654dabdb4961a2663044142fd8
Parent: de85a33
8 files changed, +368 insertions, -169 deletions
@@ -3797,57 +3797,12 @@
3797 3797 .project-paywall-page .paywall-tiers .tier-card .button { width: 100%; }
3798 3798
3799 3799 /* Collection detail page (templates/pages/collection.html). */
3800 - .collection-page .container { max-width: 700px; }
3801 - .collection-page .collection-header { margin-bottom: var(--gap-page); }
3802 - .collection-page .collection-title { font-size: var(--text-title); margin-bottom: var(--gap-bound); }
3803 -
3804 - .collection-page .collection-owner {
3805 - font-size: var(--text-note);
3806 - opacity: 0.7;
3807 - margin-bottom: var(--gap-section);
3808 - }
3809 -
3810 - .collection-page .collection-description {
3811 - font-size: var(--text-body);
3812 - opacity: 0.85;
3813 - margin-bottom: var(--gap-section);
3814 - }
3815 -
3816 - .collection-page .collection-meta { font-size: var(--text-note); opacity: 0.6; }
3817 -
3818 - .collection-page .collection-item {
3819 - display: flex;
3820 - justify-content: space-between;
3821 - align-items: flex-start;
3822 - padding: var(--gap-section);
3823 - background: var(--surface-overlay);
3824 - margin-bottom: var(--gap-peer);
3825 - }
3826 -
3827 - .collection-page .collection-item-info { flex: 1; }
3828 -
3829 - .collection-page .collection-item-title {
3830 - font-family: var(--font-display);
3831 - font-weight: bold;
3832 - font-size: var(--text-body);
3833 - margin-bottom: var(--gap-bound);
3834 - }
3835 -
3836 - .collection-page .collection-item-title a { text-decoration: none; }
3837 -
3838 - .collection-page .collection-item-meta { font-size: var(--text-note); opacity: 0.7; }
3839 -
3840 - .collection-page .collection-item-price {
3841 - font-size: var(--text-note);
3842 - white-space: nowrap;
3843 - margin-left: var(--gap-section);
3844 - }
3845 -
3846 - .collection-page .empty-state {
3847 - text-align: center;
3848 - padding: var(--gap-page) var(--gap-section);
3849 - opacity: 0.6;
3850 - }
3800 + /* The collection page moved to the description layer. Its header, its owner
3801 + line, its per-item rows and its empty state are the design system's now: the
3802 + items are a `Node::Table` whose meta line became three droppable columns
3803 + rather than one string that wraps, and the empty state is `Node::empty`.
3804 + What is left is the measure, on <main>. */
3805 + .collection-page main { max-width: 700px; }
3851 3806
3852 3807 /* Project blog index page (templates/pages/project_blog.html). */
3853 3808 .project-blog-page .blog-header { margin-bottom: var(--gap-page); }
@@ -40,6 +40,7 @@
40 40
41 41 pub mod blog_delete_act;
42 42 pub mod buyer_contacts;
43 + pub mod collections;
43 44 pub mod creators;
44 45 pub mod custom_page;
45 46 pub mod discover_search;
@@ -395,6 +396,7 @@
395 396 policy::PATH,
396 397 fan_plus::PATH,
397 398 creators::PATH,
399 + collections::PATH,
398 400 ];
399 401
400 402 /// Every screen's switch name, in the same order as [`PATHS`].
@@ -593,6 +595,15 @@
593 595 creators::PATH,
594 596 public_document_mount(app, creators::PATH, creators::screen, creators::renderer),
595 597 ),
598 + (
599 + collections::PATH,
600 + public_document_mount(
601 + app,
602 + collections::PATH,
603 + collections::screen,
604 + collections::renderer,
605 + ),
606 + ),
596 607 ]
597 608 }
598 609
@@ -246,7 +246,6 @@
246 246 UserMediaTabTemplate,
247 247 // Support
248 248 // Collections
249 - CollectionTemplate,
250 249 // Library tabs
251 250 LibraryPurchasesTabTemplate,
252 251 LibraryCollectionsTabTemplate,
@@ -15,9 +15,9 @@
15 15
16 16 use crate::auth::SessionUser;
17 17 use crate::types::{
18 - BlogPostSummary, Chapter, Collection, CollectionItem, CustomLink, DiscoverItem,
19 - DiscoverProject, Item, ItemContent, ItemSection, Project, SidebarView, SubscriptionTier,
20 - TagBreadcrumb, TagTreeNode, User, Version,
18 + BlogPostSummary, Chapter, Collection, CustomLink, DiscoverItem, DiscoverProject, Item,
19 + ItemContent, ItemSection, Project, SidebarView, SubscriptionTier, TagBreadcrumb, TagTreeNode,
20 + User, Version,
21 21 };
22 22
23 23 use super::CsrfTokenOption;
@@ -340,20 +340,6 @@
340 340 pub theme_css: &'static str,
341 341 }
342 342
343 - /// Public collection page (shareable URL).
344 - #[derive(Template)]
345 - #[template(path = "pages/collection.html")]
346 - #[allow(dead_code)] // Fields used by Askama template
347 - pub struct CollectionTemplate {
348 - pub csrf_token: CsrfTokenOption,
349 - pub session_user: Option<SessionUser>,
350 - pub collection: Collection,
351 - pub items: Vec<CollectionItem>,
352 - pub owner_username: String,
353 - pub owner_display_name: Option<String>,
354 - pub is_owner: bool,
355 - }
356 -
357 343 /// Public project page with item listing.
358 344 #[derive(Template)]
359 345 #[template(path = "pages/project.html")]
@@ -114,7 +114,6 @@
114 114 get(discover::tag_suggestions_handler).layer(GovernorLayer::new(search_rate_limit)),
115 115 )
116 116 .route_get("/u/{username}", get(content::user_page))
117 - .route_get("/c/{username}/{slug}", get(content::collection_page))
118 117 .route_get("/p/{slug}", get(content::project_page))
119 118 .route_get("/i/{item_id}", get(content::item_page))
120 119 .route_get("/l/{item_id}", get(content::library_page))
@@ -25,10 +25,8 @@
25 25 db::{self, FollowTargetType, ItemId, Username},
26 26 error::{AppError, Result},
27 27 helpers::get_csrf_token,
28 - templates::{
29 - BuyPageTemplate, CollectionTemplate, PurchaseTemplate, ReceiptTemplate, UserTemplate,
30 - },
31 - types::{Collection, CollectionItem, CustomLink, Item, Project, User},
28 + templates::{BuyPageTemplate, PurchaseTemplate, ReceiptTemplate, UserTemplate},
29 + types::{Collection, CustomLink, Item, Project, User},
32 30 };
33 31
34 32 /// Fire-and-forget page view recording. Never blocks the response.
@@ -347,55 +345,6 @@
347 345 .into_response())
348 346 }
349 347
350 - /// Render a public collection page.
351 - #[tracing::instrument(skip_all, name = "content::collection_page")]
352 - pub(super) async fn collection_page(
353 - State(db): State<PgPool>,
354 - session: Session,
355 - MaybeUserVerified(maybe_user): MaybeUserVerified,
356 - Path((username, slug)): Path<(String, String)>,
357 - ) -> Result<impl IntoResponse> {
358 - let csrf_token = get_csrf_token(&session).await;
359 - let username = Username::new(&username).map_err(|_| AppError::NotFound)?;
360 - let db_user = db::users::get_user_by_username(&db, &username)
361 - .await?
362 - .ok_or(AppError::NotFound)?;
363 -
364 - let slug = db::Slug::new(&slug).map_err(|_| AppError::NotFound)?;
365 - let collection = db::collections::get_collection_by_user_and_slug(&db, db_user.id, &slug)
366 - .await?
367 - .ok_or(AppError::NotFound)?;
368 -
369 - // Private collections are only visible to the owner
370 - let is_owner = maybe_user.as_ref().is_some_and(|u| u.id == db_user.id);
371 - if !collection.is_public && !is_owner {
372 - return Err(AppError::NotFound);
373 - }
374 -
375 - let db_items = db::collections::get_collection_items(&db, collection.id).await?;
376 - let items: Vec<CollectionItem> = db_items.iter().map(CollectionItem::from).collect();
377 -
378 - let item_count = items.len() as i64;
379 -
380 - Ok(CollectionTemplate {
381 - csrf_token,
382 - session_user: maybe_user,
383 - collection: Collection {
384 - id: collection.id.to_string(),
385 - slug: collection.slug.to_string(),
386 - title: collection.title.clone(),
387 - description: collection.description.clone(),
388 - is_public: collection.is_public,
389 - item_count,
390 - created_at: collection.created_at.format("%b %d, %Y").to_string(),
391 - },
392 - items,
393 - owner_username: db_user.username.to_string(),
394 - owner_display_name: db_user.display_name.clone(),
395 - is_owner,
396 - })
397 - }
398 -
399 348 /// Minimal direct purchase page; no navigation chrome, optimized for link-in-bio
400 349 /// and social media sharing. Shows item summary + guest checkout button.
401 350 #[tracing::instrument(skip_all, name = "content::buy_page")]
@@ -1,0 +1,346 @@
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 + }
@@ -1,46 +1,0 @@
1 - {% extends "base.html" %}
2 - {%- import "partials/_ui.html" as ui -%}
3 -
4 - {% block title %}{{ collection.title }} - {{ owner_username }} - Makenotwork{% endblock %}
5 - {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }} collection-page"{% endblock %}
6 -
7 - {% block head %}
8 - {% endblock %}
9 -
10 - {% block content %}
11 - {% include "partials/site_header.html" %}
12 -
13 - <div class="container">
14 - <header class="collection-header">
15 - <h1 class="collection-title">{{ collection.title }}</h1>
16 - <div class="collection-owner">
17 - by <a href="/u/{{ owner_username }}">{% if let Some(display) = owner_display_name %}{{ display }}{% else %}{{ owner_username }}{% endif %}</a>
18 - {% if !collection.is_public %}<span class="badge badge--inline-l">Private</span>{% endif %}
19 - </div>
20 - {% if let Some(desc) = collection.description %}
21 - <p class="collection-description">{{ desc }}</p>
22 - {% endif %}
23 - <div class="collection-meta">{{ collection.item_count }} items</div>
24 - </header>
25 -
26 - {% if items.is_empty() %}
27 - {% call ui::empty_state("", "This collection is empty.") %}{% endcall %}
28 - {% else %}
29 - <section>
30 - {% for item in items %}
31 - <div class="collection-item">
32 - <div class="collection-item-info">
33 - <div class="collection-item-title"><a href="/i/{{ item.item_id }}">{{ item.title }}</a></div>
34 - <div class="collection-item-meta">{{ item.item_type }} &middot; {{ item.username }} &middot; {{ item.project_title }}</div>
35 - </div>
36 - <div class="collection-item-price">{{ item.price_display }}</div>
37 - </div>
38 - {% endfor %}
39 - </section>
40 - {% endif %}
41 -
42 - <footer class="profile-footer mt-page">
43 - <p><a href="/u/{{ owner_username }}">View profile</a> &middot; <a href="" data-copy-link>Copy link</a></p>
44 - </footer>
45 - </div>
46 - {% endblock %}