Skip to main content

max / makenotwork

Describe the two single-file uploads, and bind the chain to what they emit Shape 3, steps 1 and 3. `crate::quasi::upload_field` is the Askama entry point for a described upload: the accept list, the multiplicity, the destination, the payload and the wait all come out of `Field::upload` and `Action::by_host` (f7261a5a, a81384d4), and the renderer emits `data-sends` and performs nothing. `static/upload.js` gains the host half. It finds every described upload, binds the drop gesture the vocabulary deliberately does not carry, and runs presign, PUT and confirm behind the one address, deriving the confirm route from the presign one rather than being told twice. Progress is drawn rather than templated, which is what `Awaiting` means by the renderer observing the rest. The audio surface and the per-version single file both move onto it. What is left in `item-upload.js` is the queue a new version is built from, which is three requests per file and a label per row, and the download and delete buttons on the version table. Steps 2, 4, 5 and 6 of the plan are open; 4 waits on 27d5e5b8.
Author: Max Johnson <me@maxj.phd> · 2026-08-20 19:36 UTC
Signed with PGP, not checked
Commit: 00cd6aa5ab52a1ac6a75e73c485abc0ef71cb352
Parent: 1bf5272
10 files changed, +616 insertions, -252 deletions
M server/Cargo.lock +13 -13
@@ -5260,7 +5260,7 @@
5260 5260
5261 5261 [[package]]
5262 5262 name = "makenotwork"
5263 - version = "0.14.0"
5263 + version = "0.15.0"
5264 5264 dependencies = [
5265 5265 "ammonia",
5266 5266 "anyhow",
@@ -10733,16 +10733,12 @@
10733 10733 ]
10734 10734
10735 10735 [[patch.unused]]
10736 - name = "kberg"
10737 - version = "0.1.0"
10736 + name = "synckit-client"
10737 + version = "0.8.0"
10738 10738
10739 10739 [[patch.unused]]
10740 - name = "ops-status"
10741 - version = "0.1.0"
10742 -
10743 - [[patch.unused]]
10744 - name = "painhours"
10745 - version = "0.1.0"
10740 + name = "synckit-config"
10741 + version = "0.2.0"
10746 10742
10747 10743 [[patch.unused]]
10748 10744 name = "makeover-immediate"
@@ -10753,12 +10749,16 @@
10753 10749 version = "0.30.0"
10754 10750
10755 10751 [[patch.unused]]
10756 - name = "synckit-client"
10757 - version = "0.8.0"
10752 + name = "kberg"
10753 + version = "0.1.0"
10758 10754
10759 10755 [[patch.unused]]
10760 - name = "synckit-config"
10761 - version = "0.2.0"
10756 + name = "ops-status"
10757 + version = "0.1.0"
10758 +
10759 + [[patch.unused]]
10760 + name = "painhours"
10761 + version = "0.1.0"
10762 10762
10763 10763 [[patch.unused]]
10764 10764 name = "quasi-immediate"
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.14.0"
3 + version = "0.15.0"
4 4 edition = "2024"
5 5 license = "LicenseRef-PolyForm-Noncommercial-1.0.0"
6 6 # Server binary: never published to a registry. Marks the crate private so
@@ -6,7 +6,7 @@
6 6 "license": {
7 7 "name": "PolyForm Noncommercial 1.0.0"
8 8 },
9 - "version": "0.14.0"
9 + "version": "0.15.0"
10 10 },
11 11 "paths": {
12 12 "/api/git/{owner}/{repo}/notes": {
@@ -1,143 +1,36 @@
1 1 /**
2 - * Item upload flows: audio file upload and version file upload.
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.
3 11 *
4 12 * Loaded once in dashboard-item.html. Re-initializes on HTMX tab swap.
5 13 * Reads item ID from data-item-id on the container element.
6 - * Depends on: upload.js (S3Uploader, initDropzone), the core module (csrfHeaders, showToast).
14 + * Depends on: upload.js (S3Uploader), the core module (csrfHeaders, showToast).
7 15 */
8 16 (function() {
9 17 function init() {
10 - initAudioUpload();
18 + initReplaceAudio();
11 19 initVersionUpload();
12 20 }
13 21
14 - // ── Audio Upload ──
15 -
16 - function initAudioUpload() {
17 - var container = document.getElementById('audio-upload');
18 - if (!container) return;
19 - var itemId = container.dataset.itemId;
20 - if (!itemId) return;
21 -
22 - var uploader = new S3Uploader({
23 - filenameEl: document.getElementById('upload-filename'),
24 - percentEl: document.getElementById('upload-percent'),
25 - progressBar: document.getElementById('progress-bar'),
26 - speedEl: document.getElementById('upload-speed'),
27 - });
28 -
29 - initDropzone(
30 - document.getElementById('audio-dropzone'),
31 - document.getElementById('audio-file-input'),
32 - function(file) {
33 - if (file.type.startsWith('audio/') || file.name.match(/\.(mp3|wav|flac|m4a|ogg)$/i)) {
34 - uploadAudio(file);
35 - }
36 - }
37 - );
22 + // ── Audio ──
38 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() {
39 27 var replaceBtn = document.getElementById('replace-audio-btn');
40 - if (replaceBtn) {
41 - replaceBtn.addEventListener('click', function() {
42 - var cur = document.getElementById('current-audio');
43 - if (cur) cur.classList.add('hidden');
44 - document.getElementById('upload-area').classList.remove('hidden');
45 - resetUpload();
46 - });
47 - }
48 -
49 - var lastFile = null;
50 -
51 - document.getElementById('cancel-upload-btn').addEventListener('click', function() {
52 - uploader.cancel();
53 - resetUpload();
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');
54 33 });
55 -
56 - document.getElementById('retry-upload-btn').addEventListener('click', function() {
57 - if (lastFile) {
58 - document.getElementById('upload-error').classList.add('hidden');
59 - uploadAudio(lastFile);
60 - } else {
61 - resetUpload();
62 - }
63 - });
64 -
65 - function resetUpload() {
66 - lastFile = null;
67 - document.getElementById('audio-dropzone').classList.remove('hidden');
68 - document.getElementById('upload-progress').classList.add('hidden');
69 - document.getElementById('upload-success').classList.add('hidden');
70 - document.getElementById('upload-error').classList.add('hidden');
71 - document.getElementById('audio-file-input').value = '';
72 - }
73 -
74 - function uploadAudio(file) {
75 - lastFile = file;
76 - document.getElementById('audio-dropzone').classList.add('hidden');
77 - document.getElementById('upload-progress').classList.remove('hidden');
78 -
79 - fetch('/api/upload/presign', {
80 - method: 'POST',
81 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
82 - body: JSON.stringify({
83 - item_id: itemId,
84 - file_type: 'audio',
85 - file_name: file.name,
86 - content_type: file.type || 'audio/mpeg',
87 - file_size_bytes: file.size
88 - })
89 - })
90 - .then(function(res) {
91 - if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
92 - throw new Error(d.error || 'Failed to get upload URL');
93 - });
94 - return res.json();
95 - })
96 - .then(function(data) {
97 - if (data.max_file_bytes && file.size > data.max_file_bytes) {
98 - var limitMB = (data.max_file_bytes / (1024 * 1024)).toFixed(0);
99 - var fileMB = (file.size / (1024 * 1024)).toFixed(1);
100 - throw new Error('File is ' + fileMB + ' MB but your plan allows up to ' + limitMB + ' MB per file. Upgrade your tier or use a smaller file.');
101 - }
102 - return uploader.upload(data.upload_url, file, data.s3_key, 'audio/mpeg', data.cache_control);
103 - })
104 - .then(function(s3Key) {
105 - return fetch('/api/upload/confirm', {
106 - method: 'POST',
107 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
108 - body: JSON.stringify({
109 - item_id: itemId,
110 - file_type: 'audio',
111 - s3_key: s3Key
112 - })
113 - });
114 - })
115 - .then(function(res) {
116 - if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
117 - throw new Error(d.error || 'Failed to confirm upload');
118 - });
119 - return res.json().catch(function() { return {}; });
120 - })
121 - .then(function(result) {
122 - document.getElementById('upload-progress').classList.add('hidden');
123 - document.getElementById('upload-success').classList.remove('hidden');
124 - // Scan flagged the file for manual review, surface it as a
125 - // toast so the creator knows their content isn't public yet.
126 - if (result && result.pending_review) {
127 - showToast(
128 - 'Upload accepted but held for review: our scanner flagged it. ' +
129 - "You'll get an email once it's cleared.",
130 - 'warning'
131 - );
132 - }
133 - setTimeout(function() { window.location.href = '/dashboard/item/' + itemId + '?tab=files'; }, 1500);
134 - })
135 - .catch(function(err) {
136 - document.getElementById('upload-progress').classList.add('hidden');
137 - document.getElementById('upload-error').classList.remove('hidden');
138 - document.getElementById('error-message').textContent = err.message || 'Upload failed';
139 - });
140 - }
141 34 }
142 35
143 36 // ── Version Upload ──
@@ -149,7 +42,6 @@
149 42 if (!itemId) return;
150 43
151 44 var fileQueue = [];
152 - var targetVersionId = null;
153 45
154 46 var uploader = new S3Uploader({
155 47 filenameEl: document.getElementById('version-upload-filename'),
@@ -158,15 +50,13 @@
158 50 speedEl: document.getElementById('version-upload-speed'),
159 51 });
160 52
161 - var versionFileInput = document.getElementById('version-file-input');
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');
162 58 var fileRows = document.getElementById('version-file-rows');
163 59
164 - // Add files button
165 - var addBtn = document.getElementById('add-version-file-btn');
166 - if (addBtn) {
167 - addBtn.addEventListener('click', function() { versionFileInput.click(); });
168 - }
169 -
170 60 if (versionFileInput) {
171 61 versionFileInput.addEventListener('change', function() {
172 62 for (var i = 0; i < this.files.length; i++) addFileRow(this.files[i]);
@@ -330,69 +220,24 @@
330 220 });
331 221 }
332 222
333 - // Existing version upload (single file)
334 - var existingDropzone = document.getElementById('existing-version-dropzone');
335 - var existingFileInput = document.getElementById('existing-version-file-input');
336 - if (existingDropzone && existingFileInput) {
337 - initDropzone(existingDropzone, existingFileInput, function(file) {
338 - if (targetVersionId) uploadSingleFile(targetVersionId, file);
339 - });
340 - }
341 -
342 - function uploadSingleFile(versionId, file) {
343 - document.getElementById('new-version-form').classList.add('hidden');
344 - document.getElementById('existing-version-upload').classList.add('hidden');
345 - document.getElementById('version-upload-progress').classList.remove('hidden');
346 -
347 - fetch('/api/versions/' + versionId + '/upload/presign', {
348 - method: 'POST',
349 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
350 - body: JSON.stringify({ file_name: file.name, content_type: file.type || 'application/octet-stream' })
351 - })
352 - .then(function(res) {
353 - if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Presign failed'); });
354 - return res.json();
355 - })
356 - .then(function(data) {
357 - return uploader.upload(data.upload_url, file, data.s3_key, 'application/octet-stream', data.cache_control);
358 - })
359 - .then(function(s3Key) {
360 - return fetch('/api/versions/' + versionId + '/upload/confirm', {
361 - method: 'POST',
362 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
363 - body: JSON.stringify({ s3_key: s3Key, file_size_bytes: file.size })
364 - });
365 - })
366 - .then(function(res) {
367 - if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Confirm failed'); });
368 - return res.json().catch(function() { return {}; });
369 - })
370 - .then(function(confirmData) {
371 - document.getElementById('version-upload-progress').classList.add('hidden');
372 - document.getElementById('version-upload-success').classList.remove('hidden');
373 - if (confirmData && confirmData.pending_review) {
374 - showToast('Version upload held for review: our scanner flagged it.', 'warning');
375 - }
376 - setTimeout(function() {
377 - var filesBtn = document.getElementById('tab-files');
378 - if (filesBtn) filesBtn.click();
379 - }, 1500);
380 - })
381 - .catch(function(err) { showVersionError(err.message || 'Upload failed'); });
382 - }
383 -
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.
384 227 document.querySelectorAll('.upload-to-version-btn').forEach(function(btn) {
385 228 btn.addEventListener('click', function() {
386 - targetVersionId = btn.dataset.versionId;
229 + var panel = document.getElementById('existing-version-upload-' + btn.dataset.versionId);
230 + if (!panel) return;
387 231 document.getElementById('new-version-form').classList.add('hidden');
388 - document.getElementById('existing-version-upload').classList.remove('hidden');
232 + panel.classList.remove('hidden');
389 233 });
390 234 });
391 235
392 - document.getElementById('cancel-existing-upload-btn').addEventListener('click', function() {
393 - targetVersionId = null;
394 - document.getElementById('existing-version-upload').classList.add('hidden');
395 - document.getElementById('new-version-form').classList.remove('hidden');
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 + });
396 241 });
397 242
398 243 document.querySelectorAll('.download-version-btn').forEach(function(btn) {
@@ -441,13 +286,14 @@
441 286
442 287 function resetVersionUpload() {
443 288 document.getElementById('new-version-form').classList.remove('hidden');
444 - document.getElementById('existing-version-upload').classList.add('hidden');
289 + document.querySelectorAll('.existing-version-upload').forEach(function(panel) {
290 + panel.classList.add('hidden');
291 + });
445 292 document.getElementById('version-upload-progress').classList.add('hidden');
446 293 document.getElementById('version-upload-success').classList.add('hidden');
447 294 document.getElementById('version-upload-error').classList.add('hidden');
448 295 fileQueue = [];
449 296 fileRows.innerHTML = '';
450 - targetVersionId = null;
451 297 var btn = document.getElementById('create-version-btn');
452 298 btn.disabled = false;
453 299 btn.textContent = 'Upload All';
@@ -112,10 +112,181 @@
112 112 dropzoneEl.classList.remove('dragover');
113 113 if (e.dataTransfer.files[0]) onFile(e.dataTransfer.files[0]);
114 114 });
115 - dropzoneEl.addEventListener('click', function() {
115 + dropzoneEl.addEventListener('click', function(e) {
116 + // Not when the picker or its label was clicked. The input is inside the
117 + // drop area now that the markup comes from the description, and both of
118 + // those already open the dialog, so opening it again on the way up is a
119 + // second dialog.
120 + if (e.target === fileInputEl) return;
121 + if (e.target.closest && e.target.closest('label')) return;
116 122 fileInputEl.click();
117 123 });
118 124 fileInputEl.addEventListener('change', function() {
119 125 if (fileInputEl.files[0]) onFile(fileInputEl.files[0]);
120 126 });
121 127 }
128 +
129 + /**
130 + * The described-upload binder.
131 + *
132 + * Shape 3 of the conversion plan. A described upload emits `data-sends` (where
133 + * the bytes go), `data-vals` (what rides with them) and `data-awaiting` (that
134 + * the call waits) and performs nothing, because the chain behind that one
135 + * address is three requests and no renderer can be told about them
136 + * (quasicoherent `a81384d4`). This is the host half: it finds every such field,
137 + * binds the drop gesture the description deliberately does not carry, and runs
138 + * presign, PUT and confirm.
139 + *
140 + * The chain is derived from the one address rather than configured twice: every
141 + * upload family here answers at `<something>/presign` and `<something>/confirm`
142 + * (`/api/upload`, `/api/versions/{id}/upload`, `/api/internal/upload`), so the
143 + * confirm route is the presign route with its last segment swapped.
144 + *
145 + * Two host attributes, both about what happens after the bytes land and neither
146 + * of them describable: `data-upload-goes` is an address to follow, and
147 + * `data-upload-refreshes` is a control to press. They sit on an ancestor of the
148 + * field, since they belong to the surface rather than to the file.
149 + */
150 + (function() {
151 + function payload(sender) {
152 + try {
153 + return JSON.parse(sender.dataset.vals || '{}');
154 + } catch (e) {
155 + return {};
156 + }
157 + }
158 +
159 + function confirmUrl(presign) {
160 + return presign.replace(/\/presign$/, '/confirm');
161 + }
162 +
163 + function jsonPost(url, body) {
164 + return fetch(url, {
165 + method: 'POST',
166 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
167 + body: JSON.stringify(body)
168 + }).then(function(res) {
169 + if (!res.ok) {
170 + return res.json().catch(function() { return {}; }).then(function(d) {
171 + throw new Error(d.error || 'Upload failed');
172 + });
173 + }
174 + return res.json().catch(function() { return {}; });
175 + });
176 + }
177 +
178 + /* The bar, built rather than templated: progress is the renderer's to
179 + observe and the host's to draw, so no markup for it is written down. */
180 + function progressPanel() {
181 + var panel = document.createElement('div');
182 + panel.className = 'upload-progress';
183 + panel.innerHTML =
184 + '<div class="progress-info"><span data-upload-name></span>' +
185 + '<span data-upload-percent>0%</span></div>' +
186 + '<div class="progress-bar-container"><div class="progress-bar"></div></div>' +
187 + '<div class="upload-speed-text" data-upload-speed></div>' +
188 + '<button type="button" class="btn-secondary" data-upload-cancel>Cancel</button>';
189 + return panel;
190 + }
191 +
192 + function bind(sender) {
193 + if (sender.dataset.uploadBound) return;
194 + var input = sender.querySelector('input[type="file"]');
195 + if (!input) return;
196 + sender.dataset.uploadBound = '1';
197 +
198 + var area = sender.closest('.file-upload-area') || sender;
199 + var surface = sender.closest('[data-upload-goes], [data-upload-refreshes]');
200 + var panel = null;
201 + var uploader = null;
202 +
203 + initDropzone(area, input, send);
204 +
205 + function done() {
206 + if (panel) { panel.remove(); panel = null; }
207 + input.value = '';
208 + }
209 +
210 + function send(file) {
211 + panel = progressPanel();
212 + area.insertAdjacentElement('afterend', panel);
213 + uploader = new S3Uploader({
214 + filenameEl: panel.querySelector('[data-upload-name]'),
215 + percentEl: panel.querySelector('[data-upload-percent]'),
216 + progressBar: panel.querySelector('.progress-bar'),
217 + speedEl: panel.querySelector('[data-upload-speed]'),
218 + });
219 + panel.querySelector('[data-upload-cancel]').addEventListener('click', function() {
220 + uploader.cancel();
221 + done();
222 + });
223 +
224 + var presign = sender.dataset.sends;
225 + var carried = payload(sender);
226 + // What to call the bytes when the browser will not say. The signed
227 + // URL binds the content type, so the PUT has to send back exactly
228 + // what the presign asked for, and `audio/*` will not accept the
229 + // generic answer.
230 + var fallback = area.dataset.uploadFallback || 'application/octet-stream';
231 + jsonPost(presign, Object.assign({}, carried, {
232 + file_name: file.name,
233 + content_type: file.type || fallback,
234 + file_size_bytes: file.size
235 + }))
236 + .then(function(data) {
237 + if (data.max_file_bytes && file.size > data.max_file_bytes) {
238 + var limitMB = (data.max_file_bytes / (1024 * 1024)).toFixed(0);
239 + var fileMB = (file.size / (1024 * 1024)).toFixed(1);
240 + throw new Error('File is ' + fileMB + ' MB but your plan allows up to ' + limitMB + ' MB per file. Upgrade your tier or use a smaller file.');
241 + }
242 + return uploader.upload(data.upload_url, file, data.s3_key, fallback, data.cache_control);
243 + })
244 + .then(function(s3Key) {
245 + return jsonPost(confirmUrl(presign), Object.assign({}, carried, {
246 + s3_key: s3Key,
247 + file_size_bytes: file.size
248 + }));
249 + })
250 + .then(function(result) {
251 + done();
252 + /* The scanner held the file, so the creator is told why their
253 + content is not public yet. */
254 + if (result && result.pending_review) {
255 + showToast(
256 + 'Upload accepted but held for review: our scanner flagged it. ' +
257 + "You'll get an email once it's cleared.",
258 + 'warning'
259 + );
260 + }
261 + if (!surface) return;
262 + if (surface.dataset.uploadGoes) {
263 + window.location.href = surface.dataset.uploadGoes;
264 + } else if (surface.dataset.uploadRefreshes) {
265 + var control = document.querySelector(surface.dataset.uploadRefreshes);
266 + if (control) control.click();
267 + }
268 + })
269 + .catch(function(err) {
270 + done();
271 + showToast(err.message || 'Upload failed');
272 + });
273 + }
274 + }
275 +
276 + function bindAll(root) {
277 + (root || document).querySelectorAll('[data-sends]').forEach(bind);
278 + }
279 +
280 + if (document.readyState === 'loading') {
281 + document.addEventListener('DOMContentLoaded', function() { bindAll(); });
282 + } else {
283 + bindAll();
284 + }
285 +
286 + /* Every described upload swapped in later, on the event htmx settles with. */
287 + document.body ? document.body.addEventListener('htmx:after:settle', function(e) {
288 + bindAll(e.target);
289 + }) : document.addEventListener('DOMContentLoaded', function() {
290 + document.body.addEventListener('htmx:after:settle', function(e) { bindAll(e.target); });
291 + });
292 + })();
@@ -40,6 +40,7 @@
40 40 pub mod rich_field;
41 41 pub mod settings_tabs;
42 42 pub mod ssh_keys;
43 + pub mod upload_field;
43 44 pub mod user_analytics;
44 45 pub mod user_tabs;
45 46 pub mod widgets;
@@ -45,5 +45,5 @@
45 45 {% block scripts %}
46 46 <script src="/static/media-picker.js?v=0518"></script>
47 47 <script src="/static/item-details.js?v=0518"></script>
48 - <script src="/static/item-upload.js?v=0518"></script>
48 + <script src="/static/item-upload.js?v=0820"></script>
49 49 {% endblock %}
@@ -1,4 +1,9 @@
1 - <div class="audio-upload" id="audio-upload" data-item-id="{{ item.id }}">
1 + {#- The upload itself is described, not written out here: the accept list, the
2 + destination, the payload and the wait all come from
3 + `crate::quasi::upload_field`, and `static/upload.js` runs the presign chain
4 + behind the one address it emits. What is left in this file is the audio
5 + that is already there and the swap between it and the picker. -#}
6 + <div class="audio-upload" id="audio-upload">
2 7 {% if let Some(s3_key) = audio_s3_key %}
3 8 <div class="current-audio" id="current-audio">
4 9 <div class="audio-info">
@@ -14,33 +19,8 @@
14 19 </div>
15 20 {% endif %}
16 21
17 - <div class="upload-area {% if audio_s3_key.is_some() %}hidden{% endif %}" id="upload-area">
18 - <div class="file-upload-area" id="audio-dropzone">
19 - <div class="upload-icon">&#9835;</div>
20 - <div class="upload-text">Drop audio file here or click to upload</div>
21 - <div class="upload-hint">Supports MP3, WAV, FLAC, M4A up to 500 MB</div>
22 - <input type="file" id="audio-file-input" class="sr-only" accept="audio/*">
23 - </div>
24 -
25 - <div class="upload-progress hidden" id="upload-progress">
26 - <div class="progress-info">
27 - <span id="upload-filename">filename.mp3</span>
28 - <span id="upload-percent">0%</span>
29 - </div>
30 - <div class="progress-bar-container">
31 - <div class="progress-bar" id="progress-bar"></div>
32 - </div>
33 - <div id="upload-speed" class="upload-speed-text"></div>
34 - <button type="button" class="btn-secondary" id="cancel-upload-btn">Cancel</button>
35 - </div>
36 -
37 - <div class="upload-success hidden" id="upload-success">
38 - <span>Upload complete</span>
39 - </div>
40 -
41 - <div class="upload-error hidden" id="upload-error">
42 - <span class="error-message" id="error-message"></span>
43 - <button type="button" class="btn-secondary" id="retry-upload-btn">Try Again</button>
44 - </div>
22 + <div class="upload-area {% if audio_s3_key.is_some() %}hidden{% endif %}" id="upload-area"
23 + data-upload-goes="/dashboard/item/{{ item.id }}?tab=files">
24 + {{ crate::quasi::upload_field::audio(item.id.as_str())|safe }}
45 25 </div>
46 26 </div>
@@ -68,10 +68,12 @@
68 68 <tbody id="version-file-rows"></tbody>
69 69 </table>
70 70
71 - <div class="item-version-upload-add-row">
72 - <input type="file" id="version-file-input" class="sr-only"
73 - accept=".zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3" multiple>
74 - <button type="button" class="btn-secondary item-version-upload-add-btn" id="add-version-file-btn">Add Files</button>
71 + {#- What the queue takes and how many of them is described, not
72 + written out here: `crate::quasi::upload_field::version_queue`.
73 + It carries no destination on purpose, because nothing is sent
74 + until Upload All names the version. -#}
75 + <div class="item-version-upload-add-row" id="version-file-picker">
76 + {{ crate::quasi::upload_field::version_queue()|safe }}
75 77 </div>
76 78 </div>
77 79
@@ -79,15 +81,16 @@
79 81 </div>
80 82
81 83 <!-- Upload to existing version (hidden by default) -->
82 - <div class="hidden" id="existing-version-upload">
83 - <div class="file-upload-area" id="existing-version-dropzone">
84 - <div class="upload-text">Drop file to upload for this version</div>
85 - <div class="upload-hint">ZIP, DMG, EXE, AppImage, DEB, tar.gz, CLAP, VST3</div>
86 - <input type="file" id="existing-version-file-input" class="sr-only"
87 - accept=".zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3">
88 - </div>
89 - <button class="btn-secondary" id="cancel-existing-upload-btn">Cancel</button>
84 + {#- One described field per version that has no file yet. Each carries its
85 + own address, so picking a file uploads it to that version and nothing
86 + has to remember which button was pressed. -#}
87 + {% for version in versions %}{% if !version.has_file %}
88 + <div class="hidden existing-version-upload" id="existing-version-upload-{{ version.id }}"
89 + data-upload-refreshes="#tab-files">
90 + {{ crate::quasi::upload_field::existing_version(version.id.as_str())|safe }}
91 + <button class="btn-secondary cancel-existing-upload-btn" type="button">Cancel</button>
90 92 </div>
93 + {% endif %}{% endfor %}
91 94
92 95 <!-- Shared upload progress/status (outside both forms so always visible) -->
93 96 <div class="upload-progress hidden" id="version-upload-progress">
@@ -1,0 +1,363 @@
1 + //! The Askama entry point for a described upload.
2 + //!
3 + //! Shape 3 of the conversion plan (wiki `mnw-shape-conversion-plans`), and the
4 + //! one that had to wait for two vocabulary rulings rather than one. `f7261a5a`
5 + //! settled what an upload says about itself: what it takes
6 + //! ([`Field::upload`] plus its accept list), how many at a time
7 + //! ([`Field::many`]), where the bytes go (the field's action) and that it
8 + //! waits ([`Action::awaiting`]). `a81384d4` settled who makes the calls, which
9 + //! none of those four axes covered: an MNW upload is presign, then a PUT
10 + //! straight to storage from the browser, then confirm, and a renderer posting
11 + //! the field at the first of those would be wrong about the response shape and
12 + //! about where the bytes end up. [`Action::by_host`] is the answer. The
13 + //! renderer emits the address as `data-sends` and performs nothing;
14 + //! `static/upload.js` reads it and runs the chain.
15 + //!
16 + //! # What is described here and what stays the host's
17 + //!
18 + //! Described: the accept list, the multiplicity, the destination, the payload
19 + //! that rides with it, and that the call waits. Every one of those was
20 + //! hand-written in a template before this, three times over with three
21 + //! spellings of the same accept list.
22 + //!
23 + //! Host: the drop gesture and the progress bar. Neither is an omission.
24 + //! `makeover-layout`'s `FieldKind::File` doc says a drop area, a picker button
25 + //! and a typed path are one field and that the gesture "is not described for
26 + //! the reason no gesture is", so the dragging stays in `upload.js`. Progress is
27 + //! `Awaiting`'s ruling: the mark says the call waits and carries a measured
28 + //! size when there is one, and the renderer observes the rest. The server
29 + //! renders this markup before a file exists, so there is no size to write down
30 + //! and the mark is the unmeasured one.
31 + //!
32 + //! # Why a function per surface rather than one builder
33 + //!
34 + //! Three call sites, each with a fixed accept list that is a fact about MNW
35 + //! rather than about the template it sits in. A generic entry point would put
36 + //! `.zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3` back in Askama, in two
37 + //! places, which is the duplication this shape exists to remove. So the lists
38 + //! live here once and each surface is named for what it is.
39 +
40 + use makeover_layout::Family;
41 + use quasi_router::{Accepted, Action, Field, Node};
42 +
43 + /// Every suffix a version file may carry.
44 + ///
45 + /// One list, read by both version surfaces. A suffix names no family by ruling
46 + /// (`f7261a5a`: a suffix-to-family table rots), so these are `Suffix` and the
47 + /// reader gets no media disclosure from them, which is right for a build
48 + /// artifact.
49 + fn version_suffixes() -> Vec<Accepted> {
50 + [
51 + ".zip",
52 + ".dmg",
53 + ".exe",
54 + ".appimage",
55 + ".deb",
56 + ".tar.gz",
57 + ".clap",
58 + ".vst3",
59 + ]
60 + .into_iter()
61 + .map(Accepted::suffix)
62 + .collect()
63 + }
64 +
65 + /// The drop area an upload field sits in, and the field itself.
66 + ///
67 + /// The wrapper is this app's and the group inside it is the renderer's, the
68 + /// same division `widgets::carousel` and `quasi::rich_field` make. What the
69 + /// wrapper carries is the gesture, which is why it is here: `upload.js` binds
70 + /// drag, drop and click-to-open on `.file-upload-area`, and finds the
71 + /// destination on the field wrapper the renderer emitted inside it.
72 + ///
73 + /// `fallback` is what to call the bytes when the browser offers no media type
74 + /// for them, which happens for the suffixes S3 has never heard of. It is a host
75 + /// fact and not a described one: the presigned URL binds the content type, so
76 + /// the PUT has to send back the exact string the presign was asked for, and
77 + /// `S3Client::validate_content_type` refuses `application/octet-stream` for an
78 + /// item's audio. It rides as an attribute here rather than as a literal in
79 + /// Askama so that both halves of the pair stay in one file.
80 + fn area(prompt: &str, hint: &str, fallback: &str, field: Field) -> String {
81 + use quasi_axum::Serves as _;
82 +
83 + // No shell: a fragment landing inside a document Askama already built.
84 + let inner = quasi_webview::Webview::new().fragment(&Node::field(field));
85 + format!(
86 + "<div class=\"file-upload-area\" data-upload-fallback=\"{}\">\
87 + <div class=\"upload-text\">{}</div>\
88 + <div class=\"upload-hint\">{}</div>{inner}</div>",
89 + crate::helpers::escape_html(fallback),
90 + crate::helpers::escape_html(prompt),
91 + crate::helpers::escape_html(hint),
92 + )
93 + }
94 +
95 + /// An item's audio file, replaced whole each time.
96 + ///
97 + /// One file, any audio media type, landing through `/api/upload/presign`. The
98 + /// two values the chain needs beyond the file ride as the action's parameters
99 + /// and reach the host as `data-vals`, which is the whole of what the old
100 + /// `data-item-id` attribute and the hard-coded `file_type: 'audio'` literal
101 + /// were doing.
102 + #[must_use]
103 + pub fn audio(item_id: &str) -> String {
104 + let field = Field::upload("audio", "Audio file", [Accepted::family(Family::Audio)]).changes(
105 + Action::post("/api/upload/presign")
106 + .with("item_id", item_id)
107 + .with("file_type", "audio")
108 + .awaiting()
109 + .by_host(),
110 + );
111 +
112 + area(
113 + "Drop audio file here or choose one to upload",
114 + "Supports MP3, WAV, FLAC, M4A up to 500 MB",
115 + "audio/mpeg",
116 + field,
117 + )
118 + }
119 +
120 + /// One file for a version that already exists.
121 + ///
122 + /// The same accept list as [`version_queue`] and a destination of its own,
123 + /// since the version this belongs to is already in the database and its id is
124 + /// in the address rather than in the payload.
125 + #[must_use]
126 + pub fn existing_version(version_id: &str) -> String {
127 + let field = Field::upload("version-file", "Version file", version_suffixes()).changes(
128 + Action::post(format!("/api/versions/{version_id}/upload/presign"))
129 + .awaiting()
130 + .by_host(),
131 + );
132 +
133 + area(
134 + "Drop file to upload for this version",
135 + "ZIP, DMG, EXE, AppImage, DEB, tar.gz, CLAP, VST3",
136 + "application/octet-stream",
137 + field,
138 + )
139 + }
140 +
141 + /// The files a new version is being built from, picked before it exists.
142 + ///
143 + /// Several at once, and deliberately with no destination: nothing can be sent
144 + /// until the reader has named the version and labelled each file, so the
145 + /// address belongs to the button that does that and not to this field. What is
146 + /// described is what it takes and how many, which is the half that was written
147 + /// out by hand in the template.
148 + #[must_use]
149 + pub fn version_queue() -> String {
150 + use quasi_axum::Serves as _;
151 +
152 + let field = Field::upload("version-files", "Files", version_suffixes()).many();
153 + quasi_webview::Webview::new().fragment(&Node::field(field))
154 + }
155 +
156 + #[cfg(test)]
157 + mod tests {
158 + /// The four axes `f7261a5a` settled, on the surface that has all of them.
159 + /// Read together they are the whole point of the conversion: before this
160 + /// the accept list was an attribute in Askama, the destination was a
161 + /// string literal in `item-upload.js`, and nothing said the call waits.
162 + #[test]
163 + fn an_audio_upload_says_what_it_takes_where_it_goes_and_that_it_waits() {
164 + let html = super::audio("11111111-1111-1111-1111-111111111111");
165 +
166 + assert!(html.contains(r#"type="file""#), "{html}");
167 + assert!(html.contains(r#"accept="audio/*""#), "{html}");
168 + assert!(!html.contains(" multiple"), "{html}");
169 + assert!(
170 + html.contains(r#"data-sends="/api/upload/presign""#),
171 + "{html}"
172 + );
173 + assert!(html.contains("data-awaiting="), "{html}");
174 + }
175 +
176 + /// The host makes this call, so no transport comes out. A `hx-post` here
177 + /// would be htmx sending the file to the signing endpoint, which answers
178 + /// JSON and is not where the bytes go.
179 + #[test]
180 + fn no_transport_is_emitted_for_a_host_made_call() {
181 + let html = super::audio("11111111-1111-1111-1111-111111111111");
182 +
183 + assert!(!html.contains("hx-post"), "{html}");
184 + assert!(!html.contains("hx-trigger"), "{html}");
185 + assert!(!html.contains("href="), "{html}");
186 + }
187 +
188 + /// The payload the chain needs, carried by the description rather than by
189 + /// a `data-item-id` attribute on an ancestor. This is the assertion that
190 + /// would be silent if it were wrong: the upload would presign against no
191 + /// item and fail at the far end.
192 + #[test]
193 + fn the_payload_rides_with_the_destination() {
194 + let html = super::audio("11111111-1111-1111-1111-111111111111");
195 +
196 + assert!(html.contains("data-vals="), "{html}");
197 + assert!(html.contains("item_id"), "{html}");
198 + assert!(
199 + html.contains("11111111-1111-1111-1111-111111111111"),
200 + "{html}"
201 + );
202 + assert!(html.contains("file_type"), "{html}");
203 + assert!(!html.contains("hx-vals"), "{html}");
204 + }
205 +
206 + /// One list, two surfaces, and it is written once. Both spellings of it
207 + /// were in Askama before, eleven lines apart in the same file.
208 + #[test]
209 + fn both_version_surfaces_take_the_same_files() {
210 + let single = super::existing_version("22222222-2222-2222-2222-222222222222");
211 + let queue = super::version_queue();
212 + let accept = r#"accept=".zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3""#;
213 +
214 + assert!(single.contains(accept), "{single}");
215 + assert!(queue.contains(accept), "{queue}");
216 + }
217 +
218 + /// The version id is in the address, which is where that route puts it.
219 + #[test]
220 + fn an_existing_version_is_addressed_by_id() {
221 + let html = super::existing_version("22222222-2222-2222-2222-222222222222");
222 +
223 + assert!(
224 + html.contains(
225 + r#"data-sends="/api/versions/22222222-2222-2222-2222-222222222222/upload/presign""#
226 + ),
227 + "{html}"
228 + );
229 + }
230 +
231 + /// Several files, and no destination at all. The queue is uploaded by the
232 + /// button that also carries the version number, so a `data-sends` here
233 + /// would be a second answer to where the bytes go.
234 + #[test]
235 + fn the_queue_takes_many_files_and_sends_none_of_them() {
236 + let html = super::version_queue();
237 +
238 + assert!(html.contains(" multiple"), "{html}");
239 + assert!(!html.contains("data-sends"), "{html}");
240 + assert!(!html.contains("data-awaiting"), "{html}");
241 + }
242 +
243 + /// The gesture is the host's and needs something to bind to. Without the
244 + /// area there is still a working file input, which is the no-script
245 + /// rendering rather than a failure, so this is the test that says the
246 + /// enhancement has a target.
247 + #[test]
248 + fn the_drop_area_is_there_for_the_binder() {
249 + let html = super::audio("11111111-1111-1111-1111-111111111111");
250 +
251 + assert!(html.contains(r#"class="file-upload-area""#), "{html}");
252 + assert!(html.contains("Drop audio file here"), "{html}");
253 + }
254 +
255 + /// An item whose only interesting field is the audio it holds. The rest is
256 + /// what `Item` needs to exist, and none of it reaches this markup.
257 + fn audio_item(audio_s3_key: Option<String>) -> crate::types::Item {
258 + crate::types::Item {
259 + id: "11111111-1111-1111-1111-111111111111".into(),
260 + title: "A recording".into(),
261 + price: "0".into(),
262 + price_cents: 0,
263 + item_type: "audio".into(),
264 + description: String::new(),
265 + thumbnail: String::new(),
266 + release_date: String::new(),
267 + sales_count: 0,
268 + tags: Vec::new(),
269 + content: crate::types::ItemContent::Audio {
270 + duration: None,
271 + duration_seconds: None,
272 + cover_url: None,
273 + episode_number: None,
274 + audio_s3_key,
275 + },
276 + cover_image_url: None,
277 + is_free: true,
278 + can_access: true,
279 + enable_license_keys: false,
280 + default_max_activations: None,
281 + pwyw_enabled: false,
282 + pwyw_min_cents: None,
283 + publish_at: None,
284 + is_public: true,
285 + listed: true,
286 + bundle_item_count: 0,
287 + license_preset: None,
288 + custom_license_text: None,
289 + ai_tier: crate::db::AiTier::Handmade,
290 + ai_disclosure: None,
291 + }
292 + }
293 +
294 + /// The details tab, rendered whole. The two call sites are Askama's, so
295 + /// one of each is rendered here to say the wiring holds: this one also
296 + /// carries the address the binder follows once the bytes have landed,
297 + /// which is a host fact and has nowhere else to live.
298 + #[test]
299 + fn the_audio_call_site_is_wired_and_carries_where_it_goes_after() {
300 + use askama::Template as _;
301 +
302 + let html = crate::templates::ItemDetailsTabTemplate {
303 + item: audio_item(None),
304 + bundle_items: Vec::new(),
305 + bundleable_items: Vec::new(),
306 + sections: Vec::new(),
307 + }
308 + .render()
309 + .expect("render the details tab");
310 +
311 + assert!(
312 + html.contains(r#"data-sends="/api/upload/presign""#),
313 + "{html}"
314 + );
315 + assert!(
316 + html.contains(
317 + r#"data-upload-goes="/dashboard/item/11111111-1111-1111-1111-111111111111?tab=files""#
318 + ),
319 + "{html}"
320 + );
321 + }
322 +
323 + /// The files tab, rendered whole. Two assertions the JS depends on: the
324 + /// queue's input keeps the id `item-upload.js` reads it back by, and a
325 + /// version with no file gets a field of its own addressed to that version.
326 + #[test]
327 + fn the_files_call_sites_keep_what_the_host_reads_them_by() {
328 + use askama::Template as _;
329 +
330 + let version = crate::types::Version {
331 + id: "22222222-2222-2222-2222-222222222222".into(),
332 + number: "1.0".into(),
333 + uploaded_date: "2026-08-20".into(),
334 + file_count: 0,
335 + size: "0 B".into(),
336 + downloads: 0,
337 + status: "draft".into(),
338 + is_current: true,
339 + has_file: false,
340 + file_name: None,
341 + label: None,
342 + };
343 +
344 + let html = crate::templates::ItemFilesTabTemplate {
345 + item: audio_item(None),
346 + versions: vec![version],
347 + }
348 + .render()
349 + .expect("render the files tab");
350 +
351 + assert!(html.contains(r#"id="version-files""#), "{html}");
352 + assert!(
353 + html.contains(r#"id="existing-version-upload-22222222-2222-2222-2222-222222222222""#),
354 + "{html}"
355 + );
356 + assert!(
357 + html.contains(
358 + r#"data-sends="/api/versions/22222222-2222-2222-2222-222222222222/upload/presign""#
359 + ),
360 + "{html}"
361 + );
362 + }
363 + }