Skip to main content

max / makenotwork

Multi-file version upload with per-file labels Replace single-file dropzone with a file queue table. Add multiple files via the Add Files button, each gets an auto-guessed label based on filename (macOS/Linux/Windows + arch). All files share the same version number and notes. Each creates its own version record and uploads sequentially with shared progress bar. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-10 21:47 UTC
Commit: 5e8da77f3fd04d8904cc731892088218467b1960
Parent: 46c5359
3 files changed, +183 insertions, -84 deletions
@@ -135,7 +135,7 @@
135 135 var itemId = container.dataset.itemId;
136 136 if (!itemId) return;
137 137
138 - var selectedFile = null;
138 + var fileQueue = [];
139 139 var targetVersionId = null;
140 140
141 141 var uploader = new S3Uploader({
@@ -145,38 +145,94 @@
145 145 speedEl: document.getElementById('version-upload-speed'),
146 146 });
147 147
148 - var versionDropzone = document.getElementById('version-dropzone');
149 148 var versionFileInput = document.getElementById('version-file-input');
149 + var fileRows = document.getElementById('version-file-rows');
150 150
151 - function onNewVersionFile(file) {
152 - selectedFile = file;
153 - versionDropzone.querySelector('.upload-text').textContent = file.name;
151 + // Add files button
152 + var addBtn = document.getElementById('add-version-file-btn');
153 + if (addBtn) {
154 + addBtn.addEventListener('click', function() { versionFileInput.click(); });
154 155 }
155 156
156 - initDropzone(versionDropzone, versionFileInput, onNewVersionFile);
157 + if (versionFileInput) {
158 + versionFileInput.addEventListener('change', function() {
159 + for (var i = 0; i < this.files.length; i++) addFileRow(this.files[i]);
160 + this.value = '';
161 + });
162 + }
157 163
158 - var existingDropzone = document.getElementById('existing-version-dropzone');
159 - var existingFileInput = document.getElementById('existing-version-file-input');
164 + function guessLabel(name) {
165 + var n = name.toLowerCase();
166 + if (n.indexOf('aarch64') !== -1 || n.indexOf('arm64') !== -1) return n.indexOf('appimage') !== -1 || n.indexOf('.deb') !== -1 ? 'Linux (aarch64)' : 'macOS (arm)';
167 + if (n.indexOf('x86_64') !== -1 || n.indexOf('amd64') !== -1 || n.indexOf('x64') !== -1) return n.indexOf('.exe') !== -1 || n.indexOf('.msi') !== -1 ? 'Windows (x64)' : 'Linux (x86_64)';
168 + if (n.indexOf('.dmg') !== -1) return 'macOS';
169 + if (n.indexOf('.msi') !== -1 || n.indexOf('.exe') !== -1) return 'Windows';
170 + if (n.indexOf('.appimage') !== -1 || n.indexOf('.deb') !== -1) return 'Linux';
171 + return '';
172 + }
160 173
161 - initDropzone(existingDropzone, existingFileInput, function(file) {
162 - if (targetVersionId) uploadVersionFile(targetVersionId, file);
163 - });
174 + function addFileRow(file) {
175 + var idx = fileQueue.length;
176 + fileQueue.push({ file: file, idx: idx });
177 + var tr = document.createElement('tr');
178 + tr.dataset.idx = idx;
179 + tr.style.borderBottom = '1px solid var(--border)';
180 + tr.innerHTML =
181 + '<td style="padding: 0.4rem 0.5rem 0.4rem 0; font-size: 0.85rem; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="' + file.name.replace(/"/g, '&quot;') + '">' + escapeHtml(file.name) + '</td>' +
182 + '<td style="padding: 0.4rem 0.5rem;"><input type="text" class="version-label-input" data-idx="' + idx + '" value="' + escapeAttr(guessLabel(file.name)) + '" placeholder="e.g., macOS (arm)" style="width: 100%; padding: 0.25rem 0.4rem; font-size: 0.85rem;"></td>' +
183 + '<td style="padding: 0.4rem 0.5rem;"><button type="button" class="secondary version-remove-file" data-idx="' + idx + '" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;">Remove</button></td>';
184 + fileRows.appendChild(tr);
185 +
186 + tr.querySelector('.version-remove-file').addEventListener('click', function() {
187 + fileQueue[idx] = null;
188 + tr.remove();
189 + });
190 + }
164 191
192 + function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
193 + function escapeAttr(s) { return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
194 +
195 + // Upload all button
165 196 document.getElementById('create-version-btn').addEventListener('click', function() {
166 197 var versionNumber = document.getElementById('new-version-number').value.trim();
167 198 var changelog = document.getElementById('version-changelog').value.trim();
199 + var entries = fileQueue.filter(function(e) { return e !== null; });
168 200
169 201 if (!versionNumber) { showToast('Please enter a version number.'); return; }
170 - if (!selectedFile) { showToast('Please select a file to upload.'); return; }
202 + if (entries.length === 0) { showToast('Please add at least one file.'); return; }
203 +
204 + this.disabled = true;
205 + this.textContent = 'Uploading...';
206 + document.getElementById('new-version-form').classList.add('hidden');
207 + document.getElementById('version-upload-progress').classList.remove('hidden');
208 +
209 + uploadSequentially(entries, 0, versionNumber, changelog);
210 + });
211 +
212 + function uploadSequentially(entries, i, versionNumber, changelog) {
213 + if (i >= entries.length) {
214 + document.getElementById('version-upload-progress').classList.add('hidden');
215 + document.getElementById('version-upload-success').classList.remove('hidden');
216 + setTimeout(function() {
217 + var filesBtn = document.getElementById('tab-files');
218 + if (filesBtn) filesBtn.click();
219 + }, 1500);
220 + return;
221 + }
222 +
223 + var entry = entries[i];
224 + var labelInput = document.querySelector('.version-label-input[data-idx="' + entry.idx + '"]');
225 + var label = labelInput ? labelInput.value.trim() : '';
226 +
227 + uploader.filenameEl.textContent = entry.file.name + (entries.length > 1 ? ' (' + (i + 1) + '/' + entries.length + ')' : '');
171 228
172 - var label = (document.getElementById('new-version-label') || {}).value || '';
173 229 fetch('/api/items/' + itemId + '/versions', {
174 230 method: 'POST',
175 231 headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
176 232 body: JSON.stringify({
177 233 version_number: versionNumber,
178 234 changelog: changelog || null,
179 - label: label.trim() || null
235 + label: label || null
180 236 })
181 237 })
182 238 .then(function(res) {
@@ -185,9 +241,89 @@
185 241 });
186 242 return res.json();
187 243 })
188 - .then(function(data) { uploadVersionFile(data.id, selectedFile); })
189 - .catch(function(err) { showVersionError(err.message || 'Failed to create version'); });
190 - });
244 + .then(function(data) {
245 + return fetch('/api/versions/' + data.id + '/upload/presign', {
246 + method: 'POST',
247 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
248 + body: JSON.stringify({
249 + file_name: entry.file.name,
250 + content_type: entry.file.type || 'application/octet-stream'
251 + })
252 + }).then(function(res) {
253 + if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
254 + throw new Error(d.error || 'Failed to get upload URL');
255 + });
256 + return res.json();
257 + }).then(function(presign) {
258 + if (presign.max_file_bytes && entry.file.size > presign.max_file_bytes) {
259 + var limitMB = (presign.max_file_bytes / (1024 * 1024)).toFixed(0);
260 + var fileMB = (entry.file.size / (1024 * 1024)).toFixed(1);
261 + throw new Error(entry.file.name + ': ' + fileMB + ' MB exceeds ' + limitMB + ' MB limit');
262 + }
263 + return uploader.upload(presign.upload_url, entry.file, presign.s3_key, 'application/octet-stream', presign.cache_control)
264 + .then(function(s3Key) { return { s3Key: s3Key, versionId: data.id }; });
265 + });
266 + })
267 + .then(function(result) {
268 + return fetch('/api/versions/' + result.versionId + '/upload/confirm', {
269 + method: 'POST',
270 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
271 + body: JSON.stringify({ s3_key: result.s3Key, file_size_bytes: entry.file.size })
272 + });
273 + })
274 + .then(function(res) {
275 + if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
276 + throw new Error(d.error || 'Failed to confirm upload');
277 + });
278 + uploadSequentially(entries, i + 1, versionNumber, changelog);
279 + })
280 + .catch(function(err) { showVersionError(err.message || 'Upload failed'); });
281 + }
282 +
283 + // Existing version upload (single file)
284 + var existingDropzone = document.getElementById('existing-version-dropzone');
285 + var existingFileInput = document.getElementById('existing-version-file-input');
286 + if (existingDropzone && existingFileInput) {
287 + initDropzone(existingDropzone, existingFileInput, function(file) {
288 + if (targetVersionId) uploadSingleFile(targetVersionId, file);
289 + });
290 + }
291 +
292 + function uploadSingleFile(versionId, file) {
293 + document.getElementById('new-version-form').classList.add('hidden');
294 + document.getElementById('existing-version-upload').classList.add('hidden');
295 + document.getElementById('version-upload-progress').classList.remove('hidden');
296 +
297 + fetch('/api/versions/' + versionId + '/upload/presign', {
298 + method: 'POST',
299 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
300 + body: JSON.stringify({ file_name: file.name, content_type: file.type || 'application/octet-stream' })
301 + })
302 + .then(function(res) {
303 + if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Presign failed'); });
304 + return res.json();
305 + })
306 + .then(function(data) {
307 + return uploader.upload(data.upload_url, file, data.s3_key, 'application/octet-stream', data.cache_control);
308 + })
309 + .then(function(s3Key) {
310 + return fetch('/api/versions/' + versionId + '/upload/confirm', {
311 + method: 'POST',
312 + headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
313 + body: JSON.stringify({ s3_key: s3Key, file_size_bytes: file.size })
314 + });
315 + })
316 + .then(function(res) {
317 + if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) { throw new Error(d.error || 'Confirm failed'); });
318 + document.getElementById('version-upload-progress').classList.add('hidden');
319 + document.getElementById('version-upload-success').classList.remove('hidden');
320 + setTimeout(function() {
321 + var filesBtn = document.getElementById('tab-files');
322 + if (filesBtn) filesBtn.click();
323 + }, 1500);
324 + })
325 + .catch(function(err) { showVersionError(err.message || 'Upload failed'); });
326 + }
191 327
192 328 document.querySelectorAll('.upload-to-version-btn').forEach(function(btn) {
193 329 btn.addEventListener('click', function() {
@@ -241,54 +377,6 @@
241 377
242 378 document.getElementById('retry-version-upload-btn').addEventListener('click', resetVersionUpload);
243 379
244 - function uploadVersionFile(versionId, file) {
245 - document.getElementById('new-version-form').classList.add('hidden');
246 - document.getElementById('existing-version-upload').classList.add('hidden');
247 - document.getElementById('version-upload-progress').classList.remove('hidden');
248 -
249 - fetch('/api/versions/' + versionId + '/upload/presign', {
250 - method: 'POST',
251 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
252 - body: JSON.stringify({
253 - file_name: file.name,
254 - content_type: file.type || 'application/octet-stream'
255 - })
256 - })
257 - .then(function(res) {
258 - if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
259 - throw new Error(d.error || 'Failed to get upload URL');
260 - });
261 - return res.json();
262 - })
263 - .then(function(data) {
264 - if (data.max_file_bytes && file.size > data.max_file_bytes) {
265 - var limitMB = (data.max_file_bytes / (1024 * 1024)).toFixed(0);
266 - var fileMB = (file.size / (1024 * 1024)).toFixed(1);
267 - 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.');
268 - }
269 - return uploader.upload(data.upload_url, file, data.s3_key, 'application/octet-stream', data.cache_control);
270 - })
271 - .then(function(s3Key) {
272 - return fetch('/api/versions/' + versionId + '/upload/confirm', {
273 - method: 'POST',
274 - headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
275 - body: JSON.stringify({ s3_key: s3Key, file_size_bytes: file.size })
276 - });
277 - })
278 - .then(function(res) {
279 - if (!res.ok) return res.json().catch(function() { return {}; }).then(function(d) {
280 - throw new Error(d.error || 'Failed to confirm upload');
281 - });
282 - document.getElementById('version-upload-progress').classList.add('hidden');
283 - document.getElementById('version-upload-success').classList.remove('hidden');
284 - setTimeout(function() {
285 - var filesBtn = document.getElementById('tab-files');
286 - if (filesBtn) filesBtn.click();
287 - }, 1500);
288 - })
289 - .catch(function(err) { showVersionError(err.message || 'Upload failed'); });
290 - }
291 -
292 380 function showVersionError(message) {
293 381 document.getElementById('version-upload-progress').classList.add('hidden');
294 382 document.getElementById('version-upload-error').classList.remove('hidden');
@@ -301,10 +389,12 @@
301 389 document.getElementById('version-upload-progress').classList.add('hidden');
302 390 document.getElementById('version-upload-success').classList.add('hidden');
303 391 document.getElementById('version-upload-error').classList.add('hidden');
304 - versionFileInput.value = '';
305 - selectedFile = null;
392 + fileQueue = [];
393 + fileRows.innerHTML = '';
306 394 targetVersionId = null;
307 - versionDropzone.querySelector('.upload-text').textContent = 'Drop file here or click to upload';
395 + var btn = document.getElementById('create-version-btn');
396 + btn.disabled = false;
397 + btn.textContent = 'Upload All';
308 398 }
309 399 }
310 400
@@ -122,8 +122,8 @@
122 122 {% endblock %}
123 123
124 124 {% block scripts %}
125 - <script src="/static/upload.js?v=0515"></script>
126 - <script src="/static/media-picker.js?v=0515"></script>
127 - <script src="/static/item-details.js?v=0515"></script>
128 - <script src="/static/item-upload.js?v=0515"></script>
125 + <script src="/static/upload.js?v=0516"></script>
126 + <script src="/static/media-picker.js?v=0516"></script>
127 + <script src="/static/item-details.js?v=0516"></script>
128 + <script src="/static/item-upload.js?v=0516"></script>
129 129 {% endblock %}
@@ -48,26 +48,35 @@
48 48 <label for="new-version-number">Version Number</label>
49 49 <input type="text" id="new-version-number" placeholder="e.g., 1.0">
50 50 </div>
51 -
52 51 <div class="form-group">
53 - <label for="new-version-label">Label (optional)</label>
54 - <input type="text" id="new-version-label" placeholder="e.g., macOS (arm), Linux (x86_64)">
52 + <label for="version-changelog">Notes (optional)</label>
53 + <input type="text" id="version-changelog" placeholder="What changed in this version...">
55 54 </div>
56 55 </div>
57 56
58 57 <div class="form-group">
59 - <label for="version-changelog">Notes (optional)</label>
60 - <textarea id="version-changelog" rows="2" placeholder="What changed in this version..."></textarea>
61 - </div>
58 + <label>Files</label>
59 + <div class="upload-hint" style="margin-bottom: 0.5rem;">Add one file per platform. Each gets its own label (e.g. "macOS (arm)", "Linux (x86_64)").</div>
62 60
63 - <div class="file-upload-area" id="version-dropzone">
64 - <div class="upload-text">Drop file here or click to upload</div>
65 - <div class="upload-hint">ZIP, DMG, EXE, AppImage, DEB, tar.gz, CLAP, VST3 up to 500 MB</div>
66 - <input type="file" id="version-file-input" style="display: none;"
67 - accept=".zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3">
61 + <table style="width: 100%; border-collapse: collapse;" id="version-file-table">
62 + <thead>
63 + <tr style="text-align: left; font-size: 0.8rem; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.03em;">
64 + <th style="padding: 0.4rem 0.5rem 0.4rem 0;">File</th>
65 + <th style="padding: 0.4rem 0.5rem;">Label</th>
66 + <th style="padding: 0.4rem 0.5rem; width: 40px;"></th>
67 + </tr>
68 + </thead>
69 + <tbody id="version-file-rows"></tbody>
70 + </table>
71 +
72 + <div style="margin-top: 0.5rem;">
73 + <input type="file" id="version-file-input" style="display: none;"
74 + accept=".zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3" multiple>
75 + <button type="button" class="secondary" id="add-version-file-btn" style="padding: 0.4rem 0.8rem;">Add Files</button>
76 + </div>
68 77 </div>
69 78
70 - <button class="primary" id="create-version-btn">Upload New Version</button>
79 + <button class="primary" id="create-version-btn">Upload All</button>
71 80 </div>
72 81
73 82 <!-- Upload to existing version (hidden by default) -->