Skip to main content

max / makenotwork

Catalog power tools and sprint 2 (v0.5.2) Bulk operations: bulk price change (POST /api/items/bulk/price) and bulk tag addition (POST /api/items/bulk/tag) with find-or-create tag logic. Inline forms in content tab for both operations. Soft delete: bulk delete now sets deleted_at + is_public=false instead of hard deleting. "Recently Deleted" collapsible section in content tab with per-item restore buttons. Existing scheduler purges after 7 days. Bulk action bar always visible (grayed out, enables on checkbox selection). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 21:17 UTC
Commit: 9891861502c77490bf2365263dfd2c3c9f626bb7
Parent: a79dbba
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');