max / makenotwork
10 files changed,
+616 insertions,
-252 deletions
| @@ -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">♫</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 | + | } |