Skip to main content

max / makenotwork

Add inline bundle item creation UI New table-based bundle contents view with "Add Item" button that creates child items inline (name + description). Items are created as unlisted Digital items, published, and added to the bundle in one step. File uploads are managed per-item via the "Manage files" link. New endpoint: POST /api/items/{id}/bundle/create-child Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-10 18:43 UTC
Commit: 39b0539dbefbba26671018d4e8940c9c9246f725
Parent: ad1798f
5 files changed, +225 insertions, -98 deletions
@@ -6,7 +6,7 @@ use axum::{
6 6 response::IntoResponse,
7 7 Json,
8 8 };
9 - use serde::Deserialize;
9 + use serde::{Deserialize, Serialize};
10 10
11 11 use crate::{
12 12 auth::AuthUser,
@@ -90,3 +90,62 @@ pub async fn bundle_toggle_listed(
90 90 db::bundles::set_item_listed(&state.db, child_id, req.listed).await?;
91 91 Ok(StatusCode::OK)
92 92 }
93 +
94 + #[derive(Debug, Deserialize)]
95 + pub struct BundleCreateChildRequest {
96 + pub title: String,
97 + pub description: Option<String>,
98 + }
99 +
100 + #[derive(Debug, Serialize)]
101 + pub struct BundleCreateChildResponse {
102 + pub item_id: ItemId,
103 + pub title: String,
104 + }
105 +
106 + /// POST /api/items/{id}/bundle/create-child — create a new item, add to bundle, set unlisted.
107 + #[tracing::instrument(skip_all, name = "items::bundle_create_child")]
108 + pub async fn bundle_create_child(
109 + State(state): State<AppState>,
110 + AuthUser(user): AuthUser,
111 + Path(bundle_id): Path<ItemId>,
112 + Json(req): Json<BundleCreateChildRequest>,
113 + ) -> Result<impl IntoResponse> {
114 + user.check_not_suspended()?;
115 + let (bundle, _project) = verify_item_ownership(&state, bundle_id, user.id).await?;
116 + if bundle.item_type != ItemType::Bundle {
117 + return Err(AppError::BadRequest("Item is not a bundle".to_string()));
118 + }
119 +
120 + crate::validation::validate_item_title(&req.title)?;
121 + if let Some(ref desc) = req.description {
122 + crate::validation::validate_item_description(desc)?;
123 + }
124 +
125 + let child = db::items::create_item(
126 + &state.db,
127 + bundle.project_id,
128 + &req.title,
129 + req.description.as_deref(),
130 + crate::db::PriceCents::from_db(0),
131 + ItemType::Digital,
132 + crate::db::AiTier::Handmade,
133 + None,
134 + )
135 + .await?;
136 +
137 + // Add to bundle and set unlisted
138 + let count = db::bundles::get_bundle_item_count(&state.db, bundle_id).await?;
139 + db::bundles::add_item_to_bundle(&state.db, bundle_id, child.id, count as i32).await?;
140 + db::bundles::set_item_listed(&state.db, child.id, false).await?;
141 +
142 + // Publish the child so it's downloadable via the bundle
143 + db::items::bulk_publish(&state.db, &[child.id], bundle.project_id, user.id).await?;
144 +
145 + db::projects::bump_cache_generation(&state.db, bundle.project_id).await?;
146 +
147 + Ok(Json(BundleCreateChildResponse {
148 + item_id: child.id,
149 + title: child.title,
150 + }))
151 + }
@@ -10,7 +10,7 @@ mod tags;
10 10 mod versions;
11 11
12 12 pub(super) use bulk::{bulk_delete, bulk_price, bulk_publish, bulk_tag, bulk_unpublish};
13 - pub use bundles::{bundle_add, bundle_remove, bundle_toggle_listed};
13 + pub use bundles::{bundle_add, bundle_create_child, 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};
16 16 pub(super) use refund::refund_transaction;
@@ -235,6 +235,7 @@ pub fn api_routes() -> Router<AppState> {
235 235 .route("/api/items/{id}/move", put(items::move_item))
236 236 // Bundle management
237 237 .route("/api/items/{id}/bundle/add", post(items::bundle_add))
238 + .route("/api/items/{id}/bundle/create-child", post(items::bundle_create_child))
238 239 .route("/api/items/{id}/bundle/{child_id}", delete(items::bundle_remove))
239 240 .route("/api/items/{id}/bundle/{child_id}/listed", put(items::bundle_toggle_listed))
240 241 // Refund
@@ -20,89 +20,135 @@
20 20 // ── Bundles ──
21 21
22 22 function initBundles(bundleId) {
23 + // "Add existing item" dropdown
23 24 var addBtn = document.getElementById('bundle-add-btn');
24 - if (!addBtn) return;
25 -
26 - addBtn.addEventListener('click', function() {
27 - var select = document.getElementById('bundle-add-select');
28 - var itemId = select.value;
29 - if (!itemId) return;
30 - var label = select.options[select.selectedIndex].text;
31 - this.disabled = true;
32 -
33 - fetch('/api/items/' + bundleId + '/bundle/add', {
34 - method: 'POST',
35 - headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
36 - body: JSON.stringify({ item_id: itemId })
37 - })
38 - .then(function(res) {
39 - if (!res.ok) throw new Error('Failed to add');
40 - select.remove(select.selectedIndex);
41 - select.value = '';
42 - var empty = document.getElementById('bundle-empty');
43 - if (empty) empty.remove();
44 -
45 - var titleMatch = label.match(/^(.+)\s+\((.+)\)$/);
46 - var title = titleMatch ? titleMatch[1] : label;
47 - var type = titleMatch ? titleMatch[2] : '';
25 + if (addBtn) {
26 + addBtn.addEventListener('click', function() {
27 + var select = document.getElementById('bundle-add-select');
28 + var itemId = select.value;
29 + if (!itemId) return;
30 + this.disabled = true;
31 +
32 + fetch('/api/items/' + bundleId + '/bundle/add', {
33 + method: 'POST',
34 + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
35 + body: JSON.stringify({ item_id: itemId })
36 + })
37 + .then(function(res) {
38 + if (!res.ok) throw new Error('Failed to add');
39 + select.remove(select.selectedIndex);
40 + select.value = '';
41 + var empty = document.getElementById('bundle-empty');
42 + if (empty) empty.remove();
43 + // Reload the tab to show updated table
44 + var detailsBtn = document.getElementById('tab-details');
45 + if (detailsBtn) detailsBtn.click();
46 + })
47 + .catch(function(err) { showToast(err.message); })
48 + .finally(function() { addBtn.disabled = false; });
49 + });
50 + }
48 51
52 + // "Add Item" row button
53 + var addRowBtn = document.getElementById('bundle-add-row-btn');
54 + if (addRowBtn) {
55 + addRowBtn.addEventListener('click', function() {
56 + var container = document.getElementById('bundle-new-rows');
49 57 var row = document.createElement('div');
50 - row.className = 'bundle-row';
51 - row.dataset.childId = itemId;
52 - row.style.cssText = 'display:flex;align-items:center;gap:0.75rem;padding:0.6rem 0;border-bottom:1px solid var(--border);';
58 + row.style.cssText = 'display: grid; grid-template-columns: 1fr 1.5fr auto; gap: 0.5rem; align-items: start; padding: 0.5rem 0; border-bottom: 1px solid var(--border);';
53 59 row.innerHTML =
54 - '<span style="font-size:0.8rem;padding:0.2rem 0.6rem;background:var(--surface-muted);white-space:nowrap;">' + escapeHtml(type) + '</span>' +
55 - '<span style="flex:1;">' + escapeHtml(title) + '</span>' +
56 - '<label style="font-size:0.8rem;display:flex;align-items:center;gap:0.25rem;cursor:pointer;">' +
57 - '<input type="checkbox" class="bundle-listed-toggle" data-child-id="' + itemId + '"> Unlisted</label>' +
58 - '<button type="button" class="secondary bundle-remove-btn" data-child-id="' + itemId + '" style="padding:0.25rem 0.6rem;font-size:0.8rem;">Remove</button>';
59 - document.getElementById('bundle-items-list').appendChild(row);
60 - updateBundleCount(1);
61 - attachBundleRowHandlers(row, bundleId);
62 - })
63 - .catch(function(err) { showToast(err.message); })
64 - .finally(function() { addBtn.disabled = false; });
65 - });
60 + '<input type="text" class="bundle-new-title" placeholder="Item name" autocomplete="off">' +
61 + '<input type="text" class="bundle-new-desc" placeholder="Description (optional)" autocomplete="off">' +
62 + '<div style="display: flex; gap: 0.25rem;">' +
63 + '<button type="button" class="primary bundle-create-btn" style="padding: 0.3rem 0.7rem; font-size: 0.85rem;">Create</button>' +
64 + '<button type="button" class="secondary bundle-cancel-btn" style="padding: 0.3rem 0.5rem; font-size: 0.85rem;">Cancel</button>' +
65 + '</div>';
66 + container.appendChild(row);
67 +
68 + var titleInput = row.querySelector('.bundle-new-title');
69 + titleInput.focus();
70 +
71 + row.querySelector('.bundle-cancel-btn').addEventListener('click', function() {
72 + row.remove();
73 + });
74 +
75 + row.querySelector('.bundle-create-btn').addEventListener('click', function() {
76 + var title = titleInput.value.trim();
77 + if (!title) { titleInput.focus(); return; }
78 + var desc = row.querySelector('.bundle-new-desc').value.trim();
79 + var btn = this;
80 + btn.disabled = true;
81 + btn.textContent = '...';
82 +
83 + fetch('/api/items/' + bundleId + '/bundle/create-child', {
84 + method: 'POST',
85 + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
86 + body: JSON.stringify({ title: title, description: desc || null })
87 + })
88 + .then(function(res) {
89 + if (!res.ok) return res.json().then(function(d) { throw new Error(d.error || 'Failed'); });
90 + return res.json();
91 + })
92 + .then(function(data) {
93 + row.remove();
94 + var empty = document.getElementById('bundle-empty');
95 + if (empty) empty.remove();
96 +
97 + // Add to table
98 + var tbody = document.getElementById('bundle-items-list');
99 + var tr = document.createElement('tr');
100 + tr.className = 'bundle-row';
101 + tr.dataset.childId = data.item_id;
102 + tr.style.borderBottom = '1px solid var(--border)';
103 + tr.innerHTML =
104 + '<td style="padding: 0.5rem 0.5rem 0.5rem 0;"><a href="/dashboard/item/' + data.item_id + '">' + escapeHtml(data.title) + '</a></td>' +
105 + '<td style="padding: 0.5rem; font-size: 0.85rem; opacity: 0.8;">' + escapeHtml(desc) + '</td>' +
106 + '<td style="padding: 0.5rem; font-size: 0.85rem;"><a href="/dashboard/item/' + data.item_id + '" style="font-size: 0.8rem;">Manage files</a></td>' +
107 + '<td style="padding: 0.5rem;"><button type="button" class="secondary bundle-remove-btn" data-child-id="' + data.item_id + '" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;">Remove</button></td>';
108 + tbody.appendChild(tr);
109 + attachBundleRowHandlers(tr, bundleId);
110 + updateBundleCount(1);
111 + })
112 + .catch(function(err) {
113 + btn.disabled = false;
114 + btn.textContent = 'Create';
115 + showToast(err.message);
116 + });
117 + });
118 +
119 + // Enter key creates
120 + titleInput.addEventListener('keydown', function(e) {
121 + if (e.key === 'Enter') { e.preventDefault(); row.querySelector('.bundle-create-btn').click(); }
122 + });
123 + row.querySelector('.bundle-new-desc').addEventListener('keydown', function(e) {
124 + if (e.key === 'Enter') { e.preventDefault(); row.querySelector('.bundle-create-btn').click(); }
125 + });
126 + });
127 + }
66 128
129 + // Attach handlers to existing rows
67 130 document.querySelectorAll('.bundle-row').forEach(function(row) {
68 131 attachBundleRowHandlers(row, bundleId);
69 132 });
70 133 }
71 134
72 135 function attachBundleRowHandlers(row, bundleId) {
73 - row.querySelector('.bundle-remove-btn')?.addEventListener('click', function() {
74 - var childId = this.dataset.childId;
75 - fetch('/api/items/' + bundleId + '/bundle/' + childId, {
76 - method: 'DELETE',
77 - headers: csrfHeaders()
78 - })
79 - .then(function(res) {
80 - if (!res.ok) throw new Error('Failed to remove');
81 - var title = row.querySelector('span[style*="flex"]').textContent;
82 - var type = row.querySelector('span[style*="surface-muted"]').textContent;
83 - var select = document.getElementById('bundle-add-select');
84 - if (select) {
85 - var opt = document.createElement('option');
86 - opt.value = childId;
87 - opt.textContent = title + ' (' + type + ')';
88 - select.appendChild(opt);
89 - }
90 - row.remove();
91 - updateBundleCount(-1);
92 - })
93 - .catch(function(err) { showToast(err.message); });
94 - });
95 -
96 - row.querySelector('.bundle-listed-toggle')?.addEventListener('change', function() {
97 - var childId = this.dataset.childId;
98 - var listed = !this.checked;
99 - fetch('/api/items/' + bundleId + '/bundle/' + childId + '/listed', {
100 - method: 'PUT',
101 - headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
102 - body: JSON.stringify({ listed: listed })
103 - })
104 - .catch(function(err) { showToast(err.message); });
105 - });
136 + var removeBtn = row.querySelector('.bundle-remove-btn');
137 + if (removeBtn) {
138 + removeBtn.addEventListener('click', function() {
139 + var childId = this.dataset.childId;
140 + fetch('/api/items/' + bundleId + '/bundle/' + childId, {
141 + method: 'DELETE',
142 + headers: csrfHeaders()
143 + })
144 + .then(function(res) {
145 + if (!res.ok) throw new Error('Failed to remove');
146 + row.remove();
147 + updateBundleCount(-1);
148 + })
149 + .catch(function(err) { showToast(err.message); });
150 + });
151 + }
106 152 }
107 153
108 154 function updateBundleCount(delta) {
@@ -138,35 +138,56 @@
138 138 <h2>Bundle Contents (<span id="bundle-count">{{ bundle_items.len() }}</span> items)</h2>
139 139 </div>
140 140
141 - <div id="bundle-items-list">
141 + <table style="width: 100%; border-collapse: collapse;" id="bundle-table">
142 + <thead>
143 + <tr style="text-align: left; font-size: 0.8rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.03em;">
144 + <th style="padding: 0.5rem 0.5rem 0.5rem 0;">Item</th>
145 + <th style="padding: 0.5rem;">Description</th>
146 + <th style="padding: 0.5rem;">File</th>
147 + <th style="padding: 0.5rem; width: 60px;"></th>
148 + </tr>
149 + </thead>
150 + <tbody id="bundle-items-list">
151 + {% for child in bundle_items %}
152 + <tr class="bundle-row" data-child-id="{{ child.id }}" style="border-bottom: 1px solid var(--border);">
153 + <td style="padding: 0.5rem 0.5rem 0.5rem 0;"><a href="/dashboard/item/{{ child.id }}">{{ child.title }}</a></td>
154 + <td style="padding: 0.5rem; font-size: 0.85rem; opacity: 0.8;">{% if let Some(desc) = child.description %}{{ desc }}{% endif %}</td>
155 + <td style="padding: 0.5rem; font-size: 0.85rem;">
156 + <a href="/dashboard/item/{{ child.id }}" style="font-size: 0.8rem;">Manage files</a>
157 + </td>
158 + <td style="padding: 0.5rem;">
159 + <button type="button" class="secondary bundle-remove-btn" data-child-id="{{ child.id }}"
160 + style="padding: 0.2rem 0.5rem; font-size: 0.75rem;">Remove</button>
161 + </td>
162 + </tr>
163 + {% endfor %}
164 + </tbody>
165 + </table>
166 +
142 167 {% if bundle_items.is_empty() %}
143 - <p id="bundle-empty" style="opacity: 0.7;">No items in this bundle yet.</p>
144 - {% else %}
145 - {% for child in bundle_items %}
146 - <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);">
147 - <span style="font-size: 0.8rem; padding: 0.2rem 0.6rem; background: var(--surface-muted); white-space: nowrap;">{{ child.item_type }}</span>
148 - <span style="flex: 1;">{{ child.title }}</span>
149 - <label style="font-size: 0.8rem; display: flex; align-items: center; gap: 0.25rem; cursor: pointer;">
150 - <input type="checkbox" class="bundle-listed-toggle" data-child-id="{{ child.id }}"
151 - {% if !child.listed %}checked{% endif %}> Unlisted
152 - </label>
153 - <button type="button" class="secondary bundle-remove-btn" data-child-id="{{ child.id }}"
154 - style="padding: 0.25rem 0.6rem; font-size: 0.8rem;">Remove</button>
155 - </div>
156 - {% endfor %}
168 + <p id="bundle-empty" style="opacity: 0.7;">No items in this bundle yet. Add a row below.</p>
157 169 {% endif %}
170 +
171 + <div id="bundle-new-rows"></div>
172 +
173 + <div style="margin-top: 1rem; display: flex; gap: 0.75rem; align-items: center;">
174 + <button type="button" class="secondary" id="bundle-add-row-btn" style="padding: 0.4rem 0.8rem;">Add Item</button>
175 + <span id="bundle-status" style="font-size: 0.85rem;"></span>
158 176 </div>
159 177
160 178 {% if !bundleable_items.is_empty() %}
161 - <div style="margin-top: 1rem; display: flex; gap: 0.5rem; align-items: center;">
162 - <select id="bundle-add-select" style="flex: 1; padding: 0.4rem;">
163 - <option value="">Add item to bundle...</option>
164 - {% for avail in bundleable_items %}
165 - <option value="{{ avail.id }}">{{ avail.title }} ({{ avail.item_type }})</option>
166 - {% endfor %}
167 - </select>
168 - <button type="button" class="secondary" id="bundle-add-btn" style="padding: 0.4rem 0.8rem;">Add</button>
169 - </div>
179 + <details style="margin-top: 1rem;">
180 + <summary style="cursor: pointer; font-size: 0.85rem; opacity: 0.7;">Add existing item</summary>
181 + <div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center;">
182 + <select id="bundle-add-select" style="flex: 1; padding: 0.4rem;">
183 + <option value="">Select an item...</option>
184 + {% for avail in bundleable_items %}
185 + <option value="{{ avail.id }}">{{ avail.title }} ({{ avail.item_type }})</option>
186 + {% endfor %}
187 + </select>
188 + <button type="button" class="secondary" id="bundle-add-btn" style="padding: 0.4rem 0.8rem;">Add</button>
189 + </div>
190 + </details>
170 191 {% endif %}
171 192 </div>
172 193