max / makenotwork
10 files changed,
+332 insertions,
-35 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.5.1" | |
| 3 | + | version = "0.5.2" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -22,12 +22,12 @@ Creators expect these from any platform. Without them, sellers hit walls during | |||
| 22 | 22 | ||
| 23 | 23 | Creators with 20+ items need these. One-at-a-time editing doesn't scale. | |
| 24 | 24 | ||
| 25 | - | - [ ] Always show bulk action bar (disabled/grayed until items selected) | |
| 25 | + | - [x] Always show bulk action bar (disabled/grayed until items selected, enables on checkbox) | |
| 26 | 26 | - [ ] Add bulk rename operation | |
| 27 | - | - [ ] Add bulk tag operation | |
| 28 | - | - [ ] Add bulk price change operation | |
| 29 | - | - [ ] Implement soft delete with 30-day recovery — "Recently Deleted" section | |
| 30 | - | - [ ] Show undo toast after bulk delete | |
| 27 | + | - [x] Add bulk tag operation (find-or-create tag, bulk INSERT ON CONFLICT) | |
| 28 | + | - [x] Add bulk price change operation (inline form with dollar input) | |
| 29 | + | - [x] Implement soft delete with 7-day recovery — "Recently Deleted" collapsible with restore buttons | |
| 30 | + | - [x] Bulk delete now soft-deletes (sets deleted_at + is_public=false, scheduler purges after 7 days) | |
| 31 | 31 | - [ ] Add global search across all projects and items from dashboard | |
| 32 | 32 | ||
| 33 | 33 | ## Sprint 3: Onboarding Overhaul |
| @@ -781,7 +781,7 @@ pub async fn bulk_unpublish( | |||
| 781 | 781 | Ok(result.rows_affected()) | |
| 782 | 782 | } | |
| 783 | 783 | ||
| 784 | - | /// Bulk-delete items from a project. | |
| 784 | + | /// Soft-delete items from a project (sets deleted_at, recoverable for 7 days). | |
| 785 | 785 | /// | |
| 786 | 786 | /// Only affects items matching both the given IDs and project. Returns rows affected. | |
| 787 | 787 | #[tracing::instrument(skip_all)] | |
| @@ -793,13 +793,75 @@ pub async fn bulk_delete( | |||
| 793 | 793 | ) -> Result<u64> { | |
| 794 | 794 | let result = sqlx::query( | |
| 795 | 795 | r#" | |
| 796 | - | DELETE FROM items WHERE id = ANY($1) AND project_id = $2 | |
| 796 | + | UPDATE items SET deleted_at = NOW(), is_public = false | |
| 797 | + | WHERE id = ANY($1) AND project_id = $2 AND deleted_at IS NULL | |
| 798 | + | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 799 | + | "#, | |
| 800 | + | ) | |
| 801 | + | .bind(item_ids) | |
| 802 | + | .bind(project_id) | |
| 803 | + | .bind(user_id) | |
| 804 | + | .execute(pool) | |
| 805 | + | .await?; | |
| 806 | + | ||
| 807 | + | Ok(result.rows_affected()) | |
| 808 | + | } | |
| 809 | + | ||
| 810 | + | /// Bulk-update price on selected items. | |
| 811 | + | /// | |
| 812 | + | /// Only affects items matching both the given IDs and project. Returns rows affected. | |
| 813 | + | #[tracing::instrument(skip_all)] | |
| 814 | + | pub async fn bulk_update_price( | |
| 815 | + | pool: &PgPool, | |
| 816 | + | item_ids: &[ItemId], | |
| 817 | + | project_id: ProjectId, | |
| 818 | + | user_id: UserId, | |
| 819 | + | price_cents: i32, | |
| 820 | + | ) -> Result<u64> { | |
| 821 | + | let result = sqlx::query( | |
| 822 | + | r#" | |
| 823 | + | UPDATE items SET price_cents = $4 | |
| 824 | + | WHERE id = ANY($1) AND project_id = $2 | |
| 797 | 825 | AND project_id IN (SELECT id FROM projects WHERE user_id = $3) | |
| 798 | 826 | "#, | |
| 799 | 827 | ) | |
| 800 | 828 | .bind(item_ids) | |
| 801 | 829 | .bind(project_id) | |
| 802 | 830 | .bind(user_id) | |
| 831 | + | .bind(price_cents) | |
| 832 | + | .execute(pool) | |
| 833 | + | .await?; | |
| 834 | + | ||
| 835 | + | Ok(result.rows_affected()) | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | /// Bulk-add a tag to selected items (skips duplicates via ON CONFLICT). | |
| 839 | + | /// | |
| 840 | + | /// Returns number of new tag associations created. | |
| 841 | + | #[tracing::instrument(skip_all)] | |
| 842 | + | pub async fn bulk_add_tag( | |
| 843 | + | pool: &PgPool, | |
| 844 | + | item_ids: &[ItemId], | |
| 845 | + | project_id: ProjectId, | |
| 846 | + | user_id: UserId, | |
| 847 | + | tag_id: super::TagId, | |
| 848 | + | ) -> Result<u64> { | |
| 849 | + | // Verify all items belong to the project owned by this user, | |
| 850 | + | // then insert tag associations for each. | |
| 851 | + | let result = sqlx::query( | |
| 852 | + | r#" | |
| 853 | + | INSERT INTO item_tags (item_id, tag_id) | |
| 854 | + | SELECT i.id, $4 | |
| 855 | + | FROM items i | |
| 856 | + | JOIN projects p ON i.project_id = p.id | |
| 857 | + | WHERE i.id = ANY($1) AND i.project_id = $2 AND p.user_id = $3 | |
| 858 | + | ON CONFLICT (item_id, tag_id) DO NOTHING | |
| 859 | + | "#, | |
| 860 | + | ) | |
| 861 | + | .bind(item_ids) | |
| 862 | + | .bind(project_id) | |
| 863 | + | .bind(user_id) | |
| 864 | + | .bind(tag_id) | |
| 803 | 865 | .execute(pool) | |
| 804 | 866 | .await?; | |
| 805 | 867 |
| @@ -73,6 +73,39 @@ pub async fn get_tags_for_items( | |||
| 73 | 73 | Ok(map) | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | /// Find an existing tag by name (case-insensitive) or create a new one. | |
| 77 | + | #[tracing::instrument(skip_all)] | |
| 78 | + | pub async fn find_or_create_tag(pool: &PgPool, name: &str) -> Result<DbTag> { | |
| 79 | + | // Try to find existing tag first | |
| 80 | + | if let Some(tag) = sqlx::query_as::<_, DbTag>( | |
| 81 | + | "SELECT id, name, slug, parent_id, sort_order, created_at, path FROM tags WHERE lower(name) = lower($1)", | |
| 82 | + | ) | |
| 83 | + | .bind(name) | |
| 84 | + | .fetch_optional(pool) | |
| 85 | + | .await? | |
| 86 | + | { | |
| 87 | + | return Ok(tag); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Create new tag with slug derived from name | |
| 91 | + | let slug = Slug::new(&name.to_lowercase().replace(' ', "-")) | |
| 92 | + | .unwrap_or_else(|_| Slug::new("tag").expect("hardcoded slug")); | |
| 93 | + | let tag = sqlx::query_as::<_, DbTag>( | |
| 94 | + | r#" | |
| 95 | + | INSERT INTO tags (name, slug, path) | |
| 96 | + | VALUES ($1, $2, $1) | |
| 97 | + | ON CONFLICT (slug) DO UPDATE SET name = tags.name | |
| 98 | + | RETURNING id, name, slug, parent_id, sort_order, created_at, path | |
| 99 | + | "#, | |
| 100 | + | ) | |
| 101 | + | .bind(name) | |
| 102 | + | .bind(&slug) | |
| 103 | + | .fetch_one(pool) | |
| 104 | + | .await?; | |
| 105 | + | ||
| 106 | + | Ok(tag) | |
| 107 | + | } | |
| 108 | + | ||
| 76 | 109 | /// Attach a tag to an item. If `is_primary` is true and the item already has | |
| 77 | 110 | /// a primary tag, the old primary is cleared first (within a transaction). | |
| 78 | 111 | #[tracing::instrument(skip_all)] |
| @@ -13,6 +13,7 @@ use crate::{ | |||
| 13 | 13 | error::{AppError, Result}, | |
| 14 | 14 | helpers::htmx_toast_response, | |
| 15 | 15 | AppState, | |
| 16 | + | constants, | |
| 16 | 17 | }; | |
| 17 | 18 | ||
| 18 | 19 | use super::super::{verify_project_ownership}; | |
| @@ -109,23 +110,77 @@ pub(in crate::routes::api) async fn bulk_delete( | |||
| 109 | 110 | user.check_not_suspended()?; | |
| 110 | 111 | let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?; | |
| 111 | 112 | ||
| 112 | - | // Calculate total file bytes before deletion (CASCADE will remove versions) | |
| 113 | - | let mut total_bytes: i64 = 0; | |
| 114 | - | for &id in &req.item_ids { | |
| 115 | - | let file_sizes = db::items::get_item_file_sizes(&state.db, id).await?; | |
| 116 | - | let version_bytes = db::versions::sum_file_sizes_for_item(&state.db, id).await?; | |
| 117 | - | total_bytes += file_sizes.audio_file_size_bytes.unwrap_or(0) | |
| 118 | - | + file_sizes.cover_file_size_bytes.unwrap_or(0) | |
| 119 | - | + file_sizes.video_file_size_bytes.unwrap_or(0) | |
| 120 | - | + version_bytes; | |
| 113 | + | // Soft-delete: items are recoverable for 7 days, then purged by the scheduler | |
| 114 | + | let count = db::items::bulk_delete(&state.db, &req.item_ids, project_id, user.id).await?; | |
| 115 | + | db::projects::bump_cache_generation(&state.db, project_id).await?; | |
| 116 | + | ||
| 117 | + | Ok(htmx_toast_response(&format!("{count} item(s) moved to Recently Deleted"), "success")) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// Form input for bulk price change. | |
| 121 | + | #[derive(Debug, Deserialize)] | |
| 122 | + | pub struct BulkPriceRequest { | |
| 123 | + | #[serde(default)] | |
| 124 | + | pub item_ids: Vec<ItemId>, | |
| 125 | + | pub price_dollars: String, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | /// Bulk-update price on selected items. | |
| 129 | + | #[tracing::instrument(skip_all, name = "items::bulk_price")] | |
| 130 | + | pub(in crate::routes::api) async fn bulk_price( | |
| 131 | + | State(state): State<AppState>, | |
| 132 | + | AuthUser(user): AuthUser, | |
| 133 | + | HtmlForm(req): HtmlForm<BulkPriceRequest>, | |
| 134 | + | ) -> Result<impl IntoResponse> { | |
| 135 | + | user.check_not_suspended()?; | |
| 136 | + | ||
| 137 | + | let price_dollars: f64 = req.price_dollars.trim().parse() | |
| 138 | + | .map_err(|_| AppError::BadRequest("Invalid price".into()))?; | |
| 139 | + | let price_cents = (price_dollars * 100.0).round() as i32; | |
| 140 | + | if price_cents < 0 || price_cents > constants::MAX_PRICE_CENTS { | |
| 141 | + | return Err(AppError::BadRequest(format!( | |
| 142 | + | "Price must be between $0 and ${:.2}", | |
| 143 | + | constants::MAX_PRICE_CENTS as f64 / 100.0 | |
| 144 | + | ))); | |
| 121 | 145 | } | |
| 122 | 146 | ||
| 123 | - | let count = db::items::bulk_delete(&state.db, &req.item_ids, project_id, user.id).await?; | |
| 147 | + | let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?; | |
| 148 | + | let count = db::items::bulk_update_price(&state.db, &req.item_ids, project_id, user.id, price_cents).await?; | |
| 124 | 149 | db::projects::bump_cache_generation(&state.db, project_id).await?; | |
| 125 | 150 | ||
| 126 | - | if total_bytes > 0 { | |
| 127 | - | db::creator_tiers::decrement_storage_used(&state.db, user.id, total_bytes).await?; | |
| 151 | + | let label = if price_cents == 0 { "Free".to_string() } else { format!("${:.2}", price_dollars) }; | |
| 152 | + | Ok(htmx_toast_response(&format!("{count} item(s) set to {label}"), "success")) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | /// Form input for bulk tag addition. | |
| 156 | + | #[derive(Debug, Deserialize)] | |
| 157 | + | pub struct BulkTagRequest { | |
| 158 | + | #[serde(default)] | |
| 159 | + | pub item_ids: Vec<ItemId>, | |
| 160 | + | pub tag_name: String, | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Bulk-add a tag to selected items (creates tag if needed). | |
| 164 | + | #[tracing::instrument(skip_all, name = "items::bulk_tag")] | |
| 165 | + | pub(in crate::routes::api) async fn bulk_tag( | |
| 166 | + | State(state): State<AppState>, | |
| 167 | + | AuthUser(user): AuthUser, | |
| 168 | + | HtmlForm(req): HtmlForm<BulkTagRequest>, | |
| 169 | + | ) -> Result<impl IntoResponse> { | |
| 170 | + | user.check_not_suspended()?; | |
| 171 | + | ||
| 172 | + | let tag_name = req.tag_name.trim().to_lowercase(); | |
| 173 | + | if tag_name.is_empty() || tag_name.len() > 50 { | |
| 174 | + | return Err(AppError::BadRequest("Tag must be 1-50 characters".into())); | |
| 128 | 175 | } | |
| 129 | 176 | ||
| 130 | - | Ok(htmx_toast_response(&format!("{count} item(s) deleted"), "success")) | |
| 177 | + | let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?; | |
| 178 | + | ||
| 179 | + | // Find or create the tag | |
| 180 | + | let tag = db::tags::find_or_create_tag(&state.db, &tag_name).await?; | |
| 181 | + | ||
| 182 | + | let count = db::items::bulk_add_tag(&state.db, &req.item_ids, project_id, user.id, tag.id).await?; | |
| 183 | + | db::projects::bump_cache_generation(&state.db, project_id).await?; | |
| 184 | + | ||
| 185 | + | Ok(htmx_toast_response(&format!("Tag \"{tag_name}\" added to {count} item(s)"), "success")) | |
| 131 | 186 | } |
| @@ -9,7 +9,7 @@ mod sections; | |||
| 9 | 9 | mod tags; | |
| 10 | 10 | mod versions; | |
| 11 | 11 | ||
| 12 | - | pub(super) use bulk::{bulk_delete, bulk_publish, bulk_unpublish}; | |
| 12 | + | pub(super) use bulk::{bulk_delete, bulk_price, bulk_publish, bulk_tag, bulk_unpublish}; | |
| 13 | 13 | pub use bundles::{bundle_add, bundle_remove, bundle_toggle_listed}; | |
| 14 | 14 | pub(super) use chapters::{create_chapter, delete_chapter, list_chapters, update_chapter}; | |
| 15 | 15 | pub(super) use crud::{create_item, delete_item, duplicate_item, move_item, restore_item, update_item}; |
| @@ -230,6 +230,8 @@ pub fn api_routes() -> Router<AppState> { | |||
| 230 | 230 | .route("/api/items/bulk/publish", post(items::bulk_publish)) | |
| 231 | 231 | .route("/api/items/bulk/unpublish", post(items::bulk_unpublish)) | |
| 232 | 232 | .route("/api/items/bulk/delete", post(items::bulk_delete)) | |
| 233 | + | .route("/api/items/bulk/price", post(items::bulk_price)) | |
| 234 | + | .route("/api/items/bulk/tag", post(items::bulk_tag)) | |
| 233 | 235 | .route("/api/items/{id}/move", put(items::move_item)) | |
| 234 | 236 | // Bundle management | |
| 235 | 237 | .route("/api/items/{id}/bundle/add", post(items::bundle_add)) |
| @@ -161,11 +161,23 @@ pub(super) async fn project_tab_content( | |||
| 161 | 161 | ||
| 162 | 162 | let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?; | |
| 163 | 163 | let bundle_map = db::bundles::get_project_bundle_map(&state.db, db_project.id).await?; | |
| 164 | + | let db_deleted = db::items::get_deleted_items_by_project(&state.db, db_project.id).await?; | |
| 164 | 165 | ||
| 165 | 166 | let items = build_content_items_with_bundles(&db_items, &bundle_map); | |
| 167 | + | let deleted_items: Vec<crate::templates::DeletedItemRow> = db_deleted | |
| 168 | + | .iter() | |
| 169 | + | .map(|i| crate::templates::DeletedItemRow { | |
| 170 | + | id: i.id.to_string(), | |
| 171 | + | title: i.title.clone(), | |
| 172 | + | deleted_at: i.deleted_at | |
| 173 | + | .map(|d| d.format("%b %d, %Y").to_string()) | |
| 174 | + | .unwrap_or_default(), | |
| 175 | + | }) | |
| 176 | + | .collect(); | |
| 166 | 177 | ||
| 167 | 178 | Ok(helpers::with_etag(generation, ProjectContentTabTemplate { | |
| 168 | 179 | items, | |
| 180 | + | deleted_items, | |
| 169 | 181 | project_slug: db_project.slug.to_string(), | |
| 170 | 182 | })) | |
| 171 | 183 | } |
| @@ -304,11 +304,19 @@ pub struct ProjectOverviewTabTemplate { | |||
| 304 | 304 | pub has_published_item: bool, | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | + | /// A soft-deleted item for the "Recently Deleted" section. | |
| 308 | + | pub struct DeletedItemRow { | |
| 309 | + | pub id: String, | |
| 310 | + | pub title: String, | |
| 311 | + | pub deleted_at: String, | |
| 312 | + | } | |
| 313 | + | ||
| 307 | 314 | /// Dashboard tab: project content items list. | |
| 308 | 315 | #[derive(Template)] | |
| 309 | 316 | #[template(path = "partials/tabs/project_content.html")] | |
| 310 | 317 | pub struct ProjectContentTabTemplate { | |
| 311 | 318 | pub items: Vec<ContentItem>, | |
| 319 | + | pub deleted_items: Vec<DeletedItemRow>, | |
| 312 | 320 | pub project_slug: String, | |
| 313 | 321 | } | |
| 314 | 322 |
| @@ -25,13 +25,36 @@ | |||
| 25 | 25 | {% for item in items %}<option value="{{ item.item_type }}" class="content-type-opt">{{ item.item_type }}</option>{% endfor %} | |
| 26 | 26 | </select> | |
| 27 | 27 | </div> | |
| 28 | - | <p class="form-hint" style="margin-bottom: 0.75rem;">Select items with checkboxes for bulk publish, unpublish, or delete.</p> | |
| 29 | - | <div id="bulk-action-bar" style="display: none; padding: 0.75rem 1rem; margin-bottom: 1rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 4px; align-items: center; gap: 1rem;"> | |
| 30 | - | <span id="bulk-count" style="font-weight: bold;"></span> | |
| 31 | - | <button class="secondary small" onclick="bulkAction('publish')">Publish</button> | |
| 32 | - | <button class="secondary small" onclick="bulkAction('unpublish')">Unpublish</button> | |
| 33 | - | <button class="secondary small" style="color: var(--danger);" onclick="bulkAction('delete')">Delete</button> | |
| 34 | - | <button class="secondary small" style="margin-left: auto;" onclick="toggleSelectAll(false)">Deselect All</button> | |
| 28 | + | <div id="bulk-action-bar" style="padding: 0.75rem 1rem; margin-bottom: 1rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 4px; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; opacity: 0.4;" data-active="false"> | |
| 29 | + | <span id="bulk-count" style="font-weight: bold; min-width: 5rem;">0 selected</span> | |
| 30 | + | <button class="secondary small" onclick="bulkAction('publish')" disabled>Publish</button> | |
| 31 | + | <button class="secondary small" onclick="bulkAction('unpublish')" disabled>Unpublish</button> | |
| 32 | + | <button class="secondary small" onclick="showBulkPrice()" disabled id="bulk-price-btn">Set Price</button> | |
| 33 | + | <button class="secondary small" onclick="showBulkTag()" disabled id="bulk-tag-btn">Add Tag</button> | |
| 34 | + | <button class="secondary small" style="color: var(--danger);" onclick="bulkAction('delete')" disabled>Delete</button> | |
| 35 | + | <button class="secondary small" style="margin-left: auto;" onclick="toggleSelectAll(false)" disabled>Deselect</button> | |
| 36 | + | </div> | |
| 37 | + | <div id="bulk-price-form" style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; background: var(--surface-muted); border: 1px solid var(--border); border-radius: 4px;"> | |
| 38 | + | <div style="display: flex; gap: 0.75rem; align-items: end;"> | |
| 39 | + | <div> | |
| 40 | + | <label style="font-size: 0.85rem; display: block; margin-bottom: 0.25rem;">New price ($)</label> | |
| 41 | + | <input type="number" id="bulk-price-input" min="0" step="0.01" placeholder="0.00" style="width: 120px; padding: 0.3rem;"> | |
| 42 | + | </div> | |
| 43 | + | <button class="primary small" onclick="submitBulkPrice()">Apply</button> | |
| 44 | + | <button class="secondary small" onclick="document.getElementById('bulk-price-form').style.display='none'">Cancel</button> | |
| 45 | + | </div> | |
| 46 | + | <p class="form-hint" style="margin-top: 0.5rem;">Enter 0 to make items free.</p> | |
| 47 | + | </div> | |
| 48 | + | <div id="bulk-tag-form" style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; background: var(--surface-muted); border: 1px solid var(--border); border-radius: 4px;"> | |
| 49 | + | <div style="display: flex; gap: 0.75rem; align-items: end;"> | |
| 50 | + | <div> | |
| 51 | + | <label style="font-size: 0.85rem; display: block; margin-bottom: 0.25rem;">Tag name</label> | |
| 52 | + | <input type="text" id="bulk-tag-input" placeholder="e.g. ambient" style="width: 200px; padding: 0.3rem;"> | |
| 53 | + | </div> | |
| 54 | + | <button class="primary small" onclick="submitBulkTag()">Apply</button> | |
| 55 | + | <button class="secondary small" onclick="document.getElementById('bulk-tag-form').style.display='none'">Cancel</button> | |
| 56 | + | </div> | |
| 57 | + | <p class="form-hint" style="margin-top: 0.5rem;">Creates the tag if it doesn't exist.</p> | |
| 35 | 58 | </div> | |
| 36 | 59 | <table class="data-table"> | |
| 37 | 60 | <thead> | |
| @@ -122,6 +145,36 @@ | |||
| 122 | 145 | </table> | |
| 123 | 146 | {% endif %} | |
| 124 | 147 | ||
| 148 | + | {% if !deleted_items.is_empty() %} | |
| 149 | + | <details style="margin-top: 2rem;"> | |
| 150 | + | <summary style="cursor: pointer; font-size: 0.95rem; opacity: 0.7;">Recently Deleted ({{ deleted_items.len() }})</summary> | |
| 151 | + | <p class="form-hint" style="margin-top: 0.5rem;">Deleted items are permanently removed after 7 days.</p> | |
| 152 | + | <table class="data-table" style="margin-top: 0.75rem;"> | |
| 153 | + | <thead> | |
| 154 | + | <tr> | |
| 155 | + | <th>Title</th> | |
| 156 | + | <th>Deleted</th> | |
| 157 | + | <th></th> | |
| 158 | + | </tr> | |
| 159 | + | </thead> | |
| 160 | + | <tbody> | |
| 161 | + | {% for item in deleted_items %} | |
| 162 | + | <tr id="deleted-{{ item.id }}"> | |
| 163 | + | <td style="opacity: 0.6;">{{ item.title }}</td> | |
| 164 | + | <td style="opacity: 0.6;">{{ item.deleted_at }}</td> | |
| 165 | + | <td> | |
| 166 | + | <button class="secondary small" | |
| 167 | + | hx-post="/api/items/{{ item.id }}/restore" | |
| 168 | + | hx-swap="none" | |
| 169 | + | hx-on::after-request="if(event.detail.successful) htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content')">Restore</button> | |
| 170 | + | </td> | |
| 171 | + | </tr> | |
| 172 | + | {% endfor %} | |
| 173 | + | </tbody> | |
| 174 | + | </table> | |
| 175 | + | </details> | |
| 176 | + | {% endif %} | |
| 177 | + | ||
| 125 | 178 | <script> | |
| 126 | 179 | function toggleSelectAll(checked) { | |
| 127 | 180 | var boxes = document.querySelectorAll('.bulk-check'); | |
| @@ -138,13 +191,18 @@ function updateBulkUI() { | |||
| 138 | 191 | var selectAll = document.getElementById('select-all'); | |
| 139 | 192 | var allBoxes = document.querySelectorAll('.bulk-check'); | |
| 140 | 193 | if (!bar) return; | |
| 141 | - | if (checked.length > 0) { | |
| 142 | - | bar.style.display = 'flex'; | |
| 143 | - | countEl.textContent = checked.length + ' selected'; | |
| 144 | - | } else { | |
| 145 | - | bar.style.display = 'none'; | |
| 146 | - | } | |
| 194 | + | var hasSelection = checked.length > 0; | |
| 195 | + | bar.style.opacity = hasSelection ? '1' : '0.4'; | |
| 196 | + | bar.dataset.active = hasSelection ? 'true' : 'false'; | |
| 197 | + | countEl.textContent = checked.length + ' selected'; | |
| 198 | + | var buttons = bar.querySelectorAll('button'); | |
| 199 | + | for (var i = 0; i < buttons.length; i++) buttons[i].disabled = !hasSelection; | |
| 147 | 200 | if (selectAll) selectAll.checked = allBoxes.length > 0 && checked.length === allBoxes.length; | |
| 201 | + | // Hide inline forms when selection changes | |
| 202 | + | if (!hasSelection) { | |
| 203 | + | document.getElementById('bulk-price-form').style.display = 'none'; | |
| 204 | + | document.getElementById('bulk-tag-form').style.display = 'none'; | |
| 205 | + | } | |
| 148 | 206 | } | |
| 149 | 207 | ||
| 150 | 208 | function bulkAction(action) { | |
| @@ -210,6 +268,73 @@ function quickPublish(itemId) { | |||
| 210 | 268 | }); | |
| 211 | 269 | } | |
| 212 | 270 | ||
| 271 | + | function showBulkPrice() { | |
| 272 | + | document.getElementById('bulk-price-form').style.display = 'block'; | |
| 273 | + | document.getElementById('bulk-tag-form').style.display = 'none'; | |
| 274 | + | document.getElementById('bulk-price-input').focus(); | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | function showBulkTag() { | |
| 278 | + | document.getElementById('bulk-tag-form').style.display = 'block'; | |
| 279 | + | document.getElementById('bulk-price-form').style.display = 'none'; | |
| 280 | + | document.getElementById('bulk-tag-input').focus(); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | function submitBulkPrice() { | |
| 284 | + | var checked = document.querySelectorAll('.bulk-check:checked'); | |
| 285 | + | if (checked.length === 0) return; | |
| 286 | + | var price = document.getElementById('bulk-price-input').value; | |
| 287 | + | if (price === '') { showToast('Enter a price'); return; } | |
| 288 | + | ||
| 289 | + | var params = new URLSearchParams(); | |
| 290 | + | for (var i = 0; i < checked.length; i++) params.append('item_ids', checked[i].value); | |
| 291 | + | params.append('price_dollars', price); | |
| 292 | + | ||
| 293 | + | fetch('/api/items/bulk/price', { | |
| 294 | + | method: 'POST', | |
| 295 | + | headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), | |
| 296 | + | body: params.toString() | |
| 297 | + | }) | |
| 298 | + | .then(function(r) { | |
| 299 | + | if (!r.ok) return r.text().then(function(t) { | |
| 300 | + | var msg = 'Failed'; | |
| 301 | + | try { var p = JSON.parse(t); if (p.error) msg = p.error; } catch (_) {} | |
| 302 | + | throw new Error(msg); | |
| 303 | + | }); | |
| 304 | + | document.getElementById('bulk-price-form').style.display = 'none'; | |
| 305 | + | htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content'); | |
| 306 | + | }) | |
| 307 | + | .catch(function(err) { showToast(err.message || 'Price update failed'); }); | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | function submitBulkTag() { | |
| 311 | + | var checked = document.querySelectorAll('.bulk-check:checked'); | |
| 312 | + | if (checked.length === 0) return; | |
| 313 | + | var tag = document.getElementById('bulk-tag-input').value.trim(); | |
| 314 | + | if (!tag) { showToast('Enter a tag name'); return; } | |
| 315 | + | ||
| 316 | + | var params = new URLSearchParams(); | |
| 317 | + | for (var i = 0; i < checked.length; i++) params.append('item_ids', checked[i].value); | |
| 318 | + | params.append('tag_name', tag); | |
| 319 | + | ||
| 320 | + | fetch('/api/items/bulk/tag', { | |
| 321 | + | method: 'POST', | |
| 322 | + | headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), | |
| 323 | + | body: params.toString() | |
| 324 | + | }) | |
| 325 | + | .then(function(r) { | |
| 326 | + | if (!r.ok) return r.text().then(function(t) { | |
| 327 | + | var msg = 'Failed'; | |
| 328 | + | try { var p = JSON.parse(t); if (p.error) msg = p.error; } catch (_) {} | |
| 329 | + | throw new Error(msg); | |
| 330 | + | }); | |
| 331 | + | document.getElementById('bulk-tag-form').style.display = 'none'; | |
| 332 | + | document.getElementById('bulk-tag-input').value = ''; | |
| 333 | + | htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content'); | |
| 334 | + | }) | |
| 335 | + | .catch(function(err) { showToast(err.message || 'Tag operation failed'); }); | |
| 336 | + | } | |
| 337 | + | ||
| 213 | 338 | function toggleBundleChildren(bundleId) { | |
| 214 | 339 | var rows = document.querySelectorAll('.bundle-child-' + bundleId); | |
| 215 | 340 | var btn = document.querySelector('tr:has(.bulk-check[value="' + bundleId + '"]) .bundle-toggle'); |