max / makenotwork
10 files changed,
+314 insertions,
-36 deletions
| @@ -802,3 +802,77 @@ pub(super) async fn list_versions( | |||
| 802 | 802 | ||
| 803 | 803 | Ok(Json(ListResponse { data })) | |
| 804 | 804 | } | |
| 805 | + | ||
| 806 | + | // ============================================================================= | |
| 807 | + | // Bundle management | |
| 808 | + | // ============================================================================= | |
| 809 | + | ||
| 810 | + | #[derive(Debug, Deserialize)] | |
| 811 | + | pub struct BundleAddRequest { | |
| 812 | + | pub item_id: ItemId, | |
| 813 | + | } | |
| 814 | + | ||
| 815 | + | #[derive(Debug, Deserialize)] | |
| 816 | + | pub struct BundleListedRequest { | |
| 817 | + | pub listed: bool, | |
| 818 | + | } | |
| 819 | + | ||
| 820 | + | /// POST /api/items/{id}/bundle/add — add an item to this bundle. | |
| 821 | + | #[tracing::instrument(skip_all, name = "items::bundle_add")] | |
| 822 | + | pub async fn bundle_add( | |
| 823 | + | State(state): State<AppState>, | |
| 824 | + | AuthUser(user): AuthUser, | |
| 825 | + | Path(bundle_id): Path<ItemId>, | |
| 826 | + | Json(req): Json<BundleAddRequest>, | |
| 827 | + | ) -> Result<impl IntoResponse> { | |
| 828 | + | user.check_not_suspended()?; | |
| 829 | + | let (item, _project) = verify_item_ownership(&state, bundle_id, user.id).await?; | |
| 830 | + | if item.item_type != ItemType::Bundle { | |
| 831 | + | return Err(AppError::BadRequest("Item is not a bundle".to_string())); | |
| 832 | + | } | |
| 833 | + | let target = db::items::get_item_by_id(&state.db, req.item_id) | |
| 834 | + | .await? | |
| 835 | + | .ok_or(AppError::NotFound)?; | |
| 836 | + | if target.project_id != item.project_id { | |
| 837 | + | return Err(AppError::BadRequest("Item must be in the same project".to_string())); | |
| 838 | + | } | |
| 839 | + | if target.item_type == ItemType::Bundle { | |
| 840 | + | return Err(AppError::BadRequest("Cannot nest bundles".to_string())); | |
| 841 | + | } | |
| 842 | + | let count = db::bundles::get_bundle_item_count(&state.db, bundle_id).await?; | |
| 843 | + | db::bundles::add_item_to_bundle(&state.db, bundle_id, req.item_id, count as i32).await?; | |
| 844 | + | Ok(StatusCode::OK) | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | /// DELETE /api/items/{id}/bundle/{child_id} — remove an item from this bundle. | |
| 848 | + | #[tracing::instrument(skip_all, name = "items::bundle_remove")] | |
| 849 | + | pub async fn bundle_remove( | |
| 850 | + | State(state): State<AppState>, | |
| 851 | + | AuthUser(user): AuthUser, | |
| 852 | + | Path((bundle_id, child_id)): Path<(ItemId, ItemId)>, | |
| 853 | + | ) -> Result<impl IntoResponse> { | |
| 854 | + | user.check_not_suspended()?; | |
| 855 | + | let (item, _project) = verify_item_ownership(&state, bundle_id, user.id).await?; | |
| 856 | + | if item.item_type != ItemType::Bundle { | |
| 857 | + | return Err(AppError::BadRequest("Item is not a bundle".to_string())); | |
| 858 | + | } | |
| 859 | + | db::bundles::remove_item_from_bundle(&state.db, bundle_id, child_id).await?; | |
| 860 | + | Ok(StatusCode::OK) | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | /// PUT /api/items/{id}/bundle/{child_id}/listed — toggle listed status. | |
| 864 | + | #[tracing::instrument(skip_all, name = "items::bundle_toggle_listed")] | |
| 865 | + | pub async fn bundle_toggle_listed( | |
| 866 | + | State(state): State<AppState>, | |
| 867 | + | AuthUser(user): AuthUser, | |
| 868 | + | Path((bundle_id, child_id)): Path<(ItemId, ItemId)>, | |
| 869 | + | Json(req): Json<BundleListedRequest>, | |
| 870 | + | ) -> Result<impl IntoResponse> { | |
| 871 | + | user.check_not_suspended()?; | |
| 872 | + | let (item, _project) = verify_item_ownership(&state, bundle_id, user.id).await?; | |
| 873 | + | if item.item_type != ItemType::Bundle { | |
| 874 | + | return Err(AppError::BadRequest("Item is not a bundle".to_string())); | |
| 875 | + | } | |
| 876 | + | db::bundles::set_item_listed(&state.db, child_id, req.listed).await?; | |
| 877 | + | Ok(StatusCode::OK) | |
| 878 | + | } |
| @@ -190,6 +190,10 @@ pub fn api_routes() -> Router<AppState> { | |||
| 190 | 190 | .route("/api/items/bulk/unpublish", post(items::bulk_unpublish)) | |
| 191 | 191 | .route("/api/items/bulk/delete", post(items::bulk_delete)) | |
| 192 | 192 | .route("/api/items/{id}/move", put(items::move_item)) | |
| 193 | + | // Bundle management | |
| 194 | + | .route("/api/items/{id}/bundle/add", post(items::bundle_add)) | |
| 195 | + | .route("/api/items/{id}/bundle/{child_id}", delete(items::bundle_remove)) | |
| 196 | + | .route("/api/items/{id}/bundle/{child_id}/listed", put(items::bundle_toggle_listed)) | |
| 193 | 197 | // Tag routes (HTMX) | |
| 194 | 198 | .route("/api/items/{id}/tags", post(items::add_tag)) | |
| 195 | 199 | .route("/api/items/{id}/tags/{tag_id}", delete(items::remove_tag)) |
| @@ -285,18 +285,29 @@ pub(super) async fn dashboard_item( | |||
| 285 | 285 | let license_keys: Vec<LicenseKeyRow> = db_license_keys.into_iter().map(LicenseKeyRow::from).collect(); | |
| 286 | 286 | let promo_codes: Vec<PromoCodeRow> = db_promo_codes.into_iter().map(PromoCodeRow::from).collect(); | |
| 287 | 287 | ||
| 288 | - | // Load bundle child items if this is a bundle | |
| 289 | - | let bundle_items = if db_item.item_type == db::ItemType::Bundle { | |
| 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 | 290 | let children = db::bundles::get_bundle_items(&state.db, item_id).await?; | |
| 291 | - | children | |
| 291 | + | let bundle_child_ids: Vec<db::ItemId> = children.iter().map(|c| c.id).collect(); | |
| 292 | + | let child_items: Vec<Item> = children | |
| 292 | 293 | .iter() | |
| 293 | 294 | .map(|child| { | |
| 294 | - | let child_tags = vec![]; // Tags not needed for dashboard bundle list | |
| 295 | + | let child_tags = vec![]; | |
| 295 | 296 | Item::from_db_list(child, &child_tags, false, true) | |
| 296 | 297 | }) | |
| 297 | - | .collect() | |
| 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) | |
| 298 | 309 | } else { | |
| 299 | - | vec![] | |
| 310 | + | (vec![], vec![]) | |
| 300 | 311 | }; | |
| 301 | 312 | ||
| 302 | 313 | Ok(DashboardItemTemplate { | |
| @@ -309,6 +320,7 @@ pub(super) async fn dashboard_item( | |||
| 309 | 320 | promo_codes, | |
| 310 | 321 | project_labels, | |
| 311 | 322 | bundle_items, | |
| 323 | + | bundleable_items, | |
| 312 | 324 | }) | |
| 313 | 325 | } | |
| 314 | 326 |
| @@ -42,6 +42,29 @@ const ITEM_LABELS: &[&str] = &[ | |||
| 42 | 42 | "Preview", | |
| 43 | 43 | ]; | |
| 44 | 44 | ||
| 45 | + | /// Whether the distribution step has actionable content for this item type. | |
| 46 | + | /// Only types that support license keys need the distribution step. | |
| 47 | + | fn needs_distribution(item_type: ItemType) -> bool { | |
| 48 | + | matches!( | |
| 49 | + | item_type, | |
| 50 | + | ItemType::Plugin | ItemType::Preset | ItemType::Template | ItemType::Digital | ItemType::Course | |
| 51 | + | ) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Get effective steps and labels for an item type, excluding distribution when not needed. | |
| 55 | + | fn effective_steps(item_type: ItemType) -> (Vec<&'static str>, Vec<&'static str>) { | |
| 56 | + | if needs_distribution(item_type) { | |
| 57 | + | (ITEM_STEPS.to_vec(), ITEM_LABELS.to_vec()) | |
| 58 | + | } else { | |
| 59 | + | let steps: Vec<&str> = ITEM_STEPS.iter().copied().filter(|&s| s != "distribution").collect(); | |
| 60 | + | let labels: Vec<&str> = ITEM_STEPS.iter().zip(ITEM_LABELS.iter()) | |
| 61 | + | .filter(|(s, _)| **s != "distribution") | |
| 62 | + | .map(|(_, &l)| l) | |
| 63 | + | .collect(); | |
| 64 | + | (steps, labels) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 45 | 68 | /// Verify the user owns the project + item for wizard steps 2-6. | |
| 46 | 69 | async fn verify_item_wizard_access( | |
| 47 | 70 | state: &AppState, | |
| @@ -226,7 +249,8 @@ pub async fn step_save( | |||
| 226 | 249 | .await? | |
| 227 | 250 | .ok_or(AppError::NotFound)?; | |
| 228 | 251 | ||
| 229 | - | let next = super::next_step(ITEM_STEPS, &step).ok_or(AppError::NotFound)?; | |
| 252 | + | let (steps, _) = effective_steps(item.item_type); | |
| 253 | + | let next = super::next_step(&steps, &step).ok_or(AppError::NotFound)?; | |
| 230 | 254 | render_step(&state, &session, &user, &project, &item, next).await | |
| 231 | 255 | } | |
| 232 | 256 | ||
| @@ -538,7 +562,8 @@ async fn render_step( | |||
| 538 | 562 | item: &db::DbItem, | |
| 539 | 563 | step: &str, | |
| 540 | 564 | ) -> Result<Response> { | |
| 541 | - | let nav = build_step_nav(ITEM_STEPS, ITEM_LABELS, step); | |
| 565 | + | let (steps, labels) = effective_steps(item.item_type); | |
| 566 | + | let nav = build_step_nav(&steps, &labels, step); | |
| 542 | 567 | let project_slug = project.slug.to_string(); | |
| 543 | 568 | let item_id = item.id.to_string(); | |
| 544 | 569 | let csrf_token = get_csrf_token(session).await; | |
| @@ -631,6 +656,8 @@ async fn render_step( | |||
| 631 | 656 | "free" | |
| 632 | 657 | }; | |
| 633 | 658 | ||
| 659 | + | let next = super::next_step(&steps, "pricing").unwrap_or("preview"); | |
| 660 | + | ||
| 634 | 661 | Ok(WizardItemPricingTemplate { | |
| 635 | 662 | nav, | |
| 636 | 663 | project_slug, | |
| @@ -651,25 +678,48 @@ async fn render_step( | |||
| 651 | 678 | item.pwyw_min_cents.unwrap_or(0) / 100, | |
| 652 | 679 | item.pwyw_min_cents.unwrap_or(0) % 100 | |
| 653 | 680 | ), | |
| 681 | + | next_step: next.to_string(), | |
| 654 | 682 | } | |
| 655 | 683 | .into_response()) | |
| 656 | 684 | } | |
| 657 | 685 | ||
| 658 | 686 | "distribution" => { | |
| 659 | - | let show_license_keys = matches!( | |
| 660 | - | item.item_type, | |
| 661 | - | ItemType::Plugin | |
| 662 | - | | ItemType::Preset | |
| 663 | - | | ItemType::Template | |
| 664 | - | | ItemType::Digital | |
| 665 | - | | ItemType::Course | |
| 666 | - | ); | |
| 687 | + | if !needs_distribution(item.item_type) { | |
| 688 | + | // Skip to preview for types without distribution options | |
| 689 | + | let nav = build_step_nav(&steps, &labels, "preview"); | |
| 690 | + | let tags = db::tags::get_tags_for_item(&state.db, item.id).await?; | |
| 691 | + | let tag_names: Vec<String> = tags.iter().map(|t| t.tag_name.clone()).collect(); | |
| 692 | + | return Ok(WizardItemPreviewTemplate { | |
| 693 | + | csrf_token, | |
| 694 | + | nav, | |
| 695 | + | project_slug, | |
| 696 | + | item_id, | |
| 697 | + | title: item.title.clone(), | |
| 698 | + | item_type: item.item_type.to_string(), | |
| 699 | + | description: item.description.clone().unwrap_or_default(), | |
| 700 | + | price_display: if item.pwyw_enabled { | |
| 701 | + | format!("PWYW (min ${}.{:02})", item.pwyw_min_cents.unwrap_or(0) / 100, item.pwyw_min_cents.unwrap_or(0) % 100) | |
| 702 | + | } else if item.price_cents == 0 { | |
| 703 | + | "Free".to_string() | |
| 704 | + | } else { | |
| 705 | + | format!("${}.{:02}", item.price_cents / 100, item.price_cents % 100) | |
| 706 | + | }, | |
| 707 | + | tag_names, | |
| 708 | + | enable_license_keys: item.enable_license_keys, | |
| 709 | + | has_content: item.body.is_some() | |
| 710 | + | || item.audio_s3_key.is_some() | |
| 711 | + | || (item.item_type == ItemType::Bundle | |
| 712 | + | && db::bundles::get_bundle_item_count(&state.db, item.id).await.unwrap_or(0) > 0), | |
| 713 | + | is_public: item.is_public, | |
| 714 | + | back_step: "pricing".to_string(), | |
| 715 | + | }.into_response()); | |
| 716 | + | } | |
| 667 | 717 | ||
| 668 | 718 | Ok(WizardItemDistributionTemplate { | |
| 669 | 719 | nav, | |
| 670 | 720 | project_slug: project_slug.clone(), | |
| 671 | 721 | item_id: item_id.clone(), | |
| 672 | - | show_license_keys, | |
| 722 | + | show_license_keys: true, | |
| 673 | 723 | enable_license_keys: item.enable_license_keys, | |
| 674 | 724 | max_activations: item.default_max_activations, | |
| 675 | 725 | } | |
| @@ -713,6 +763,11 @@ async fn render_step( | |||
| 713 | 763 | .unwrap_or(0) | |
| 714 | 764 | > 0), | |
| 715 | 765 | is_public: item.is_public, | |
| 766 | + | back_step: if needs_distribution(item.item_type) { | |
| 767 | + | "distribution".to_string() | |
| 768 | + | } else { | |
| 769 | + | "pricing".to_string() | |
| 770 | + | }, | |
| 716 | 771 | } | |
| 717 | 772 | .into_response()) | |
| 718 | 773 | } |
| @@ -72,6 +72,8 @@ pub struct DashboardItemTemplate { | |||
| 72 | 72 | pub project_labels: Vec<String>, | |
| 73 | 73 | /// Child items in this bundle (empty for non-bundle items). | |
| 74 | 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>, | |
| 75 | 77 | } | |
| 76 | 78 | ||
| 77 | 79 | // ============================================================================ | |
| @@ -344,6 +346,7 @@ pub struct WizardItemPricingTemplate { | |||
| 344 | 346 | pub price_dollars: String, | |
| 345 | 347 | pub pwyw_suggested_dollars: String, | |
| 346 | 348 | pub pwyw_min_dollars: String, | |
| 349 | + | pub next_step: String, | |
| 347 | 350 | } | |
| 348 | 351 | ||
| 349 | 352 | #[derive(Template)] | |
| @@ -374,4 +377,5 @@ pub struct WizardItemPreviewTemplate { | |||
| 374 | 377 | pub enable_license_keys: bool, | |
| 375 | 378 | pub has_content: bool, | |
| 376 | 379 | pub is_public: bool, | |
| 380 | + | pub back_step: String, | |
| 377 | 381 | } |
| @@ -165,25 +165,140 @@ | |||
| 165 | 165 | ||
| 166 | 166 | {% if item.item_type == "Bundle" %} | |
| 167 | 167 | <!-- Bundle Contents --> | |
| 168 | - | <div class="content-section"> | |
| 168 | + | <div class="content-section" id="bundle-section"> | |
| 169 | 169 | <div class="section-header"> | |
| 170 | - | <h2>Bundle Contents ({{ bundle_items.len() }} items)</h2> | |
| 170 | + | <h2>Bundle Contents (<span id="bundle-count">{{ bundle_items.len() }}</span> items)</h2> | |
| 171 | 171 | </div> | |
| 172 | + | ||
| 173 | + | <div id="bundle-items-list"> | |
| 172 | 174 | {% if bundle_items.is_empty() %} | |
| 173 | - | <p style="opacity: 0.7;">No items in this bundle yet. Use the item wizard to add items.</p> | |
| 175 | + | <p id="bundle-empty" style="opacity: 0.7;">No items in this bundle yet.</p> | |
| 174 | 176 | {% else %} | |
| 175 | 177 | {% for child in bundle_items %} | |
| 176 | - | <div style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid var(--border);"> | |
| 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);"> | |
| 177 | 179 | <span style="font-size: 0.8rem; padding: 0.2rem 0.6rem; background: var(--surface-muted); white-space: nowrap;">{{ child.item_type }}</span> | |
| 178 | 180 | <span style="flex: 1;">{{ child.title }}</span> | |
| 179 | - | {% if !child.listed %} | |
| 180 | - | <span style="font-size: 0.75rem; padding: 0.15rem 0.5rem; background: var(--border); opacity: 0.7;">Unlisted</span> | |
| 181 | - | {% endif %} | |
| 182 | - | <span style="font-size: 0.9rem; opacity: 0.7;">{{ child.price }}</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> | |
| 183 | 187 | </div> | |
| 184 | 188 | {% endfor %} | |
| 185 | 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 %} | |
| 186 | 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> | |
| 187 | 302 | {% endif %} | |
| 188 | 303 | ||
| 189 | 304 | <!-- Pay What You Want --> |
| @@ -154,6 +154,18 @@ | |||
| 154 | 154 | } | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | + | function setProgressBar(idx) { | |
| 158 | + | var el = document.querySelector('.batch-status[data-idx="' + idx + '"]'); | |
| 159 | + | if (!el) return {}; | |
| 160 | + | el.style.color = ''; | |
| 161 | + | el.innerHTML = | |
| 162 | + | '<div style="display:flex;align-items:center;gap:0.4rem;">' + | |
| 163 | + | '<div style="flex:1;height:6px;background:var(--border);border-radius:3px;overflow:hidden;min-width:60px;">' + | |
| 164 | + | '<div class="bp-fill" style="height:100%;width:0%;background:var(--accent,#6c5ce7);transition:width 0.15s;"></div></div>' + | |
| 165 | + | '<span class="bp-pct" style="font-size:0.75rem;min-width:2.5em;text-align:right;">0%</span></div>'; | |
| 166 | + | return { percentEl: el.querySelector('.bp-pct'), progressBar: el.querySelector('.bp-fill') }; | |
| 167 | + | } | |
| 168 | + | ||
| 157 | 169 | function appendBundleRow(itemId, title, typeLabel) { | |
| 158 | 170 | var picker = document.getElementById('bundle-picker'); | |
| 159 | 171 | var row = document.createElement('div'); | |
| @@ -205,12 +217,12 @@ | |||
| 205 | 217 | entry.itemId = itemId; | |
| 206 | 218 | ||
| 207 | 219 | // 2. Upload file | |
| 208 | - | setStatus(entry.idx, 'Uploading...', ''); | |
| 220 | + | var progressEls = setProgressBar(entry.idx); | |
| 209 | 221 | try { | |
| 210 | 222 | if (entry.type === 'audio') { | |
| 211 | - | await uploadAudio(entry, itemId); | |
| 223 | + | await uploadAudio(entry, itemId, progressEls); | |
| 212 | 224 | } else { | |
| 213 | - | await uploadVersion(entry, itemId); | |
| 225 | + | await uploadVersion(entry, itemId, progressEls); | |
| 214 | 226 | } | |
| 215 | 227 | } catch (e) { | |
| 216 | 228 | setStatus(entry.idx, e.message || 'Upload failed', 'var(--error, #c0392b)'); | |
| @@ -227,7 +239,7 @@ | |||
| 227 | 239 | try { return JSON.parse(text).error || fallback; } catch (_) { return text || fallback; } | |
| 228 | 240 | } | |
| 229 | 241 | ||
| 230 | - | async function uploadAudio(entry, itemId) { | |
| 242 | + | async function uploadAudio(entry, itemId, progressEls) { | |
| 231 | 243 | var file = entry.file; | |
| 232 | 244 | var contentType = file.type || 'audio/mpeg'; | |
| 233 | 245 | var presignRes = await fetch('/api/upload/presign', { | |
| @@ -238,7 +250,7 @@ | |||
| 238 | 250 | if (!presignRes.ok) throw new Error(await extractError(presignRes, 'Presign failed')); | |
| 239 | 251 | var presignData = await presignRes.json(); | |
| 240 | 252 | ||
| 241 | - | var uploader = new S3Uploader({}); | |
| 253 | + | var uploader = new S3Uploader(progressEls); | |
| 242 | 254 | await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType, presignData.cache_control); | |
| 243 | 255 | ||
| 244 | 256 | var confirmRes = await fetch('/api/upload/confirm', { | |
| @@ -249,7 +261,7 @@ | |||
| 249 | 261 | if (!confirmRes.ok) throw new Error(await extractError(confirmRes, 'Confirm failed')); | |
| 250 | 262 | } | |
| 251 | 263 | ||
| 252 | - | async function uploadVersion(entry, itemId) { | |
| 264 | + | async function uploadVersion(entry, itemId, progressEls) { | |
| 253 | 265 | var file = entry.file; | |
| 254 | 266 | var contentType = file.type || 'application/octet-stream'; | |
| 255 | 267 | var verRes = await fetch('/api/items/' + itemId + '/versions', { | |
| @@ -269,7 +281,7 @@ | |||
| 269 | 281 | if (!presignRes.ok) throw new Error(await extractError(presignRes, 'Presign failed')); | |
| 270 | 282 | var presignData = await presignRes.json(); | |
| 271 | 283 | ||
| 272 | - | var uploader = new S3Uploader({}); | |
| 284 | + | var uploader = new S3Uploader(progressEls); | |
| 273 | 285 | await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType, presignData.cache_control); | |
| 274 | 286 | ||
| 275 | 287 | var confirmRes = await fetch('/api/versions/' + versionId + '/upload/confirm', { |
| @@ -6,7 +6,8 @@ | |||
| 6 | 6 | ||
| 7 | 7 | <form hx-post="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/distribution" | |
| 8 | 8 | hx-target="#wizard-step" hx-swap="innerHTML" | |
| 9 | - | hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/preview"> | |
| 9 | + | hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/preview" | |
| 10 | + | novalidate> | |
| 10 | 11 | ||
| 11 | 12 | {% if show_license_keys %} | |
| 12 | 13 | <div class="distribution-section"> |
| @@ -62,9 +62,9 @@ | |||
| 62 | 62 | ||
| 63 | 63 | <div class="wizard-actions"> | |
| 64 | 64 | <button type="button" class="secondary" | |
| 65 | - | hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/distribution" | |
| 65 | + | hx-get="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/{{ back_step }}" | |
| 66 | 66 | hx-target="#wizard-step" hx-swap="innerHTML" | |
| 67 | - | hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/distribution">Back</button> | |
| 67 | + | hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/{{ back_step }}">Back</button> | |
| 68 | 68 | <button type="submit" name="action" value="draft" class="secondary">Save as Draft</button> | |
| 69 | 69 | <button type="button" class="secondary" | |
| 70 | 70 | onclick="var f=document.getElementById('schedule-fields'); f.style.display=f.style.display==='none'?'':'none';">Schedule</button> |
| @@ -6,7 +6,8 @@ | |||
| 6 | 6 | ||
| 7 | 7 | <form hx-post="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/pricing" | |
| 8 | 8 | hx-target="#wizard-step" hx-swap="innerHTML" | |
| 9 | - | hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/distribution"> | |
| 9 | + | hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/{{ next_step }}" | |
| 10 | + | novalidate> | |
| 10 | 11 | ||
| 11 | 12 | <div class="pricing-cards"> | |
| 12 | 13 | <label class="pricing-card"> |