max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 { |