max / makenotwork
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 · 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?">×</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()">☆</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 & 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?">×</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()">☆</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 & 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 %} |