Skip to main content

max / makenotwork

Consolidate tabs and add overflow mechanism - Footer: 11 → 5 links (Pricing, Creators, Docs, Legal, Changelog) - Library: 7 → 3 tabs (Purchases+Subscriptions, Feed, Collections+Wishlists) plus conditional Communities/Contacts - Project dashboard: 10 → 5 tabs (Overview, Content+Blog, Analytics, Monetization (tiers+promos+team), Settings) plus conditional Code/Sync - User dashboard: 10 → 5 tabs (Projects, Payments, Analytics, Settings with sub-nav, Support) - Tab overflow JS: moves excess tabs into a "More" dropdown on resize - Bump to v0.5.14 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-11 00:59 UTC
Commit: cb328fadd7d6d66a73b01559b113007d8532e927
Parent: 2983c9d
21 files changed, +610 insertions, -203 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.5.13"
3 + version = "0.5.14"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -47,6 +47,7 @@ pub fn dashboard_routes() -> Router<AppState> {
47 47 // Tab endpoints — rate limited to prevent rapid polling
48 48 let tab_routes = Router::new()
49 49 .route("/dashboard/tabs/details", get(tabs::dashboard_tab_details))
50 + .route("/dashboard/tabs/settings", get(tabs::dashboard_tab_settings))
50 51 .route("/dashboard/tabs/profile", get(tabs::dashboard_tab_profile))
51 52 .route("/dashboard/tabs/account", get(tabs::dashboard_tab_account))
52 53 .route("/dashboard/tabs/payments", get(tabs::dashboard_tab_payments))
@@ -66,6 +67,7 @@ pub fn dashboard_routes() -> Router<AppState> {
66 67 .route("/dashboard/project/{slug}/tabs/code", get(project_tabs::project_tab_code))
67 68 .route("/dashboard/project/{slug}/tabs/settings", get(project_tabs::project_tab_settings))
68 69 .route("/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog))
70 + .route("/dashboard/project/{slug}/tabs/monetization", get(project_tabs::project_tab_monetization))
69 71 .route("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions))
70 72 .route("/dashboard/project/{slug}/tabs/subscriptions", get(project_tabs::project_tab_subscriptions))
71 73 .route("/dashboard/project/{slug}/tabs/members", get(project_tabs::project_tab_members))
@@ -162,6 +162,7 @@ pub(super) async fn project_tab_content(
162 162 let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
163 163 let bundle_map = db::bundles::get_project_bundle_map(&state.db, db_project.id).await?;
164 164 let db_deleted = db::items::get_deleted_items_by_project(&state.db, db_project.id).await?;
165 + let db_posts = db::blog_posts::get_blog_posts_by_project(&state.db, db_project.id).await?;
165 166
166 167 let items = build_content_items_with_bundles(&db_items, &bundle_map);
167 168 let deleted_items: Vec<crate::templates::DeletedItemRow> = db_deleted
@@ -175,10 +176,25 @@ pub(super) async fn project_tab_content(
175 176 })
176 177 .collect();
177 178
179 + let posts: Vec<BlogPostDashboardRow> = db_posts
180 + .into_iter()
181 + .map(|p| BlogPostDashboardRow {
182 + id: p.id.to_string(),
183 + title: p.title,
184 + slug: p.slug.to_string(),
185 + status: if p.published_at.is_some() { "Published".to_string() } else { "Draft".to_string() },
186 + published_at: p.published_at
187 + .map(|d| d.format("%b %d, %Y").to_string())
188 + .unwrap_or_else(|| "-".to_string()),
189 + })
190 + .collect();
191 +
178 192 Ok(helpers::with_etag(generation, ProjectContentTabTemplate {
179 193 items,
180 194 deleted_items,
181 195 project_slug: db_project.slug.to_string(),
196 + project_id: db_project.id.to_string(),
197 + posts,
182 198 }))
183 199 }
184 200
@@ -507,6 +523,68 @@ pub(super) async fn project_tab_members(
507 523 }))
508 524 }
509 525
526 + /// Combined monetization tab: tiers, promo codes, and team splits.
527 + #[tracing::instrument(skip_all, name = "project_tabs::project_tab_monetization")]
528 + pub(super) async fn project_tab_monetization(
529 + State(state): State<AppState>,
530 + AuthUser(session_user): AuthUser,
531 + headers: HeaderMap,
532 + Path(slug): Path<String>,
533 + ) -> Result<axum::response::Response> {
534 + let (db_project, generation) = match resolve_project_etag(&state, session_user.id, &slug, &headers).await? {
535 + Ok(pair) => pair,
536 + Err(not_modified) => return Ok(not_modified),
537 + };
538 +
539 + let db_user = db::users::get_user_by_id(&state.db, session_user.id)
540 + .await?
541 + .ok_or(AppError::NotFound)?;
542 +
543 + // Tiers
544 + let db_tiers = db::subscriptions::get_all_tiers_by_project(&state.db, db_project.id).await?;
545 + let tiers: Vec<SubscriptionTier> = db_tiers.iter().map(SubscriptionTier::from).collect();
546 + let subscriber_count = db::subscriptions::get_project_subscriber_count(&state.db, db_project.id).await?;
547 +
548 + // Promo codes
549 + let codes = db::promo_codes::get_promo_codes_by_project(&state.db, db_project.id).await?;
550 + let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
551 + let items: Vec<ContentItem> = db_items
552 + .iter()
553 + .enumerate()
554 + .map(|(i, item)| ContentItem::from_db(item, (i + 1) as u32))
555 + .collect();
556 +
557 + // Members
558 + let db_members = db::project_members::get_project_members(&state.db, db_project.id).await?;
559 + let members: Vec<ProjectMemberRow> = db_members
560 + .iter()
561 + .map(|m| ProjectMemberRow {
562 + id: m.id.to_string(),
563 + user_id: m.user_id.to_string(),
564 + username: m.username.clone(),
565 + display_name: m.display_name.clone(),
566 + role: m.role.to_string(),
567 + split_percent: m.split_percent,
568 + stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled,
569 + added_at: m.added_at.format("%Y-%m-%d").to_string(),
570 + })
571 + .collect();
572 + let total_member_split = db::project_members::get_total_split_percent(&state.db, db_project.id).await?;
573 + let owner_split = 100 - total_member_split;
574 +
575 + Ok(helpers::with_etag(generation, ProjectMonetizationTabTemplate {
576 + project_id: db_project.id.to_string(),
577 + project_slug: db_project.slug.to_string(),
578 + tiers,
579 + subscriber_count,
580 + stripe_connected: db_user.stripe_account_id.is_some(),
581 + promo_codes: codes.into_iter().map(PromoCodeRow::from).collect(),
582 + items,
583 + members,
584 + owner_split,
585 + }))
586 + }
587 +
510 588 /// Render the HTMX partial for SyncKit apps linked to a project.
511 589 #[tracing::instrument(skip_all, name = "project_tabs::project_tab_synckit")]
512 590 pub(super) async fn project_tab_synckit(
@@ -11,6 +11,6 @@ pub(super) use user::{
11 11 dashboard_tab_account, dashboard_tab_analytics, dashboard_tab_contacts,
12 12 dashboard_tab_creator, dashboard_tab_details, dashboard_tab_forums,
13 13 dashboard_tab_media, dashboard_tab_payments, dashboard_tab_profile,
14 - dashboard_tab_projects, dashboard_tab_ssh_keys, dashboard_tab_support,
15 - dashboard_tab_synckit, dashboard_transactions,
14 + dashboard_tab_projects, dashboard_tab_settings, dashboard_tab_ssh_keys,
15 + dashboard_tab_support, dashboard_tab_synckit, dashboard_transactions,
16 16 };
@@ -29,6 +29,74 @@ use crate::{
29 29 AppState,
30 30 };
31 31
32 + /// Render the HTMX partial for the dashboard settings meta-tab.
33 + /// Includes profile content inline; other sections loaded via HTMX sub-nav.
34 + #[tracing::instrument(skip_all, name = "dashboard_tabs::dashboard_tab_settings")]
35 + pub(in crate::routes::pages::dashboard) async fn dashboard_tab_settings(
36 + State(state): State<AppState>,
37 + AuthUser(session_user): AuthUser,
38 + ) -> Result<impl IntoResponse> {
39 + let db_user = db::users::get_user_by_id(&state.db, session_user.id)
40 + .await?
41 + .ok_or(AppError::NotFound)?;
42 +
43 + let db_links =
44 + db::custom_links::get_custom_links_by_user(&state.db, session_user.id).await?;
45 +
46 + let user = User::from(&db_user);
47 +
48 + let custom_links: Vec<CustomLinkWithId> = db_links
49 + .into_iter()
50 + .map(|l| CustomLinkWithId {
51 + id: l.id.to_string(),
52 + url: l.url,
53 + title: l.title,
54 + })
55 + .collect();
56 +
57 + let feed_url = helpers::generate_feed_url(
58 + &state.config.host_url,
59 + session_user.id,
60 + &state.config.signing_secret,
61 + );
62 +
63 + let custom_domain =
64 + db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id)
65 + .await?
66 + .map(|d| {
67 + let instructions = if d.verified {
68 + String::new()
69 + } else {
70 + format!(
71 + "Add a DNS TXT record: _mnw-verify.{} with value {}",
72 + d.domain, d.verification_token
73 + )
74 + };
75 + crate::templates::CustomDomainInfo {
76 + id: d.id.to_string(),
77 + domain: d.domain,
78 + verified: d.verified,
79 + verification_token: d.verification_token,
80 + instructions,
81 + }
82 + });
83 +
84 + let has_media = session_user.can_create_projects;
85 + let git_enabled = state.config.git_repos_path.is_some();
86 + let has_mt_memberships = state.config.mt_base_url.is_some();
87 +
88 + Ok(UserSettingsTabTemplate {
89 + user,
90 + custom_links,
91 + feed_url,
92 + can_create_projects: session_user.can_create_projects,
93 + custom_domain,
94 + has_media,
95 + git_enabled,
96 + has_mt_memberships,
97 + })
98 + }
99 +
32 100 /// Legacy route — redirects to the profile tab.
33 101 pub(in crate::routes::pages::dashboard) async fn dashboard_tab_details(
34 102 state: State<AppState>,
@@ -63,10 +63,15 @@ pub(super) async fn library(
63 63 AuthUser(user): AuthUser,
64 64 ) -> Result<impl IntoResponse> {
65 65 let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?;
66 + let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?;
67 + let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
68 + let has_mt_memberships = state.config.mt_base_url.is_some();
66 69 Ok(LibraryTemplate {
67 70 csrf_token: get_csrf_token(&session).await,
68 71 session_user: Some(user),
69 72 purchases,
73 + subscriptions,
74 + has_mt_memberships,
70 75 })
71 76 }
72 77
@@ -143,14 +148,16 @@ pub(super) async fn cart_page(
143 148 })
144 149 }
145 150
146 - /// HTMX partial: library purchases tab.
151 + /// HTMX partial: library purchases tab (includes subscriptions).
147 152 #[tracing::instrument(skip_all, name = "landing::library_tab_purchases")]
148 153 pub(super) async fn library_tab_purchases(
149 154 State(state): State<AppState>,
150 155 AuthUser(user): AuthUser,
151 156 ) -> Result<impl IntoResponse> {
152 157 let purchases = db::transactions::get_user_purchases(&state.db, user.id).await?;
153 - Ok(LibraryPurchasesTabTemplate { purchases })
158 + let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?;
159 + let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
160 + Ok(LibraryPurchasesTabTemplate { purchases, subscriptions })
154 161 }
155 162
156 163 /// HTMX partial: library feed tab.
@@ -193,18 +200,7 @@ pub(super) async fn library_tab_feed(
193 200 })
194 201 }
195 202
196 - /// HTMX partial: library subscriptions tab.
197 - #[tracing::instrument(skip_all, name = "landing::library_tab_subscriptions")]
198 - pub(super) async fn library_tab_subscriptions(
199 - State(state): State<AppState>,
200 - AuthUser(user): AuthUser,
201 - ) -> Result<impl IntoResponse> {
202 - let db_subs = db::subscriptions::get_user_subscriptions_with_details(&state.db, user.id).await?;
203 - let subscriptions: Vec<UserSubscription> = db_subs.iter().map(UserSubscription::from).collect();
204 - Ok(LibrarySubscriptionsTabTemplate { subscriptions })
205 - }
206 -
207 - /// HTMX partial: library collections tab.
203 + /// HTMX partial: library collections tab (includes wishlists).
208 204 #[tracing::instrument(skip_all, name = "landing::library_tab_collections")]
209 205 pub(super) async fn library_tab_collections(
210 206 State(state): State<AppState>,
@@ -212,9 +208,11 @@ pub(super) async fn library_tab_collections(
212 208 ) -> Result<impl IntoResponse> {
213 209 let db_collections = db::collections::get_collections_by_user(&state.db, user.id).await?;
214 210 let collections: Vec<Collection> = db_collections.iter().map(Collection::from).collect();
211 + let wishlists = db::wishlists::get_wishlist(&state.db, user.id).await?;
215 212 Ok(LibraryCollectionsTabTemplate {
216 213 collections,
217 214 username: user.username.to_string(),
215 + wishlists,
218 216 })
219 217 }
220 218
@@ -250,16 +248,6 @@ pub(super) async fn library_tab_contacts(
250 248 Ok(LibraryContactsTabTemplate { shared_creators, buyer_contacts, total_buyer_contacts })
251 249 }
252 250
253 - /// HTMX partial: library wishlists tab.
254 - #[tracing::instrument(skip_all, name = "landing::library_tab_wishlists")]
255 - pub(super) async fn library_tab_wishlists(
256 - State(state): State<AppState>,
257 - AuthUser(user): AuthUser,
258 - ) -> Result<impl IntoResponse> {
259 - let wishlists = db::wishlists::get_wishlist(&state.db, user.id).await?;
260 - Ok(LibraryWishlistsTabTemplate { wishlists })
261 - }
262 -
263 251 /// HTMX partial: library communities tab (Multithreaded forum memberships).
264 252 #[tracing::instrument(skip_all, name = "landing::library_tab_communities")]
265 253 pub(super) async fn library_tab_communities(
@@ -41,9 +41,7 @@ pub fn public_routes() -> Router<AppState> {
41 41 .route("/cart", get(landing::cart_page))
42 42 .route("/library/tabs/purchases", get(landing::library_tab_purchases))
43 43 .route("/library/tabs/feed", get(landing::library_tab_feed))
44 - .route("/library/tabs/subscriptions", get(landing::library_tab_subscriptions))
45 44 .route("/library/tabs/collections", get(landing::library_tab_collections))
46 - .route("/library/tabs/wishlists", get(landing::library_tab_wishlists))
47 45 .route("/library/tabs/contacts", get(landing::library_tab_contacts))
48 46 .route("/library/tabs/communities", get(landing::library_tab_communities))
49 47 .route("/health", get(health::health))
@@ -120,6 +120,7 @@ impl_into_response!(
120 120 ExportContentReadyTemplate,
121 121 TransactionsTableTemplate,
122 122 UserProfileTabTemplate,
123 + UserSettingsTabTemplate,
123 124 UserAccountTabTemplate,
124 125 UserSshKeysTabTemplate,
125 126 UserPaymentsTabTemplate,
@@ -135,6 +136,7 @@ impl_into_response!(
135 136 ProjectBlogTabTemplate,
136 137 ProjectSubscriptionsTabTemplate,
137 138 ProjectMembersTabTemplate,
139 + ProjectMonetizationTabTemplate,
138 140 ItemEditRowTemplate,
139 141 // Admin partials
140 142 AdminWaitlistEntriesTemplate,
@@ -164,9 +166,7 @@ impl_into_response!(
164 166 // Library tabs
165 167 LibraryPurchasesTabTemplate,
166 168 LibraryFeedTabTemplate,
167 - LibrarySubscriptionsTabTemplate,
168 169 LibraryCollectionsTabTemplate,
169 - LibraryWishlistsTabTemplate,
170 170 LibraryContactsTabTemplate,
171 171 LibraryCommunitiesTabTemplate,
172 172 // Follow button
@@ -174,6 +174,20 @@ pub struct UserProfileTabTemplate {
174 174 pub custom_domain: Option<CustomDomainInfo>,
175 175 }
176 176
177 + /// Dashboard settings meta-tab with sub-navigation (profile, account, plan, etc.)
178 + #[derive(Template)]
179 + #[template(path = "partials/tabs/user_settings.html")]
180 + pub struct UserSettingsTabTemplate {
181 + pub user: User,
182 + pub custom_links: Vec<CustomLinkWithId>,
183 + pub feed_url: String,
184 + pub can_create_projects: bool,
185 + pub custom_domain: Option<CustomDomainInfo>,
186 + pub has_media: bool,
187 + pub git_enabled: bool,
188 + pub has_mt_memberships: bool,
189 + }
190 +
177 191 /// Dashboard tab: account mechanics — security, sessions, notifications, data.
178 192 #[derive(Template)]
179 193 #[template(path = "partials/tabs/user_account.html")]
@@ -320,6 +334,8 @@ pub struct ProjectContentTabTemplate {
320 334 pub items: Vec<ContentItem>,
321 335 pub deleted_items: Vec<DeletedItemRow>,
322 336 pub project_slug: String,
337 + pub project_id: String,
338 + pub posts: Vec<BlogPostDashboardRow>,
323 339 }
324 340
325 341 /// Dashboard tab: project analytics with stats, chart, and top items.
@@ -413,6 +429,21 @@ pub struct ProjectMembersTabTemplate {
413 429 pub owner_split: i64,
414 430 }
415 431
432 + /// Combined monetization tab: tiers, promo codes, and team splits.
433 + #[derive(Template)]
434 + #[template(path = "partials/tabs/project_monetization.html")]
435 + pub struct ProjectMonetizationTabTemplate {
436 + pub project_id: String,
437 + pub project_slug: String,
438 + pub tiers: Vec<SubscriptionTier>,
439 + pub subscriber_count: i64,
440 + pub stripe_connected: bool,
441 + pub promo_codes: Vec<crate::types::PromoCodeRow>,
442 + pub items: Vec<ContentItem>,
443 + pub members: Vec<ProjectMemberRow>,
444 + pub owner_split: i64,
445 + }
446 +
416 447 /// SyncKit tab in the user dashboard for managing sync apps.
417 448 #[derive(Template)]
418 449 #[template(path = "partials/tabs/user_synckit.html")]
@@ -476,12 +507,6 @@ pub struct UserSupportTabTemplate {
476 507 #[template(path = "partials/tabs/library_purchases.html")]
477 508 pub struct LibraryPurchasesTabTemplate {
478 509 pub purchases: Vec<crate::db::DbPurchaseRow>,
479 - }
480 -
481 - /// Library subscriptions tab.
482 - #[derive(Template)]
483 - #[template(path = "partials/tabs/library_subscriptions.html")]
484 - pub struct LibrarySubscriptionsTabTemplate {
485 510 pub subscriptions: Vec<UserSubscription>,
486 511 }
487 512
@@ -491,12 +516,6 @@ pub struct LibrarySubscriptionsTabTemplate {
491 516 pub struct LibraryCollectionsTabTemplate {
492 517 pub collections: Vec<Collection>,
493 518 pub username: String,
494 - }
495 -
496 - /// Library wishlists tab.
497 - #[derive(Template)]
498 - #[template(path = "partials/tabs/library_wishlists.html")]
499 - pub struct LibraryWishlistsTabTemplate {
500 519 pub wishlists: Vec<crate::db::wishlists::WishlistItem>,
501 520 }
502 521
@@ -48,6 +48,8 @@ pub struct LibraryTemplate {
48 48 pub csrf_token: CsrfTokenOption,
49 49 pub session_user: Option<SessionUser>,
50 50 pub purchases: Vec<crate::db::DbPurchaseRow>,
51 + pub subscriptions: Vec<UserSubscription>,
52 + pub has_mt_memberships: bool,
51 53 }
52 54
53 55 /// Shopping cart page with items grouped by seller.
@@ -58,7 +58,9 @@ function safeStorageSet(key, value) {
58 58 =========================================== */
59 59
60 60 function setActiveTab(btn) {
61 - btn.closest('.tabs').querySelectorAll('.tab').forEach(function(tab) {
61 + var container = btn.closest('.tabs');
62 + if (!container) return;
63 + container.querySelectorAll('.tab').forEach(function(tab) {
62 64 tab.classList.remove('active');
63 65 tab.setAttribute('aria-selected', 'false');
64 66 });
@@ -67,17 +69,138 @@ function setActiveTab(btn) {
67 69 var panel = document.getElementById('tab-content');
68 70 if (panel) panel.setAttribute('aria-labelledby', btn.id);
69 71 if (btn.id) history.replaceState(null, '', '#' + btn.id);
72 + var menu = btn.closest('.tab-overflow-menu');
73 + if (menu) menu.style.display = 'none';
74 + tabOverflow.updateHighlight(container);
70 75 }
71 76
72 - document.addEventListener('DOMContentLoaded', function() {
73 - var hash = location.hash.replace('#', '');
74 - if (hash) {
75 - var tab = document.getElementById(hash);
76 - if (tab && tab.classList.contains('tab')) {
77 - tab.click();
77 + /* ===========================================
78 + TAB OVERFLOW
79 + Moves tabs that don't fit into a "More" dropdown.
80 + No wrapper divs — tabs are moved directly.
81 + =========================================== */
82 +
83 + var tabOverflow = (function() {
84 + var containers = [];
85 +
86 + function init() {
87 + containers = Array.from(document.querySelectorAll('.tabs[role="tablist"]'));
88 + containers.forEach(setup);
89 + window.addEventListener('resize', debounce(reflowAll, 150));
90 + }
91 +
92 + function setup(tabsEl) {
93 + if (tabsEl.dataset.overflowInit) return;
94 + tabsEl.dataset.overflowInit = '1';
95 +
96 + var moreWrap = document.createElement('div');
97 + moreWrap.className = 'tab-more-wrap';
98 + moreWrap.style.display = 'none';
99 +
100 + var moreBtn = document.createElement('button');
101 + moreBtn.className = 'tab tab-more-btn';
102 + moreBtn.type = 'button';
103 + moreBtn.textContent = 'More';
104 + moreBtn.setAttribute('aria-haspopup', 'true');
105 + moreBtn.setAttribute('aria-expanded', 'false');
106 + moreBtn.addEventListener('click', function(e) {
107 + e.stopPropagation();
108 + var m = moreWrap.querySelector('.tab-overflow-menu');
109 + var open = m.style.display === 'block';
110 + m.style.display = open ? 'none' : 'block';
111 + moreBtn.setAttribute('aria-expanded', open ? 'false' : 'true');
112 + });
113 +
114 + var menu = document.createElement('div');
115 + menu.className = 'tab-overflow-menu';
116 + menu.style.display = 'none';
117 +
118 + moreWrap.appendChild(moreBtn);
119 + moreWrap.appendChild(menu);
120 +
121 + // Insert before spinner if present, otherwise append
122 + var spinner = tabsEl.querySelector('.htmx-indicator');
123 + if (spinner) {
124 + tabsEl.insertBefore(moreWrap, spinner);
125 + } else {
126 + tabsEl.appendChild(moreWrap);
127 + }
128 +
129 + reflow(tabsEl);
130 + }
131 +
132 + function reflow(tabsEl) {
133 + var moreWrap = tabsEl.querySelector('.tab-more-wrap');
134 + if (!moreWrap) return;
135 + var menu = moreWrap.querySelector('.tab-overflow-menu');
136 +
137 + // Move all tabs back from menu into the row (before moreWrap)
138 + Array.from(menu.children).forEach(function(t) {
139 + tabsEl.insertBefore(t, moreWrap);
140 + });
141 + moreWrap.style.display = 'none';
142 +
143 + // Collect all tab buttons (exclude the More button itself)
144 + var tabs = Array.from(tabsEl.querySelectorAll(':scope > .tab'));
145 + if (tabs.length === 0) return;
146 +
147 + // Check if everything fits without More
148 + var available = tabsEl.clientWidth;
149 + var totalWidth = 0;
150 + tabs.forEach(function(t) { totalWidth += t.offsetWidth; });
151 + if (totalWidth <= available) return;
152 +
153 + // Find the cutoff point (reserve space for More button)
154 + var moreBtnWidth = 90;
155 + var used = 0;
156 + var cutoff = tabs.length;
157 +
158 + for (var i = 0; i < tabs.length; i++) {
159 + used += tabs[i].offsetWidth;
160 + if (used + moreBtnWidth > available) {
161 + cutoff = i;
162 + break;
163 + }
78 164 }
165 +
166 + // Ensure at least 1 tab stays visible
167 + if (cutoff < 1) cutoff = 1;
168 +
169 + // Move tabs from cutoff onward into the menu
170 + for (var j = cutoff; j < tabs.length; j++) {
171 + menu.appendChild(tabs[j]);
172 + }
173 + moreWrap.style.display = '';
174 + updateHighlight(tabsEl);
175 + }
176 +
177 + function reflowAll() {
178 + containers.forEach(reflow);
179 + }
180 +
181 + function updateHighlight(tabsEl) {
182 + var moreWrap = tabsEl.querySelector('.tab-more-wrap');
183 + if (!moreWrap) return;
184 + var moreBtn = moreWrap.querySelector('.tab-more-btn');
185 + var menu = moreWrap.querySelector('.tab-overflow-menu');
186 + if (!moreBtn || !menu) return;
187 + var hasActive = menu.querySelector('.tab.active');
188 + moreBtn.classList.toggle('active', !!hasActive);
79 189 }
80 190
191 + function debounce(fn, ms) {
192 + var timer;
193 + return function() {
194 + clearTimeout(timer);
195 + timer = setTimeout(fn, ms);
196 + };
197 + }
198 +
199 + return { init: init, updateHighlight: updateHighlight };
200 + })();
201 +
202 + document.addEventListener('DOMContentLoaded', function() {
203 + // Tab preloading on hover
81 204 document.querySelectorAll('.tab').forEach(function(btn) {
82 205 btn.addEventListener('mouseenter', function() {
83 206 if (this.dataset.preloaded) return;
@@ -87,6 +210,28 @@ document.addEventListener('DOMContentLoaded', function() {
87 210 fetch(url, { headers: { 'HX-Request': 'true' } }).catch(function() {});
88 211 });
89 212 });
213 +
214 + // Initialize tab overflow
215 + tabOverflow.init();
216 +
217 + // Restore hash-based tab selection (after overflow init so tabs are placed)
218 + var hash = location.hash.replace('#', '');
219 + if (hash) {
220 + var tab = document.getElementById(hash);
221 + if (tab && tab.classList.contains('tab')) {
222 + tab.click();
223 + }
224 + }
225 +
226 + // Close More dropdown on outside click
227 + document.addEventListener('click', function() {
228 + document.querySelectorAll('.tab-overflow-menu').forEach(function(m) {
229 + m.style.display = 'none';
230 + });
231 + document.querySelectorAll('.tab-more-btn').forEach(function(b) {
232 + b.setAttribute('aria-expanded', 'false');
233 + });
234 + });
90 235 });
91 236
92 237 /* ===========================================
@@ -429,6 +429,7 @@ form button:hover {
429 429
430 430 .tabs {
431 431 display: flex;
432 + flex-wrap: nowrap;
432 433 gap: 0;
433 434 margin-bottom: 0;
434 435 }
@@ -445,6 +446,8 @@ form button:hover {
445 446 background 0.2s ease,
446 447 opacity 0.2s ease;
447 448 opacity: 0.6;
449 + white-space: nowrap;
450 + flex-shrink: 0;
448 451 }
449 452
450 453 .tab.active {
@@ -456,6 +459,38 @@ form button:hover {
456 459 opacity: 1;
457 460 }
458 461
462 + .tab-more-wrap {
463 + position: relative;
464 + flex-shrink: 0;
465 + }
466 +
467 + .tab-overflow-menu {
468 + position: absolute;
469 + top: 100%;
470 + right: 0;
471 + z-index: 10;
472 + background: var(--background);
473 + border: 1px solid var(--border);
474 + min-width: 180px;
475 + box-shadow: 0 2px 8px rgba(0,0,0,0.1);
476 + }
477 +
478 + .tab-overflow-menu .tab {
479 + display: block;
480 + width: 100%;
481 + text-align: left;
482 + padding: 0.6rem 1rem;
483 + opacity: 0.7;
484 + }
485 +
486 + .tab-overflow-menu .tab:hover {
487 + opacity: 1;
488 + }
489 +
490 + .tab-overflow-menu .tab.active {
491 + opacity: 1;
492 + }
493 +
459 494 .tab-content {
460 495 display: none;
461 496 background: var(--light-background);
@@ -3744,10 +3779,6 @@ textarea:focus-visible {
3744 3779 grid-template-columns: 1fr;
3745 3780 }
3746 3781
3747 - .tabs {
3748 - flex-wrap: wrap;
3749 - }
3750 -
3751 3782 .tab {
3752 3783 padding: 0.6rem 1.25rem;
3753 3784 font-size: 0.9rem;
@@ -18,15 +18,9 @@
18 18 <footer class="site-footer">
19 19 <div class="site-footer-links">
20 20 <a href="/pricing">Pricing</a>
21 - <a href="/use-cases">Use Cases</a>
22 21 <a href="/creators">Creators</a>
23 22 <a href="/docs">Docs</a>
24 - <a href="/fan-plus">Fan+</a>
25 - <a href="/docs/faq">FAQ</a>
26 - <a href="/policy">Policy</a>
27 - <a href="/docs/terms-of-service">Terms</a>
28 - <a href="/docs/privacy-policy">Privacy</a>
29 - <a href="/health">Status</a>
23 + <a href="/policy">Legal</a>
30 24 <a href="/changelog">Changelog</a>
31 25 </div>
32 26 <p>&copy; 2026 Makenotwork</p>
@@ -35,8 +29,8 @@
35 29 <!-- Toast notification container -->
36 30 <div id="notifications" class="toast-container" role="alert" aria-live="polite"></div>
37 31
38 - <script src="/static/mnw.js?v=0513"></script>
39 - <script src="/static/collections.js?v=0513"></script>
32 + <script src="/static/mnw.js?v=0514"></script>
33 + <script src="/static/collections.js?v=0514"></script>
40 34 {% block scripts %}{% endblock %}
41 35 </body>
42 36 </html>
@@ -58,7 +58,7 @@
58 58 aria-selected="false"
59 59 aria-controls="tab-content"
60 60 id="tab-content-btn"
61 - title="Manage items, uploads, and versions"
61 + title="Items, files, and blog posts"
62 62 hx-get="/dashboard/project/{{ project.slug }}/tabs/content"
63 63 hx-target="#tab-content"
64 64 hx-swap="innerHTML"
@@ -79,46 +79,13 @@
79 79 role="tab"
80 80 aria-selected="false"
81 81 aria-controls="tab-content"
82 - id="tab-blog"
83 - title="Write and publish blog posts for this project"
84 - hx-get="/dashboard/project/{{ project.slug }}/tabs/blog"
82 + id="tab-monetization"
83 + title="Tiers, promo codes, and revenue splits"
84 + hx-get="/dashboard/project/{{ project.slug }}/tabs/monetization"
85 85 hx-target="#tab-content"
86 86 hx-swap="innerHTML"
87 87 hx-indicator="#tab-spinner"
88 - onclick="setActiveTab(this)">Blog</button>
89 - <button class="tab"
90 - role="tab"
91 - aria-selected="false"
92 - aria-controls="tab-content"
93 - id="tab-promotions"
94 - title="Promo codes and discount campaigns"
95 - hx-get="/dashboard/project/{{ project.slug }}/tabs/promotions"
96 - hx-target="#tab-content"
97 - hx-swap="innerHTML"
98 - hx-indicator="#tab-spinner"
99 - onclick="setActiveTab(this)">Promo Codes</button>
100 - <button class="tab"
101 - role="tab"
102 - aria-selected="false"
103 - aria-controls="tab-content"
104 - id="tab-subscriptions"
105 - title="Recurring membership tiers for fans"
106 - hx-get="/dashboard/project/{{ project.slug }}/tabs/subscriptions"
107 - hx-target="#tab-content"
108 - hx-swap="innerHTML"
109 - hx-indicator="#tab-spinner"
110 - onclick="setActiveTab(this)">Membership Tiers</button>
111 - <button class="tab"
112 - role="tab"
113 - aria-selected="false"
114 - aria-controls="tab-content"
115 - id="tab-members"
116 - title="Collaborators and team access"
117 - hx-get="/dashboard/project/{{ project.slug }}/tabs/members"
118 - hx-target="#tab-content"
119 - hx-swap="innerHTML"
120 - hx-indicator="#tab-spinner"
121 - onclick="setActiveTab(this)">Team</button>
88 + onclick="setActiveTab(this)">Monetization</button>
122 89 {% if git_enabled %}
123 90 <button class="tab"
124 91 role="tab"
@@ -155,103 +155,27 @@
155 155 role="tab"
156 156 aria-selected="false"
157 157 aria-controls="tab-content"
158 - id="tab-profile"
159 - title="Display name, bio, avatar, and public links"
160 - hx-get="/dashboard/tabs/profile"
158 + id="tab-settings"
159 + title="Profile, account, plan, and integrations"
160 + hx-get="/dashboard/tabs/settings"
161 161 hx-target="#tab-content"
162 162 hx-swap="innerHTML"
163 163 hx-indicator="#tab-spinner"
164 - onclick="setActiveTab(this)">Profile</button>
165 - <button class="tab"
166 - role="tab"
167 - aria-selected="false"
168 - aria-controls="tab-content"
169 - id="tab-account"
170 - title="Email, password, security, and data export"
171 - hx-get="/dashboard/tabs/account"
172 - hx-target="#tab-content"
173 - hx-swap="innerHTML"
174 - hx-indicator="#tab-spinner"
175 - onclick="setActiveTab(this)">Account</button>
176 - <button class="tab"
177 - role="tab"
178 - aria-selected="false"
179 - aria-controls="tab-content"
180 - id="tab-plan"
181 - title="Your creator subscription tier and usage"
182 - hx-get="/dashboard/tabs/creator"
183 - hx-target="#tab-content"
184 - hx-swap="innerHTML"
185 - hx-indicator="#tab-spinner"
186 - onclick="setActiveTab(this)">Creator Plan</button>
187 -
188 - {% if let Some(su) = session_user %}{% if su.can_create_projects && !projects.is_empty() %}
189 - <div class="tab-overflow" style="position: relative; display: inline-block;">
190 - <button class="tab" onclick="var m=this.nextElementSibling; m.style.display=m.style.display==='block'?'none':'block';" type="button" title="Media, SSH Keys, Forums, Support">More &darr;</button>
191 - <div class="tab-overflow-menu" style="display: none; position: absolute; top: 100%; left: 0; z-index: 10; background: var(--background); border: 1px solid var(--border); min-width: 160px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
192 - <div style="padding: 0.4rem 1rem 0.2rem; font-size: 0.75rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em;">Content</div>
193 - <button class="tab" style="display: block; width: 100%; text-align: left; padding: 0.5rem 1rem;"
194 - title="Uploaded images and avatars"
195 - hx-get="/dashboard/tabs/media"
196 - hx-target="#tab-content"
197 - hx-swap="innerHTML"
198 - hx-indicator="#tab-spinner"
199 - onclick="setActiveTab(this); this.closest('.tab-overflow-menu').style.display='none';">Media</button>
200 - {% if git_enabled || has_mt_memberships %}
201 - <div style="padding: 0.4rem 1rem 0.2rem; font-size: 0.75rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; border-top: 1px solid var(--border); margin-top: 0.25rem;">Integration</div>
202 - {% endif %}
203 - {% if git_enabled %}
204 - <button class="tab" style="display: block; width: 100%; text-align: left; padding: 0.5rem 1rem;"
205 - title="Public keys for Git authentication"
206 - hx-get="/dashboard/tabs/ssh-keys"
207 - hx-target="#tab-content"
208 - hx-swap="innerHTML"
209 - hx-indicator="#tab-spinner"
210 - onclick="setActiveTab(this); this.closest('.tab-overflow-menu').style.display='none';">SSH Keys</button>
211 - {% endif %}
212 - {% if has_mt_memberships %}
213 - <button class="tab" style="display: block; width: 100%; text-align: left; padding: 0.5rem 1rem;"
214 - title="Your forum community memberships"
215 - hx-get="/dashboard/tabs/forums"
216 - hx-target="#tab-content"
217 - hx-swap="innerHTML"
218 - hx-indicator="#tab-spinner"
219 - onclick="setActiveTab(this); this.closest('.tab-overflow-menu').style.display='none';">Forums</button>
220 - {% endif %}
221 - <div style="padding: 0.4rem 1rem 0.2rem; font-size: 0.75rem; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; border-top: 1px solid var(--border); margin-top: 0.25rem;">Support</div>
222 - <button class="tab" style="display: block; width: 100%; text-align: left; padding: 0.5rem 1rem;"
223 - title="Contact support or report an issue"
224 - hx-get="/dashboard/tabs/support"
225 - hx-target="#tab-content"
226 - hx-swap="innerHTML"
227 - hx-indicator="#tab-spinner"
228 - onclick="setActiveTab(this); this.closest('.tab-overflow-menu').style.display='none';">Support</button>
229 - </div>
230 - </div>
231 - {% else %}
164 + onclick="setActiveTab(this)">Settings</button>
232 165 <button class="tab"
233 166 role="tab"
234 167 aria-selected="false"
235 168 aria-controls="tab-content"
236 169 id="tab-support"
170 + title="Contact support or report an issue"
237 171 hx-get="/dashboard/tabs/support"
238 172 hx-target="#tab-content"
239 173 hx-swap="innerHTML"
240 174 hx-indicator="#tab-spinner"
241 175 onclick="setActiveTab(this)">Support</button>
242 - {% endif %}{% endif %}
243 176 <span id="tab-spinner" class="htmx-indicator" style="margin-left: 1rem;" aria-live="polite"> Loading...</span>
244 177 </div>
245 178
246 - <script>
247 - document.addEventListener('click', function(e) {
248 - var menus = document.querySelectorAll('.tab-overflow-menu');
249 - menus.forEach(function(m) {
250 - if (!m.parentElement.contains(e.target)) m.style.display = 'none';
251 - });
252 - });
253 - </script>
254 -
255 179 <!-- Tab Content Container -->
256 180 <div id="tab-content" class="tab-content active"
257 181 role="tabpanel"
@@ -35,33 +35,13 @@
35 35 role="tab"
36 36 aria-selected="false"
37 37 aria-controls="tab-content"
38 - id="tab-subscriptions"
39 - hx-get="/library/tabs/subscriptions"
40 - hx-target="#tab-content"
41 - hx-swap="innerHTML"
42 - hx-indicator="#tab-spinner"
43 - onclick="setActiveTab(this)">Subscriptions</button>
44 - <button class="tab"
45 - role="tab"
46 - aria-selected="false"
47 - aria-controls="tab-content"
48 - id="tab-wishlists"
49 - title="Items you saved for later"
50 - hx-get="/library/tabs/wishlists"
51 - hx-target="#tab-content"
52 - hx-swap="innerHTML"
53 - hx-indicator="#tab-spinner"
54 - onclick="setActiveTab(this)">Wishlists</button>
55 - <button class="tab"
56 - role="tab"
57 - aria-selected="false"
58 - aria-controls="tab-content"
59 38 id="tab-collections"
60 39 hx-get="/library/tabs/collections"
61 40 hx-target="#tab-content"
62 41 hx-swap="innerHTML"
63 42 hx-indicator="#tab-spinner"
64 43 onclick="setActiveTab(this)">Collections</button>
44 + {% if has_mt_memberships %}
65 45 <button class="tab"
66 46 role="tab"
67 47 aria-selected="false"
@@ -72,6 +52,8 @@
72 52 hx-swap="innerHTML"
73 53 hx-indicator="#tab-spinner"
74 54 onclick="setActiveTab(this)">Communities</button>
55 + {% endif %}
56 + {% if let Some(user) = session_user %}{% if user.can_create_projects %}
75 57 <button class="tab"
76 58 role="tab"
77 59 aria-selected="false"
@@ -82,6 +64,7 @@
82 64 hx-swap="innerHTML"
83 65 hx-indicator="#tab-spinner"
84 66 onclick="setActiveTab(this)">Contacts</button>
67 + {% endif %}{% endif %}
85 68 <span id="tab-spinner" class="htmx-indicator" style="margin-left: 1rem;" aria-live="polite"> Loading...</span>
86 69 </div>
87 70
@@ -72,3 +72,49 @@
72 72 }
73 73 });
74 74 </script>
75 +
76 + <h2 style="font-size: 1.1rem; margin-top: 2.5rem; margin-bottom: 1rem; opacity: 0.8;">Wishlist</h2>
77 + {% if wishlists.is_empty() %}
78 + <div class="content-section">
79 + <p class="muted">No wishlisted items. Add items from any item page to save them for later.</p>
80 + </div>
81 + {% else %}
82 + <div class="content-section" style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
83 + <table class="data-table" style="min-width: 500px;">
84 + <thead>
85 + <tr>
86 + <th>Title</th>
87 + <th>Creator</th>
88 + <th>Type</th>
89 + <th>Price</th>
90 + <th>Added</th>
91 + <th></th>
92 + </tr>
93 + </thead>
94 + <tbody>
95 + {% for item in wishlists %}
96 + <tr id="wishlist-row-{{ item.item_id }}">
97 + <td><a href="/i/{{ item.item_id }}">{{ item.title }}</a></td>
98 + <td><a href="/u/{{ item.creator }}">{{ item.creator }}</a></td>
99 + <td><span class="badge">{{ item.item_type }}</span></td>
100 + <td>{% if item.price_cents == 0 %}Free{% else %}${{ item.price_cents / 100 }}.{{ "{:02}"|format(item.price_cents % 100) }}{% endif %}</td>
101 + <td>{{ item.added_at.format("%b %d, %Y") }}</td>
102 + <td style="white-space: nowrap;">
103 + <button class="primary small"
104 + hx-post="/api/cart/{{ item.item_id }}"
105 + hx-swap="none"
106 + hx-on::after-request="if(event.detail.successful){this.textContent='Added';this.disabled=true}"
107 + style="font-size: 0.8rem; padding: 0.2rem 0.5rem; margin-right: 0.25rem;">Add to Cart</button>
108 + <button class="secondary small"
109 + hx-post="/api/wishlists/{{ item.item_id }}"
110 + hx-target="#wishlist-row-{{ item.item_id }}"
111 + hx-swap="outerHTML swap:0.2s"
112 + hx-confirm="Remove from wishlist?"
113 + style="font-size: 0.8rem; padding: 0.2rem 0.5rem;">Remove</button>
114 + </td>
115 + </tr>
116 + {% endfor %}
117 + </tbody>
118 + </table>
119 + </div>
120 + {% endif %}
@@ -156,3 +156,35 @@
156 156 document.querySelectorAll('.context-menu').forEach(function(m) { m.classList.remove('open'); });
157 157 });
158 158 </script>
159 +
160 + <h2 style="font-size: 1.1rem; margin-top: 2.5rem; margin-bottom: 1rem; opacity: 0.8;">Subscriptions</h2>
161 + {% if subscriptions.is_empty() %}
162 + <div class="content-section">
163 + <p class="muted">No active subscriptions. Browse creators and projects to find membership tiers.</p>
164 + </div>
165 + {% else %}
166 + <div class="content-section" style="overflow-x: auto; -webkit-overflow-scrolling: touch;">
167 + <table class="data-table" style="min-width: 500px;">
168 + <thead>
169 + <tr>
170 + <th>Project</th>
171 + <th>Tier</th>
172 + <th>Price</th>
173 + <th>Status</th>
174 + <th>Renews</th>
175 + </tr>
176 + </thead>
177 + <tbody>
178 + {% for sub in subscriptions %}
179 + <tr>
180 + <td><a href="/p/{{ sub.project_slug }}">{{ sub.project_title }}</a></td>
181 + <td>{{ sub.tier_name }}</td>
182 + <td>{{ sub.price }}</td>
183 + <td><span class="badge {{ sub.status }}">{{ sub.status }}</span></td>
184 + <td>{{ sub.current_period_end.as_deref().unwrap_or("-") }}</td>
185 + </tr>
186 + {% endfor %}
187 + </tbody>
188 + </table>
189 + </div>
190 + {% endif %}
@@ -494,3 +494,51 @@ function inlineRename(itemId) {
494 494 input.select();
495 495 }
496 496 </script>
497 +
498 + <hr style="margin: 3rem 0; border: none; border-top: 1px solid var(--border);">
499 +
500 + <div class="content-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
501 + <h2>Blog Posts</h2>
502 + <a href="/dashboard/project/{{ project_slug }}/blog/new">
503 + <button class="primary">New Post</button>
504 + </a>
505 + </div>
506 +
507 + {% if posts.is_empty() %}
508 + <div class="empty-state" style="padding: 2rem 0;">
509 + <p>No blog posts yet. Share updates, release notes, or stories with your audience.</p>
510 + </div>
511 + {% else %}
512 + <table class="data-table">
513 + <thead>
514 + <tr>
515 + <th style="width: 45%">Title</th>
516 + <th style="width: 15%">Status</th>
517 + <th style="width: 20%">Published</th>
518 + <th style="width: 20%">Actions</th>
519 + </tr>
520 + </thead>
521 + <tbody>
522 + {% for post in posts %}
523 + <tr id="post-row-{{ post.id }}">
524 + <td>
525 + <a href="/p/{{ project_slug }}/blog/{{ post.slug }}" style="font-weight: bold;">{{ post.title }}</a>
526 + </td>
527 + <td><span class="badge {{ post.status|lowercase }}">{{ post.status }}</span></td>
528 + <td>{{ post.published_at }}</td>
529 + <td>
530 + <div style="display: flex; gap: 0.5rem;">
531 + <a href="/p/{{ project_slug }}/blog/{{ post.slug }}"><button class="secondary small">View</button></a>
532 + <a href="/dashboard/project/{{ project_slug }}/blog/new?post={{ post.id }}"><button class="secondary small">Edit</button></a>
533 + <button class="secondary small" style="color: var(--danger);"
534 + hx-delete="/api/blog/{{ post.id }}"
535 + hx-target="#post-row-{{ post.id }}"
536 + hx-swap="outerHTML"
537 + hx-confirm="Delete this blog post?">Delete</button>
538 + </div>
539 + </td>
540 + </tr>
541 + {% endfor %}
542 + </tbody>
543 + </table>
544 + {% endif %}
@@ -0,0 +1,9 @@
1 + {% include "partials/tabs/project_subscriptions.html" %}
2 +
3 + <hr style="margin: 3rem 0; border: none; border-top: 1px solid var(--border);">
4 +
5 + {% include "partials/tabs/project_promotions.html" %}
6 +
7 + <hr style="margin: 3rem 0; border: none; border-top: 1px solid var(--border);">
8 +
9 + {% include "partials/tabs/project_members.html" %}