Skip to main content

max / makenotwork

22.9 KB · 470 lines History Blame Raw
1 /**
2 * Item upload flows: audio file upload and version file upload.
3 *
4 * Loaded once in dashboard-item.html. Re-initializes on HTMX tab swap.
5 * Reads item ID from data-item-id on the container element.
6 * Depends on: upload.js (S3Uploader, initDropzone), mnw.js (csrfHeaders, showToast).
7 */
8 (function() {
9 function init() {
10 initAudioUpload();
11 initVersionUpload();
12 }
13
14 // ── Audio Upload ──
15
16 function initAudioUpload() {
17 var container = document.getElementById('audio-upload');
18 if (!container) return;
19 var itemId = container.dataset.itemId;
20 if (!itemId) return;
21
22 var uploader = new S3Uploader({
23 filenameEl: document.getElementById('upload-filename'),
24 percentEl: document.getElementById('upload-percent'),
25 progressBar: document.getElementById('progress-bar'),
26 speedEl: document.getElementById('upload-speed'),
27 });
28
29 initDropzone(
30 document.getElementById('audio-dropzone'),
31 document.getElementById('audio-file-input'),
32 function(file) {
33 if (file.type.startsWith('audio/') || file.name.match(/\.(mp3|wav|flac|m4a|ogg)$/i)) {
34 uploadAudio(file);
35 }
36 }
37 );
38
39 var replaceBtn = document.getElementById('replace-audio-btn');
40 if (replaceBtn) {
41 replaceBtn.addEventListener('click', function() {
42 var cur = document.getElementById('current-audio');
43 if (cur) cur.classList.add('hidden');
44 document.getElementById('upload-area').classList.remove('hidden');
45 resetUpload();
46 });
47 }
48
49 var lastFile = null;
50
51 document.getElementById('cancel-upload-btn').addEventListener('click', function() {
52 uploader.cancel();
53 resetUpload();
54 });
55
56 document.getElementById('retry-upload-btn').addEventListener('click', function() {
57 if (lastFile) {
58 document.getElementById('upload-error').classList.add('hidden');
59 uploadAudio(lastFile);
60 } else {
61 resetUpload();
62 }
63 });
64
65 function resetUpload() {
66 lastFile = null;
67 document.getElementById('audio-dropzone').classList.remove('hidden');
68 document.getElementById('upload-progress').classList.add('hidden');
69 document.getElementById('upload-success').classList.add('hidden');
70 document.getElementById('upload-error').classList.add('hidden');
71 document.getElementById('audio-file-input').value = '';
72 }
73
74 function uploadAudio(file) {
75 lastFile = file;
76 document.getElementById('audio-dropzone').classList.add('hidden');
77 document.getElementById('upload-progress').classList.remove('hidden');
78
79 fetch('/api/upload/presign', {
80 method: 'POST',
81 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
82 body: JSON.stringify({
83 item_id: itemId,
84 file_type: 'audio',
85 file_name: file.name,
86 content_type: file.type || 'audio/mpeg',
87 file_size_bytes: file.size
88 })
89 })
90 .then(function(res) {
91 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
92 throw new Error(d.error || 'Failed to get upload URL');
93 });
94 return res.json();
95 })
96 .then(function(data) {
97 if (data.max_file_bytes && file.size > data.max_file_bytes) {
98 var limitMB = (data.max_file_bytes / (1024 * 1024)).toFixed(0);
99 var fileMB = (file.size / (1024 * 1024)).toFixed(1);
100 throw new Error('File is ' + fileMB + ' MB but your plan allows up to ' + limitMB + ' MB per file. Upgrade your tier or use a smaller file.');
101 }
102 return uploader.upload(data.upload_url, file, data.s3_key, 'audio/mpeg', data.cache_control);
103 })
104 .then(function(s3Key) {
105 return fetch('/api/upload/confirm', {
106 method: 'POST',
107 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
108 body: JSON.stringify({
109 item_id: itemId,
110 file_type: 'audio',
111 s3_key: s3Key
112 })
113 });
114 })
115 .then(function(res) {
116 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
117 throw new Error(d.error || 'Failed to confirm upload');
118 });
119 return res.json().catch(function() { return {}; });
120 })
121 .then(function(result) {
122 document.getElementById('upload-progress').classList.add('hidden');
123 document.getElementById('upload-success').classList.remove('hidden');
124 // Scan flagged the file for manual review — surface it as a
125 // toast so the creator knows their content isn't public yet.
126 if (result && result.pending_review) {
127 showToast(
128 'Upload accepted but held for review — our scanner flagged it. ' +
129 'You’ll get an email once it’s cleared.',
130 'warning'
131 );
132 }
133 setTimeout(function() { window.location.href = '/dashboard/item/' + itemId + '#tab-files'; }, 1500);
134 })
135 .catch(function(err) {
136 document.getElementById('upload-progress').classList.add('hidden');
137 document.getElementById('upload-error').classList.remove('hidden');
138 document.getElementById('error-message').textContent = err.message || 'Upload failed';
139 });
140 }
141 }
142
143 // ── Version Upload ──
144
145 function initVersionUpload() {
146 var container = document.getElementById('version-upload');
147 if (!container) return;
148 var itemId = container.dataset.itemId;
149 if (!itemId) return;
150
151 var fileQueue = [];
152 var targetVersionId = null;
153
154 var uploader = new S3Uploader({
155 filenameEl: document.getElementById('version-upload-filename'),
156 percentEl: document.getElementById('version-upload-percent'),
157 progressBar: document.getElementById('version-progress-bar'),
158 speedEl: document.getElementById('version-upload-speed'),
159 });
160
161 var versionFileInput = document.getElementById('version-file-input');
162 var fileRows = document.getElementById('version-file-rows');
163
164 // Add files button
165 var addBtn = document.getElementById('add-version-file-btn');
166 if (addBtn) {
167 addBtn.addEventListener('click', function() { versionFileInput.click(); });
168 }
169
170 if (versionFileInput) {
171 versionFileInput.addEventListener('change', function() {
172 for (var i = 0; i < this.files.length; i++) addFileRow(this.files[i]);
173 this.value = '';
174 });
175 }
176
177 function guessLabel(name) {
178 var n = name.toLowerCase();
179 if (n.indexOf('aarch64') !== -1 || n.indexOf('arm64') !== -1) return n.indexOf('appimage') !== -1 || n.indexOf('.deb') !== -1 ? 'Linux (aarch64)' : 'macOS (arm)';
180 if (n.indexOf('x86_64') !== -1 || n.indexOf('amd64') !== -1 || n.indexOf('x64') !== -1) return n.indexOf('.exe') !== -1 || n.indexOf('.msi') !== -1 ? 'Windows (x64)' : 'Linux (x86_64)';
181 if (n.indexOf('.dmg') !== -1) return 'macOS';
182 if (n.indexOf('.msi') !== -1 || n.indexOf('.exe') !== -1) return 'Windows';
183 if (n.indexOf('.appimage') !== -1 || n.indexOf('.deb') !== -1) return 'Linux';
184 return '';
185 }
186
187 function addFileRow(file) {
188 var idx = fileQueue.length;
189 fileQueue.push({ file: file, idx: idx });
190 var tr = document.createElement('tr');
191 tr.dataset.idx = idx;
192 tr.style.borderBottom = '1px solid var(--border)';
193 tr.innerHTML =
194 '<td style="padding: 0.4rem 0.5rem 0.4rem 0; font-size: 0.85rem; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="' + file.name.replace(/"/g, '&quot;') + '">' + escapeHtml(file.name) + '</td>' +
195 '<td style="padding: 0.4rem 0.5rem;"><input type="text" class="version-label-input" data-idx="' + idx + '" value="' + escapeAttr(guessLabel(file.name)) + '" placeholder="e.g., macOS (arm)" style="width: 100%; padding: 0.25rem 0.4rem; font-size: 0.85rem;"></td>' +
196 '<td style="padding: 0.4rem 0.5rem;"><button type="button" class="btn-secondary version-remove-file" data-idx="' + idx + '" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;">Remove</button></td>';
197 fileRows.appendChild(tr);
198
199 tr.querySelector('.version-remove-file').addEventListener('click', function() {
200 fileQueue[idx] = null;
201 tr.remove();
202 });
203 }
204
205 function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
206 function escapeAttr(s) { return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
207
208 // Upload all button
209 document.getElementById('create-version-btn').addEventListener('click', function() {
210 var versionNumber = document.getElementById('new-version-number').value.trim();
211 var changelog = document.getElementById('version-changelog').value.trim();
212 var entries = fileQueue.filter(function(e) { return e !== null; });
213
214 if (!versionNumber) { showToast('Please enter a version number.'); return; }
215 if (entries.length === 0) { showToast('Please add at least one file.'); return; }
216
217 this.disabled = true;
218 this.textContent = 'Uploading...';
219 document.getElementById('new-version-form').classList.add('hidden');
220 document.getElementById('version-upload-progress').classList.remove('hidden');
221
222 // Build queue display
223 var queueEl = document.getElementById('version-upload-queue');
224 queueEl.innerHTML = '';
225 for (var q = 0; q < entries.length; q++) {
226 var li = document.createElement('div');
227 li.id = 'queue-item-' + entries[q].idx;
228 li.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; font-size: 0.85rem;';
229 var labelInput = document.querySelector('.version-label-input[data-idx="' + entries[q].idx + '"]');
230 var labelText = labelInput ? labelInput.value.trim() : '';
231 var displayName = entries[q].file.name + (labelText ? ' (' + escapeHtml(labelText) + ')' : '');
232 li.innerHTML = '<span class="queue-status" style="width: 1.5em; text-align: center; opacity: 0.5;">-</span><span style="flex: 1;">' + displayName + '</span><span class="queue-size" style="opacity: 0.5;">' + formatSize(entries[q].file.size) + '</span>';
233 queueEl.appendChild(li);
234 }
235
236 uploadSequentially(entries, 0, versionNumber, changelog);
237 });
238
239 function formatSize(bytes) {
240 if (bytes > 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
241 if (bytes > 1024) return (bytes / 1024).toFixed(0) + ' KB';
242 return bytes + ' B';
243 }
244
245 function updateQueueStatus(idx, status) {
246 var el = document.getElementById('queue-item-' + idx);
247 if (!el) return;
248 var s = el.querySelector('.queue-status');
249 if (status === 'uploading') { s.textContent = '...'; s.style.opacity = '1'; }
250 else if (status === 'done') { s.textContent = 'OK'; s.style.opacity = '0.7'; el.style.opacity = '0.6'; }
251 else if (status === 'error') { s.textContent = '!'; s.style.color = 'var(--error, #c0392b)'; s.style.opacity = '1'; }
252 }
253
254 function uploadSequentially(entries, i, versionNumber, changelog) {
255 if (i >= entries.length) {
256 document.getElementById('version-upload-progress').classList.add('hidden');
257 document.getElementById('version-upload-success').classList.remove('hidden');
258 setTimeout(function() {
259 var filesBtn = document.getElementById('tab-files');
260 if (filesBtn) filesBtn.click();
261 }, 1500);
262 return;
263 }
264
265 var entry = entries[i];
266 var labelInput = document.querySelector('.version-label-input[data-idx="' + entry.idx + '"]');
267 var label = labelInput ? labelInput.value.trim() : '';
268
269 updateQueueStatus(entry.idx, 'uploading');
270 uploader.filenameEl.textContent = entry.file.name + (entries.length > 1 ? ' (' + (i + 1) + '/' + entries.length + ')' : '');
271
272 fetch('/api/items/' + itemId + '/versions', {
273 method: 'POST',
274 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
275 body: JSON.stringify({
276 version_number: versionNumber,
277 changelog: changelog || null,
278 label: label || null
279 })
280 })
281 .then(function(res) {
282 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
283 throw new Error(d.error || 'Failed to create version');
284 });
285 return res.json();
286 })
287 .then(function(data) {
288 return fetch('/api/versions/' + data.id + '/upload/presign', {
289 method: 'POST',
290 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
291 body: JSON.stringify({
292 file_name: entry.file.name,
293 content_type: entry.file.type || 'application/octet-stream'
294 })
295 }).then(function(res) {
296 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
297 throw new Error(d.error || 'Failed to get upload URL');
298 });
299 return res.json();
300 }).then(function(presign) {
301 if (presign.max_file_bytes && entry.file.size > presign.max_file_bytes) {
302 var limitMB = (presign.max_file_bytes / (1024 * 1024)).toFixed(0);
303 var fileMB = (entry.file.size / (1024 * 1024)).toFixed(1);
304 throw new Error(entry.file.name + ': ' + fileMB + ' MB exceeds ' + limitMB + ' MB limit');
305 }
306 return uploader.upload(presign.upload_url, entry.file, presign.s3_key, 'application/octet-stream', presign.cache_control)
307 .then(function(s3Key) { return { s3Key: s3Key, versionId: data.id }; });
308 });
309 })
310 .then(function(result) {
311 return fetch('/api/versions/' + result.versionId + '/upload/confirm', {
312 method: 'POST',
313 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
314 body: JSON.stringify({ s3_key: result.s3Key, file_size_bytes: entry.file.size })
315 });
316 })
317 .then(function(res) {
318 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
319 throw new Error(d.error || 'Failed to confirm upload');
320 });
321 return res.json().catch(function() { return {}; });
322 })
323 .then(function(confirmData) {
324 if (confirmData && confirmData.pending_review) {
325 showToast('Version upload held for review — our scanner flagged it.', 'warning');
326 }
327 updateQueueStatus(entry.idx, 'done');
328 uploadSequentially(entries, i + 1, versionNumber, changelog);
329 })
330 .catch(function(err) {
331 updateQueueStatus(entry.idx, 'error');
332 showVersionError(err.message || 'Upload failed');
333 });
334 }
335
336 // Existing version upload (single file)
337 var existingDropzone = document.getElementById('existing-version-dropzone');
338 var existingFileInput = document.getElementById('existing-version-file-input');
339 if (existingDropzone && existingFileInput) {
340 initDropzone(existingDropzone, existingFileInput, function(file) {
341 if (targetVersionId) uploadSingleFile(targetVersionId, file);
342 });
343 }
344
345 function uploadSingleFile(versionId, file) {
346 document.getElementById('new-version-form').classList.add('hidden');
347 document.getElementById('existing-version-upload').classList.add('hidden');
348 document.getElementById('version-upload-progress').classList.remove('hidden');
349
350 fetch('/api/versions/' + versionId + '/upload/presign', {
351 method: 'POST',
352 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
353 body: JSON.stringify({ file_name: file.name, content_type: file.type || 'application/octet-stream' })
354 })
355 .then(function(res) {
356 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Presign failed'); });
357 return res.json();
358 })
359 .then(function(data) {
360 return uploader.upload(data.upload_url, file, data.s3_key, 'application/octet-stream', data.cache_control);
361 })
362 .then(function(s3Key) {
363 return fetch('/api/versions/' + versionId + '/upload/confirm', {
364 method: 'POST',
365 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
366 body: JSON.stringify({ s3_key: s3Key, file_size_bytes: file.size })
367 });
368 })
369 .then(function(res) {
370 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Confirm failed'); });
371 return res.json().catch(function() { return {}; });
372 })
373 .then(function(confirmData) {
374 document.getElementById('version-upload-progress').classList.add('hidden');
375 document.getElementById('version-upload-success').classList.remove('hidden');
376 if (confirmData && confirmData.pending_review) {
377 showToast('Version upload held for review — our scanner flagged it.', 'warning');
378 }
379 setTimeout(function() {
380 var filesBtn = document.getElementById('tab-files');
381 if (filesBtn) filesBtn.click();
382 }, 1500);
383 })
384 .catch(function(err) { showVersionError(err.message || 'Upload failed'); });
385 }
386
387 document.querySelectorAll('.upload-to-version-btn').forEach(function(btn) {
388 btn.addEventListener('click', function() {
389 targetVersionId = btn.dataset.versionId;
390 document.getElementById('new-version-form').classList.add('hidden');
391 document.getElementById('existing-version-upload').classList.remove('hidden');
392 });
393 });
394
395 document.getElementById('cancel-existing-upload-btn').addEventListener('click', function() {
396 targetVersionId = null;
397 document.getElementById('existing-version-upload').classList.add('hidden');
398 document.getElementById('new-version-form').classList.remove('hidden');
399 });
400
401 document.querySelectorAll('.download-version-btn').forEach(function(btn) {
402 btn.addEventListener('click', function() {
403 fetch('/api/versions/' + btn.dataset.versionId + '/download')
404 .then(function(res) {
405 if (!res.ok) throw new Error('Failed to get download URL');
406 return res.json();
407 })
408 .then(function(data) { window.location.href = data.download_url; })
409 .catch(function(err) { showToast(err.message); });
410 });
411 });
412
413 document.querySelectorAll('.delete-version-btn').forEach(function(btn) {
414 btn.addEventListener('click', function() {
415 if (!confirm('Delete this version?')) return;
416 var versionId = btn.dataset.versionId;
417 fetch('/api/items/' + itemId + '/versions/' + versionId, {
418 method: 'DELETE',
419 headers: csrfHeaders()
420 })
421 .then(function(res) {
422 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
423 throw new Error(d.error || 'Failed to delete version');
424 });
425 var row = btn.closest('tr');
426 if (row) row.remove();
427 })
428 .catch(function(err) { showToast(err.message || 'Failed to delete version'); });
429 });
430 });
431
432 document.getElementById('cancel-version-upload-btn').addEventListener('click', function() {
433 uploader.cancel();
434 resetVersionUpload();
435 });
436
437 document.getElementById('retry-version-upload-btn').addEventListener('click', resetVersionUpload);
438
439 function showVersionError(message) {
440 document.getElementById('version-upload-progress').classList.add('hidden');
441 document.getElementById('version-upload-error').classList.remove('hidden');
442 document.getElementById('version-error-message').textContent = message;
443 }
444
445 function resetVersionUpload() {
446 document.getElementById('new-version-form').classList.remove('hidden');
447 document.getElementById('existing-version-upload').classList.add('hidden');
448 document.getElementById('version-upload-progress').classList.add('hidden');
449 document.getElementById('version-upload-success').classList.add('hidden');
450 document.getElementById('version-upload-error').classList.add('hidden');
451 fileQueue = [];
452 fileRows.innerHTML = '';
453 targetVersionId = null;
454 var btn = document.getElementById('create-version-btn');
455 btn.disabled = false;
456 btn.textContent = 'Upload All';
457 }
458 }
459
460 // Run on initial load
461 init();
462
463 // Re-run when HTMX swaps in upload tabs
464 document.body.addEventListener('htmx:afterSwap', function(e) {
465 if (e.detail.target.id === 'tab-content') {
466 init();
467 }
468 });
469 })();
470