/** * What is left of the item upload flows once the uploads themselves are * described. * * The audio file and the single file for an existing version are both one * described field apiece now (`crate::quasi::upload_field`), and the binder in * `upload.js` runs the presign chain behind each. What stays here is the part * that is not one file going to one place: the queue a new version is built * from, which is a version number, a label per file and three requests per * file, and the download and delete buttons on the version table. * * 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), the core module (csrfHeaders, showToast). */ (function() { function init() { initReplaceAudio(); initVersionUpload(); } // ── Audio ── /* The upload is described; what is not is the swap between the audio that is already there and the picker that replaces it. */ function initReplaceAudio() { var replaceBtn = document.getElementById('replace-audio-btn'); if (!replaceBtn) return; replaceBtn.addEventListener('click', function() { var cur = document.getElementById('current-audio'); if (cur) cur.classList.add('hidden'); document.getElementById('upload-area').classList.remove('hidden'); }); } // ── Version Upload ── function initVersionUpload() { var container = document.getElementById('version-upload'); if (!container) return; var itemId = container.dataset.itemId; if (!itemId) return; var fileQueue = []; 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'), }); // The picker is the described field's own input: what it takes and how // many is `upload_field::version_queue`, and the rows below it are this // file's, because a queue with a label per row is not one file going to // one place. var versionFileInput = document.getElementById('version-files'); var fileRows = document.getElementById('version-file-rows'); 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(); }); } // 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 = escapeHtml(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'); }); } // Upload to an existing version. One described field per version that // has no file yet, each carrying its own address, so pressing the // button reveals that version's field and the binder in `upload.js` // does the rest. document.querySelectorAll('.upload-to-version-btn').forEach(function(btn) { btn.addEventListener('click', function() { var panel = document.getElementById('existing-version-upload-' + btn.dataset.versionId); if (!panel) return; document.getElementById('new-version-form').classList.add('hidden'); panel.classList.remove('hidden'); }); }); document.querySelectorAll('.cancel-existing-upload-btn').forEach(function(btn) { btn.addEventListener('click', function() { btn.closest('.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() { window.location.href = '/api/versions/' + btn.dataset.versionId + '/download'; }); }); // The delete button is described: `crate::quasi::version_delete_act`, // Shape 3 step 4. Route, row, prompt and tone all live on the act, and // htmx performs it. The download button above is now a plain navigation // because its route answers 303 to the presigned URL (`8fc6b1af`); it is // describable as an `Action::get` whenever the files tab converts, which // is what taking the JSON away bought. 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.querySelectorAll('.existing-version-upload').forEach(function(panel) { panel.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 = ''; 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 the files panel. Described strip, one region // per panel (`6b24f2df`); this used to be the strip's single container. document.body.addEventListener('htmx:after:settle', function(e) { if (e.target && e.target.id === 'item-files') { init(); } }); })();