Skip to main content

max / makenotwork

13.8 KB · 292 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 label per file and three requests per file.
10 *
11 * The version number, the notes, the picker and Upload All are all described
12 * (`crate::quasi::upload_field`), so nothing here writes a route or a label
13 * down: the run starts at the act's `data-sends` and lands on the surface's
14 * `data-upload-goes`. What is left with no member is the queue itself, a row
15 * per picked file with a field in it; the counting behind that is in
16 * `crate::quasi::item_files`.
17 *
18 * Loaded once in dashboard-item.html. Re-initializes on HTMX tab swap.
19 * Depends on: upload.js (S3Uploader), the core module (csrfHeaders, showToast).
20 */
21 (function() {
22 function init() {
23 initReplaceAudio();
24 initVersionUpload();
25 }
26
27 // ── Audio ──
28
29 /* The upload is described; what is not is the swap between the audio that
30 is already there and the picker that replaces it. */
31 function initReplaceAudio() {
32 var replaceBtn = document.getElementById('replace-audio-btn');
33 if (!replaceBtn) return;
34 replaceBtn.addEventListener('click', function() {
35 var cur = document.getElementById('current-audio');
36 if (cur) cur.classList.add('hidden');
37 document.getElementById('upload-area').classList.remove('hidden');
38 });
39 }
40
41 // ── Version Upload ──
42
43 function initVersionUpload() {
44 var container = document.getElementById('version-upload');
45 if (!container) return;
46
47 // Both addresses are on the markup now: the run starts at the act's
48 // own `data-sends` and lands on the surface's `data-upload-goes`.
49 var createBtn = container.querySelector('button[data-act][data-sends]');
50 if (!createBtn) return;
51 var createRoute = createBtn.dataset.sends;
52 var createLabel = createBtn.textContent;
53 var landsOn = container.dataset.uploadGoes;
54
55 var fileQueue = [];
56
57 var uploader = new S3Uploader({
58 filenameEl: document.getElementById('version-upload-filename'),
59 percentEl: document.getElementById('version-upload-percent'),
60 progressBar: document.getElementById('version-progress-bar'),
61 speedEl: document.getElementById('version-upload-speed'),
62 });
63
64 // The picker and the slots it fills are both described now
65 // (`upload_field::version_queue` and `version_file_queue`), and
66 // `quasi-repeat.js` makes a slot per picked file. What it cannot know
67 // is what a File is called or what to guess for its label, so it
68 // announces each slot and that is filled here.
69 var fileQueue = document.getElementById('version-file');
70
71 document.addEventListener('quasi:repeat:took', function(event) {
72 var slot = event.detail.slot;
73 var file = event.detail.file;
74 if (!fileQueue || !fileQueue.contains(slot)) return;
75
76 // The File itself rides on the slot. Nothing else holds the queue:
77 // the slots are the queue, so removing one removes its file and
78 // the renumbering `quasi-repeat.js` does is not this file's
79 // problem any more.
80 slot.file = file;
81
82 var named = slot.querySelector('label[for]');
83 if (named) named.textContent = file.name;
84 slot.setAttribute('data-repeat-named', '');
85
86 var label = slot.querySelector('input[type="text"]');
87 if (label && !label.value) label.value = guessLabel(file.name);
88 });
89
90 /** The slots standing now, in order, each with its file and label. */
91 function pickedFiles() {
92 if (!fileQueue) return [];
93 return [].slice.call(fileQueue.querySelectorAll('[data-repeat-at]'))
94 .filter(function(slot) { return slot.file; })
95 .map(function(slot) {
96 var input = slot.querySelector('input[type="text"]');
97 return {
98 file: slot.file,
99 slot: slot,
100 label: input ? input.value.trim() : ''
101 };
102 });
103 }
104
105 function guessLabel(name) {
106 var n = name.toLowerCase();
107 if (n.indexOf('aarch64') !== -1 || n.indexOf('arm64') !== -1) return n.indexOf('appimage') !== -1 || n.indexOf('.deb') !== -1 ? 'Linux (aarch64)' : 'macOS (arm)';
108 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)';
109 if (n.indexOf('.dmg') !== -1) return 'macOS';
110 if (n.indexOf('.msi') !== -1 || n.indexOf('.exe') !== -1) return 'Windows';
111 if (n.indexOf('.appimage') !== -1 || n.indexOf('.deb') !== -1) return 'Linux';
112 return '';
113 }
114
115 // Upload all button
116 createBtn.addEventListener('click', function() {
117 var versionNumber = document.getElementById('new-version-number').value.trim();
118 var changelog = document.getElementById('version-changelog').value.trim();
119 var entries = pickedFiles();
120
121 if (!versionNumber) { showToast('Please enter a version number.'); return; }
122 if (entries.length === 0) { showToast('Please add at least one file.'); return; }
123
124 this.disabled = true;
125 this.textContent = 'Uploading...';
126 document.getElementById('version-upload-progress').classList.remove('hidden');
127
128 // The queue stays on screen and says its own progress, one slot at
129 // a time, which is what `Progress` is for. The second copy of this
130 // list that used to live inside the progress panel is gone with it.
131 for (var q = 0; q < entries.length; q++) {
132 entries[q].slot.querySelectorAll('input, button').forEach(function(control) {
133 control.disabled = true;
134 });
135 }
136
137 uploadSequentially(entries, 0, versionNumber, changelog);
138 });
139
140 function formatSize(bytes) {
141 if (bytes > 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
142 if (bytes > 1024) return (bytes / 1024).toFixed(0) + ' KB';
143 return bytes + ' B';
144 }
145
146 /**
147 * Where a slot's own work has got to, in the renderer's own word for
148 * it. `working`, `done` and `failed` are what `Progress` emits, so the
149 * stylesheet has one thing to read whether the server described the
150 * state or this set it.
151 */
152 function slotProgress(entry, progress) {
153 if (entry.slot) entry.slot.setAttribute('data-repeat-progress', progress);
154 }
155
156 function uploadSequentially(entries, i, versionNumber, changelog) {
157 if (i >= entries.length) {
158 document.getElementById('version-upload-progress').classList.add('hidden');
159 document.getElementById('version-upload-success').classList.remove('hidden');
160 // Land back on the files tab, filled. The address is the
161 // surface's `data-upload-goes` rather than one built here: this
162 // clicked `#tab-files`, the tab button, which the described
163 // strip (`6b24f2df`) stopped emitting, so the refresh had been
164 // a no-op. `?tab=files` is what `item_tabs::shown_at` reads.
165 if (landsOn) window.location.href = landsOn;
166 return;
167 }
168
169 var entry = entries[i];
170 var label = entry.label;
171
172 slotProgress(entry, 'working');
173 uploader.filenameEl.textContent = entry.file.name + (entries.length > 1 ? ' (' + (i + 1) + '/' + entries.length + ')' : '');
174
175 fetch(createRoute, {
176 method: 'POST',
177 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
178 body: JSON.stringify({
179 version_number: versionNumber,
180 changelog: changelog || null,
181 label: label || null
182 })
183 })
184 .then(function(res) {
185 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
186 throw new Error(d.error || 'Failed to create version');
187 });
188 return res.json();
189 })
190 .then(function(data) {
191 return fetch('/api/versions/' + data.id + '/upload/presign', {
192 method: 'POST',
193 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
194 body: JSON.stringify({
195 file_name: entry.file.name,
196 content_type: entry.file.type || 'application/octet-stream'
197 })
198 }).then(function(res) {
199 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
200 throw new Error(d.error || 'Failed to get upload URL');
201 });
202 return res.json();
203 }).then(function(presign) {
204 if (presign.max_file_bytes && entry.file.size > presign.max_file_bytes) {
205 var limitMB = (presign.max_file_bytes / (1024 * 1024)).toFixed(0);
206 var fileMB = (entry.file.size / (1024 * 1024)).toFixed(1);
207 throw new Error(entry.file.name + ': ' + fileMB + ' MB exceeds ' + limitMB + ' MB limit');
208 }
209 return uploader.upload(presign.upload_url, entry.file, presign.s3_key, 'application/octet-stream', presign.cache_control)
210 .then(function(s3Key) { return { s3Key: s3Key, versionId: data.id }; });
211 });
212 })
213 .then(function(result) {
214 return fetch('/api/versions/' + result.versionId + '/upload/confirm', {
215 method: 'POST',
216 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
217 body: JSON.stringify({ s3_key: result.s3Key, file_size_bytes: entry.file.size })
218 });
219 })
220 .then(function(res) {
221 if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
222 throw new Error(d.error || 'Failed to confirm upload');
223 });
224 return res.json().catch(function() { return {}; });
225 })
226 .then(function(confirmData) {
227 if (confirmData && confirmData.pending_review) {
228 showToast('Version upload held for review: our scanner flagged it.', 'warning');
229 }
230 slotProgress(entry, 'done');
231 uploadSequentially(entries, i + 1, versionNumber, changelog);
232 })
233 .catch(function(err) {
234 slotProgress(entry, 'failed');
235 showVersionError(err.message || 'Upload failed');
236 });
237 }
238
239 // Nothing binds the version rows any more. The files tab is described
240 // (`crate::quasi::item_files`, `138ad5ab`) and each control in a row
241 // says what it is on the act itself:
242 //
243 // Download an `Action::get` at the route that answers 303 to the
244 // presigned URL (`8fc6b1af`, `9dbe9206`). The five lines
245 // that navigated by hand are gone with it.
246 // Delete `crate::quasi::version_delete_act`, since Shape 3 step 4.
247 //
248 // The reveal pair went with them: a version with no file now shows its
249 // upload field rather than a button that unhides one, because a
250 // described cell holds leaves and acts and cannot name a panel.
251
252 document.getElementById('cancel-version-upload-btn').addEventListener('click', function() {
253 uploader.cancel();
254 resetVersionUpload();
255 });
256
257 document.getElementById('retry-version-upload-btn').addEventListener('click', resetVersionUpload);
258
259 function showVersionError(message) {
260 document.getElementById('version-upload-progress').classList.add('hidden');
261 document.getElementById('version-upload-error').classList.remove('hidden');
262 document.getElementById('version-error-message').textContent = message;
263 }
264
265 function resetVersionUpload() {
266 document.getElementById('new-version-form').classList.remove('hidden');
267 // The per-version upload fields are not hidden any more, so there is
268 // nothing to re-hide here. See `crate::quasi::item_files`.
269 document.getElementById('version-upload-progress').classList.add('hidden');
270 document.getElementById('version-upload-success').classList.add('hidden');
271 document.getElementById('version-upload-error').classList.add('hidden');
272 fileQueue = [];
273 fileRows.innerHTML = '';
274 createBtn.disabled = false;
275 // The label is the description's, read off the control rather than
276 // written out here a second time.
277 createBtn.textContent = createLabel;
278 }
279 }
280
281 // Run on initial load
282 init();
283
284 // Re-run when HTMX swaps in the files panel. Described strip, one region
285 // per panel (`6b24f2df`); this used to be the strip's single container.
286 document.body.addEventListener('htmx:after:settle', function(e) {
287 if (e.target && e.target.id === 'item-files') {
288 init();
289 }
290 });
291 })();
292