/**
* 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() {
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); });
});
});
// 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 stays here because its
// route answers JSON carrying a presigned URL rather than the file.
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();
}
});
})();