/** * Item upload flows: audio file upload and version file upload. * * Loaded once in dashboard-item.html. Re-initializes on HTMX tab swap. * Reads item ID from data-item-id on the container element. * Depends on: upload.js (S3Uploader, initDropzone), mnw.js (csrfHeaders, showToast). */ (function() { function init() { initAudioUpload(); initVersionUpload(); } // ── Audio Upload ── function initAudioUpload() { var container = document.getElementById('audio-upload'); if (!container) return; var itemId = container.dataset.itemId; if (!itemId) return; var uploader = new S3Uploader({ filenameEl: document.getElementById('upload-filename'), percentEl: document.getElementById('upload-percent'), progressBar: document.getElementById('progress-bar'), speedEl: document.getElementById('upload-speed'), }); initDropzone( document.getElementById('audio-dropzone'), document.getElementById('audio-file-input'), function(file) { if (file.type.startsWith('audio/') || file.name.match(/\.(mp3|wav|flac|m4a|ogg)$/i)) { uploadAudio(file); } } ); var replaceBtn = document.getElementById('replace-audio-btn'); if (replaceBtn) { replaceBtn.addEventListener('click', function() { var cur = document.getElementById('current-audio'); if (cur) cur.classList.add('hidden'); document.getElementById('upload-area').classList.remove('hidden'); resetUpload(); }); } var lastFile = null; document.getElementById('cancel-upload-btn').addEventListener('click', function() { uploader.cancel(); resetUpload(); }); document.getElementById('retry-upload-btn').addEventListener('click', function() { if (lastFile) { document.getElementById('upload-error').classList.add('hidden'); uploadAudio(lastFile); } else { resetUpload(); } }); function resetUpload() { lastFile = null; document.getElementById('audio-dropzone').classList.remove('hidden'); document.getElementById('upload-progress').classList.add('hidden'); document.getElementById('upload-success').classList.add('hidden'); document.getElementById('upload-error').classList.add('hidden'); document.getElementById('audio-file-input').value = ''; } function uploadAudio(file) { lastFile = file; document.getElementById('audio-dropzone').classList.add('hidden'); document.getElementById('upload-progress').classList.remove('hidden'); fetch('/api/upload/presign', { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, body: JSON.stringify({ item_id: itemId, file_type: 'audio', file_name: file.name, content_type: file.type || 'audio/mpeg', file_size_bytes: file.size }) }) .then(function(res) { if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Failed to get upload URL'); }); return res.json(); }) .then(function(data) { if (data.max_file_bytes && file.size > data.max_file_bytes) { var limitMB = (data.max_file_bytes / (1024 * 1024)).toFixed(0); var fileMB = (file.size / (1024 * 1024)).toFixed(1); 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.'); } return uploader.upload(data.upload_url, file, data.s3_key, 'audio/mpeg', data.cache_control); }) .then(function(s3Key) { return fetch('/api/upload/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, body: JSON.stringify({ item_id: itemId, file_type: 'audio', s3_key: s3Key }) }); }) .then(function(res) { if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Failed to confirm upload'); }); return res.json().catch(function() { return {}; }); }) .then(function(result) { document.getElementById('upload-progress').classList.add('hidden'); document.getElementById('upload-success').classList.remove('hidden'); // Scan flagged the file for manual review — surface it as a // toast so the creator knows their content isn't public yet. if (result && result.pending_review) { showToast( 'Upload accepted but held for review — our scanner flagged it. ' + 'You’ll get an email once it’s cleared.', 'warning' ); } setTimeout(function() { window.location.href = '/dashboard/item/' + itemId + '#tab-files'; }, 1500); }) .catch(function(err) { document.getElementById('upload-progress').classList.add('hidden'); document.getElementById('upload-error').classList.remove('hidden'); document.getElementById('error-message').textContent = err.message || 'Upload failed'; }); } } // ── Version Upload ── function initVersionUpload() { var container = document.getElementById('version-upload'); if (!container) return; var itemId = container.dataset.itemId; if (!itemId) return; var fileQueue = []; var targetVersionId = null; var uploader = new S3Uploader({ filenameEl: document.getElementById('version-upload-filename'), percentEl: document.getElementById('version-upload-percent'), progressBar: document.getElementById('version-progress-bar'), speedEl: document.getElementById('version-upload-speed'), }); var versionFileInput = document.getElementById('version-file-input'); var fileRows = document.getElementById('version-file-rows'); // Add files button var addBtn = document.getElementById('add-version-file-btn'); if (addBtn) { addBtn.addEventListener('click', function() { versionFileInput.click(); }); } if (versionFileInput) { versionFileInput.addEventListener('change', function() { for (var i = 0; i < this.files.length; i++) addFileRow(this.files[i]); this.value = ''; }); } function guessLabel(name) { var n = name.toLowerCase(); if (n.indexOf('aarch64') !== -1 || n.indexOf('arm64') !== -1) return n.indexOf('appimage') !== -1 || n.indexOf('.deb') !== -1 ? 'Linux (aarch64)' : 'macOS (arm)'; 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)'; if (n.indexOf('.dmg') !== -1) return 'macOS'; if (n.indexOf('.msi') !== -1 || n.indexOf('.exe') !== -1) return 'Windows'; if (n.indexOf('.appimage') !== -1 || n.indexOf('.deb') !== -1) return 'Linux'; return ''; } function addFileRow(file) { var idx = fileQueue.length; fileQueue.push({ file: file, idx: idx }); var tr = document.createElement('tr'); tr.dataset.idx = idx; tr.style.borderBottom = '1px solid var(--border)'; tr.innerHTML = '' + escapeHtml(file.name) + '' + '' + ''; fileRows.appendChild(tr); tr.querySelector('.version-remove-file').addEventListener('click', function() { fileQueue[idx] = null; tr.remove(); }); } function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function escapeAttr(s) { return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } // Upload all button document.getElementById('create-version-btn').addEventListener('click', function() { var versionNumber = document.getElementById('new-version-number').value.trim(); var changelog = document.getElementById('version-changelog').value.trim(); var entries = fileQueue.filter(function(e) { return e !== null; }); if (!versionNumber) { showToast('Please enter a version number.'); return; } if (entries.length === 0) { showToast('Please add at least one file.'); return; } this.disabled = true; this.textContent = 'Uploading...'; document.getElementById('new-version-form').classList.add('hidden'); document.getElementById('version-upload-progress').classList.remove('hidden'); // Build queue display var queueEl = document.getElementById('version-upload-queue'); queueEl.innerHTML = ''; for (var q = 0; q < entries.length; q++) { var li = document.createElement('div'); li.id = 'queue-item-' + entries[q].idx; li.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; font-size: 0.85rem;'; var labelInput = document.querySelector('.version-label-input[data-idx="' + entries[q].idx + '"]'); var labelText = labelInput ? labelInput.value.trim() : ''; var displayName = entries[q].file.name + (labelText ? ' (' + escapeHtml(labelText) + ')' : ''); li.innerHTML = '-' + displayName + '' + formatSize(entries[q].file.size) + ''; queueEl.appendChild(li); } uploadSequentially(entries, 0, versionNumber, changelog); }); function formatSize(bytes) { if (bytes > 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; if (bytes > 1024) return (bytes / 1024).toFixed(0) + ' KB'; return bytes + ' B'; } function updateQueueStatus(idx, status) { var el = document.getElementById('queue-item-' + idx); if (!el) return; var s = el.querySelector('.queue-status'); if (status === 'uploading') { s.textContent = '...'; s.style.opacity = '1'; } else if (status === 'done') { s.textContent = 'OK'; s.style.opacity = '0.7'; el.style.opacity = '0.6'; } else if (status === 'error') { s.textContent = '!'; s.style.color = 'var(--error, #c0392b)'; s.style.opacity = '1'; } } function uploadSequentially(entries, i, versionNumber, changelog) { if (i >= entries.length) { document.getElementById('version-upload-progress').classList.add('hidden'); document.getElementById('version-upload-success').classList.remove('hidden'); setTimeout(function() { var filesBtn = document.getElementById('tab-files'); if (filesBtn) filesBtn.click(); }, 1500); return; } var entry = entries[i]; var labelInput = document.querySelector('.version-label-input[data-idx="' + entry.idx + '"]'); var label = labelInput ? labelInput.value.trim() : ''; updateQueueStatus(entry.idx, 'uploading'); uploader.filenameEl.textContent = entry.file.name + (entries.length > 1 ? ' (' + (i + 1) + '/' + entries.length + ')' : ''); fetch('/api/items/' + itemId + '/versions', { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, body: JSON.stringify({ version_number: versionNumber, changelog: changelog || null, label: label || null }) }) .then(function(res) { if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Failed to create version'); }); return res.json(); }) .then(function(data) { return fetch('/api/versions/' + data.id + '/upload/presign', { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, body: JSON.stringify({ file_name: entry.file.name, content_type: entry.file.type || 'application/octet-stream' }) }).then(function(res) { if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Failed to get upload URL'); }); return res.json(); }).then(function(presign) { if (presign.max_file_bytes && entry.file.size > presign.max_file_bytes) { var limitMB = (presign.max_file_bytes / (1024 * 1024)).toFixed(0); var fileMB = (entry.file.size / (1024 * 1024)).toFixed(1); throw new Error(entry.file.name + ': ' + fileMB + ' MB exceeds ' + limitMB + ' MB limit'); } return uploader.upload(presign.upload_url, entry.file, presign.s3_key, 'application/octet-stream', presign.cache_control) .then(function(s3Key) { return { s3Key: s3Key, versionId: data.id }; }); }); }) .then(function(result) { return fetch('/api/versions/' + result.versionId + '/upload/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, body: JSON.stringify({ s3_key: result.s3Key, file_size_bytes: entry.file.size }) }); }) .then(function(res) { if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Failed to confirm upload'); }); return res.json().catch(function() { return {}; }); }) .then(function(confirmData) { if (confirmData && confirmData.pending_review) { showToast('Version upload held for review — our scanner flagged it.', 'warning'); } updateQueueStatus(entry.idx, 'done'); uploadSequentially(entries, i + 1, versionNumber, changelog); }) .catch(function(err) { updateQueueStatus(entry.idx, 'error'); showVersionError(err.message || 'Upload failed'); }); } // Existing version upload (single file) var existingDropzone = document.getElementById('existing-version-dropzone'); var existingFileInput = document.getElementById('existing-version-file-input'); if (existingDropzone && existingFileInput) { initDropzone(existingDropzone, existingFileInput, function(file) { if (targetVersionId) uploadSingleFile(targetVersionId, file); }); } function uploadSingleFile(versionId, file) { document.getElementById('new-version-form').classList.add('hidden'); document.getElementById('existing-version-upload').classList.add('hidden'); document.getElementById('version-upload-progress').classList.remove('hidden'); fetch('/api/versions/' + versionId + '/upload/presign', { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, body: JSON.stringify({ file_name: file.name, content_type: file.type || 'application/octet-stream' }) }) .then(function(res) { if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Presign failed'); }); return res.json(); }) .then(function(data) { return uploader.upload(data.upload_url, file, data.s3_key, 'application/octet-stream', data.cache_control); }) .then(function(s3Key) { return fetch('/api/versions/' + versionId + '/upload/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, body: JSON.stringify({ s3_key: s3Key, file_size_bytes: file.size }) }); }) .then(function(res) { if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Confirm failed'); }); return res.json().catch(function() { return {}; }); }) .then(function(confirmData) { document.getElementById('version-upload-progress').classList.add('hidden'); document.getElementById('version-upload-success').classList.remove('hidden'); if (confirmData && confirmData.pending_review) { showToast('Version upload held for review — our scanner flagged it.', 'warning'); } setTimeout(function() { var filesBtn = document.getElementById('tab-files'); if (filesBtn) filesBtn.click(); }, 1500); }) .catch(function(err) { showVersionError(err.message || 'Upload failed'); }); } document.querySelectorAll('.upload-to-version-btn').forEach(function(btn) { btn.addEventListener('click', function() { targetVersionId = btn.dataset.versionId; document.getElementById('new-version-form').classList.add('hidden'); document.getElementById('existing-version-upload').classList.remove('hidden'); }); }); document.getElementById('cancel-existing-upload-btn').addEventListener('click', function() { targetVersionId = null; document.getElementById('existing-version-upload').classList.add('hidden'); document.getElementById('new-version-form').classList.remove('hidden'); }); document.querySelectorAll('.download-version-btn').forEach(function(btn) { btn.addEventListener('click', function() { fetch('/api/versions/' + btn.dataset.versionId + '/download') .then(function(res) { if (!res.ok) throw new Error('Failed to get download URL'); return res.json(); }) .then(function(data) { window.location.href = data.download_url; }) .catch(function(err) { showToast(err.message); }); }); }); document.querySelectorAll('.delete-version-btn').forEach(function(btn) { btn.addEventListener('click', function() { if (!confirm('Delete this version?')) return; var versionId = btn.dataset.versionId; fetch('/api/items/' + itemId + '/versions/' + versionId, { method: 'DELETE', headers: csrfHeaders() }) .then(function(res) { if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Failed to delete version'); }); var row = btn.closest('tr'); if (row) row.remove(); }) .catch(function(err) { showToast(err.message || 'Failed to delete version'); }); }); }); document.getElementById('cancel-version-upload-btn').addEventListener('click', function() { uploader.cancel(); resetVersionUpload(); }); document.getElementById('retry-version-upload-btn').addEventListener('click', resetVersionUpload); function showVersionError(message) { document.getElementById('version-upload-progress').classList.add('hidden'); document.getElementById('version-upload-error').classList.remove('hidden'); document.getElementById('version-error-message').textContent = message; } function resetVersionUpload() { document.getElementById('new-version-form').classList.remove('hidden'); document.getElementById('existing-version-upload').classList.add('hidden'); document.getElementById('version-upload-progress').classList.add('hidden'); document.getElementById('version-upload-success').classList.add('hidden'); document.getElementById('version-upload-error').classList.add('hidden'); fileQueue = []; fileRows.innerHTML = ''; targetVersionId = null; var btn = document.getElementById('create-version-btn'); btn.disabled = false; btn.textContent = 'Upload All'; } } // Run on initial load init(); // Re-run when HTMX swaps in upload tabs document.body.addEventListener('htmx:afterSwap', function(e) { if (e.detail.target.id === 'tab-content') { init(); } }); })();