Skip to main content

max / makenotwork

Describe the item dashboard's tab strip Shape 2 step 2 (6b24f2df). Five buttons and their panel container leave dashboard-item.html for src/quasi/item_tabs.rs, the second described strip after the library's. Every panel here is still an Askama route, so unlike the library there is no per-tab decision about who names the region an answer lands in: every unshown tab carries Action::replacing. That is what this step proves, that a strip converts without any of its panels converting. The page loses a request. It gave #tab-content an hx-trigger="load" and fetched Overview after the document arrived, which is a placeholder before first content and is what 9b958e7b refuses. The overview is rendered by the handler now, out of the Item it already built, so no query is added. The hash deep link becomes a query. core/tabs.ts restored a tab by finding the button by id and clicking it, and the described strip has no button ids for that to find. ?tab= is read server-side instead, so the strip opens on what was asked for, filled, at first paint. Its one caller in the tree is static/item-upload.js after an upload, and it asks for files. Three things outside the strip named #tab-content and now name the panel they meant: the refund refresh in item_sales.html and the afterSwap re-init hooks in item-details.js and item-upload.js. Retires five onSetActiveTab sites, the largest data-action verb (17050ff5, 160 sites to 155), and the tab-spinner-indicator spelling, since Action::awaiting is what a control in flight says now (736f45a5). Dropped, with nothing to put them on: five button titles and the strip's aria-label. Filed as quasicoherent aad33ecc.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-19 14:58 UTC
Signed with PGP, not checked
Commit: 90e214362f90895ce7f67beac4ea82dc6ff544c6
Parent: 8ba54c9
9 files changed, +388 insertions, -79 deletions
@@ -411,9 +411,11 @@
411 411 // Run on initial load
412 412 init();
413 413
414 - // Re-run when HTMX swaps in tab content
414 + // Re-run when HTMX swaps in the details panel. The item strip is described
415 + // (`6b24f2df`), so each panel is its own region and a swap lands in that
416 + // panel's id rather than in the one container the strip used to have.
415 417 document.body.addEventListener('htmx:afterSwap', function(e) {
416 - if (e.detail.target && e.detail.target.id === 'tab-content') {
418 + if (e.detail.target && e.detail.target.id === 'item-details') {
417 419 setTimeout(init, 0);
418 420 }
419 421 });
@@ -130,7 +130,7 @@
130 130 'warning'
131 131 );
132 132 }
133 - setTimeout(function() { window.location.href = '/dashboard/item/' + itemId + '#tab-files'; }, 1500);
133 + setTimeout(function() { window.location.href = '/dashboard/item/' + itemId + '?tab=files'; }, 1500);
134 134 })
135 135 .catch(function(err) {
136 136 document.getElementById('upload-progress').classList.add('hidden');
@@ -457,9 +457,10 @@
457 457 // Run on initial load
458 458 init();
459 459
460 - // Re-run when HTMX swaps in upload tabs
460 + // Re-run when HTMX swaps in the files panel. Described strip, one region
461 + // per panel (`6b24f2df`); this used to be the strip's single container.
461 462 document.body.addEventListener('htmx:afterSwap', function(e) {
462 - if (e.detail.target.id === 'tab-content') {
463 + if (e.detail.target && e.detail.target.id === 'item-files') {
463 464 init();
464 465 }
465 466 });
@@ -33,6 +33,7 @@
33 33
34 34 pub mod buyer_contacts;
35 35 pub mod forum_memberships;
36 + pub mod item_tabs;
36 37 pub mod library_contacts;
37 38 pub mod library_tabs;
38 39 pub mod ssh_keys;
@@ -68,7 +68,7 @@
68 68 pub synckit_enabled: bool,
69 69 }
70 70
71 - /// Item management dashboard shell with tabbed navigation (content loaded via HTMX).
71 + /// Item management dashboard shell with tabbed navigation.
72 72 #[derive(Template)]
73 73 #[template(path = "dashboards/dashboard-item.html")]
74 74 pub struct DashboardItemTemplate {
@@ -77,6 +77,12 @@
77 77 pub item: Item,
78 78 pub project_title: String,
79 79 pub project_slug: String,
80 + /// The tab strip and its panels, described rather than written out here.
81 + ///
82 + /// `6b24f2df`. Built by `crate::quasi::item_tabs`, which needs the shown
83 + /// panel's markup and whether the item is a bundle, so the handler
84 + /// assembles it and this carries the answer.
85 + pub tabs: String,
80 86 }
81 87
82 88 // Admin
@@ -37,76 +37,8 @@
37 37 </div>
38 38 </header>
39 39
40 - <div class="tabs" role="tablist" aria-label="Item sections">
41 - <button class="tab chosen"
42 - role="tab"
43 - aria-selected="true"
44 - aria-controls="tab-content"
45 - id="tab-overview"
46 - title="Stats, quick actions, and embed codes"
47 - hx-get="/dashboard/item/{{ item.id }}/tabs/overview"
48 - hx-target="#tab-content"
49 - hx-swap="innerHTML"
50 - hx-indicator="#tab-spinner"
51 - data-action="onSetActiveTab">Overview</button>
52 - <button class="tab"
53 - role="tab"
54 - aria-selected="false"
55 - aria-controls="tab-content"
56 - id="tab-details"
57 - title="Title, description, tags, content, and publishing"
58 - hx-get="/dashboard/item/{{ item.id }}/tabs/details"
59 - hx-target="#tab-content"
60 - hx-swap="innerHTML"
61 - hx-indicator="#tab-spinner"
62 - data-action="onSetActiveTab">Details</button>
63 - <button class="tab"
64 - role="tab"
65 - aria-selected="false"
66 - aria-controls="tab-content"
67 - id="tab-pricing"
68 - title="Price, pay-what-you-want, license keys, and promo codes"
69 - hx-get="/dashboard/item/{{ item.id }}/tabs/pricing"
70 - hx-target="#tab-content"
71 - hx-swap="innerHTML"
72 - hx-indicator="#tab-spinner"
73 - data-action="onSetActiveTab">Pricing</button>
74 - {% if item.item_type != "bundle" %}
75 - <button class="tab"
76 - role="tab"
77 - aria-selected="false"
78 - aria-controls="tab-content"
79 - id="tab-files"
80 - title="Uploaded files and version history"
81 - hx-get="/dashboard/item/{{ item.id }}/tabs/files"
82 - hx-target="#tab-content"
83 - hx-swap="innerHTML"
84 - hx-indicator="#tab-spinner"
85 - data-action="onSetActiveTab">Files</button>
86 - {% endif %}
87 - <button class="tab"
88 - role="tab"
89 - aria-selected="false"
90 - aria-controls="tab-content"
91 - id="tab-sales"
92 - title="Transaction history and refunds"
93 - hx-get="/dashboard/item/{{ item.id }}/tabs/sales"
94 - hx-target="#tab-content"
95 - hx-swap="innerHTML"
96 - hx-indicator="#tab-spinner"
97 - data-action="onSetActiveTab">Sales</button>
98 - <span id="tab-spinner" class="htmx-indicator tab-spinner-indicator" aria-live="polite"> Loading...</span>
99 - </div>
40 + {{ tabs|safe }}
100 41
101 - <!-- Tab Content Container - initially loaded with Overview -->
102 - <div id="tab-content" class="tab-content active"
103 - role="tabpanel"
104 - aria-labelledby="tab-overview"
105 - hx-get="/dashboard/item/{{ item.id }}/tabs/overview"
106 - hx-trigger="load"
107 - hx-swap="innerHTML">
108 - <!-- Content loaded via HTMX -->
109 - </div>
110 42 </div>
111 43 {% endblock %}
112 44
@@ -35,7 +35,7 @@
35 35 hx-confirm="Issue a full refund for {{ sale.amount_display }}? This cannot be undone."
36 36 hx-target="#sale-{{ sale.transaction_id }}"
37 37 hx-swap="outerHTML"
38 - data-after="refresh" data-arg="/dashboard/item/{{ item.id }}/tabs/sales" data-arg2="#tab-content">Refund</button>
38 + data-after="refresh" data-arg="/dashboard/item/{{ item.id }}/tabs/sales" data-arg2="#item-sales">Refund</button>
39 39 {% endif %}
40 40 </td>
41 41 </tr>
@@ -1,5 +1,7 @@
1 1 //! Main dashboard pages: user dashboard, project dashboard, item dashboard.
2 2
3 + use askama::Template as _;
4 +
3 5 use crate::extractors::ValidatedQuery;
4 6 use axum::{
5 7 extract::{Path, State},
@@ -14,18 +16,20 @@
14 16 db::{self, ItemId, Slug, analytics::TimeRange},
15 17 error::{AppError, Result, ResultExt},
16 18 helpers::get_csrf_token,
19 + quasi,
17 20 templates::{
18 21 DashboardItemTemplate, DashboardProjectTemplate, DashboardUserTemplate,
19 - ItemAnalyticsPartialTemplate, OnboardingChecklistPartialTemplate,
22 + ItemAnalyticsPartialTemplate, ItemFilesTabTemplate, ItemOverviewTabTemplate,
23 + OnboardingChecklistPartialTemplate,
20 24 },
21 25 types::{
22 26 ContentItem, Item, OnboardingChecklist, OnboardingStep, Project, ProjectCard, StatCard,
23 - User,
27 + User, Version,
24 28 },
25 29 };
26 30 use sqlx::PgPool;
27 31
28 - use super::AnalyticsQuery;
32 + use super::{AnalyticsQuery, ItemTabQuery};
29 33
30 34 const ONBOARDING_DISMISSED_KEY: &str = "onboarding_dismissed";
31 35
@@ -289,6 +293,7 @@
289 293 session: Session,
290 294 AuthUser(session_user): AuthUser,
291 295 Path(id): Path<String>,
296 + ValidatedQuery(query): ValidatedQuery<ItemTabQuery>,
292 297 ) -> Result<impl IntoResponse> {
293 298 let csrf_token = get_csrf_token(&session).await;
294 299
@@ -319,12 +324,39 @@
319 324 session_user.settlement_currency,
320 325 );
321 326
327 + // The shown panel is rendered here rather than fetched. The page used to
328 + // give its panel container an `hx-trigger="load"`, so it drew an empty box
329 + // and filled it a round trip later; a described strip cannot say that
330 + // (`9b958e7b`), and the overview template wants nothing this handler has not
331 + // already built. `6b24f2df`.
332 + let is_bundle = item.item_type == "bundle";
333 + let shown = quasi::item_tabs::shown_at(query.tab.as_deref(), is_bundle);
334 + // Only the two tabs anything links to are fillable here. Nothing in the tree
335 + // links to details, pricing or sales, measured 2026-08-19, and each of those
336 + // wants queries this handler does not make; add one when a link appears
337 + // rather than paying for four panels nobody asks for.
338 + let panel = match quasi::item_tabs::route_at(shown, is_bundle) {
339 + "files" => {
340 + let db_versions = db::versions::get_versions_by_item(&db, item_id).await?;
341 + let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
342 + ItemFilesTabTemplate {
343 + item: item.clone(),
344 + versions,
345 + }
346 + .render()
347 + }
348 + _ => ItemOverviewTabTemplate { item: item.clone() }.render(),
349 + }
350 + .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
351 + let tabs = quasi::item_tabs::html(&item.id, shown, &panel, is_bundle);
352 +
322 353 Ok(DashboardItemTemplate {
323 354 csrf_token,
324 355 session_user: Some(session_user),
325 356 item,
326 357 project_title: db_project.title,
327 358 project_slug: db_project.slug.to_string(),
359 + tabs,
328 360 })
329 361 }
330 362
@@ -19,6 +19,15 @@
19 19 types::{ChartBar, Transaction},
20 20 };
21 21
22 + /// Which item dashboard tab the strip opens on.
23 + ///
24 + /// Replaces the `#tab-files` hash `core/tabs.ts` used to restore by clicking
25 + /// after load. `6b24f2df`.
26 + #[derive(Deserialize)]
27 + pub(super) struct ItemTabQuery {
28 + pub tab: Option<String>,
29 + }
30 +
22 31 /// Query parameters for analytics time range selection.
23 32 #[derive(Deserialize)]
24 33 pub(super) struct AnalyticsQuery {