Skip to main content

max / makenotwork

15.1 KB · 300 lines History Blame Raw
1 /**
2 * What is left of the item upload flows once the uploads themselves are
3 * described.
4 *
5 * The audio file and the single file for an existing version are both one
6 * described field apiece now (`crate::quasi::upload_field`), and the binder in
7 * `upload.js` runs the presign chain behind each. What stays here is the part
8 * that is not one file going to one place: the queue a new version is built
9 * from, which is a version number, a label per file and three requests per
10 * file, and the download and delete buttons on the version table.
11 *
12 * Loaded once in dashboard-item.html. Re-initializes on HTMX tab swap.
13 * Reads item ID from data-item-id on the container element.
14 * Depends on: upload.js (S3Uploader), the core module (csrfHeaders, showToast).
15 */
16 (function() {
17 function init() {
18 initReplaceAudio();
19 initVersionUpload();
20 }
21
22 // ── Audio ──
23
24 /* The upload is described; what is not is the swap between the audio that
25 is already there and the picker that replaces it. */
26 function initReplaceAudio() {
27 var replaceBtn = document.getElementById('replace-audio-btn');
28 if (!replaceBtn) return;
29 replaceBtn.addEventListener('click', function() {
30 var cur = document.getElementById('current-audio');
31 if (cur) cur.classList.add('hidden');
32 document.getElementById('upload-area').classList.remove('hidden');
33 });
34 }
35
36 // ── Version Upload ──
37
38 function initVersionUpload() {
39 var container = document.getElementById('version-upload');
40 if (!container) return;
41 var itemId = container.dataset.itemId;
42 if (!itemId) return;
43
44 var fileQueue = [];
45
46 var uploader = new S3Uploader({
47 filenameEl: document.getElementById('version-upload-filename'),
48 percentEl: document.getElementById('version-upload-percent'),
49 progressBar: document.getElementById('version-progress-bar'),
50 speedEl: document.getElementById('version-upload-speed'),
51 });
52
53 // The picker is the described field's own input: what it takes and how
54 // many is `upload_field::version_queue`, and the rows below it are this
55 // file's, because a queue with a label per row is not one file going to
56 // one place.
57 var versionFileInput = document.getElementById('version-files');
58 var fileRows = document.getElementById('version-file-rows');
59
60 if (versionFileInput) {
61 versionFileInput.addEventListener('change', function() {
62 for (var i = 0; i < this.files.length; i++) addFileRow(this.files[i]);
63 this.value = '';
64 });
65 }
66
67 function guessLabel(name) {
68 var n = name.toLowerCase();
69 if (n.indexOf('aarch64') !== -1 || n.indexOf('arm64') !== -1) return n.indexOf('appimage') !== -1 || n.indexOf('.deb') !== -1 ? 'Linux (aarch64)' : 'macOS (arm)';
70 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)';
71 if (n.indexOf('.dmg') !== -1) return 'macOS';
72 if (n.indexOf('.msi') !== -1 || n.indexOf('.exe') !== -1) return 'Windows';
73 if (n.indexOf('.appimage') !== -1 || n.indexOf('.deb') !== -1) return 'Linux';
74 return '';
75 }
76
77 function addFileRow(file) {
78 var idx = fileQueue.length;
79 fileQueue.push({ file: file, idx: idx });
80 var tr = document.createElement('tr');
81 tr.dataset.idx = idx;
82 tr.style.borderBottom = '1px solid var(--border)';
83 tr.innerHTML =
84 '<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="' + escapeHtml(file.name) + '">' + escapeHtml(file.name) + '</td>' +
85 '<td style="padding: 0.4rem 0.5rem;"><input type="text" class="version-label-input" data-idx="' + idx + '" value="' + escapeHtml(guessLabel(file.name)) + '" placeholder="e.g., macOS (arm)" style="width: 100%; padding: 0.25rem 0.4rem; font-size: 0.85rem;"></td>' +
86 '<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>';
87 fileRows.appendChild(tr);
88
89 tr.querySelector('.version-remove-file').addEventListener('click', function() {
90 fileQueue[idx] = null;
91 tr.remove();
92 });
93 }
94
95 // Upload all button
96 document.getElementById('create-version-btn').addEventListener('click', function() {
97 var versionNumber = document.getElementById('new-version-number').value.trim();
98 var changelog = document.getElementById('version-changelog').value.trim();
99 var entries = fileQueue.filter(function(e) { return e !== null; });
100
101 if (!versionNumber) { showToast('Please enter a version number.'); return; }
102 if (entries.length === 0) { showToast('Please add at least one file.'); return; }
103
104 this.disabled = true;
105 this.textContent = 'Uploading...';
106 document.getElementById('new-version-form').classList.add('hidden');
107 document.getElementById('version-upload-progress').classList.remove('hidden');
108
109 // Build queue display
110 var queueEl = document.getElementById('version-upload-queue');
111 queueEl.innerHTML = '';
112 for (var q = 0; q < entries.length; q++) {
113 var li = document.createElement('div');
114 li.id = 'queue-item-' + entries[q].idx;
115 li.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; font-size: 0.85rem;';
116 var labelInput = document.querySelector('.version-label-input[data-idx="' + entries[q].idx + '"]');
117 var labelText = labelInput ? labelInput.value.trim() : '';
118 var displayName = escapeHtml(entries[q].file.name) + (labelText ? ' (' + escapeHtml(labelText) + ')' : '');
119 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>';
120 queueEl.appendChild(li);
121 }
122
123 uploadSequentially(entries, 0, versionNumber, changelog);
124 });
125
126 function formatSize(bytes) {
127 if (bytes > 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
128 if (bytes > 1024) return (bytes / 1024).toFixed(0) + ' KB';
129 return bytes + ' B';
130 }
131
132 function updateQueueStatus(idx, status) {
133 var el = document.getElementById('queue-item-' + idx);
134 if (!el) return;
135 var s = el.querySelector('.queue-status');
136 if (status === 'uploading') { s.textContent = '...'; s.style.opacity = '1'; }
137 else if (status === 'done') { s.textContent = 'OK'; s.style.opacity = '0.7'; el.style.opacity = '0.6'; }
138 else if (status === 'error') { s.textContent = '!'; s.style.color = 'var(--error, #c0392b)'; s.style.opacity = '1'; }
139 }
140
141 function uploadSequentially(entries, i, versionNumber, changelog) {
142 if (i >= entries.length) {
143 document.getElementById('version-upload-progress').classList.add('hidden');
144 document.getElementById('version-upload-success').classList.remove('hidden');
145 setTimeout(function() {
146 var filesBtn = document.getElementById('tab-files');
147 if (filesBtn) filesBtn.click();
148 }, 1500);
149 return;
150 }
151
152 var entry = entries[i];
153 var labelInput = document.querySelector('.version-label-input[data-idx="' + entry.idx + '"]');
154 var label = labelInput ? labelInput.value.trim() : '';
155
156 updateQueueStatus(entry.idx, 'uploading');
157 uploader.filenameEl.textContent = entry.file.name + (entries.length > 1 ? ' (' + (i + 1) + '/' + entries.length + ')' : '');
158
159 fetch('/api/items/' + itemId + '/versions', {
160 method: 'POST',
161 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
162 body: JSON.stringify({
163 version_number: versionNumber,
164 changelog: changelog || null,
165 label: label || null
166 })
167 })
168 .then(function(res) {
169 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
170 throw new Error(d.error || 'Failed to create version');
171 });
172 return res.json();
173 })
174 .then(function(data) {
175 return fetch('/api/versions/' + data.id + '/upload/presign', {
176 method: 'POST',
177 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
178 body: JSON.stringify({
179 file_name: entry.file.name,
180 content_type: entry.file.type || 'application/octet-stream'
181 })
182 }).then(function(res) {
183 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
184 throw new Error(d.error || 'Failed to get upload URL');
185 });
186 return res.json();
187 }).then(function(presign) {
188 if (presign.max_file_bytes && entry.file.size > presign.max_file_bytes) {
189 var limitMB = (presign.max_file_bytes / (1024 * 1024)).toFixed(0);
190 var fileMB = (entry.file.size / (1024 * 1024)).toFixed(1);
191 throw new Error(entry.file.name + ': ' + fileMB + ' MB exceeds ' + limitMB + ' MB limit');
192 }
193 return uploader.upload(presign.upload_url, entry.file, presign.s3_key, 'application/octet-stream', presign.cache_control)
194 .then(function(s3Key) { return { s3Key: s3Key, versionId: data.id }; });
195 });
196 })
197 .then(function(result) {
198 return fetch('/api/versions/' + result.versionId + '/upload/confirm', {
199 method: 'POST',
200 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
201 body: JSON.stringify({ s3_key: result.s3Key, file_size_bytes: entry.file.size })
202 });
203 })
204 .then(function(res) {
205 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
206 throw new Error(d.error || 'Failed to confirm upload');
207 });
208 return res.json().catch(function() { return {}; });
209 })
210 .then(function(confirmData) {
211 if (confirmData && confirmData.pending_review) {
212 showToast('Version upload held for review: our scanner flagged it.', 'warning');
213 }
214 updateQueueStatus(entry.idx, 'done');
215 uploadSequentially(entries, i + 1, versionNumber, changelog);
216 })
217 .catch(function(err) {
218 updateQueueStatus(entry.idx, 'error');
219 showVersionError(err.message || 'Upload failed');
220 });
221 }
222
223 // Upload to an existing version. One described field per version that
224 // has no file yet, each carrying its own address, so pressing the
225 // button reveals that version's field and the binder in `upload.js`
226 // does the rest.
227 document.querySelectorAll('.upload-to-version-btn').forEach(function(btn) {
228 btn.addEventListener('click', function() {
229 var panel = document.getElementById('existing-version-upload-' + btn.dataset.versionId);
230 if (!panel) return;
231 document.getElementById('new-version-form').classList.add('hidden');
232 panel.classList.remove('hidden');
233 });
234 });
235
236 document.querySelectorAll('.cancel-existing-upload-btn').forEach(function(btn) {
237 btn.addEventListener('click', function() {
238 btn.closest('.existing-version-upload').classList.add('hidden');
239 document.getElementById('new-version-form').classList.remove('hidden');
240 });
241 });
242
243 document.querySelectorAll('.download-version-btn').forEach(function(btn) {
244 btn.addEventListener('click', function() {
245 fetch('/api/versions/' + btn.dataset.versionId + '/download')
246 .then(function(res) {
247 if (!res.ok) throw new Error('Failed to get download URL');
248 return res.json();
249 })
250 .then(function(data) { window.location.href = data.download_url; })
251 .catch(function(err) { showToast(err.message); });
252 });
253 });
254
255 // The delete button is described: `crate::quasi::version_delete_act`,
256 // Shape 3 step 4. Route, row, prompt and tone all live on the act, and
257 // htmx performs it. The download button above stays here because its
258 // route answers JSON carrying a presigned URL rather than the file.
259
260 document.getElementById('cancel-version-upload-btn').addEventListener('click', function() {
261 uploader.cancel();
262 resetVersionUpload();
263 });
264
265 document.getElementById('retry-version-upload-btn').addEventListener('click', resetVersionUpload);
266
267 function showVersionError(message) {
268 document.getElementById('version-upload-progress').classList.add('hidden');
269 document.getElementById('version-upload-error').classList.remove('hidden');
270 document.getElementById('version-error-message').textContent = message;
271 }
272
273 function resetVersionUpload() {
274 document.getElementById('new-version-form').classList.remove('hidden');
275 document.querySelectorAll('.existing-version-upload').forEach(function(panel) {
276 panel.classList.add('hidden');
277 });
278 document.getElementById('version-upload-progress').classList.add('hidden');
279 document.getElementById('version-upload-success').classList.add('hidden');
280 document.getElementById('version-upload-error').classList.add('hidden');
281 fileQueue = [];
282 fileRows.innerHTML = '';
283 var btn = document.getElementById('create-version-btn');
284 btn.disabled = false;
285 btn.textContent = 'Upload All';
286 }
287 }
288
289 // Run on initial load
290 init();
291
292 // Re-run when HTMX swaps in the files panel. Described strip, one region
293 // per panel (`6b24f2df`); this used to be the strip's single container.
294 document.body.addEventListener('htmx:after:settle', function(e) {
295 if (e.target && e.target.id === 'item-files') {
296 init();
297 }
298 });
299 })();
300