/** * 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 label per file and three requests per file. * * The version number, the notes, the picker and Upload All are all described * (`crate::quasi::upload_field`), so nothing here writes a route or a label * down: the run starts at the act's `data-sends` and lands on the surface's * `data-upload-goes`. What is left with no member is the queue itself, a row * per picked file with a field in it; the counting behind that is in * `crate::quasi::item_files`. * * Loaded once in dashboard-item.html. Re-initializes on HTMX tab swap. * 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; // Both addresses are on the markup now: the run starts at the act's // own `data-sends` and lands on the surface's `data-upload-goes`. var createBtn = container.querySelector('button[data-act][data-sends]'); if (!createBtn) return; var createRoute = createBtn.dataset.sends; var createLabel = createBtn.textContent; var landsOn = container.dataset.uploadGoes; 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 and the slots it fills are both described now // (`upload_field::version_queue` and `version_file_queue`), and // `quasi-repeat.js` makes a slot per picked file. What it cannot know // is what a File is called or what to guess for its label, so it // announces each slot and that is filled here. var fileQueue = document.getElementById('version-file'); document.addEventListener('quasi:repeat:took', function(event) { var slot = event.detail.slot; var file = event.detail.file; if (!fileQueue || !fileQueue.contains(slot)) return; // The File itself rides on the slot. Nothing else holds the queue: // the slots are the queue, so removing one removes its file and // the renumbering `quasi-repeat.js` does is not this file's // problem any more. slot.file = file; var named = slot.querySelector('label[for]'); if (named) named.textContent = file.name; slot.setAttribute('data-repeat-named', ''); var label = slot.querySelector('input[type="text"]'); if (label && !label.value) label.value = guessLabel(file.name); }); /** The slots standing now, in order, each with its file and label. */ function pickedFiles() { if (!fileQueue) return []; return [].slice.call(fileQueue.querySelectorAll('[data-repeat-at]')) .filter(function(slot) { return slot.file; }) .map(function(slot) { var input = slot.querySelector('input[type="text"]'); return { file: slot.file, slot: slot, label: input ? input.value.trim() : '' }; }); } 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 ''; } // Upload all button createBtn.addEventListener('click', function() { var versionNumber = document.getElementById('new-version-number').value.trim(); var changelog = document.getElementById('version-changelog').value.trim(); var entries = pickedFiles(); 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('version-upload-progress').classList.remove('hidden'); // The queue stays on screen and says its own progress, one slot at // a time, which is what `Progress` is for. The second copy of this // list that used to live inside the progress panel is gone with it. for (var q = 0; q < entries.length; q++) { entries[q].slot.querySelectorAll('input, button').forEach(function(control) { control.disabled = true; }); } 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'; } /** * Where a slot's own work has got to, in the renderer's own word for * it. `working`, `done` and `failed` are what `Progress` emits, so the * stylesheet has one thing to read whether the server described the * state or this set it. */ function slotProgress(entry, progress) { if (entry.slot) entry.slot.setAttribute('data-repeat-progress', progress); } 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'); // Land back on the files tab, filled. The address is the // surface's `data-upload-goes` rather than one built here: this // clicked `#tab-files`, the tab button, which the described // strip (`6b24f2df`) stopped emitting, so the refresh had been // a no-op. `?tab=files` is what `item_tabs::shown_at` reads. if (landsOn) window.location.href = landsOn; return; } var entry = entries[i]; var label = entry.label; slotProgress(entry, 'working'); uploader.filenameEl.textContent = entry.file.name + (entries.length > 1 ? ' (' + (i + 1) + '/' + entries.length + ')' : ''); fetch(createRoute, { 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'); } slotProgress(entry, 'done'); uploadSequentially(entries, i + 1, versionNumber, changelog); }) .catch(function(err) { slotProgress(entry, 'failed'); showVersionError(err.message || 'Upload failed'); }); } // Nothing binds the version rows any more. The files tab is described // (`crate::quasi::item_files`, `138ad5ab`) and each control in a row // says what it is on the act itself: // // Download an `Action::get` at the route that answers 303 to the // presigned URL (`8fc6b1af`, `9dbe9206`). The five lines // that navigated by hand are gone with it. // Delete `crate::quasi::version_delete_act`, since Shape 3 step 4. // // The reveal pair went with them: a version with no file now shows its // upload field rather than a button that unhides one, because a // described cell holds leaves and acts and cannot name a panel. 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'); // The per-version upload fields are not hidden any more, so there is // nothing to re-hide here. See `crate::quasi::item_files`. 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 = ''; createBtn.disabled = false; // The label is the description's, read off the control rather than // written out here a second time. createBtn.textContent = createLabel; } } // 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(); } }); })();