Skip to main content

max / makenotwork

v0.3.16: Item dashboard tab conversion, library communities tab Convert the monolithic 573-line item dashboard into 5 lazy-loaded HTMX tabs (Overview, Details, Pricing, Files, Settings), matching the pattern used by user and project dashboards. Add library communities tab for Multithreaded forum memberships. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 05:50 UTC
Commit: 98dbce79c276e5cf90c2ddae271e629c36576bb5
Parent: 9185090
18 files changed, +918 insertions, -496 deletions
@@ -3350,7 +3350,7 @@ dependencies = [
3350 3350
3351 3351 [[package]]
3352 3352 name = "makenotwork"
3353 - version = "0.3.14"
3353 + version = "0.3.16"
3354 3354 dependencies = [
3355 3355 "anyhow",
3356 3356 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.15"
3 + version = "0.3.16"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -13,7 +13,6 @@ use crate::{
13 13 error::{AppError, Result},
14 14 helpers::{self, get_csrf_token},
15 15 templates::*,
16 - types::{LicenseKeyRow, PromoCodeRow},
17 16 types::*,
18 17 AppState,
19 18 };
@@ -233,7 +232,7 @@ pub(super) async fn dashboard_project(
233 232 })
234 233 }
235 234
236 - /// Render the dashboard view for a single owned item with versions and stats.
235 + /// Render the dashboard shell for a single owned item (tabs loaded via HTMX).
237 236 #[tracing::instrument(skip_all, name = "dashboard::dashboard_item")]
238 237 pub(super) async fn dashboard_item(
239 238 State(state): State<AppState>,
@@ -258,69 +257,16 @@ pub(super) async fn dashboard_item(
258 257 return Err(AppError::Forbidden);
259 258 }
260 259
261 - let db_versions = db::versions::get_versions_by_item(&state.db, item_id).await?;
262 -
263 - // Fetch license keys if enabled
264 - let db_license_keys = if db_item.enable_license_keys {
265 - db::license_keys::get_license_keys_by_item(&state.db, item_id).await?
266 - } else {
267 - vec![]
268 - };
269 -
270 260 let is_free = db_item.price_cents == 0;
271 261 let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
272 262 let item = Item::from_db_detail(&db_item, &item_tags, None, None, is_free, true);
273 263
274 - // Fetch promo codes scoped to this item
275 - let db_promo_codes = db::promo_codes::get_promo_codes_by_item(&state.db, item_id).await?;
276 -
277 - // Fetch project labels (display names only) for publish reminder
278 - let project_labels: Vec<String> = db::labels::get_labels_for_project(&state.db, db_project.id)
279 - .await?
280 - .into_iter()
281 - .map(|l| l.display_name)
282 - .collect();
283 -
284 - let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
285 - let license_keys: Vec<LicenseKeyRow> = db_license_keys.into_iter().map(LicenseKeyRow::from).collect();
286 - let promo_codes: Vec<PromoCodeRow> = db_promo_codes.into_iter().map(PromoCodeRow::from).collect();
287 -
288 - // Load bundle child items and available items if this is a bundle
289 - let (bundle_items, bundleable_items) = if db_item.item_type == db::ItemType::Bundle {
290 - let children = db::bundles::get_bundle_items(&state.db, item_id).await?;
291 - let bundle_child_ids: Vec<db::ItemId> = children.iter().map(|c| c.id).collect();
292 - let child_items: Vec<Item> = children
293 - .iter()
294 - .map(|child| {
295 - let child_tags = vec![];
296 - Item::from_db_list(child, &child_tags, false, true)
297 - })
298 - .collect();
299 - let available = db::bundles::get_bundleable_items(&state.db, db_project.id, Some(item_id)).await?;
300 - let available_items: Vec<Item> = available
301 - .iter()
302 - .filter(|a| !bundle_child_ids.contains(&a.id))
303 - .map(|a| {
304 - let a_tags = vec![];
305 - Item::from_db_list(a, &a_tags, false, true)
306 - })
307 - .collect();
308 - (child_items, available_items)
309 - } else {
310 - (vec![], vec![])
311 - };
312 -
313 264 Ok(DashboardItemTemplate {
314 265 csrf_token,
315 266 session_user: Some(session_user),
316 267 item,
317 268 project_title: db_project.title,
318 - versions,
319 - license_keys,
320 - promo_codes,
321 - project_labels,
322 - bundle_items,
323 - bundleable_items,
269 + project_slug: db_project.slug.to_string(),
324 270 })
325 271 }
326 272
@@ -58,6 +58,11 @@ pub fn dashboard_routes() -> Router<AppState> {
58 58 .route("/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog))
59 59 .route("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions))
60 60 .route("/dashboard/project/{slug}/tabs/subscriptions", get(project_tabs::project_tab_subscriptions))
61 + .route("/dashboard/item/{id}/tabs/overview", get(tabs::item_tab_overview))
62 + .route("/dashboard/item/{id}/tabs/details", get(tabs::item_tab_details))
63 + .route("/dashboard/item/{id}/tabs/pricing", get(tabs::item_tab_pricing))
64 + .route("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files))
65 + .route("/dashboard/item/{id}/tabs/settings", get(tabs::item_tab_settings))
61 66 .route("/dashboard/item/{id}/analytics", get(main::dashboard_item_analytics))
62 67 .route("/dashboard/item/{id}/edit-row", get(forms::item_edit_row))
63 68 .route("/dashboard/export", get(forms::export_portal))
@@ -1,6 +1,6 @@
1 1 //! User dashboard HTMX tab partials.
2 2
3 - use axum::extract::{Query, State};
3 + use axum::extract::{Path, Query, State};
4 4 use axum::response::IntoResponse;
5 5 use tower_sessions::Session;
6 6
@@ -9,7 +9,7 @@ use axum::http::HeaderMap;
9 9 use crate::{
10 10 auth::{AuthUser, SESSION_TRACKING_KEY},
11 11 constants::{self, DASHBOARD_TRANSACTION_LIMIT},
12 - db::{self, analytics::TimeRange},
12 + db::{self, analytics::TimeRange, ItemId},
13 13 error::{AppError, Result},
14 14 helpers::{self, get_csrf_token},
15 15 templates::*,
@@ -517,4 +517,147 @@ pub(super) async fn dashboard_tab_synckit(
517 517 Ok(UserSyncKitTabTemplate { apps, projects })
518 518 }
519 519
520 + // ============================================================================
521 + // Item Dashboard Tab Handlers
522 + // ============================================================================
523 +
524 + /// Resolve an item by ID, verify ownership, and return the item + db_item for tab handlers.
525 + async fn resolve_owned_item(
526 + state: &AppState,
527 + user_id: db::UserId,
528 + id: &str,
529 + ) -> Result<(db::DbItem, db::DbProject)> {
530 + let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
531 + let db_item = db::items::get_item_by_id(&state.db, item_id)
532 + .await?
533 + .ok_or(AppError::NotFound)?;
534 + let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id)
535 + .await?
536 + .ok_or(AppError::NotFound)?;
537 + if db_project.user_id != user_id {
538 + return Err(AppError::Forbidden);
539 + }
540 + Ok((db_item, db_project))
541 + }
542 +
543 + /// Build an Item view model from a DbItem (shared by item tab handlers).
544 + fn build_item_view(db_item: &db::DbItem, item_tags: &[db::DbItemTag]) -> Item {
545 + let is_free = db_item.price_cents == 0;
546 + Item::from_db_detail(db_item, item_tags, None, None, is_free, true)
547 + }
548 +
549 + /// Item overview tab: quick actions + analytics (lazy-loaded).
550 + #[tracing::instrument(skip_all, name = "item_tabs::item_tab_overview")]
551 + pub(super) async fn item_tab_overview(
552 + State(state): State<AppState>,
553 + AuthUser(session_user): AuthUser,
554 + Path(id): Path<String>,
555 + ) -> Result<impl IntoResponse> {
556 + let (db_item, _db_project) = resolve_owned_item(&state, session_user.id, &id).await?;
557 + let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?;
558 + let item = build_item_view(&db_item, &item_tags);
559 + Ok(ItemOverviewTabTemplate { item })
560 + }
561 +
562 + /// Item details tab: name, description, tags, content editor, bundle contents.
563 + #[tracing::instrument(skip_all, name = "item_tabs::item_tab_details")]
564 + pub(super) async fn item_tab_details(
565 + State(state): State<AppState>,
566 + AuthUser(session_user): AuthUser,
567 + Path(id): Path<String>,
568 + ) -> Result<impl IntoResponse> {
569 + let (db_item, db_project) = resolve_owned_item(&state, session_user.id, &id).await?;
570 + let item_id = db_item.id;
571 + let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
572 + let item = build_item_view(&db_item, &item_tags);
573 +
574 + let (bundle_items, bundleable_items) = if db_item.item_type == db::ItemType::Bundle {
575 + let children = db::bundles::get_bundle_items(&state.db, item_id).await?;
576 + let bundle_child_ids: Vec<db::ItemId> = children.iter().map(|c| c.id).collect();
577 + let child_items: Vec<Item> = children
578 + .iter()
579 + .map(|child| {
580 + let child_tags = vec![];
581 + Item::from_db_list(child, &child_tags, false, true)
582 + })
583 + .collect();
584 + let available = db::bundles::get_bundleable_items(&state.db, db_project.id, Some(item_id)).await?;
585 + let available_items: Vec<Item> = available
586 + .iter()
587 + .filter(|a| !bundle_child_ids.contains(&a.id))
588 + .map(|a| {
589 + let a_tags = vec![];
590 + Item::from_db_list(a, &a_tags, false, true)
591 + })
592 + .collect();
593 + (child_items, available_items)
594 + } else {
595 + (vec![], vec![])
596 + };
597 +
598 + Ok(ItemDetailsTabTemplate { item, bundle_items, bundleable_items })
599 + }
520 600
601 + /// Item pricing tab: PWYW settings, license keys, promo codes.
602 + #[tracing::instrument(skip_all, name = "item_tabs::item_tab_pricing")]
603 + pub(super) async fn item_tab_pricing(
604 + State(state): State<AppState>,
605 + AuthUser(session_user): AuthUser,
606 + Path(id): Path<String>,
607 + ) -> Result<impl IntoResponse> {
608 + let (db_item, _db_project) = resolve_owned_item(&state, session_user.id, &id).await?;
609 + let item_id = db_item.id;
610 + let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
611 + let item = build_item_view(&db_item, &item_tags);
612 +
613 + let db_license_keys = if db_item.enable_license_keys {
614 + db::license_keys::get_license_keys_by_item(&state.db, item_id).await?
615 + } else {
616 + vec![]
617 + };
618 + let license_keys: Vec<LicenseKeyRow> = db_license_keys.into_iter().map(LicenseKeyRow::from).collect();
619 +
620 + let db_promo_codes = db::promo_codes::get_promo_codes_by_item(&state.db, item_id).await?;
621 + let promo_codes: Vec<PromoCodeRow> = db_promo_codes.into_iter().map(PromoCodeRow::from).collect();
622 +
623 + Ok(ItemPricingTabTemplate { item, license_keys, promo_codes })
624 + }
625 +
626 + /// Item files tab: version upload + download table.
627 + #[tracing::instrument(skip_all, name = "item_tabs::item_tab_files")]
628 + pub(super) async fn item_tab_files(
629 + State(state): State<AppState>,
630 + AuthUser(session_user): AuthUser,
631 + Path(id): Path<String>,
632 + ) -> Result<impl IntoResponse> {
633 + let (db_item, _db_project) = resolve_owned_item(&state, session_user.id, &id).await?;
634 + let item_id = db_item.id;
635 + let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
636 + let item = build_item_view(&db_item, &item_tags);
637 +
638 + let db_versions = db::versions::get_versions_by_item(&state.db, item_id).await?;
639 + let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
640 +
641 + Ok(ItemFilesTabTemplate { item, versions })
642 + }
643 +
644 + /// Item settings tab: publish/unpublish/schedule + danger zone (delete).
645 + #[tracing::instrument(skip_all, name = "item_tabs::item_tab_settings")]
646 + pub(super) async fn item_tab_settings(
647 + State(state): State<AppState>,
648 + AuthUser(session_user): AuthUser,
649 + Path(id): Path<String>,
650 + ) -> Result<impl IntoResponse> {
651 + let (db_item, db_project) = resolve_owned_item(&state, session_user.id, &id).await?;
652 + let item_id = db_item.id;
653 + let item_tags = db::tags::get_tags_for_item(&state.db, item_id).await?;
654 + let item = build_item_view(&db_item, &item_tags);
655 +
656 + let project_labels: Vec<String> = db::labels::get_labels_for_project(&state.db, db_project.id)
657 + .await?
658 + .into_iter()
659 + .map(|l| l.display_name)
660 + .collect();
661 +
662 + Ok(ItemSettingsTabTemplate { item, project_labels })
663 + }
@@ -11,7 +11,7 @@ use tower_sessions::Session;
11 11 use crate::{
12 12 auth::{AuthUser, MaybeUser},
13 13 db,
14 - error::Result,
14 + error::{AppError, Result},
15 15 helpers::get_csrf_token,
16 16 routes::custom_domain,
17 17 templates::*,
@@ -103,6 +103,80 @@ pub(super) async fn library_tab_contacts(
103 103 Ok(LibraryContactsTabTemplate { shared_creators })
104 104 }
105 105
106 + /// HTMX partial: library communities tab (Multithreaded forum memberships).
107 + #[tracing::instrument(skip_all, name = "landing::library_tab_communities")]
108 + pub(super) async fn library_tab_communities(
109 + State(state): State<AppState>,
110 + AuthUser(user): AuthUser,
111 + ) -> Result<axum::response::Response> {
112 + let mt_base_url = match state.config.mt_base_url.as_ref() {
113 + Some(url) => url,
114 + None => {
115 + return Ok(LibraryCommunitiesTabTemplate {
116 + memberships: vec![],
117 + mt_base_url: String::new(),
118 + }
119 + .into_response())
120 + }
121 + };
122 +
123 + let url = format!("{}/api/user/{}/summary", mt_base_url, user.id);
124 +
125 + let resp = reqwest::Client::new()
126 + .get(&url)
127 + .timeout(std::time::Duration::from_secs(5))
128 + .send()
129 + .await
130 + .map_err(|e| {
131 + tracing::warn!(error = ?e, "failed to fetch MT user summary");
132 + AppError::Internal(anyhow::anyhow!("MT API unavailable"))
133 + })?;
134 +
135 + if !resp.status().is_success() {
136 + return Ok(LibraryCommunitiesTabTemplate {
137 + memberships: vec![],
138 + mt_base_url: mt_base_url.clone(),
139 + }
140 + .into_response());
141 + }
142 +
143 + let json: serde_json::Value = resp.json().await.map_err(|e| {
144 + tracing::warn!(error = ?e, "failed to parse MT summary response");
145 + AppError::Internal(anyhow::anyhow!("MT API response invalid"))
146 + })?;
147 +
148 + let memberships = json["memberships"]
149 + .as_array()
150 + .map(|arr| {
151 + arr.iter()
152 + .filter_map(|m| {
153 + let community_slug = m["community_slug"].as_str()?;
154 + Some(ForumMembership {
155 + community_name: m["community_name"].as_str()?.to_string(),
156 + profile_url: format!(
157 + "{}/p/{}/u/{}",
158 + mt_base_url, community_slug, user.username
159 + ),
160 + role: m["role"].as_str()?.to_string(),
161 + joined: m["joined_at"]
162 + .as_str()
163 + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
164 + .map(|dt| dt.format("%b %d, %Y").to_string())
165 + .unwrap_or_default(),
166 + post_count: m["post_count"].as_i64().unwrap_or(0),
167 + })
168 + })
169 + .collect()
170 + })
171 + .unwrap_or_default();
172 +
173 + Ok(LibraryCommunitiesTabTemplate {
174 + memberships,
175 + mt_base_url: mt_base_url.clone(),
176 + }
177 + .into_response())
178 + }
179 +
106 180 /// Render the login page.
107 181 #[tracing::instrument(skip_all, name = "landing::login_page")]
108 182 pub(super) async fn login_page(session: Session) -> impl IntoResponse {
@@ -42,6 +42,7 @@ pub fn public_routes() -> Router<AppState> {
42 42 .route("/library/tabs/subscriptions", get(landing::library_tab_subscriptions))
43 43 .route("/library/tabs/collections", get(landing::library_tab_collections))
44 44 .route("/library/tabs/contacts", get(landing::library_tab_contacts))
45 + .route("/library/tabs/communities", get(landing::library_tab_communities))
45 46 .route("/health", get(health::health))
46 47 .route("/api/health", get(health::health_json))
47 48 .route("/login", get(landing::login_page))
@@ -54,7 +54,7 @@ pub struct DashboardProjectTemplate {
54 54 pub has_blog: bool,
55 55 }
56 56
57 - /// Item management dashboard with versions, stats, and license keys.
57 + /// Item management dashboard shell with tabbed navigation (content loaded via HTMX).
58 58 #[derive(Template)]
59 59 #[template(path = "dashboards/dashboard-item.html")]
60 60 pub struct DashboardItemTemplate {
@@ -62,18 +62,7 @@ pub struct DashboardItemTemplate {
62 62 pub session_user: Option<SessionUser>,
63 63 pub item: Item,
64 64 pub project_title: String,
65 - /// All versions of this item, newest first.
66 - pub versions: Vec<Version>,
67 - /// License keys issued for this item (if license keys are enabled).
68 - pub license_keys: Vec<LicenseKeyRow>,
69 - /// Promo codes for this item (free_access codes, etc.).
70 - pub promo_codes: Vec<PromoCodeRow>,
71 - /// Project labels (display names only) for publish reminder.
72 - pub project_labels: Vec<String>,
73 - /// Child items in this bundle (empty for non-bundle items).
74 - pub bundle_items: Vec<Item>,
75 - /// Items available to add to this bundle (empty for non-bundle items).
76 - pub bundleable_items: Vec<Item>,
65 + pub project_slug: String,
77 66 }
78 67
79 68 // ============================================================================
@@ -148,6 +148,7 @@ impl_into_response!(
148 148 LibrarySubscriptionsTabTemplate,
149 149 LibraryCollectionsTabTemplate,
150 150 LibraryContactsTabTemplate,
151 + LibraryCommunitiesTabTemplate,
151 152 // Follow button
152 153 FollowButtonTemplate,
153 154 TagFollowToggleTemplate,
@@ -155,6 +156,12 @@ impl_into_response!(
155 156 TagSuggestionsTemplate,
156 157 // Item analytics
157 158 ItemAnalyticsPartialTemplate,
159 + // Item dashboard tabs
160 + ItemOverviewTabTemplate,
161 + ItemDetailsTabTemplate,
162 + ItemPricingTabTemplate,
163 + ItemFilesTabTemplate,
164 + ItemSettingsTabTemplate,
158 165 // Onboarding checklist
159 166 OnboardingChecklistPartialTemplate,
160 167 // Tag tree browser
@@ -375,6 +375,14 @@ pub struct LibraryContactsTabTemplate {
375 375 pub shared_creators: Vec<crate::db::transactions::SharedCreatorRow>,
376 376 }
377 377
378 + /// Library communities tab (Multithreaded forum memberships).
379 + #[derive(Template)]
380 + #[template(path = "partials/tabs/library_communities.html")]
381 + pub struct LibraryCommunitiesTabTemplate {
382 + pub memberships: Vec<ForumMembership>,
383 + pub mt_base_url: String,
384 + }
385 +
378 386 /// Per-project revenue for the user analytics top projects list.
379 387 pub struct ProjectRevenue {
380 388 pub title: String,
@@ -676,3 +684,48 @@ pub struct PlacementListTemplate {
676 684 pub placements: Vec<PlacementDisplay>,
677 685 pub available_insertions: Vec<InsertionDisplay>,
678 686 }
687 +
688 + // ============================================================================
689 + // Item Dashboard Tab Partials
690 + // ============================================================================
691 +
692 + /// Item overview tab: quick actions + analytics (lazy-loaded).
693 + #[derive(Template)]
694 + #[template(path = "partials/tabs/item_overview.html")]
695 + pub struct ItemOverviewTabTemplate {
696 + pub item: Item,
697 + }
698 +
699 + /// Item details tab: name, description, tags, content editor, bundle contents.
700 + #[derive(Template)]
701 + #[template(path = "partials/tabs/item_details.html")]
702 + pub struct ItemDetailsTabTemplate {
703 + pub item: Item,
704 + pub bundle_items: Vec<Item>,
705 + pub bundleable_items: Vec<Item>,
706 + }
707 +
708 + /// Item pricing tab: PWYW settings, license keys, promo codes.
709 + #[derive(Template)]
710 + #[template(path = "partials/tabs/item_pricing.html")]
711 + pub struct ItemPricingTabTemplate {
712 + pub item: Item,
713 + pub license_keys: Vec<LicenseKeyRow>,
714 + pub promo_codes: Vec<PromoCodeRow>,
715 + }
716 +
717 + /// Item files tab: version upload + download table.
718 + #[derive(Template)]
719 + #[template(path = "partials/tabs/item_files.html")]
720 + pub struct ItemFilesTabTemplate {
721 + pub item: Item,
722 + pub versions: Vec<Version>,
723 + }
724 +
725 + /// Item settings tab: publish/unpublish/schedule + danger zone.
726 + #[derive(Template)]
727 + #[template(path = "partials/tabs/item_settings.html")]
728 + pub struct ItemSettingsTabTemplate {
729 + pub item: Item,
730 + pub project_labels: Vec<String>,
731 + }
@@ -7,9 +7,9 @@
7 7 <style>
8 8 h1 { font-size: 2.5rem; margin-bottom: 0.5rem; text-align: left; }
9 9 .item-meta { font-size: 0.9rem; opacity: 0.7; display: flex; gap: 1rem; align-items: center; }
10 + .stat-value { font-size: 1.5rem; }
10 11 .section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
11 12 .section-header h2 { font-size: 1.3rem; }
12 - .stat-value { font-size: 1.5rem; }
13 13 @media (max-width: 768px) {
14 14 .section-header { flex-direction: column; align-items: flex-start; gap: 0.75rem; }
15 15 .section-header .action-buttons { width: 100%; }
@@ -23,7 +23,7 @@
23 23 <div class="container">
24 24 <header>
25 25 <div class="breadcrumb">
26 - <a href="/dashboard">Dashboard</a> / <a href="/dashboard">Projects</a> / <a href="/dashboard/project/audiofiles">{{ project_title }}</a> / {{ item.title }}
26 + <a href="/dashboard">Dashboard</a> / <a href="/dashboard">Projects</a> / <a href="/dashboard/project/{{ project_slug }}">{{ project_title }}</a> / {{ item.title }}
27 27 </div>
28 28 <h1>{{ item.title }}</h1>
29 29 <div class="item-meta">
@@ -34,539 +34,73 @@
34 34 {% else %}
35 35 <span class="badge inactive">Draft</span>
36 36 {% endif %}
37 - <span>{{ item.sales_count }} sales &middot; Revenue data</span>
37 + <span>{{ item.sales_count }} sales</span>
38 38 <span class="uuid">UUID: {{ item.id }}</span>
39 39 </div>
40 40 </header>
41 41
42 - <!-- Quick Stats -->
43 - <div class="content-section">
44 - <div class="section-header">
45 - <h2>Overview</h2>
46 - <div class="action-buttons">
47 - <button class="secondary" onclick="window.open('/i/{{ item.id }}', '_blank')">View Public Page</button>
48 - <button class="secondary" onclick="navigator.clipboard.writeText(window.location.origin + '/i/{{ item.id }}').then(() => this.textContent = 'Copied!').catch(() => this.textContent = 'Failed')">Copy Link</button>
49 - <button class="secondary"
50 - hx-post="/api/items/{{ item.id }}/duplicate"
51 - hx-confirm="Duplicate this item? A new draft copy will be created.">Duplicate Item</button>
52 - </div>
53 - </div>
54 -
55 - <div id="item-analytics"
56 - hx-get="/dashboard/item/{{ item.id }}/analytics"
57 - hx-trigger="load"
58 - hx-swap="innerHTML">
59 - </div>
60 - </div>
61 -
62 - <!-- Basic Information -->
63 - <div class="content-section">
64 - <div class="section-header">
65 - <h2>Basic Information</h2>
66 - </div>
67 -
68 - <div class="form-group">
69 - <label for="item-name">Item Name</label>
70 - <input type="text" id="item-name" value="{{ item.title }}">
71 - </div>
72 -
73 - <div class="form-group">
74 - <label for="item-description">Description</label>
75 - <textarea id="item-description">{{ item.description }}</textarea>
76 - </div>
77 -
78 - <div class="form-row">
79 - <div class="form-group">
80 - <label for="item-type">Item Type</label>
81 - <select id="item-type">
82 - <option>Plugin</option>
83 - <option>Sample Pack</option>
84 - <option>Preset Pack</option>
85 - <option>Course</option>
86 - <option>Documentation</option>
87 - <option>Template</option>
88 - <option>Other</option>
89 - </select>
90 - </div>
91 -
92 - <div class="form-group">
93 - <label for="release-date">Release Date</label>
94 - <input type="date" id="release-date" value="{{ item.release_date }}">
95 - </div>
96 - </div>
97 -
98 - <div class="form-group">
99 - <label for="item-tags">Tags</label>
100 - <div class="tag-input" id="tags-container">
101 - {% for tag in item.tags %}
102 - <span class="tag{% if tag.is_primary %} tag-primary{% endif %}">
103 - {{ tag.name }}
104 - {% if tag.is_primary %}<span class="tag-primary-dot" title="Primary tag"></span>{% endif %}
105 - <button hx-delete="/api/items/{{ item.id }}/tags/{{ tag.id }}"
106 - hx-target="closest .tag"
107 - hx-swap="outerHTML"
108 - hx-confirm="Remove this tag?">&times;</button>
109 - {% if !tag.is_primary %}
110 - <button class="tag-star" title="Set as primary"
111 - hx-put="/api/items/{{ item.id }}/primary-tag"
112 - hx-vals='{"tag_id": "{{ tag.id }}"}'
113 - hx-swap="none"
114 - hx-on::after-request="window.location.reload()">&#9734;</button>
115 - {% endif %}
116 - </span>
117 - {% endfor %}
118 - </div>
119 - <div id="tag-suggestions-auto"
120 - hx-get="/api/items/{{ item.id }}/tag-suggestions"
121 - hx-trigger="load"
122 - hx-swap="innerHTML">
123 - </div>
124 - <div style="position: relative;">
125 - <input type="text" id="item-tags" placeholder="Search tags..."
126 - autocomplete="off"
127 - oninput="searchTags(this.value)">
128 - <div id="tag-suggestions" style="display: none; position: absolute; z-index: 10; background: var(--surface-muted); border: 1px solid var(--border); width: 100%; max-height: 200px; overflow-y: auto;"></div>
129 - </div>
130 - </div>
131 -
132 - <form hx-put="/api/items/{{ item.id }}"
133 - hx-target="#item-save-status"
134 - hx-swap="innerHTML"
135 - hx-indicator="#item-spinner">
136 - <input type="hidden" name="title" id="hidden-title">
137 - <input type="hidden" name="description" id="hidden-desc">
138 - <button class="primary" type="submit" onclick="document.getElementById('hidden-title').value=document.getElementById('item-name').value; document.getElementById('hidden-desc').value=document.getElementById('item-description').value;">
139 - Save Changes
140 - <span id="item-spinner" class="htmx-indicator"> ...</span>
141 - </button>
142 - <span id="item-save-status"></span>
143 - </form>
144 - </div>
145 -
146 - {% match item.content %}
147 - {% when crate::types::ItemContent::Text with { body, word_count, reading_time_minutes, .. } %}
148 - <!-- Text Content Editor -->
149 - <div class="content-section">
150 - <div class="section-header">
151 - <h2>Content</h2>
152 - </div>
153 - {% include "partials/item_text_editor.html" %}
154 - </div>
155 - {% when crate::types::ItemContent::Audio with { audio_s3_key, duration_seconds, .. } %}
156 - <!-- Audio File Upload -->
157 - <div class="content-section">
158 - <div class="section-header">
159 - <h2>Audio File</h2>
160 - </div>
161 - {% include "partials/item_audio_upload.html" %}
162 - </div>
163 - {% when crate::types::ItemContent::Other %}
164 - {% endmatch %}
165 -
166 - {% if item.item_type == "bundle" %}
167 - <!-- Bundle Contents -->
168 - <div class="content-section" id="bundle-section">
169 - <div class="section-header">
170 - <h2>Bundle Contents (<span id="bundle-count">{{ bundle_items.len() }}</span> items)</h2>
171 - </div>
172 -
173 - <div id="bundle-items-list">
174 - {% if bundle_items.is_empty() %}
175 - <p id="bundle-empty" style="opacity: 0.7;">No items in this bundle yet.</p>
176 - {% else %}
177 - {% for child in bundle_items %}
178 - <div class="bundle-row" data-child-id="{{ child.id }}" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; border-bottom: 1px solid var(--border);">
179 - <span style="font-size: 0.8rem; padding: 0.2rem 0.6rem; background: var(--surface-muted); white-space: nowrap;">{{ child.item_type }}</span>
180 - <span style="flex: 1;">{{ child.title }}</span>
181 - <label style="font-size: 0.8rem; display: flex; align-items: center; gap: 0.25rem; cursor: pointer;">
182 - <input type="checkbox" class="bundle-listed-toggle" data-child-id="{{ child.id }}"
183 - {% if !child.listed %}checked{% endif %}> Unlisted
184 - </label>
185 - <button type="button" class="secondary bundle-remove-btn" data-child-id="{{ child.id }}"
186 - style="padding: 0.25rem 0.6rem; font-size: 0.8rem;">Remove</button>
187 - </div>
188 - {% endfor %}
189 - {% endif %}
190 - </div>
191 -
192 - {% if !bundleable_items.is_empty() %}
193 - <div style="margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center;">
194 - <select id="bundle-add-select" style="flex: 1; padding: 0.4rem;">
195 - <option value="">Add item to bundle...</option>
196 - {% for avail in bundleable_items %}
197 - <option value="{{ avail.id }}">{{ avail.title }} ({{ avail.item_type }})</option>
198 - {% endfor %}
199 - </select>
200 - <button type="button" class="secondary" id="bundle-add-btn" style="padding: 0.4rem 0.8rem;">Add</button>
201 - </div>
202 - {% endif %}
203 - </div>
204 -
205 - <script>
206 - (function() {
207 - var bundleId = '{{ item.id }}';
208 -
209 - document.getElementById('bundle-add-btn')?.addEventListener('click', function() {
210 - var select = document.getElementById('bundle-add-select');
211 - var itemId = select.value;
212 - if (!itemId) return;
213 - var label = select.options[select.selectedIndex].text;
214 - this.disabled = true;
215 -
216 - fetch('/api/items/' + bundleId + '/bundle/add', {
217 - method: 'POST',
218 - headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
219 - body: JSON.stringify({ item_id: itemId })
220 - })
221 - .then(function(res) {
222 - if (!res.ok) throw new Error('Failed to add');
223 - // Remove from dropdown, add to list
224 - select.remove(select.selectedIndex);
225 - select.value = '';
226 - var empty = document.getElementById('bundle-empty');
227 - if (empty) empty.remove();
228 -
229 - var titleMatch = label.match(/^(.+)\s+\((.+)\)$/);
230 - var title = titleMatch ? titleMatch[1] : label;
231 - var type = titleMatch ? titleMatch[2] : '';
232 -
233 - var row = document.createElement('div');
234 - row.className = 'bundle-row';
235 - row.dataset.childId = itemId;
236 - row.style.cssText = 'display:flex;align-items:center;gap:0.75rem;padding:0.6rem 0;border-bottom:1px solid var(--border);';
237 - row.innerHTML =
238 - '<span style="font-size:0.8rem;padding:0.2rem 0.6rem;background:var(--surface-muted);white-space:nowrap;">' + escapeHtml(type) + '</span>' +
239 - '<span style="flex:1;">' + escapeHtml(title) + '</span>' +
240 - '<label style="font-size:0.8rem;display:flex;align-items:center;gap:0.25rem;cursor:pointer;">' +
241 - '<input type="checkbox" class="bundle-listed-toggle" data-child-id="' + itemId + '"> Unlisted</label>' +
242 - '<button type="button" class="secondary bundle-remove-btn" data-child-id="' + itemId + '" style="padding:0.25rem 0.6rem;font-size:0.8rem;">Remove</button>';
243 - document.getElementById('bundle-items-list').appendChild(row);
244 - updateCount(1);
245 - attachRowHandlers(row);
246 - })
247 - .catch(function(err) { showToast(err.message); })
248 - .finally(function() { document.getElementById('bundle-add-btn').disabled = false; });
249 - });
250 -
251 - function attachRowHandlers(row) {
252 - row.querySelector('.bundle-remove-btn')?.addEventListener('click', function() {
253 - var childId = this.dataset.childId;
254 - fetch('/api/items/' + bundleId + '/bundle/' + childId, {
255 - method: 'DELETE',
256 - headers: csrfHeaders()
257 - })
258 - .then(function(res) {
259 - if (!res.ok) throw new Error('Failed to remove');
260 - // Move back to dropdown
261 - var title = row.querySelector('span[style*="flex"]').textContent;
262 - var type = row.querySelector('span[style*="surface-muted"]').textContent;
263 - var select = document.getElementById('bundle-add-select');
264 - if (select) {
265 - var opt = document.createElement('option');
266 - opt.value = childId;
267 - opt.textContent = title + ' (' + type + ')';
268 - select.appendChild(opt);
269 - }
270 - row.remove();
271 - updateCount(-1);
272 - })
273 - .catch(function(err) { showToast(err.message); });
274 - });
275 -
276 - row.querySelector('.bundle-listed-toggle')?.addEventListener('change', function() {
277 - var childId = this.dataset.childId;
278 - var listed = !this.checked;
279 - fetch('/api/items/' + bundleId + '/bundle/' + childId + '/listed', {
280 - method: 'PUT',
281 - headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
282 - body: JSON.stringify({ listed: listed })
283 - })
284 - .catch(function(err) { showToast(err.message); });
285 - });
286 - }
287 -
288 - document.querySelectorAll('.bundle-row').forEach(attachRowHandlers);
289 -
290 - function updateCount(delta) {
291 - var el = document.getElementById('bundle-count');
292 - el.textContent = parseInt(el.textContent) + delta;
293 - }
294 -
295 - function escapeHtml(s) {
296 - var d = document.createElement('div');
297 - d.textContent = s;
298 - return d.innerHTML;
299 - }
300 - })();
301 - </script>
302 - {% endif %}
303 -
304 - <!-- Pay What You Want -->
305 - <div class="content-section">
306 - <div class="section-header">
307 - <h2>Pay What You Want</h2>
308 - </div>
309 -
310 - <form hx-put="/api/items/{{ item.id }}"
311 - hx-target="#pwyw-save-status"
312 - hx-swap="innerHTML"
313 - hx-indicator="#pwyw-spinner">
314 - <div class="form-group">
315 - <label class="checkbox-label">
316 - <input type="hidden" name="pwyw_enabled" value="off">
317 - <input type="checkbox" name="pwyw_enabled" value="on"
318 - {% if item.pwyw_enabled %}checked{% endif %}
319 - onchange="document.getElementById('pwyw-settings').style.display = this.checked ? 'block' : 'none'">
320 - Enable pay-what-you-want pricing
321 - </label>
322 - <p class="form-hint">The item price becomes the suggested amount. Buyers can choose any amount at or above the minimum.</p>
323 - </div>
324 -
325 - <div id="pwyw-settings" style="{% if !item.pwyw_enabled %}display: none;{% endif %}">
326 - <div class="form-group">
327 - <label for="pwyw-min">Minimum price (cents)</label>
328 - <input type="number" id="pwyw-min" name="pwyw_min_cents"
329 - value="{% if let Some(min) = item.pwyw_min_cents %}{{ min }}{% endif %}"
330 - min="0" placeholder="0 (any amount)">
331 - <p class="form-hint">The lowest amount a buyer can pay, in cents. 0 = any amount.</p>
332 - </div>
333 - </div>
334 -
335 - <button class="primary" type="submit">
336 - Save PWYW Settings
337 - <span id="pwyw-spinner" class="htmx-indicator"> ...</span>
338 - </button>
339 - <span id="pwyw-save-status"></span>
340 - </form>
42 + <div class="tabs" role="tablist" aria-label="Item sections">
43 + <button class="tab active"
44 + role="tab"
45 + aria-selected="true"
46 + aria-controls="tab-content"
47 + id="tab-overview"
48 + hx-get="/dashboard/item/{{ item.id }}/tabs/overview"
49 + hx-target="#tab-content"
50 + hx-swap="innerHTML"
51 + hx-indicator="#tab-spinner"
52 + onclick="setActiveTab(this)">Overview</button>
53 + <button class="tab"
54 + role="tab"
55 + aria-selected="false"
56 + aria-controls="tab-content"
57 + id="tab-details"
58 + hx-get="/dashboard/item/{{ item.id }}/tabs/details"
59 + hx-target="#tab-content"
60 + hx-swap="innerHTML"
61 + hx-indicator="#tab-spinner"
62 + onclick="setActiveTab(this)">Details</button>
63 + <button class="tab"
64 + role="tab"
65 + aria-selected="false"
66 + aria-controls="tab-content"
67 + id="tab-pricing"
68 + hx-get="/dashboard/item/{{ item.id }}/tabs/pricing"
69 + hx-target="#tab-content"
70 + hx-swap="innerHTML"
71 + hx-indicator="#tab-spinner"
72 + onclick="setActiveTab(this)">Pricing</button>
73 + <button class="tab"
74 + role="tab"
75 + aria-selected="false"
76 + aria-controls="tab-content"
77 + id="tab-files"
78 + hx-get="/dashboard/item/{{ item.id }}/tabs/files"
79 + hx-target="#tab-content"
80 + hx-swap="innerHTML"
81 + hx-indicator="#tab-spinner"
82 + onclick="setActiveTab(this)">Files</button>
83 + <button class="tab"
84 + role="tab"
85 + aria-selected="false"
86 + aria-controls="tab-content"
87 + id="tab-settings"
88 + hx-get="/dashboard/item/{{ item.id }}/tabs/settings"
89 + hx-target="#tab-content"
90 + hx-swap="innerHTML"
91 + hx-indicator="#tab-spinner"
92 + onclick="setActiveTab(this)">Settings</button>
93 + <span id="tab-spinner" class="htmx-indicator" style="margin-left: 1rem;" aria-live="polite"> Loading...</span>
341 94 </div>
342 95
343 - <!-- License Keys -->
344 - <div class="content-section">
345 - <div class="section-header">
346 - <h2>License Keys</h2>
347 - </div>
348 -
349 - <form hx-put="/api/items/{{ item.id }}/license-settings"
350 - hx-target="#license-save-status"
351 - hx-swap="innerHTML"
352 - hx-indicator="#license-spinner">
353 - <div class="form-group">
354 - <label class="checkbox-label">
355 - <input type="checkbox" name="enable_license_keys" value="on"
356 - {% if item.enable_license_keys %}checked{% endif %}
357 - onchange="document.getElementById('license-keys-section').style.display = this.checked ? 'block' : 'none'">
358 - Enable license keys for this item
359 - </label>
360 - <p class="form-hint">Buyers will receive a license key they can use to activate your software.</p>
361 - </div>
362 -
363 - <div class="form-group">
364 - <label for="default-max-activations">Default max activations per key</label>
365 - <input type="number" id="default-max-activations" name="default_max_activations"
366 - value="{% if let Some(max) = item.default_max_activations %}{{ max }}{% endif %}"
367 - min="1" placeholder="Leave blank for unlimited">
368 - <p class="form-hint">How many machines can activate each key. Blank = unlimited.</p>
369 - </div>
370 -
371 - <button class="primary" type="submit">
372 - Save License Settings
373 - <span id="license-spinner" class="htmx-indicator"> ...</span>
374 - </button>
375 - <span id="license-save-status"></span>
376 - </form>
377 -
378 - <div id="license-keys-section" style="{% if !item.enable_license_keys %}display: none;{% endif %} margin-top: 2rem;">
379 - <div class="section-header">
380 - <h3>Generated Keys</h3>
381 - <form hx-post="/api/items/{{ item.id }}/keys"
382 - hx-target="#license-keys-list"
383 - hx-swap="outerHTML">
384 - <button class="secondary" type="submit">Generate Key</button>
385 - </form>
386 - </div>
387 -
388 - {% include "partials/item_license_keys.html" %}
389 - </div>
390 - </div>
391 -
392 - <!-- Promo Codes -->
393 - <div class="content-section">
394 - <div class="section-header">
395 - <h2>Promo Codes</h2>
396 - </div>
397 - <p class="form-hint" style="margin-bottom: 1rem;">Generate codes that grant free access to this item. Share them with reviewers, collaborators, or for promotions.</p>
398 -
399 - <form hx-post="/api/promo-codes"
400 - hx-target="#promo-codes-list"
401 - hx-swap="outerHTML"
402 - style="margin-bottom: 1.5rem;">
403 - <input type="hidden" name="item_id" value="{{ item.id }}">
404 - <input type="hidden" name="code_purpose" value="free_access">
405 - <div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
406 - <input type="number" name="max_uses" min="1" placeholder="Max uses (blank = unlimited)"
407 - style="width: 200px; padding: 0.4rem 0.6rem; font-size: 0.85rem;">
408 - <input type="date" name="expires_at" placeholder="Expires (optional)"
409 - title="Expiry date (optional)"
410 - style="width: 150px; padding: 0.4rem 0.6rem; font-size: 0.85rem;">
411 - <button class="secondary" type="submit">Generate Code</button>
412 - </div>
413 - </form>
414 -
415 - {% include "partials/promo_codes_list.html" %}
416 - </div>
417 -
418 - <!-- Files & Versions -->
419 - <div class="content-section">
420 - <div class="section-header">
421 - <h2>Files &amp; Versions</h2>
422 - </div>
423 - {% include "partials/item_version_upload.html" %}
424 - </div>
425 -
426 - <!-- Danger Zone -->
427 - <div class="content-section">
428 - <div class="section-header">
429 - <h2>Danger Zone</h2>
430 - </div>
431 -
432 - <div class="warning-box">
433 - These actions are permanent and cannot be undone.
434 - </div>
435 -
436 - {% if let Some(scheduled) = item.publish_at %}
437 - <!-- Scheduled: show cancel button -->
438 - <p style="margin: 1rem 0; opacity: 0.7; text-align: left;">
439 - Scheduled for {{ scheduled }}.
440 - </p>
441 - <div class="action-buttons">
442 - <button class="secondary"
443 - hx-put="/api/items/{{ item.id }}"
444 - hx-vals='{"publish_at": ""}'
445 - hx-confirm="Cancel the scheduled publish?"
446 - hx-on::after-request="if(event.detail.successful) window.location.reload()">Cancel Schedule</button>
447 - </div>
448 - {% else if item.is_public %}
449 - <!-- Published: show unpublish button -->
450 - <p style="margin: 1rem 0; opacity: 0.7; text-align: left;">
451 - Unpublishing removes this item from public view but preserves all data.
452 - </p>
453 - <div class="action-buttons">
454 - <button class="secondary"
455 - hx-put="/api/items/{{ item.id }}"
456 - hx-vals='{"is_public": "false"}'
457 - hx-confirm="Unpublish this item? It will be hidden from public view."
458 - hx-on::after-request="if(event.detail.successful) window.location.reload()">Unpublish Item</button>
459 - </div>
460 - {% else %}
461 - <!-- Draft: show publish now + schedule -->
462 - {% if !project_labels.is_empty() %}
Lines truncated
@@ -44,6 +44,16 @@
44 44 role="tab"
45 45 aria-selected="false"
46 46 aria-controls="tab-content"
47 + id="tab-communities"
48 + hx-get="/library/tabs/communities"
49 + hx-target="#tab-content"
50 + hx-swap="innerHTML"
51 + hx-indicator="#tab-spinner"
52 + onclick="setActiveTab(this)">Communities</button>
53 + <button class="tab"
54 + role="tab"
55 + aria-selected="false"
56 + aria-controls="tab-content"
47 57 id="tab-contacts"
48 58 hx-get="/library/tabs/contacts"
49 59 hx-target="#tab-content"
@@ -0,0 +1,295 @@
1 + <!-- Basic Information -->
2 + <div class="content-section">
3 + <div class="section-header">
4 + <h2>Basic Information</h2>
5 + </div>
6 +
7 + <div class="form-group">
8 + <label for="item-name">Item Name</label>
9 + <input type="text" id="item-name" value="{{ item.title }}">
10 + </div>
11 +
12 + <div class="form-group">
13 + <label for="item-description">Description</label>
14 + <textarea id="item-description">{{ item.description }}</textarea>
15 + </div>
16 +
17 + <div class="form-row">
18 + <div class="form-group">
19 + <label for="item-type">Item Type</label>
20 + <select id="item-type">
21 + <option>Plugin</option>
22 + <option>Sample Pack</option>
23 + <option>Preset Pack</option>
24 + <option>Course</option>
25 + <option>Documentation</option>
26 + <option>Template</option>
27 + <option>Other</option>
28 + </select>
29 + </div>
30 +
31 + <div class="form-group">
32 + <label for="release-date">Release Date</label>
33 + <input type="date" id="release-date" value="{{ item.release_date }}">
34 + </div>
35 + </div>
36 +
37 + <div class="form-group">
38 + <label for="item-tags">Tags</label>
39 + <div class="tag-input" id="tags-container">
40 + {% for tag in item.tags %}
41 + <span class="tag{% if tag.is_primary %} tag-primary{% endif %}">
42 + {{ tag.name }}
43 + {% if tag.is_primary %}<span class="tag-primary-dot" title="Primary tag"></span>{% endif %}
44 + <button hx-delete="/api/items/{{ item.id }}/tags/{{ tag.id }}"
45 + hx-target="closest .tag"
46 + hx-swap="outerHTML"
47 + hx-confirm="Remove this tag?">&times;</button>
48 + {% if !tag.is_primary %}
49 + <button class="tag-star" title="Set as primary"
50 + hx-put="/api/items/{{ item.id }}/primary-tag"
51 + hx-vals='{"tag_id": "{{ tag.id }}"}'
52 + hx-swap="none"
53 + hx-on::after-request="window.location.reload()">&#9734;</button>
54 + {% endif %}
55 + </span>
56 + {% endfor %}
57 + </div>
58 + <div id="tag-suggestions-auto"
59 + hx-get="/api/items/{{ item.id }}/tag-suggestions"
60 + hx-trigger="load"
61 + hx-swap="innerHTML">
62 + </div>
63 + <div style="position: relative;">
64 + <input type="text" id="item-tags" placeholder="Search tags..."
65 + autocomplete="off"
66 + oninput="searchTags(this.value)">
67 + <div id="tag-suggestions" style="display: none; position: absolute; z-index: 10; background: var(--surface-muted); border: 1px solid var(--border); width: 100%; max-height: 200px; overflow-y: auto;"></div>
68 + </div>
69 + </div>
70 +
71 + <form hx-put="/api/items/{{ item.id }}"
72 + hx-target="#item-save-status"
73 + hx-swap="innerHTML"
74 + hx-indicator="#item-spinner">
75 + <input type="hidden" name="title" id="hidden-title">
76 + <input type="hidden" name="description" id="hidden-desc">
77 + <button class="primary" type="submit" onclick="document.getElementById('hidden-title').value=document.getElementById('item-name').value; document.getElementById('hidden-desc').value=document.getElementById('item-description').value;">
78 + Save Changes
79 + <span id="item-spinner" class="htmx-indicator"> ...</span>
80 + </button>
81 + <span id="item-save-status"></span>
82 + </form>
83 + </div>
84 +
85 + {% match item.content %}
86 + {% when crate::types::ItemContent::Text with { body, word_count, reading_time_minutes, .. } %}
87 + <!-- Text Content Editor -->
88 + <div class="content-section">
89 + <div class="section-header">
90 + <h2>Content</h2>
91 + </div>
92 + {% include "partials/item_text_editor.html" %}
93 + </div>
94 + {% when crate::types::ItemContent::Audio with { audio_s3_key, duration_seconds, .. } %}
95 + <!-- Audio File Upload -->
96 + <div class="content-section">
97 + <div class="section-header">
98 + <h2>Audio File</h2>
99 + </div>
100 + {% include "partials/item_audio_upload.html" %}
101 + </div>
102 + {% when crate::types::ItemContent::Other %}
103 + {% endmatch %}
104 +
105 + {% if item.item_type == "bundle" %}
106 + <!-- Bundle Contents -->
107 + <div class="content-section" id="bundle-section">
108 + <div class="section-header">
109 + <h2>Bundle Contents (<span id="bundle-count">{{ bundle_items.len() }}</span> items)</h2>
110 + </div>
111 +
112 + <div id="bundle-items-list">
113 + {% if bundle_items.is_empty() %}
114 + <p id="bundle-empty" style="opacity: 0.7;">No items in this bundle yet.</p>
115 + {% else %}
116 + {% for child in bundle_items %}
117 + <div class="bundle-row" data-child-id="{{ child.id }}" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; border-bottom: 1px solid var(--border);">
118 + <span style="font-size: 0.8rem; padding: 0.2rem 0.6rem; background: var(--surface-muted); white-space: nowrap;">{{ child.item_type }}</span>
119 + <span style="flex: 1;">{{ child.title }}</span>
120 + <label style="font-size: 0.8rem; display: flex; align-items: center; gap: 0.25rem; cursor: pointer;">
121 + <input type="checkbox" class="bundle-listed-toggle" data-child-id="{{ child.id }}"
122 + {% if !child.listed %}checked{% endif %}> Unlisted
123 + </label>
124 + <button type="button" class="secondary bundle-remove-btn" data-child-id="{{ child.id }}"
125 + style="padding: 0.25rem 0.6rem; font-size: 0.8rem;">Remove</button>
126 + </div>
127 + {% endfor %}
128 + {% endif %}
129 + </div>
130 +
131 + {% if !bundleable_items.is_empty() %}
132 + <div style="margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center;">
133 + <select id="bundle-add-select" style="flex: 1; padding: 0.4rem;">
134 + <option value="">Add item to bundle...</option>
135 + {% for avail in bundleable_items %}
136 + <option value="{{ avail.id }}">{{ avail.title }} ({{ avail.item_type }})</option>
137 + {% endfor %}
138 + </select>
139 + <button type="button" class="secondary" id="bundle-add-btn" style="padding: 0.4rem 0.8rem;">Add</button>
140 + </div>
141 + {% endif %}
142 + </div>
143 +
144 + <script>
145 + (function() {
146 + var bundleId = '{{ item.id }}';
147 +
148 + document.getElementById('bundle-add-btn')?.addEventListener('click', function() {
149 + var select = document.getElementById('bundle-add-select');
150 + var itemId = select.value;
151 + if (!itemId) return;
152 + var label = select.options[select.selectedIndex].text;
153 + this.disabled = true;
154 +
155 + fetch('/api/items/' + bundleId + '/bundle/add', {
156 + method: 'POST',
157 + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
158 + body: JSON.stringify({ item_id: itemId })
159 + })
160 + .then(function(res) {
161 + if (!res.ok) throw new Error('Failed to add');
162 + select.remove(select.selectedIndex);
163 + select.value = '';
164 + var empty = document.getElementById('bundle-empty');
165 + if (empty) empty.remove();
166 +
167 + var titleMatch = label.match(/^(.+)\s+\((.+)\)$/);
168 + var title = titleMatch ? titleMatch[1] : label;
169 + var type = titleMatch ? titleMatch[2] : '';
170 +
171 + var row = document.createElement('div');
172 + row.className = 'bundle-row';
173 + row.dataset.childId = itemId;
174 + row.style.cssText = 'display:flex;align-items:center;gap:0.75rem;padding:0.6rem 0;border-bottom:1px solid var(--border);';
175 + row.innerHTML =
176 + '<span style="font-size:0.8rem;padding:0.2rem 0.6rem;background:var(--surface-muted);white-space:nowrap;">' + escapeHtml(type) + '</span>' +
177 + '<span style="flex:1;">' + escapeHtml(title) + '</span>' +
178 + '<label style="font-size:0.8rem;display:flex;align-items:center;gap:0.25rem;cursor:pointer;">' +
179 + '<input type="checkbox" class="bundle-listed-toggle" data-child-id="' + itemId + '"> Unlisted</label>' +
180 + '<button type="button" class="secondary bundle-remove-btn" data-child-id="' + itemId + '" style="padding:0.25rem 0.6rem;font-size:0.8rem;">Remove</button>';
181 + document.getElementById('bundle-items-list').appendChild(row);
182 + updateCount(1);
183 + attachRowHandlers(row);
184 + })
185 + .catch(function(err) { showToast(err.message); })
186 + .finally(function() { document.getElementById('bundle-add-btn').disabled = false; });
187 + });
188 +
189 + function attachRowHandlers(row) {
190 + row.querySelector('.bundle-remove-btn')?.addEventListener('click', function() {
191 + var childId = this.dataset.childId;
192 + fetch('/api/items/' + bundleId + '/bundle/' + childId, {
193 + method: 'DELETE',
194 + headers: csrfHeaders()
195 + })
196 + .then(function(res) {
197 + if (!res.ok) throw new Error('Failed to remove');
198 + var title = row.querySelector('span[style*="flex"]').textContent;
199 + var type = row.querySelector('span[style*="surface-muted"]').textContent;
200 + var select = document.getElementById('bundle-add-select');
201 + if (select) {
202 + var opt = document.createElement('option');
203 + opt.value = childId;
204 + opt.textContent = title + ' (' + type + ')';
205 + select.appendChild(opt);
206 + }
207 + row.remove();
208 + updateCount(-1);
209 + })
210 + .catch(function(err) { showToast(err.message); });
211 + });
212 +
213 + row.querySelector('.bundle-listed-toggle')?.addEventListener('change', function() {
214 + var childId = this.dataset.childId;
215 + var listed = !this.checked;
216 + fetch('/api/items/' + bundleId + '/bundle/' + childId + '/listed', {
217 + method: 'PUT',
218 + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
219 + body: JSON.stringify({ listed: listed })
220 + })
221 + .catch(function(err) { showToast(err.message); });
222 + });
223 + }
224 +
225 + document.querySelectorAll('.bundle-row').forEach(attachRowHandlers);
226 +
227 + function updateCount(delta) {
228 + var el = document.getElementById('bundle-count');
229 + el.textContent = parseInt(el.textContent) + delta;
230 + }
231 +
232 + function escapeHtml(s) {
233 + var d = document.createElement('div');
234 + d.textContent = s;
235 + return d.innerHTML;
236 + }
237 + })();
238 + </script>
239 + {% endif %}
240 +
241 + <!-- Tag Search Script -->
242 + <script>
243 + var tagSearchTimeout;
244 + function searchTags(q) {
245 + clearTimeout(tagSearchTimeout);
246 + var suggestions = document.getElementById('tag-suggestions');
247 + if (!q.trim()) { suggestions.style.display = 'none'; return; }
248 + tagSearchTimeout = setTimeout(function() {
249 + fetch('/api/tags/search?q=' + encodeURIComponent(q))
250 + .then(function(r) { return r.json(); })
251 + .then(function(tags) {
252 + if (!tags.length) { suggestions.style.display = 'none'; return; }
253 + suggestions.innerHTML = '';
254 + tags.forEach(function(t) {
255 + var div = document.createElement('div');
256 + div.style.cssText = 'padding: 0.4rem 0.6rem; cursor: pointer; font-size: 0.85rem;';
257 + div.textContent = t.name;
258 + div.addEventListener('mouseover', function() { this.style.background = 'var(--border)'; });
259 + div.addEventListener('mouseout', function() { this.style.background = 'transparent'; });
260 + div.addEventListener('click', function() { addTagById(t.id); });
261 + suggestions.appendChild(div);
262 + });
263 + suggestions.style.display = 'block';
264 + })
265 + .catch(function() {
266 + suggestions.style.display = 'none';
267 + });
268 + }, 200);
269 + }
270 +
271 + function addTagById(tagId) {
272 + var form = new FormData();
273 + form.append('tag_id', tagId);
274 + fetch('/api/items/{{ item.id }}/tags', { method: 'POST', headers: csrfHeaders(), body: form })
275 + .then(function(r) { return r.text(); })
276 + .then(function(html) {
277 + if (html) {
278 + document.getElementById('tags-container').insertAdjacentHTML('beforeend', html);
279 + }
280 + document.getElementById('item-tags').value = '';
281 + document.getElementById('tag-suggestions').style.display = 'none';
282 + htmx.process(document.getElementById('tags-container'));
283 + })
284 + .catch(function() {
285 + var msg = document.getElementById('item-save-status');
286 + if (msg) msg.textContent = 'Failed to add tag. Please try again.';
287 + });
288 + }
289 +
290 + document.addEventListener('click', function(e) {
291 + if (!e.target.closest('#item-tags') && !e.target.closest('#tag-suggestions')) {
292 + document.getElementById('tag-suggestions').style.display = 'none';
293 + }
294 + });
295 + </script>
@@ -0,0 +1,6 @@
1 + <div class="content-section">
2 + <div class="section-header">
3 + <h2>Files &amp; Versions</h2>
4 + </div>
5 + {% include "partials/item_version_upload.html" %}
6 + </div>
@@ -0,0 +1,18 @@
1 + <div class="content-section">
2 + <div class="section-header">
3 + <h2>Overview</h2>
4 + <div class="action-buttons">
5 + <button class="secondary" onclick="window.open('/i/{{ item.id }}', '_blank')">View Public Page</button>
6 + <button class="secondary" onclick="navigator.clipboard.writeText(window.location.origin + '/i/{{ item.id }}').then(() => this.textContent = 'Copied!').catch(() => this.textContent = 'Failed')">Copy Link</button>
7 + <button class="secondary"
8 + hx-post="/api/items/{{ item.id }}/duplicate"
9 + hx-confirm="Duplicate this item? A new draft copy will be created.">Duplicate Item</button>
10 + </div>
11 + </div>
12 +
13 + <div id="item-analytics"
14 + hx-get="/dashboard/item/{{ item.id }}/analytics"
15 + hx-trigger="load"
16 + hx-swap="innerHTML">
17 + </div>
18 + </div>
@@ -0,0 +1,113 @@
1 + <!-- Pay What You Want -->
2 + <div class="content-section">
3 + <div class="section-header">
4 + <h2>Pay What You Want</h2>
5 + </div>
6 +
7 + <form hx-put="/api/items/{{ item.id }}"
8 + hx-target="#pwyw-save-status"
9 + hx-swap="innerHTML"
10 + hx-indicator="#pwyw-spinner">
11 + <div class="form-group">
12 + <label class="checkbox-label">
13 + <input type="hidden" name="pwyw_enabled" value="off">
14 + <input type="checkbox" name="pwyw_enabled" value="on"
15 + {% if item.pwyw_enabled %}checked{% endif %}
16 + onchange="document.getElementById('pwyw-settings').style.display = this.checked ? 'block' : 'none'">
17 + Enable pay-what-you-want pricing
18 + </label>
19 + <p class="form-hint">The item price becomes the suggested amount. Buyers can choose any amount at or above the minimum.</p>
20 + </div>
21 +
22 + <div id="pwyw-settings" style="{% if !item.pwyw_enabled %}display: none;{% endif %}">
23 + <div class="form-group">
24 + <label for="pwyw-min">Minimum price (cents)</label>
25 + <input type="number" id="pwyw-min" name="pwyw_min_cents"
26 + value="{% if let Some(min) = item.pwyw_min_cents %}{{ min }}{% endif %}"
27 + min="0" placeholder="0 (any amount)">
28 + <p class="form-hint">The lowest amount a buyer can pay, in cents. 0 = any amount.</p>
29 + </div>
30 + </div>
31 +
32 + <button class="primary" type="submit">
33 + Save PWYW Settings
34 + <span id="pwyw-spinner" class="htmx-indicator"> ...</span>
35 + </button>
36 + <span id="pwyw-save-status"></span>
37 + </form>
38 + </div>
39 +
40 + <!-- License Keys -->
41 + <div class="content-section">
42 + <div class="section-header">
43 + <h2>License Keys</h2>
44 + </div>
45 +
46 + <form hx-put="/api/items/{{ item.id }}/license-settings"
47 + hx-target="#license-save-status"
48 + hx-swap="innerHTML"
49 + hx-indicator="#license-spinner">
50 + <div class="form-group">
51 + <label class="checkbox-label">
52 + <input type="checkbox" name="enable_license_keys" value="on"
53 + {% if item.enable_license_keys %}checked{% endif %}
54 + onchange="document.getElementById('license-keys-section').style.display = this.checked ? 'block' : 'none'">
55 + Enable license keys for this item
56 + </label>
57 + <p class="form-hint">Buyers will receive a license key they can use to activate your software.</p>
58 + </div>
59 +
60 + <div class="form-group">
61 + <label for="default-max-activations">Default max activations per key</label>
62 + <input type="number" id="default-max-activations" name="default_max_activations"
63 + value="{% if let Some(max) = item.default_max_activations %}{{ max }}{% endif %}"
64 + min="1" placeholder="Leave blank for unlimited">
65 + <p class="form-hint">How many machines can activate each key. Blank = unlimited.</p>
66 + </div>
67 +
68 + <button class="primary" type="submit">
69 + Save License Settings
70 + <span id="license-spinner" class="htmx-indicator"> ...</span>
71 + </button>
72 + <span id="license-save-status"></span>
73 + </form>
74 +
75 + <div id="license-keys-section" style="{% if !item.enable_license_keys %}display: none;{% endif %} margin-top: 2rem;">
76 + <div class="section-header">
77 + <h3>Generated Keys</h3>
78 + <form hx-post="/api/items/{{ item.id }}/keys"
79 + hx-target="#license-keys-list"
80 + hx-swap="outerHTML">
81 + <button class="secondary" type="submit">Generate Key</button>
82 + </form>
83 + </div>
84 +
85 + {% include "partials/item_license_keys.html" %}
86 + </div>
87 + </div>
88 +
89 + <!-- Promo Codes -->
90 + <div class="content-section">
91 + <div class="section-header">
92 + <h2>Promo Codes</h2>
93 + </div>
94 + <p class="form-hint" style="margin-bottom: 1rem;">Generate codes that grant free access to this item. Share them with reviewers, collaborators, or for promotions.</p>
95 +
96 + <form hx-post="/api/promo-codes"
97 + hx-target="#promo-codes-list"
98 + hx-swap="outerHTML"
99 + style="margin-bottom: 1.5rem;">
100 + <input type="hidden" name="item_id" value="{{ item.id }}">
101 + <input type="hidden" name="code_purpose" value="free_access">
102 + <div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
103 + <input type="number" name="max_uses" min="1" placeholder="Max uses (blank = unlimited)"
104 + style="width: 200px; padding: 0.4rem 0.6rem; font-size: 0.85rem;">
105 + <input type="date" name="expires_at" placeholder="Expires (optional)"
106 + title="Expiry date (optional)"
107 + style="width: 150px; padding: 0.4rem 0.6rem; font-size: 0.85rem;">
108 + <button class="secondary" type="submit">Generate Code</button>
109 + </div>
110 + </form>
111 +
112 + {% include "partials/promo_codes_list.html" %}
113 + </div>
@@ -0,0 +1,93 @@
1 + <div class="content-section">
2 + <div class="section-header">
3 + <h2>Publishing</h2>
4 + </div>
5 +
6 + {% if let Some(scheduled) = item.publish_at %}
7 + <!-- Scheduled: show cancel button -->
8 + <p style="margin: 1rem 0; opacity: 0.7; text-align: left;">
9 + Scheduled for {{ scheduled }}.
10 + </p>
11 + <div class="action-buttons">
12 + <button class="secondary"
13 + hx-put="/api/items/{{ item.id }}"
14 + hx-vals='{"publish_at": ""}'
15 + hx-confirm="Cancel the scheduled publish?"
16 + hx-on::after-request="if(event.detail.successful) window.location.reload()">Cancel Schedule</button>
17 + </div>
18 + {% else if item.is_public %}
19 + <!-- Published: show unpublish button -->
20 + <p style="margin: 1rem 0; opacity: 0.7; text-align: left;">
21 + Unpublishing removes this item from public view but preserves all data.
22 + </p>
23 + <div class="action-buttons">
24 + <button class="secondary"
25 + hx-put="/api/items/{{ item.id }}"
26 + hx-vals='{"is_public": "false"}'
27 + hx-confirm="Unpublish this item? It will be hidden from public view."
28 + hx-on::after-request="if(event.detail.successful) window.location.reload()">Unpublish Item</button>
29 + </div>
30 + {% else %}
31 + <!-- Draft: show publish now + schedule -->
32 + {% if !project_labels.is_empty() %}
33 + <div style="margin: 1rem 0; padding: 1rem; background: var(--surface-muted); border: 1px solid var(--border);">
34 + <p style="font-size: 0.85rem; opacity: 0.7; margin-bottom: 0.5rem;">Before publishing, confirm this item fits your project labels:</p>
35 + <ul style="list-style: none; padding: 0; margin: 0;">
36 + {% for label in project_labels %}
37 + <li style="font-size: 0.9rem; padding: 0.2rem 0;">
38 + <label class="checkbox-label" style="opacity: 0.85;">
39 + <input type="checkbox"> {{ label }}
40 + </label>
41 + </li>
42 + {% endfor %}
43 + </ul>
44 + </div>
45 + {% endif %}
46 + <p style="margin: 1rem 0; opacity: 0.7; text-align: left;">
47 + Publish this item to make it visible to the public.
48 + </p>
49 + <div class="action-buttons">
50 + <button class="primary"
51 + hx-put="/api/items/{{ item.id }}"
52 + hx-vals='{"is_public": "true"}'
53 + hx-confirm="Publish this item now?"
54 + hx-on::after-request="if(event.detail.successful) window.location.reload()">Publish Now</button>
55 + </div>
56 +
57 + <div class="form-group" style="margin-top: 1.5rem;">
58 + <label for="publish-at">Schedule Publish</label>
59 + <div style="display: flex; gap: 0.5rem; align-items: center;">
60 + <input type="datetime-local" id="publish-at" name="publish_at"
61 + style="flex: 1;">
62 + <button class="secondary"
63 + hx-put="/api/items/{{ item.id }}"
64 + hx-include="#publish-at"
65 + hx-confirm="Schedule this item for publication?"
66 + hx-on::after-request="if(event.detail.successful) window.location.reload()">
67 + Schedule
68 + </button>
69 + </div>
70 + <p class="form-hint">The item will be automatically published at the scheduled time.</p>
71 + </div>
72 + {% endif %}
73 + </div>
74 +
75 + <!-- Danger Zone -->
76 + <div class="content-section">
77 + <div class="section-header">
78 + <h2>Danger Zone</h2>
79 + </div>
80 +
81 + <div class="warning-box">
82 + These actions are permanent and cannot be undone.
83 + </div>
84 +
85 + <div class="action-buttons" style="margin-top: 1.5rem;">
86 + <button class="danger"
87 + hx-delete="/api/items/{{ item.id }}"
88 + hx-confirm="Delete this item permanently? This cannot be undone."
89 + hx-on::after-request="if(event.detail.successful) window.location.href='/dashboard'">
90 + Delete Item Permanently
91 + </button>
92 + </div>
93 + </div>
@@ -0,0 +1,36 @@
1 + {% if memberships.is_empty() %}
2 + <div class="content-section">
3 + <p class="muted">You haven't joined any forum communities yet.</p>
4 + {% if !mt_base_url.is_empty() %}
5 + <p style="margin-top: 1rem;">
6 + <a href="{{ mt_base_url }}" class="btn-primary" style="display: inline-block; text-decoration: none;">Browse Communities</a>
7 + </p>
8 + {% endif %}
9 + </div>
10 + {% else %}
11 + <div class="content-section">
12 + <p class="form-hint" style="margin-bottom: 1.5rem;">Your memberships across <a href="{{ mt_base_url }}">Multithreaded</a> forum communities.</p>
13 + <div class="scroll-x">
14 + <table class="data-table">
15 + <thead>
16 + <tr>
17 + <th>Community</th>
18 + <th>Role</th>
19 + <th>Posts</th>
20 + <th>Joined</th>
21 + </tr>
22 + </thead>
23 + <tbody>
24 + {% for m in memberships %}
25 + <tr>
26 + <td><a href="{{ m.profile_url }}">{{ m.community_name }}</a></td>
27 + <td><span class="badge">{{ m.role }}</span></td>
28 + <td>{{ m.post_count }}</td>
29 + <td>{{ m.joined }}</td>
30 + </tr>
31 + {% endfor %}
32 + </tbody>
33 + </table>
34 + </div>
35 + </div>
36 + {% endif %}