Skip to main content

max / makenotwork

v0.3.13: Fix uploads/deletion, bundle grouping on dashboard - Add .msi to allowed download extensions and MIME types - Fix bulk actions (delete/publish/unpublish) — was sending multipart/form-data but handler expects url-encoded - Add storage tracking to bulk delete (was missing) - Improve error messages: status-code fallback instead of JSON.parse on HTML error pages - Bundle children shown as collapsible rows under parent bundle on project content tab (unlisted children hidden from top level) - Better error surfacing in batch file upload (extractError helper) - Add .aac to allowed audio types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 00:24 UTC
Commit: 6e3ff439a045ca1b569b27eec7faa5294b8de72f
Parent: bee5f45
10 files changed, +185 insertions, -29 deletions
@@ -3350,7 +3350,7 @@ dependencies = [
3350 3350
3351 3351 [[package]]
3352 3352 name = "makenotwork"
3353 - version = "0.3.11"
3353 + version = "0.3.13"
3354 3354 dependencies = [
3355 3355 "anyhow",
3356 3356 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.12"
3 + version = "0.3.13"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -177,6 +177,29 @@ pub async fn get_bundle_item_count(pool: &PgPool, bundle_id: ItemId) -> Result<i
177 177 Ok(count)
178 178 }
179 179
180 + /// Get all bundle→child relationships for items within a project.
181 + ///
182 + /// Returns `(bundle_id, child_item_id)` pairs ordered by bundle then sort order.
183 + pub async fn get_project_bundle_map(
184 + pool: &PgPool,
185 + project_id: ProjectId,
186 + ) -> Result<Vec<(ItemId, ItemId)>> {
187 + let rows: Vec<(ItemId, ItemId)> = sqlx::query_as(
188 + r#"
189 + SELECT bi.bundle_id, bi.item_id
190 + FROM bundle_items bi
191 + JOIN items i ON i.id = bi.bundle_id
192 + WHERE i.project_id = $1
193 + ORDER BY bi.bundle_id, bi.sort_order
194 + "#,
195 + )
196 + .bind(project_id)
197 + .fetch_all(pool)
198 + .await?;
199 +
200 + Ok(rows)
201 + }
202 +
180 203 /// Set the `listed` flag on an item.
181 204 pub async fn set_item_listed(pool: &PgPool, item_id: ItemId, listed: bool) -> Result<()> {
182 205 sqlx::query("UPDATE items SET listed = $2 WHERE id = $1")
@@ -409,9 +409,23 @@ pub(super) async fn bulk_delete(
409 409 user.check_not_suspended()?;
410 410 let project_id = verify_bulk_ownership(&state, &req.item_ids, user.id).await?;
411 411
412 + // Calculate total file bytes before deletion (CASCADE will remove versions)
413 + let mut total_bytes: i64 = 0;
414 + for &id in &req.item_ids {
415 + let file_sizes = db::items::get_item_file_sizes(&state.db, id).await?;
416 + let version_bytes = db::versions::sum_file_sizes_for_item(&state.db, id).await?;
417 + total_bytes += file_sizes.audio_file_size_bytes.unwrap_or(0)
418 + + file_sizes.cover_file_size_bytes.unwrap_or(0)
419 + + version_bytes;
420 + }
421 +
412 422 let count = db::items::bulk_delete(&state.db, &req.item_ids, project_id).await?;
413 423 db::projects::bump_cache_generation(&state.db, project_id).await?;
414 424
425 + if total_bytes > 0 {
426 + db::creator_tiers::decrement_storage_used(&state.db, user.id, total_bytes).await?;
427 + }
428 +
415 429 Ok(htmx_toast_response(&format!("{count} item(s) deleted"), "success"))
416 430 }
417 431
@@ -4,9 +4,11 @@ use axum::extract::{Path, Query, State};
4 4 use axum::http::HeaderMap;
5 5 use axum::response::IntoResponse;
6 6
7 + use std::collections::{HashMap, HashSet};
8 +
7 9 use crate::{
8 10 auth::AuthUser,
9 - db::{self, analytics::TimeRange, Slug},
11 + db::{self, analytics::TimeRange, ItemId, Slug},
10 12 error::{AppError, Result},
11 13 helpers,
12 14 templates::*,
@@ -16,6 +18,58 @@ use crate::{
16 18
17 19 use super::AnalyticsQuery;
18 20
21 + /// Build the content items list with bundle children nested under their parent.
22 + ///
23 + /// Unlisted items that belong to a bundle are shown only as children of that bundle,
24 + /// not as top-level rows. Listed items always appear at the top level even if they
25 + /// are also in a bundle.
26 + fn build_content_items_with_bundles(
27 + db_items: &[db::DbItem],
28 + bundle_map: &[(ItemId, ItemId)],
29 + ) -> Vec<ContentItem> {
30 + // Build bundle_id → [child_item_id] map
31 + let mut children_of: HashMap<ItemId, Vec<ItemId>> = HashMap::new();
32 + let mut child_to_bundle: HashMap<ItemId, ItemId> = HashMap::new();
33 + for &(bundle_id, child_id) in bundle_map {
34 + children_of.entry(bundle_id).or_default().push(child_id);
35 + child_to_bundle.insert(child_id, bundle_id);
36 + }
37 +
38 + // Index all items by ID for lookup
39 + let item_by_id: HashMap<ItemId, &db::DbItem> = db_items.iter().map(|i| (i.id, i)).collect();
40 +
41 + // Items that are unlisted AND belong to a bundle should be hidden from top level
42 + let hidden_at_top: HashSet<ItemId> = db_items
43 + .iter()
44 + .filter(|i| !i.listed && child_to_bundle.contains_key(&i.id))
45 + .map(|i| i.id)
46 + .collect();
47 +
48 + let mut items = Vec::new();
49 + let mut pos = 1u32;
50 + for db_item in db_items {
51 + if hidden_at_top.contains(&db_item.id) {
52 + continue;
53 + }
54 +
55 + let mut content_item = ContentItem::from_db(db_item, pos);
56 + pos += 1;
57 +
58 + // If this is a bundle, attach its children
59 + if let Some(child_ids) = children_of.get(&db_item.id) {
60 + for (ci, child_id) in child_ids.iter().enumerate() {
61 + if let Some(child_db) = item_by_id.get(child_id) {
62 + content_item.children.push(ContentItem::from_db(child_db, (ci + 1) as u32));
63 + }
64 + }
65 + }
66 +
67 + items.push(content_item);
68 + }
69 +
70 + items
71 + }
72 +
19 73 /// Resolve a project by slug for the authenticated user and check its ETag.
20 74 /// Returns `Ok(Err(304))` if the client's cached version is fresh,
21 75 /// or `Ok(Ok((project, generation)))` if rendering is needed.
@@ -96,12 +150,9 @@ pub(super) async fn project_tab_content(
96 150 };
97 151
98 152 let db_items = db::items::get_items_by_project(&state.db, db_project.id).await?;
153 + let bundle_map = db::bundles::get_project_bundle_map(&state.db, db_project.id).await?;
99 154
100 - let items: Vec<ContentItem> = db_items
101 - .iter()
102 - .enumerate()
103 - .map(|(i, item)| ContentItem::from_db(item, (i + 1) as u32))
104 - .collect();
155 + let items = build_content_items_with_bundles(&db_items, &bundle_map);
105 156
106 157 Ok(helpers::with_etag(generation, ProjectContentTabTemplate {
107 158 items,
@@ -20,6 +20,7 @@ const ALLOWED_AUDIO_TYPES: &[(&str, &str)] = &[
20 20 ("m4a", "audio/mp4"),
21 21 ("ogg", "audio/ogg"),
22 22 ("flac", "audio/flac"),
23 + ("aac", "audio/aac"),
23 24 ];
24 25
25 26 /// Allowed image file extensions and their MIME types
@@ -49,6 +50,8 @@ const ALLOWED_DOWNLOAD_MIMES: &[&str] = &[
49 50 "application/zip",
50 51 "application/x-zip-compressed",
51 52 "application/x-apple-diskimage",
53 + "application/x-msi",
54 + "application/x-ole-storage",
52 55 "application/gzip",
53 56 "application/x-tar",
54 57 "application/x-gtar",
@@ -60,7 +63,7 @@ const ALLOWED_DOWNLOAD_MIMES: &[&str] = &[
60 63
61 64 /// Allowed download file extensions (checked separately from MIME)
62 65 const ALLOWED_DOWNLOAD_EXTENSIONS: &[&str] = &[
63 - "zip", "dmg", "exe", "appimage", "deb", "tar.gz", "clap", "vst3",
66 + "zip", "dmg", "exe", "msi", "appimage", "deb", "tar.gz", "clap", "vst3",
64 67 ];
65 68
66 69 /// Maximum file sizes in bytes
@@ -428,6 +428,8 @@ impl ContentItem {
428 428 revenue: "$0".to_string(),
429 429 status,
430 430 id: item.id.to_string(),
431 + is_unlisted: !item.listed,
432 + children: Vec::new(),
431 433 }
432 434 }
433 435 }
@@ -355,6 +355,10 @@ pub struct ContentItem {
355 355 pub revenue: String,
356 356 pub status: String,
357 357 pub id: String,
358 + /// True if this item is unlisted (only accessible via bundle).
359 + pub is_unlisted: bool,
360 + /// Child items if this is a bundle (shown nested in the dashboard).
361 + pub children: Vec<ContentItem>,
358 362 }
359 363
360 364 /// A single step in the creator onboarding checklist.
@@ -53,10 +53,14 @@
53 53 </td>
54 54 <td>
55 55 <span id="item-title-{{ item.id }}">
56 + {% if !item.children.is_empty() %}
57 + <button class="bundle-toggle" onclick="toggleBundleChildren('{{ item.id }}')" style="background: none; border: none; cursor: pointer; padding: 0 0.3rem 0 0; font-size: 0.8rem; color: var(--text-muted);" title="Toggle bundle contents">&#9654;</button>
58 + {% endif %}
56 59 <a href="/dashboard/item/{{ item.id }}" style="font-weight: bold;">{{ item.title }}</a>
60 + {% if item.is_unlisted %}<span class="badge" style="font-size: 0.7rem; margin-left: 0.3rem; opacity: 0.7;">Unlisted</span>{% endif %}
57 61 </span>
58 62 </td>
59 - <td>{{ item.item_type }}</td>
63 + <td>{{ item.item_type }}{% if !item.children.is_empty() %} <span style="opacity: 0.6; font-size: 0.8em;">({{ item.children.len() }})</span>{% endif %}</td>
60 64 <td>{{ item.price }}</td>
61 65 <td>{{ item.sales }}</td>
62 66 <td>{{ item.revenue }}</td>
@@ -74,6 +78,29 @@
74 78 </div>
75 79 </td>
76 80 </tr>
81 + {% for child in item.children %}
82 + <tr class="bundle-child bundle-child-{{ item.id }}" style="display: none;">
83 + <td><input type="checkbox" class="bulk-check" value="{{ child.id }}" onchange="updateBulkUI()"></td>
84 + <td></td>
85 + <td style="padding-left: 2.5rem;">
86 + <span id="item-title-{{ child.id }}" style="opacity: 0.85;">
87 + <span style="color: var(--text-muted); margin-right: 0.3rem;">&#8627;</span>
88 + <a href="/dashboard/item/{{ child.id }}">{{ child.title }}</a>
89 + {% if child.is_unlisted %}<span class="badge" style="font-size: 0.7rem; margin-left: 0.3rem; opacity: 0.7;">Unlisted</span>{% endif %}
90 + </span>
91 + </td>
92 + <td style="opacity: 0.7;">{{ child.item_type }}</td>
93 + <td style="opacity: 0.7;">{{ child.price }}</td>
94 + <td style="opacity: 0.7;">{{ child.sales }}</td>
95 + <td style="opacity: 0.7;">{{ child.revenue }}</td>
96 + <td><span class="badge {{ child.status|lowercase }}">{{ child.status }}</span></td>
97 + <td>
98 + <div class="item-actions" style="display: flex; gap: 0.5rem;">
99 + <a href="/dashboard/item/{{ child.id }}"><button class="secondary small">Edit</button></a>
100 + </div>
101 + </td>
102 + </tr>
103 + {% endfor %}
77 104 {% endfor %}
78 105 </tbody>
79 106 </table>
@@ -111,14 +138,22 @@ function bulkAction(action) {
111 138 var label = action === 'delete' ? 'delete' : action;
112 139 if (action === 'delete' && !confirm('Delete ' + checked.length + ' item(s)? This cannot be undone.')) return;
113 140
114 - var form = new FormData();
141 + var params = new URLSearchParams();
115 142 for (var i = 0; i < checked.length; i++) {
116 - form.append('item_ids', checked[i].value);
143 + params.append('item_ids', checked[i].value);
117 144 }
118 145
119 - fetch('/api/items/bulk/' + action, { method: 'POST', headers: csrfHeaders(), body: form })
146 + fetch('/api/items/bulk/' + action, {
147 + method: 'POST',
148 + headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
149 + body: params.toString()
150 + })
120 151 .then(function(r) {
121 - if (!r.ok) return r.json().then(function(d) { throw new Error(d.error || 'Failed'); });
152 + if (!r.ok) return r.text().then(function(t) {
153 + var msg = 'Bulk ' + action + ' failed (' + r.status + ')';
154 + try { var parsed = JSON.parse(t); if (parsed.error) msg = parsed.error; } catch (_) {}
155 + throw new Error(msg);
156 + });
122 157 htmx.ajax('GET', '/dashboard/project/{{ project_slug }}/tabs/content', '#tab-content');
123 158 })
124 159 .catch(function(err) {
@@ -126,6 +161,16 @@ function bulkAction(action) {
126 161 });
127 162 }
128 163
164 + function toggleBundleChildren(bundleId) {
165 + var rows = document.querySelectorAll('.bundle-child-' + bundleId);
166 + var btn = document.querySelector('tr:has(.bulk-check[value="' + bundleId + '"]) .bundle-toggle');
167 + var visible = rows.length > 0 && rows[0].style.display !== 'none';
168 + for (var i = 0; i < rows.length; i++) {
169 + rows[i].style.display = visible ? 'none' : '';
170 + }
171 + if (btn) btn.innerHTML = visible ? '&#9654;' : '&#9660;';
172 + }
173 +
129 174 function inlineRename(itemId) {
130 175 var container = document.getElementById('item-title-' + itemId);
131 176 var link = container.querySelector('a');
@@ -188,11 +188,19 @@
188 188 return;
189 189 }
190 190 if (!createRes.ok) {
191 - var errText = await createRes.text().catch(function() { return 'unknown error'; });
192 - setStatus(entry.idx, 'Error: ' + createRes.status, 'var(--error, #c0392b)');
191 + var errBody = await createRes.text().catch(function() { return ''; });
192 + var errMsg = '';
193 + try { errMsg = JSON.parse(errBody).error || errBody; } catch (_) { errMsg = errBody; }
194 + setStatus(entry.idx, 'Error: ' + (errMsg || createRes.status), 'var(--error, #c0392b)');
195 + return;
196 + }
197 + var itemData;
198 + try {
199 + itemData = await createRes.json();
200 + } catch (e) {
201 + setStatus(entry.idx, 'Error: bad response', 'var(--error, #c0392b)');
193 202 return;
194 203 }
195 - var itemData = await createRes.json();
196 204 var itemId = itemData.id;
197 205 entry.itemId = itemId;
198 206
@@ -205,65 +213,71 @@
205 213 await uploadVersion(entry, itemId);
206 214 }
207 215 } catch (e) {
208 - setStatus(entry.idx, 'Upload failed', 'var(--error, #c0392b)');
209 - appendBundleRow(itemId, entry.title, entry.type === 'audio' ? 'Audio' : 'Digital');
216 + setStatus(entry.idx, e.message || 'Upload failed', 'var(--error, #c0392b)');
210 217 return;
211 218 }
212 219
213 - // 3. Done
220 + // 3. Done — only add to picker on success
214 221 setStatus(entry.idx, 'Done', 'var(--success, #27ae60)');
215 222 appendBundleRow(itemId, entry.title, entry.type === 'audio' ? 'Audio' : 'Digital');
216 223 }
217 224
225 + async function extractError(res, fallback) {
226 + var text = await res.text().catch(function() { return ''; });
227 + try { return JSON.parse(text).error || fallback; } catch (_) { return text || fallback; }
228 + }
229 +
218 230 async function uploadAudio(entry, itemId) {
219 231 var file = entry.file;
232 + var contentType = file.type || 'audio/mpeg';
220 233 var presignRes = await fetch('/api/upload/presign', {
221 234 method: 'POST',
222 235 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
223 - body: JSON.stringify({ item_id: itemId, file_type: 'audio', file_name: file.name, content_type: file.type || 'audio/mpeg' })
236 + body: JSON.stringify({ item_id: itemId, file_type: 'audio', file_name: file.name, content_type: contentType })
224 237 });
225 - if (!presignRes.ok) throw new Error('Presign failed');
238 + if (!presignRes.ok) throw new Error(await extractError(presignRes, 'Presign failed'));
226 239 var presignData = await presignRes.json();
227 240
228 241 var uploader = new S3Uploader({});
229 - await uploader.upload(presignData.upload_url, file, presignData.s3_key, 'audio/mpeg');
242 + await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType);
230 243
231 244 var confirmRes = await fetch('/api/upload/confirm', {
232 245 method: 'POST',
233 246 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
234 247 body: JSON.stringify({ item_id: itemId, file_type: 'audio', s3_key: presignData.s3_key })
235 248 });
236 - if (!confirmRes.ok) throw new Error('Confirm failed');
249 + if (!confirmRes.ok) throw new Error(await extractError(confirmRes, 'Confirm failed'));
237 250 }
238 251
239 252 async function uploadVersion(entry, itemId) {
240 253 var file = entry.file;
254 + var contentType = file.type || 'application/octet-stream';
241 255 var verRes = await fetch('/api/items/' + itemId + '/versions', {
242 256 method: 'POST',
243 257 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
244 258 body: JSON.stringify({ version_number: '1.0', changelog: null })
245 259 });
246 - if (!verRes.ok) throw new Error('Version creation failed');
260 + if (!verRes.ok) throw new Error(await extractError(verRes, 'Version creation failed'));
247 261 var verData = await verRes.json();
248 262 var versionId = verData.id;
249 263
250 264 var presignRes = await fetch('/api/versions/' + versionId + '/upload/presign', {
251 265 method: 'POST',
252 266 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
253 - body: JSON.stringify({ file_name: file.name, content_type: file.type || 'application/octet-stream' })
267 + body: JSON.stringify({ file_name: file.name, content_type: contentType })
254 268 });
255 - if (!presignRes.ok) throw new Error('Version presign failed');
269 + if (!presignRes.ok) throw new Error(await extractError(presignRes, 'Presign failed'));
256 270 var presignData = await presignRes.json();
257 271
258 272 var uploader = new S3Uploader({});
259 - await uploader.upload(presignData.upload_url, file, presignData.s3_key, 'application/octet-stream');
273 + await uploader.upload(presignData.upload_url, file, presignData.s3_key, contentType);
260 274
261 275 var confirmRes = await fetch('/api/versions/' + versionId + '/upload/confirm', {
262 276 method: 'POST',
263 277 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
264 278 body: JSON.stringify({ s3_key: presignData.s3_key, file_size_bytes: file.size })
265 279 });
266 - if (!confirmRes.ok) throw new Error('Version confirm failed');
280 + if (!confirmRes.ok) throw new Error(await extractError(confirmRes, 'Confirm failed'));
267 281 }
268 282
269 283 async function uploadAll() {