Skip to main content

max / makenotwork

14.1 KB · 276 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 // Land back on the files tab, filled. This clicked
146 // `#tab-files`, the tab button, which the described strip
147 // (`6b24f2df`) stopped emitting -- so the refresh had been a
148 // no-op. `?tab=files` is what `item_tabs::shown_at` reads.
149 window.location.href = '/dashboard/item/' + itemId + '?tab=files';
150 return;
151 }
152
153 var entry = entries[i];
154 var labelInput = document.querySelector('.version-label-input[data-idx="' + entry.idx + '"]');
155 var label = labelInput ? labelInput.value.trim() : '';
156
157 updateQueueStatus(entry.idx, 'uploading');
158 uploader.filenameEl.textContent = entry.file.name + (entries.length > 1 ? ' (' + (i + 1) + '/' + entries.length + ')' : '');
159
160 fetch('/api/items/' + itemId + '/versions', {
161 method: 'POST',
162 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
163 body: JSON.stringify({
164 version_number: versionNumber,
165 changelog: changelog || null,
166 label: label || null
167 })
168 })
169 .then(function(res) {
170 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
171 throw new Error(d.error || 'Failed to create version');
172 });
173 return res.json();
174 })
175 .then(function(data) {
176 return fetch('/api/versions/' + data.id + '/upload/presign', {
177 method: 'POST',
178 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
179 body: JSON.stringify({
180 file_name: entry.file.name,
181 content_type: entry.file.type || 'application/octet-stream'
182 })
183 }).then(function(res) {
184 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
185 throw new Error(d.error || 'Failed to get upload URL');
186 });
187 return res.json();
188 }).then(function(presign) {
189 if (presign.max_file_bytes && entry.file.size > presign.max_file_bytes) {
190 var limitMB = (presign.max_file_bytes / (1024 * 1024)).toFixed(0);
191 var fileMB = (entry.file.size / (1024 * 1024)).toFixed(1);
192 throw new Error(entry.file.name + ': ' + fileMB + ' MB exceeds ' + limitMB + ' MB limit');
193 }
194 return uploader.upload(presign.upload_url, entry.file, presign.s3_key, 'application/octet-stream', presign.cache_control)
195 .then(function(s3Key) { return { s3Key: s3Key, versionId: data.id }; });
196 });
197 })
198 .then(function(result) {
199 return fetch('/api/versions/' + result.versionId + '/upload/confirm', {
200 method: 'POST',
201 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
202 body: JSON.stringify({ s3_key: result.s3Key, file_size_bytes: entry.file.size })
203 });
204 })
205 .then(function(res) {
206 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
207 throw new Error(d.error || 'Failed to confirm upload');
208 });
209 return res.json().catch(function() { return {}; });
210 })
211 .then(function(confirmData) {
212 if (confirmData && confirmData.pending_review) {
213 showToast('Version upload held for review: our scanner flagged it.', 'warning');
214 }
215 updateQueueStatus(entry.idx, 'done');
216 uploadSequentially(entries, i + 1, versionNumber, changelog);
217 })
218 .catch(function(err) {
219 updateQueueStatus(entry.idx, 'error');
220 showVersionError(err.message || 'Upload failed');
221 });
222 }
223
224 // Nothing binds the version rows any more. The files tab is described
225 // (`crate::quasi::item_files`, `138ad5ab`) and each control in a row
226 // says what it is on the act itself:
227 //
228 // Download an `Action::get` at the route that answers 303 to the
229 // presigned URL (`8fc6b1af`, `9dbe9206`). The five lines
230 // that navigated by hand are gone with it.
231 // Delete `crate::quasi::version_delete_act`, since Shape 3 step 4.
232 //
233 // The reveal pair went with them: a version with no file now shows its
234 // upload field rather than a button that unhides one, because a
235 // described cell holds leaves and acts and cannot name a panel.
236
237 document.getElementById('cancel-version-upload-btn').addEventListener('click', function() {
238 uploader.cancel();
239 resetVersionUpload();
240 });
241
242 document.getElementById('retry-version-upload-btn').addEventListener('click', resetVersionUpload);
243
244 function showVersionError(message) {
245 document.getElementById('version-upload-progress').classList.add('hidden');
246 document.getElementById('version-upload-error').classList.remove('hidden');
247 document.getElementById('version-error-message').textContent = message;
248 }
249
250 function resetVersionUpload() {
251 document.getElementById('new-version-form').classList.remove('hidden');
252 // The per-version upload fields are not hidden any more, so there is
253 // nothing to re-hide here. See `crate::quasi::item_files`.
254 document.getElementById('version-upload-progress').classList.add('hidden');
255 document.getElementById('version-upload-success').classList.add('hidden');
256 document.getElementById('version-upload-error').classList.add('hidden');
257 fileQueue = [];
258 fileRows.innerHTML = '';
259 var btn = document.getElementById('create-version-btn');
260 btn.disabled = false;
261 btn.textContent = 'Upload All';
262 }
263 }
264
265 // Run on initial load
266 init();
267
268 // Re-run when HTMX swaps in the files panel. Described strip, one region
269 // per panel (`6b24f2df`); this used to be the strip's single container.
270 document.body.addEventListener('htmx:after:settle', function(e) {
271 if (e.target && e.target.id === 'item-files') {
272 init();
273 }
274 });
275 })();
276