Skip to main content

max / makenotwork

Describe the project dashboard's tab strip Shape 2 step 3 (6b24f2df). Seven buttons and their panel container leave dashboard-project.html for src/quasi/project_tabs.rs. Eleven project tab routes exist and seven of them are tabs. Monetization is a composite whose three includes each carry their own route, used only to refresh themselves, and blog is reached from the content panel. Those four are deliberately not buttons, and a test asserts none of them appears in the strip: a tab the page never had is the failure this strip was most likely to produce. Deep links work by query rather than by hash. Three tabs are linked to from somewhere in the tree, measured rather than guessed: overview, content from the Go to Content button in the overview panel, and synckit from the Stripe billing return. The page fills whichever of those it opens, from builders split out of project_tabs so the page and the route answer the same markup. A ?tab= naming anything else answers the first tab rather than opening a panel the page cannot render. The page stops fetching on load, same as the item strip, and the handler's duplicate stats vector goes with it. That vector, items, creator_username, stripe_connected and has_blog were all built per request for template fields nothing read. Sixteen sites named #tab-content and meant a panel: four analytics range buttons, three content refreshes, two members and two subscriptions refreshes inside the monetization composite, four htmx.ajax calls in tab-project-content and three in the code tab's JS. Each names its panel now. Fixes a regression from step 1 that the library conversion shipped: the feed tab's three pagination links target #tab-content, which that conversion made the strip rather than the panel, so paging the library feed replaced the whole strip with a page of the feed. They target #library-feed now. It is the same tail this step found sixteen of, missed once from the other direction. Retires seven onSetActiveTab sites and the Go to Content click wrapper (155 to 147), and the last tab-spinner-indicator markup and its CSS rule, since Action::awaiting is what a control in flight says now.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-19 15:19 UTC
Signed with PGP, not checked
Commit: 614154fda7837e0089017edb4bad4d705be54500
Parent: 90e2143
18 files changed, +478 insertions, -225 deletions
@@ -11281,7 +11281,6 @@
11281 11281 =========================================== */
11282 11282
11283 11283 /* Tab-spinner loading indicator (used by 3+ dashboards). */
11284 - .tab-spinner-indicator { margin-left: var(--gap-section); }
11285 11284
11286 11285 /* Inline-block secondary action link styled as a button. */
11287 11286 .btn-link {
@@ -16,7 +16,7 @@
16 16 body: body.toString()
17 17 }).then(function(r) {
18 18 if (r.ok) {
19 - htmx.ajax('GET', '/dashboard/project/' + projectSlug + '/tabs/code', {target: '#tab-content', swap: 'innerHTML'});
19 + htmx.ajax('GET', '/dashboard/project/' + projectSlug + '/tabs/code', {target: '#project-code', swap: 'innerHTML'});
20 20 } else {
21 21 return r.json().then(function(data) {
22 22 alert(data.error || 'Failed to add collaborator');
@@ -15,7 +15,7 @@
15 15 body: JSON.stringify({name: name})
16 16 }).then(function(r) {
17 17 if (r.ok) {
18 - htmx.ajax('GET', '/dashboard/project/' + projectSlug + '/tabs/code', {target: '#tab-content', swap: 'innerHTML'});
18 + htmx.ajax('GET', '/dashboard/project/' + projectSlug + '/tabs/code', {target: '#project-code', swap: 'innerHTML'});
19 19 } else {
20 20 return r.json().then(function(data) {
21 21 status.textContent = data.error || 'Failed to create repository';
@@ -13,7 +13,7 @@
13 13 headers: {'Content-Type': 'application/json', ...csrfHeaders()},
14 14 body: JSON.stringify({name: name})
15 15 }).then(function() {
16 - htmx.ajax('GET', '/dashboard/project/' + projectSlug + '/tabs/code', {target: '#tab-content', swap: 'innerHTML'});
16 + htmx.ajax('GET', '/dashboard/project/' + projectSlug + '/tabs/code', {target: '#project-code', swap: 'innerHTML'});
17 17 }).catch(function() {
18 18 var status = document.getElementById('create-repo-status');
19 19 if (status) {
@@ -65,7 +65,7 @@
65 65 });
66 66 var label = action === 'delete' ? 'deleted' : action === 'publish' ? 'published' : 'unpublished';
67 67 showToast(count + ' item(s) ' + label + '.', 'success');
68 - htmx.ajax('GET', '/dashboard/project/' + PROJECT_SLUG + '/tabs/content', '#tab-content');
68 + htmx.ajax('GET', '/dashboard/project/' + PROJECT_SLUG + '/tabs/content', '#project-content');
69 69 })
70 70 .catch(function(err) {
71 71 showToast(err.message || 'Bulk operation failed');
@@ -86,7 +86,7 @@
86 86 throw new Error(msg);
87 87 });
88 88 showToast('Item published.', 'success');
89 - htmx.ajax('GET', '/dashboard/project/' + PROJECT_SLUG + '/tabs/content', '#tab-content');
89 + htmx.ajax('GET', '/dashboard/project/' + PROJECT_SLUG + '/tabs/content', '#project-content');
90 90 }).catch(function(err) {
91 91 showToast(err.message || 'Publish failed');
92 92 });
@@ -126,7 +126,7 @@
126 126 throw new Error(msg);
127 127 });
128 128 document.getElementById('bulk-price-form').classList.add('hidden');
129 - htmx.ajax('GET', '/dashboard/project/' + PROJECT_SLUG + '/tabs/content', '#tab-content');
129 + htmx.ajax('GET', '/dashboard/project/' + PROJECT_SLUG + '/tabs/content', '#project-content');
130 130 })
131 131 .catch(function(err) { showToast(err.message || 'Could not update prices. Try again, or refresh the page.'); });
132 132 }
@@ -154,7 +154,7 @@
154 154 });
155 155 document.getElementById('bulk-tag-form').classList.add('hidden');
156 156 document.getElementById('bulk-tag-input').value = '';
157 - htmx.ajax('GET', '/dashboard/project/' + PROJECT_SLUG + '/tabs/content', '#tab-content');
157 + htmx.ajax('GET', '/dashboard/project/' + PROJECT_SLUG + '/tabs/content', '#project-content');
158 158 })
159 159 .catch(function(err) { showToast(err.message || 'Could not add the tag. Try again, or refresh the page.'); });
160 160 }
@@ -36,6 +36,7 @@
36 36 pub mod item_tabs;
37 37 pub mod library_contacts;
38 38 pub mod library_tabs;
39 + pub mod project_tabs;
39 40 pub mod ssh_keys;
40 41 pub mod user_analytics;
41 42 pub mod widgets;
@@ -5,9 +5,8 @@
5 5 use crate::auth::SessionUser;
6 6 use crate::types::{
7 7 AdminAppealRow, AdminAuditLogRow, AdminCompCodeRow, AdminHeldUploadRow, AdminReportRow,
8 - AdminSignupRow, AdminUserRow, AdminWaitlistRow, ContentItem, Item, OnboardingChecklist,
9 - Project, ProjectCard, ReportStats, ScanHistoryDisplay, StatCard, Transaction, User,
10 - WaitlistStats,
8 + AdminSignupRow, AdminUserRow, AdminWaitlistRow, Item, OnboardingChecklist, Project,
9 + ProjectCard, ReportStats, ScanHistoryDisplay, Transaction, User, WaitlistStats,
11 10 };
12 11
13 12 use super::CsrfTokenOption;
@@ -55,17 +54,14 @@
55 54 pub csrf_token: CsrfTokenOption,
56 55 pub session_user: Option<SessionUser>,
57 56 pub project: Project,
58 - pub creator_username: String,
59 - pub stats: Vec<StatCard>,
60 - pub items: Vec<ContentItem>,
61 - /// Whether this creator has connected their Stripe account (controls Subscriptions tab visibility).
62 - pub stripe_connected: bool,
63 - /// Whether this project has the "blog" feature enabled (controls Blog tab visibility).
64 - pub has_blog: bool,
65 - /// Whether git hosting is configured on this server (controls Code tab visibility).
66 - pub git_enabled: bool,
67 - /// Whether this project has the "cloud_sync" feature enabled (controls Cloud Sync tab visibility).
68 - pub synckit_enabled: bool,
57 + /// The tab strip and its panels, described rather than written out here.
58 + ///
59 + /// `6b24f2df`. Built by `crate::quasi::project_tabs`, which needs the shown
60 + /// panel's markup and the two feature tests. Six fields left with the strip:
61 + /// `creator_username`, `stats`, `items`, `stripe_connected`, `has_blog` and
62 + /// the two gates. The template read none of them -- the gates decided which
63 + /// buttons it wrote, and the rest were built per request for nothing.
64 + pub tabs: String,
69 65 }
70 66
71 67 /// Item management dashboard shell with tabbed navigation.
@@ -29,100 +29,8 @@
29 29 </div>
30 30 </header>
31 31
32 - <div class="tabs" role="tablist" aria-label="Project sections">
33 - <button class="tab chosen"
34 - role="tab"
35 - aria-selected="true"
36 - aria-controls="tab-content"
37 - id="tab-overview"
38 - title="Stats and quick actions"
39 - hx-get="/dashboard/project/{{ project.slug }}/tabs/overview"
40 - hx-target="#tab-content"
41 - hx-swap="innerHTML"
42 - hx-indicator="#tab-spinner"
43 - data-action="onSetActiveTab">Overview</button>
44 - <button class="tab"
45 - role="tab"
46 - aria-selected="false"
47 - aria-controls="tab-content"
48 - id="tab-content-btn"
49 - title="Items, files, and blog posts"
50 - hx-get="/dashboard/project/{{ project.slug }}/tabs/content"
51 - hx-target="#tab-content"
52 - hx-swap="innerHTML"
53 - hx-indicator="#tab-spinner"
54 - data-action="onSetActiveTab">Content</button>
55 - <button class="tab"
56 - role="tab"
57 - aria-selected="false"
58 - aria-controls="tab-content"
59 - id="tab-analytics"
60 - title="Sales, revenue, and views over time"
61 - hx-get="/dashboard/project/{{ project.slug }}/tabs/analytics"
62 - hx-target="#tab-content"
63 - hx-swap="innerHTML"
64 - hx-indicator="#tab-spinner"
65 - data-action="onSetActiveTab">Analytics</button>
66 - <button class="tab"
67 - role="tab"
68 - aria-selected="false"
69 - aria-controls="tab-content"
70 - id="tab-monetization"
71 - title="Tiers, promo codes, and revenue splits"
72 - hx-get="/dashboard/project/{{ project.slug }}/tabs/monetization"
73 - hx-target="#tab-content"
74 - hx-swap="innerHTML"
75 - hx-indicator="#tab-spinner"
76 - data-action="onSetActiveTab">Monetization</button>
77 - {% if git_enabled %}
78 - <button class="tab"
79 - role="tab"
80 - aria-selected="false"
81 - aria-controls="tab-content"
82 - id="tab-code"
83 - title="Linked Git repositories and source browser"
84 - hx-get="/dashboard/project/{{ project.slug }}/tabs/code"
85 - hx-target="#tab-content"
86 - hx-swap="innerHTML"
87 - hx-indicator="#tab-spinner"
88 - data-action="onSetActiveTab">Code</button>
89 - {% endif %}
90 - {% if synckit_enabled %}
91 - <button class="tab"
92 - role="tab"
93 - aria-selected="false"
94 - aria-controls="tab-content"
95 - id="tab-synckit"
96 - title="End-to-end encrypted sync for your apps"
97 - hx-get="/dashboard/project/{{ project.slug }}/tabs/synckit"
98 - hx-target="#tab-content"
99 - hx-swap="innerHTML"
100 - hx-indicator="#tab-spinner"
101 - data-action="onSetActiveTab">Cloud Sync</button>
102 - {% endif %}
103 - <button class="tab"
104 - role="tab"
105 - aria-selected="false"
106 - aria-controls="tab-content"
107 - id="tab-settings"
108 - title="Visibility, URL name, deletion, and project options"
109 - hx-get="/dashboard/project/{{ project.slug }}/tabs/settings"
110 - hx-target="#tab-content"
111 - hx-swap="innerHTML"
112 - hx-indicator="#tab-spinner"
113 - data-action="onSetActiveTab">Settings</button>
114 - <span id="tab-spinner" class="htmx-indicator tab-spinner-indicator" aria-live="polite"> Loading...</span>
115 - </div>
32 + {{ tabs|safe }}
116 33
117 - <!-- Tab Content Container - initially loaded with Overview -->
118 - <div id="tab-content" class="tab-content active"
119 - role="tabpanel"
120 - aria-labelledby="tab-overview"
121 - hx-get="/dashboard/project/{{ project.slug }}/tabs/overview"
122 - hx-trigger="load"
123 - hx-swap="innerHTML">
124 - <!-- Content loaded via HTMX -->
125 - </div>
126 34 </div>
127 35 {% endblock %}
128 36