max / makenotwork
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">▶</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;">↳</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 ? '▶' : '▼'; | |
| 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() { |