max / makenotwork
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>© 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 ↓</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" %} |