Skip to main content

max / makenotwork

22.8 KB · 469 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), the core module (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 escapeAttr(s) { return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
206
207 // Upload all button
208 document.getElementById('create-version-btn').addEventListener('click', function() {
209 var versionNumber = document.getElementById('new-version-number').value.trim();
210 var changelog = document.getElementById('version-changelog').value.trim();
211 var entries = fileQueue.filter(function(e) { return e !== null; });
212
213 if (!versionNumber) { showToast('Please enter a version number.'); return; }
214 if (entries.length === 0) { showToast('Please add at least one file.'); return; }
215
216 this.disabled = true;
217 this.textContent = 'Uploading...';
218 document.getElementById('new-version-form').classList.add('hidden');
219 document.getElementById('version-upload-progress').classList.remove('hidden');
220
221 // Build queue display
222 var queueEl = document.getElementById('version-upload-queue');
223 queueEl.innerHTML = '';
224 for (var q = 0; q < entries.length; q++) {
225 var li = document.createElement('div');
226 li.id = 'queue-item-' + entries[q].idx;
227 li.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; font-size: 0.85rem;';
228 var labelInput = document.querySelector('.version-label-input[data-idx="' + entries[q].idx + '"]');
229 var labelText = labelInput ? labelInput.value.trim() : '';
230 var displayName = escapeHtml(entries[q].file.name) + (labelText ? ' (' + escapeHtml(labelText) + ')' : '');
231 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>';
232 queueEl.appendChild(li);
233 }
234
235 uploadSequentially(entries, 0, versionNumber, changelog);
236 });
237
238 function formatSize(bytes) {
239 if (bytes > 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
240 if (bytes > 1024) return (bytes / 1024).toFixed(0) + ' KB';
241 return bytes + ' B';
242 }
243
244 function updateQueueStatus(idx, status) {
245 var el = document.getElementById('queue-item-' + idx);
246 if (!el) return;
247 var s = el.querySelector('.queue-status');
248 if (status === 'uploading') { s.textContent = '...'; s.style.opacity = '1'; }
249 else if (status === 'done') { s.textContent = 'OK'; s.style.opacity = '0.7'; el.style.opacity = '0.6'; }
250 else if (status === 'error') { s.textContent = '!'; s.style.color = 'var(--error, #c0392b)'; s.style.opacity = '1'; }
251 }
252
253 function uploadSequentially(entries, i, versionNumber, changelog) {
254 if (i >= entries.length) {
255 document.getElementById('version-upload-progress').classList.add('hidden');
256 document.getElementById('version-upload-success').classList.remove('hidden');
257 setTimeout(function() {
258 var filesBtn = document.getElementById('tab-files');
259 if (filesBtn) filesBtn.click();
260 }, 1500);
261 return;
262 }
263
264 var entry = entries[i];
265 var labelInput = document.querySelector('.version-label-input[data-idx="' + entry.idx + '"]');
266 var label = labelInput ? labelInput.value.trim() : '';
267
268 updateQueueStatus(entry.idx, 'uploading');
269 uploader.filenameEl.textContent = entry.file.name + (entries.length > 1 ? ' (' + (i + 1) + '/' + entries.length + ')' : '');
270
271 fetch('/api/items/' + itemId + '/versions', {
272 method: 'POST',
273 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
274 body: JSON.stringify({
275 version_number: versionNumber,
276 changelog: changelog || null,
277 label: label || null
278 })
279 })
280 .then(function(res) {
281 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
282 throw new Error(d.error || 'Failed to create version');
283 });
284 return res.json();
285 })
286 .then(function(data) {
287 return fetch('/api/versions/' + data.id + '/upload/presign', {
288 method: 'POST',
289 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
290 body: JSON.stringify({
291 file_name: entry.file.name,
292 content_type: entry.file.type || 'application/octet-stream'
293 })
294 }).then(function(res) {
295 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
296 throw new Error(d.error || 'Failed to get upload URL');
297 });
298 return res.json();
299 }).then(function(presign) {
300 if (presign.max_file_bytes && entry.file.size > presign.max_file_bytes) {
301 var limitMB = (presign.max_file_bytes / (1024 * 1024)).toFixed(0);
302 var fileMB = (entry.file.size / (1024 * 1024)).toFixed(1);
303 throw new Error(entry.file.name + ': ' + fileMB + ' MB exceeds ' + limitMB + ' MB limit');
304 }
305 return uploader.upload(presign.upload_url, entry.file, presign.s3_key, 'application/octet-stream', presign.cache_control)
306 .then(function(s3Key) { return { s3Key: s3Key, versionId: data.id }; });
307 });
308 })
309 .then(function(result) {
310 return fetch('/api/versions/' + result.versionId + '/upload/confirm', {
311 method: 'POST',
312 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
313 body: JSON.stringify({ s3_key: result.s3Key, file_size_bytes: entry.file.size })
314 });
315 })
316 .then(function(res) {
317 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
318 throw new Error(d.error || 'Failed to confirm upload');
319 });
320 return res.json().catch(function() { return {}; });
321 })
322 .then(function(confirmData) {
323 if (confirmData && confirmData.pending_review) {
324 showToast('Version upload held for review: our scanner flagged it.', 'warning');
325 }
326 updateQueueStatus(entry.idx, 'done');
327 uploadSequentially(entries, i + 1, versionNumber, changelog);
328 })
329 .catch(function(err) {
330 updateQueueStatus(entry.idx, 'error');
331 showVersionError(err.message || 'Upload failed');
332 });
333 }
334
335 // Existing version upload (single file)
336 var existingDropzone = document.getElementById('existing-version-dropzone');
337 var existingFileInput = document.getElementById('existing-version-file-input');
338 if (existingDropzone && existingFileInput) {
339 initDropzone(existingDropzone, existingFileInput, function(file) {
340 if (targetVersionId) uploadSingleFile(targetVersionId, file);
341 });
342 }
343
344 function uploadSingleFile(versionId, file) {
345 document.getElementById('new-version-form').classList.add('hidden');
346 document.getElementById('existing-version-upload').classList.add('hidden');
347 document.getElementById('version-upload-progress').classList.remove('hidden');
348
349 fetch('/api/versions/' + versionId + '/upload/presign', {
350 method: 'POST',
351 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
352 body: JSON.stringify({ file_name: file.name, content_type: file.type || 'application/octet-stream' })
353 })
354 .then(function(res) {
355 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Presign failed'); });
356 return res.json();
357 })
358 .then(function(data) {
359 return uploader.upload(data.upload_url, file, data.s3_key, 'application/octet-stream', data.cache_control);
360 })
361 .then(function(s3Key) {
362 return fetch('/api/versions/' + versionId + '/upload/confirm', {
363 method: 'POST',
364 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
365 body: JSON.stringify({ s3_key: s3Key, file_size_bytes: file.size })
366 });
367 })
368 .then(function(res) {
369 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Confirm failed'); });
370 return res.json().catch(function() { return {}; });
371 })
372 .then(function(confirmData) {
373 document.getElementById('version-upload-progress').classList.add('hidden');
374 document.getElementById('version-upload-success').classList.remove('hidden');
375 if (confirmData && confirmData.pending_review) {
376 showToast('Version upload held for review: our scanner flagged it.', 'warning');
377 }
378 setTimeout(function() {
379 var filesBtn = document.getElementById('tab-files');
380 if (filesBtn) filesBtn.click();
381 }, 1500);
382 })
383 .catch(function(err) { showVersionError(err.message || 'Upload failed'); });
384 }
385
386 document.querySelectorAll('.upload-to-version-btn').forEach(function(btn) {
387 btn.addEventListener('click', function() {
388 targetVersionId = btn.dataset.versionId;
389 document.getElementById('new-version-form').classList.add('hidden');
390 document.getElementById('existing-version-upload').classList.remove('hidden');
391 });
392 });
393
394 document.getElementById('cancel-existing-upload-btn').addEventListener('click', function() {
395 targetVersionId = null;
396 document.getElementById('existing-version-upload').classList.add('hidden');
397 document.getElementById('new-version-form').classList.remove('hidden');
398 });
399
400 document.querySelectorAll('.download-version-btn').forEach(function(btn) {
401 btn.addEventListener('click', function() {
402 fetch('/api/versions/' + btn.dataset.versionId + '/download')
403 .then(function(res) {
404 if (!res.ok) throw new Error('Failed to get download URL');
405 return res.json();
406 })
407 .then(function(data) { window.location.href = data.download_url; })
408 .catch(function(err) { showToast(err.message); });
409 });
410 });
411
412 document.querySelectorAll('.delete-version-btn').forEach(function(btn) {
413 btn.addEventListener('click', function() {
414 if (!confirm('Delete this version?')) return;
415 var versionId = btn.dataset.versionId;
416 fetch('/api/items/' + itemId + '/versions/' + versionId, {
417 method: 'DELETE',
418 headers: csrfHeaders()
419 })
420 .then(function(res) {
421 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
422 throw new Error(d.error || 'Failed to delete version');
423 });
424 var row = btn.closest('tr');
425 if (row) row.remove();
426 })
427 .catch(function(err) { showToast(err.message || 'Failed to delete version'); });
428 });
429 });
430
431 document.getElementById('cancel-version-upload-btn').addEventListener('click', function() {
432 uploader.cancel();
433 resetVersionUpload();
434 });
435
436 document.getElementById('retry-version-upload-btn').addEventListener('click', resetVersionUpload);
437
438 function showVersionError(message) {
439 document.getElementById('version-upload-progress').classList.add('hidden');
440 document.getElementById('version-upload-error').classList.remove('hidden');
441 document.getElementById('version-error-message').textContent = message;
442 }
443
444 function resetVersionUpload() {
445 document.getElementById('new-version-form').classList.remove('hidden');
446 document.getElementById('existing-version-upload').classList.add('hidden');
447 document.getElementById('version-upload-progress').classList.add('hidden');
448 document.getElementById('version-upload-success').classList.add('hidden');
449 document.getElementById('version-upload-error').classList.add('hidden');
450 fileQueue = [];
451 fileRows.innerHTML = '';
452 targetVersionId = null;
453 var btn = document.getElementById('create-version-btn');
454 btn.disabled = false;
455 btn.textContent = 'Upload All';
456 }
457 }
458
459 // Run on initial load
460 init();
461
462 // Re-run when HTMX swaps in upload tabs
463 document.body.addEventListener('htmx:afterSwap', function(e) {
464 if (e.detail.target.id === 'tab-content') {
465 init();
466 }
467 });
468 })();
469