Skip to main content

max / makenotwork

Describe the two feed surfaces and the item files tab Two conversions in one pass, both at the flip. The feeds (830b1661). `partials/tabs/library_feed.html` and `pages/feed.html` drew the same table from the same query in two templates. One description now, `quasi::feeds`, and both surfaces call it. First described paging on this server: Node::Table.more carries a Rest with a jump per offered page, windowed server-side by build_pagination_range as Rest::jumps asks. The panel's controls swap the panel, the page's replace the document, and that is the only thing that differs between them. No ?page= grammar left in a template. The files tab (138ad5ab). The read half is described, quasi::item_files: the section, the versions table, and the two acts in each row. The upload machine stays markup in a bespoke region, which is the split that task's own SCOPE authorised; filed as 7f04f751. The download button with it (9dbe9206). Its route has answered 303 to the presigned URL since 34adcf76, so it is an Action::get now and the navigation written by hand in item-upload.js is gone. The delete beside it moved to replacing_enclosing: a described row carries data-row and no id, so the id target would have named nothing. Two things worth knowing. A version with no file shows its upload field rather than a button that unhides one, because a described cell holds leaves and acts and cannot name a panel to reveal; recorded in the module header. And the post-upload refresh clicked #tab-files, the tab button the described strip stopped emitting at 6b24f2df, so it had been a no-op since then; both refresh paths go to ?tab=files now. cargo check --all-targets and cargo fmt clean; quasi:: lib suite 241 passed, including the 15 new tests here.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
Author: Max Johnson <me@maxj.phd> · 2026-08-31 00:50 UTC
Signed with PGP, not checked
Commit: ea6c2acfc52e37e61b2a0d22ae1a1abbf6ebfc2f
Parent: 2ae3c9c
19 files changed, +926 insertions, -307 deletions
M server/Cargo.lock +29 -29
@@ -2875,7 +2875,7 @@
2875 2875 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
2876 2876 dependencies = [
2877 2877 "libc",
2878 - "windows-sys 0.52.0",
2878 + "windows-sys 0.59.0",
2879 2879 ]
2880 2880
2881 2881 [[package]]
@@ -4811,7 +4811,7 @@
4811 4811 source = "registry+https://github.com/rust-lang/crates.io-index"
4812 4812 checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
4813 4813 dependencies = [
4814 - "windows-sys 0.61.2",
4814 + "windows-sys 0.59.0",
4815 4815 ]
4816 4816
4817 4817 [[package]]
@@ -5722,7 +5722,7 @@
5722 5722 source = "registry+https://github.com/rust-lang/crates.io-index"
5723 5723 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
5724 5724 dependencies = [
5725 - "windows-sys 0.61.2",
5725 + "windows-sys 0.59.0",
5726 5726 ]
5727 5727
5728 5728 [[package]]
@@ -6636,7 +6636,7 @@
6636 6636
6637 6637 [[package]]
6638 6638 name = "quasi-axum"
6639 - version = "0.91.1"
6639 + version = "0.92.0"
6640 6640 dependencies = [
6641 6641 "axum",
6642 6642 "http 1.4.2",
@@ -6647,7 +6647,7 @@
6647 6647
6648 6648 [[package]]
6649 6649 name = "quasi-basics"
6650 - version = "0.91.1"
6650 + version = "0.92.0"
6651 6651 dependencies = [
6652 6652 "makeover-layout",
6653 6653 "quasi-router",
@@ -6655,7 +6655,7 @@
6655 6655
6656 6656 [[package]]
6657 6657 name = "quasi-http"
6658 - version = "0.91.1"
6658 + version = "0.92.0"
6659 6659 dependencies = [
6660 6660 "form_urlencoded",
6661 6661 "http 1.4.2",
@@ -6664,7 +6664,7 @@
6664 6664
6665 6665 [[package]]
6666 6666 name = "quasi-router"
6667 - version = "0.91.1"
6667 + version = "0.92.0"
6668 6668 dependencies = [
6669 6669 "makeover-layout",
6670 6670 ]
@@ -6687,7 +6687,7 @@
6687 6687
6688 6688 [[package]]
6689 6689 name = "quasi-webview"
6690 - version = "0.91.1"
6690 + version = "0.92.0"
6691 6691 dependencies = [
6692 6692 "docengine",
6693 6693 "makeover-layout",
@@ -6763,7 +6763,7 @@
6763 6763 "once_cell",
6764 6764 "socket2",
6765 6765 "tracing",
6766 - "windows-sys 0.52.0",
6766 + "windows-sys 0.59.0",
6767 6767 ]
6768 6768
6769 6769 [[package]]
@@ -7326,7 +7326,7 @@
7326 7326 "errno",
7327 7327 "libc",
7328 7328 "linux-raw-sys 0.4.15",
7329 - "windows-sys 0.52.0",
7329 + "windows-sys 0.59.0",
7330 7330 ]
7331 7331
7332 7332 [[package]]
@@ -7339,7 +7339,7 @@
7339 7339 "errno",
7340 7340 "libc",
7341 7341 "linux-raw-sys 0.12.1",
7342 - "windows-sys 0.52.0",
7342 + "windows-sys 0.59.0",
7343 7343 ]
7344 7344
7345 7345 [[package]]
@@ -7419,7 +7419,7 @@
7419 7419 "security-framework 3.7.0",
7420 7420 "security-framework-sys",
7421 7421 "webpki-root-certs",
7422 - "windows-sys 0.52.0",
7422 + "windows-sys 0.59.0",
7423 7423 ]
7424 7424
7425 7425 [[package]]
@@ -8545,7 +8545,7 @@
8545 8545 "getrandom 0.4.3",
8546 8546 "once_cell",
8547 8547 "rustix 1.1.4",
8548 - "windows-sys 0.52.0",
8548 + "windows-sys 0.59.0",
8549 8549 ]
8550 8550
8551 8551 [[package]]
@@ -10759,6 +10759,22 @@
10759 10759 "pkg-config",
10760 10760 ]
10761 10761
10762 + [[patch.unused]]
10763 + name = "quasi-immediate"
10764 + version = "0.92.0"
10765 +
10766 + [[patch.unused]]
10767 + name = "quasi-notifs"
10768 + version = "0.92.0"
10769 +
10770 + [[patch.unused]]
10771 + name = "quasi-store"
10772 + version = "0.1.0"
10773 +
10774 + [[patch.unused]]
10775 + name = "quasi-tauri"
10776 + version = "0.92.0"
10777 +
10762 10778 [[patch.unused]]
10763 10779 name = "synckit-client"
10764 10780 version = "0.10.0"
@@ -10778,19 +10794,3 @@
10778 10794 [[patch.unused]]
10779 10795 name = "painhours"
10780 10796 version = "0.1.0"
10781 -
10782 - [[patch.unused]]
10783 - name = "quasi-immediate"
10784 - version = "0.91.1"
10785 -
10786 - [[patch.unused]]
10787 - name = "quasi-notifs"
10788 - version = "0.91.1"
10789 -
10790 - [[patch.unused]]
10791 - name = "quasi-store"
10792 - version = "0.1.0"
10793 -
10794 - [[patch.unused]]
10795 - name = "quasi-tauri"
10796 - version = "0.91.1"
@@ -163,7 +163,7 @@
163 163 # through quasi beside its Askama equivalent, so the cost of describing a screen
164 164 # here is measured rather than argued. Not load-bearing for any shipped route.
165 165 # See wiki look-wave-2, tier G.
166 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.91" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" }
167 167 # The description vocabulary quasi's screen types are built from. Pinned here
168 168 # rather than reached through quasi-router's re-export because a described
169 169 # screen names FieldKind and Tone directly; it has to track what quasi-router
@@ -179,13 +179,13 @@
179 179 # For the request head the per-viewer state factory reads. axum re-exports it,
180 180 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
181 181 http = "1.3.1"
182 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.91" }
183 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.91" }
182 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" }
183 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" }
184 184 # The first-party widget set. The carousel was proved here against three pages
185 185 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
186 186 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
187 187 # sits above quasi-router rather than inside the description suite.
188 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.91" }
188 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" }
189 189
190 190 # Git source browser
191 191 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -277,7 +277,7 @@
277 277 # Written from the crate rather than copied into the repo for the reason the
278 278 # constants exist: a stale copy in a static directory is a silently dead bulk
279 279 # bar, and the version has to be the one the emitter agrees with.
280 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.91" }
280 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" }
281 281
282 282 [dev-dependencies]
283 283 tower = { version = "0.5.3", features = ["util"] }
@@ -142,8 +142,11 @@
142 142 if (i >= entries.length) {
143 143 document.getElementById('version-upload-progress').classList.add('hidden');
144 144 document.getElementById('version-upload-success').classList.remove('hidden');
145 - var filesBtn = document.getElementById('tab-files');
146 - if (filesBtn) filesBtn.click();
145 + // Land back on the files tab, filled. This clicked
146 + // `#tab-files`, the tab button, which the described strip
147 + // (`6b24f2df`) stopped emitting -- so the refresh had been a
148 + // no-op. `?tab=files` is what `item_tabs::shown_at` reads.
149 + window.location.href = '/dashboard/item/' + itemId + '?tab=files';
147 150 return;
148 151 }
149 152
@@ -218,39 +221,18 @@
218 221 });
219 222 }
220 223
221 - // Upload to an existing version. One described field per version that
222 - // has no file yet, each carrying its own address, so pressing the
223 - // button reveals that version's field and the binder in `upload.js`
224 - // does the rest.
225 - document.querySelectorAll('.upload-to-version-btn').forEach(function(btn) {
226 - btn.addEventListener('click', function() {
227 - var panel = document.getElementById('existing-version-upload-' + btn.dataset.versionId);
228 - if (!panel) return;
229 - document.getElementById('new-version-form').classList.add('hidden');
230 - panel.classList.remove('hidden');
231 - });
232 - });
233 -
234 - document.querySelectorAll('.cancel-existing-upload-btn').forEach(function(btn) {
235 - btn.addEventListener('click', function() {
236 - btn.closest('.existing-version-upload').classList.add('hidden');
237 - document.getElementById('new-version-form').classList.remove('hidden');
238 - });
239 - });
240 -
241 - document.querySelectorAll('.download-version-btn').forEach(function(btn) {
242 - btn.addEventListener('click', function() {
243 - window.location.href =
244 - '/api/versions/' + btn.dataset.versionId + '/download';
245 - });
246 - });
247 -
248 - // The delete button is described: `crate::quasi::version_delete_act`,
249 - // Shape 3 step 4. Route, row, prompt and tone all live on the act, and
250 - // htmx performs it. The download button above is now a plain navigation
251 - // because its route answers 303 to the presigned URL (`8fc6b1af`); it is
252 - // describable as an `Action::get` whenever the files tab converts, which
253 - // is what taking the JSON away bought.
224 + // Nothing binds the version rows any more. The files tab is described
225 + // (`crate::quasi::item_files`, `138ad5ab`) and each control in a row
226 + // says what it is on the act itself:
227 + //
228 + // Download an `Action::get` at the route that answers 303 to the
229 + // presigned URL (`8fc6b1af`, `9dbe9206`). The five lines
230 + // that navigated by hand are gone with it.
231 + // Delete `crate::quasi::version_delete_act`, since Shape 3 step 4.
232 + //
233 + // The reveal pair went with them: a version with no file now shows its
234 + // upload field rather than a button that unhides one, because a
235 + // described cell holds leaves and acts and cannot name a panel.
254 236
255 237 document.getElementById('cancel-version-upload-btn').addEventListener('click', function() {
256 238 uploader.cancel();
@@ -267,9 +249,8 @@
267 249
268 250 function resetVersionUpload() {
269 251 document.getElementById('new-version-form').classList.remove('hidden');
270 - document.querySelectorAll('.existing-version-upload').forEach(function(panel) {
271 - panel.classList.add('hidden');
272 - });
252 + // The per-version upload fields are not hidden any more, so there is
253 + // nothing to re-hide here. See `crate::quasi::item_files`.
273 254 document.getElementById('version-upload-progress').classList.add('hidden');
274 255 document.getElementById('version-upload-success').classList.add('hidden');
275 256 document.getElementById('version-upload-error').classList.add('hidden');
@@ -44,7 +44,9 @@
44 44 pub mod discover_typeahead;
45 45 pub mod embeds;
46 46 pub mod export_act;
47 + pub mod feeds;
47 48 pub mod forum_memberships;
49 + pub mod item_files;
48 50 pub mod item_sales;
49 51 pub mod item_tabs;
50 52 pub mod library_contacts;
@@ -341,12 +341,12 @@
341 341 label: None,
342 342 };
343 343
344 - let html = crate::templates::ItemFilesTabTemplate {
344 + let html = crate::templates::ItemVersionUploadTemplate {
345 345 item: audio_item(None),
346 346 versions: vec![version],
347 347 }
348 348 .render()
349 - .expect("render the files tab");
349 + .expect("render the uploader");
350 350
351 351 assert!(html.contains(r#"id="version-files""#), "{html}");
352 352 assert!(
@@ -15,22 +15,20 @@
15 15 //! but a browser. As an [`Act`] it is one sentence: what it is called, where it
16 16 //! goes, what it replaces, that it is destructive, and what to ask first.
17 17 //!
18 - //! # Why the row id is a parameter, and why the row grew one
18 + //! # Why it names what it replaced, and why the spelling changed
19 19 //!
20 20 //! `DELETE /api/items/{item}/versions/{version}` is a plain API route the
21 21 //! description layer does not serve; it answers `200` with an empty body, so it
22 22 //! cannot name what it changed and the control has to. See
23 23 //! [`Action::replaces`].
24 24 //!
25 - //! The blog pair ([`super::blog_delete_act`]) converted because its templates
26 - //! already gave each row a real id. This one's `<tr>` did not, and the handler
27 - //! it replaces reached the row through `closest('tr')`. So the template grew
28 - //! `id="version-row-{id}"` in the same change. That is worth telling apart from
29 - //! the two rows that are still hand-written: `partials/link_row.html` and
30 - //! `partials/tag.html` target `closest .<class>` on rows whose identity is a
31 - //! CSS class and nothing else, and inventing an id for them would be describing
32 - //! a fact the page does not have. A version row has an id in the database and
33 - //! the template was simply not writing it down.
25 + //! It said `#version-row-{id}`, an id the Askama table grew in the same change
26 + //! that landed this module, because the handler it replaced reached the row
27 + //! through `closest('tr')`. The table is described now (`super::item_files`,
28 + //! `138ad5ab`) and a described row carries `data-row` and no id at all, so an
29 + //! id target would name nothing. [`Action::replacing_enclosing`] is the same
30 + //! sentence in the spelling a described row understands, and it needs no
31 + //! parameter: the act is inside the row it removes.
34 32 //!
35 33 //! # The download button beside it is sayable now, and was not when this landed
36 34 //!
@@ -47,47 +45,49 @@
47 45 //! files tab converts. Nothing blocks it any more.
48 46
49 47 use makeover_layout::Tone;
48 + use quasi_router::Action;
50 49 use quasi_router::screen::Act;
51 - use quasi_router::{Action, Node};
52 50
53 51 /// The delete button for one version's row.
54 52 ///
55 - /// Both ids are needed and they are not the same fact: the route is addressed
56 - /// by item and version together, and the row is named by the version alone.
53 + /// Both ids are needed for the address, which is item and version together.
54 + /// The row it removes is the one it sits in, so nothing has to name it.
57 55 #[must_use]
58 - pub fn act(item_id: &str, version_id: &str) -> Node {
59 - Node::Act(
60 - Act::new(
61 - "Delete",
62 - Action::delete(format!("/api/items/{item_id}/versions/{version_id}"))
63 - .replacing(format!("version-row-{version_id}")),
64 - )
65 - .tone(Tone::Danger)
66 - .confirm("Delete this version?"),
56 + pub fn act(item_id: &str, version_id: &str) -> Act {
57 + Act::new(
58 + "Delete",
59 + Action::delete(format!("/api/items/{item_id}/versions/{version_id}")).replacing_enclosing(),
67 60 )
68 - }
69 -
70 - /// The same act as a fragment, for the Askama call site.
71 - #[must_use]
72 - pub fn html(item_id: &str, version_id: &str) -> String {
73 - use quasi_axum::Serves as _;
74 -
75 - quasi_webview::Webview::new().fragment(&act(item_id, version_id))
61 + .tone(Tone::Danger)
62 + .confirm("Delete this version?")
76 63 }
77 64
78 65 #[cfg(test)]
79 66 mod tests {
67 + use quasi_router::Node;
68 +
80 69 /// What the click handler said in four places, said once: where it goes,
81 70 /// what it replaces, and what to ask first.
71 + ///
72 + /// Rendered through a bare `Node::Act` rather than through the panel: this
73 + /// module owns the sentence, and `super::item_files` asserts it arrives in
74 + /// the row.
75 + fn html() -> String {
76 + use quasi_axum::Serves as _;
77 +
78 + quasi_webview::Webview::new().fragment(&Node::Act(super::act("7", "42")))
79 + }
80 +
82 81 #[test]
83 82 fn a_delete_names_its_route_its_row_and_its_prompt() {
84 - let html = super::html("7", "42");
83 + let html = html();
85 84
86 85 assert!(
87 86 html.contains(r#"hx-delete="/api/items/7/versions/42""#),
88 87 "{html}"
89 88 );
90 - assert!(html.contains(r##"hx-target="#version-row-42""##), "{html}");
89 + // The row it sits in, rather than an id the table had to grow.
90 + assert!(!html.contains("version-row-42"), "{html}");
91 91 assert!(
92 92 html.contains(r#"hx-confirm="Delete this version?""#),
93 93 "{html}"
@@ -99,16 +99,14 @@
99 99 /// the stylesheet.
100 100 #[test]
101 101 fn a_delete_says_it_is_dangerous_in_a_word_every_renderer_reads() {
102 - let html = super::html("7", "42");
103 -
104 - assert!(html.contains(r#"data-tone="danger""#), "{html}");
102 + assert!(html().contains(r#"data-tone="danger""#));
105 103 }
106 104
107 105 /// The negative half, as `export_act` asserts it: the dispatcher is gone
108 106 /// from this control, not merely bypassed.
109 107 #[test]
110 108 fn nothing_here_goes_through_the_dispatcher() {
111 - let html = super::html("7", "42");
109 + let html = html();
112 110
113 111 assert!(!html.contains("data-action"), "{html}");
114 112 assert!(!html.contains("delete-version-btn"), "{html}");
@@ -256,7 +256,6 @@
256 256 CollectionTemplate,
257 257 // Library tabs
258 258 LibraryPurchasesTabTemplate,
259 - LibraryFeedTabTemplate,
260 259 LibraryCollectionsTabTemplate,
261 260 // Follow button
262 261 FollowButtonTemplate,
@@ -269,7 +268,7 @@
269 268 ItemOverviewTabTemplate,
270 269 ItemDetailsTabTemplate,
271 270 ItemPricingTabTemplate,
272 - ItemFilesTabTemplate,
271 + ItemVersionUploadTemplate,
273 272 ItemEmbedTabTemplate,
274 273 // Onboarding checklist
275 274 OnboardingChecklistPartialTemplate,
@@ -593,19 +593,6 @@
593 593 pub wishlists: Vec<crate::db::wishlists::WishlistItem>,
594 594 }
595 595
596 - /// Library feed tab (items from followed users, projects, and tags).
597 - #[derive(Template)]
598 - #[template(path = "partials/tabs/library_feed.html")]
599 - pub struct LibraryFeedTabTemplate {
600 - pub items: Vec<DiscoverItem>,
601 - pub total_items: u32,
602 - pub current_page: u32,
603 - pub total_pages: u32,
604 - pub pagination_range: Vec<u32>,
605 - pub showing_start: u32,
606 - pub showing_end: u32,
607 - }
608 -
609 596 /// Per-project revenue for the user analytics top projects list.
610 597 pub struct ProjectRevenue {
611 598 pub title: String,
@@ -909,10 +896,15 @@
909 896 pub license_preset_options: Vec<(&'static str, &'static str)>,
910 897 }
911 898
912 - /// Item files tab: version upload + download table.
899 + /// The upload machine on the item's Files panel.
900 + ///
901 + /// The bespoke half of a described panel (`crate::quasi::item_files`): the
902 + /// versions table above it is a description, and this is the markup
903 + /// `static/item-upload.js` drives. `versions` is still needed, for the one
904 + /// upload field per version that has no file yet.
913 905 #[derive(Template)]
914 - #[template(path = "partials/tabs/item_files.html")]
915 - pub struct ItemFilesTabTemplate {
906 + #[template(path = "partials/item_version_upload.html")]
907 + pub struct ItemVersionUploadTemplate {
916 908 pub item: Item,
917 909 pub versions: Vec<Version>,
918 910 }
@@ -1,5 +1,4 @@
1 1 {% extends "base.html" %}
2 - {%- import "partials/_ui.html" as ui -%}
3 2
4 3 {% block title %}Feed - Makenotwork{% endblock %}
5 4 {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }} feed-page"{% endblock %}
@@ -10,50 +9,9 @@
10 9 <div class="container">
11 10 <h1 class="page-title">Your Feed</h1>
12 11
13 - {% if items.is_empty() %}
14 - {% call ui::empty_state_with_action("Nothing here yet", "Follow users, projects, or tags to see their items here.", "/discover", "Browse Discover") %}{% endcall %}
15 - {% else %}
16 - <div class="feed-meta">Showing {{ showing_start }}-{{ showing_end }} of {{ total_items }} items</div>
17 -
18 - <div class="table-header">
19 - <span>Type</span>
20 - <span>Name</span>
21 - <span>Tag</span>
22 - <span class="col-right">Price</span>
23 - <span class="col-right">Date</span>
24 - </div>
25 - <div class="results-table well">
26 - {% for item in items %}
27 - <a href="/i/{{ item.id }}" class="table-row">
28 - <span class="badge">{{ item.item_type }}</span>
29 - <div class="item-name-cell">
30 - <span class="item-name">{{ item.name }}</span>
31 - <span class="item-creator">{{ item.creator }}</span>
32 - </div>
33 - <span>{{ item.primary_tag }}</span>
34 - <span class="col-right">{% if item.is_free %}<span class="badge" data-tone="success">Free</span>{% else %}{{ item.price }}{% endif %}</span>
35 - <span class="col-right">{{ item.date }}</span>
36 - </a>
37 - {% endfor %}
38 - </div>
39 -
40 - {% if total_pages > 1 %}
41 - <div class="pagination">
42 - {% if current_page > 1 %}
43 - <a href="/feed?page={{ current_page - 1 }}">&laquo;</a>
44 - {% endif %}
45 - {% for p in pagination_range %}
46 - {% if *p == current_page %}
47 - <span class="current">{{ p }}</span>
48 - {% else %}
49 - <a href="/feed?page={{ p }}">{{ p }}</a>
50 - {% endif %}
51 - {% endfor %}
52 - {% if current_page < total_pages %}
53 - <a href="/feed?page={{ current_page + 1 }}">&raquo;</a>
54 - {% endif %}
55 - </div>
56 - {% endif %}
57 - {% endif %}
12 + {#- The feed itself is described: `crate::quasi::feeds` draws the table,
13 + the empty state and the numbered strip, and the library's Feed panel
14 + draws the same one. What is left here is the public shell. -#}
15 + {{ body|safe }}
58 16 </div>
59 17 {% endblock %}