Skip to content

Commit bb9da44

Browse files
vpeterssonclaude
andauthored
fix(server): restore multi-file (bulk) upload in the Add Asset modal (#3049)
* fix(server): restore multi-file (bulk) upload in the Add Asset modal Multi-file upload (added in #2778 for the React UI) was lost in the #2818 React→Alpine/HTMX rewrite: the file picker accepted a single file only. The assets_upload endpoint already takes one file per POST, so this is a client-only fix. - Add `multiple` to the #add-file input. - Drive the upload from uploadFiles() in home.ts: iterate the selected files and POST them sequentially (one XHR per file) against the existing single-file endpoint, with "X of N" progress. htmx's single-form submit would only ever send the first file, so the file tab is no longer htmx-managed; toasts are replayed from the server's HX-Trigger header by hand. - Single-file uploads still flow through the same path unchanged. Adds an integration test (test_add_multiple_uploads_at_once) that selects two files in one go and asserts both persist. Fixes #3045 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): don't clear upload state on closeModal mid-batch Hiding the modal during an in-flight 'sending' upload cleared uploadState, which disarmed uploadFiles()'s re-entry guard and let a reopened modal start a second batch racing the first over the shared progress/index fields. Only reset upload state when nothing is in flight. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): surface upload rejections instead of swallowing them assets_upload refused invalid/empty uploads with messages.error + HTTP 200, which the HTMX/XHR path drops silently (the partial carries no toast header) — so the operator saw nothing and the batch uploader counted the rejection as a success. Pass the rejection through _asset_table_response(toast=('error', …)) so it rides the HX-Trigger header on every transport. Client side, uploadOne() now distinguishes 'ok' / 'rejected' (2xx + error toast) / 'error' (transport): a rejected file surfaces its server toast and the batch skips it and keeps going, while a true transport failure aborts. Addresses Copilot review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(server): update uploadOne comment for the UploadResult return The doc comment still described the old boolean return; it now documents the ok / rejected / error tri-state. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(server): attribute single-file limit to the endpoint, not htmx The comments said htmx "would only ever send the first file", but htmx includes every selected file in the multipart body — the real single-file constraint is assets_upload reading request.FILES.get. Reword both comments. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3b8cf7a commit bb9da44

4 files changed

Lines changed: 280 additions & 52 deletions

File tree

src/anthias_server/app/static/src/home.ts

Lines changed: 165 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ interface HomeAppData {
4646
uploadState: UploadState
4747
uploadProgress: number
4848
uploadFileName: string
49+
uploadIndex: number
50+
uploadTotal: number
4951
init(): void
5052
openAdd(): void
5153
openEdit(asset: AssetEdit): void
@@ -54,11 +56,17 @@ interface HomeAppData {
5456
closeModal(): void
5557
closePreview(): void
5658
bindFlatpickr(): void
57-
onUploadStart(): void
58-
onUploadProgress(ev: CustomEvent<ProgressEvent>): void
59-
onUploadDone(ev: CustomEvent<{ successful: boolean; xhr: XMLHttpRequest }>): void
59+
uploadFiles(input: HTMLInputElement): Promise<void>
60+
uploadOne(url: string, csrf: string, file: File): Promise<UploadResult>
6061
}
6162

63+
// 'ok' — file accepted and the asset row was created.
64+
// 'rejected' — server reached, but it refused this file (HTTP 200 +
65+
// error toast, e.g. invalid type). The toast already
66+
// informed the user; the batch skips it and carries on.
67+
// 'error' — transport failure / non-2xx. Aborts the batch.
68+
type UploadResult = 'ok' | 'rejected' | 'error'
69+
6270
const DATE_FMT_MAP: Record<string, string> = {
6371
'mm/dd/yyyy': 'm/d/Y',
6472
'dd/mm/yyyy': 'd/m/Y',
@@ -97,6 +105,8 @@ function homeApp(): HomeAppData {
97105
uploadState: null,
98106
uploadProgress: 0,
99107
uploadFileName: '',
108+
uploadIndex: 0,
109+
uploadTotal: 0,
100110

101111
init(this: HomeAppData & { $watch: (k: string, cb: () => void) => void }) {
102112
// Re-bind Flatpickr every time the edit modal opens. The
@@ -190,57 +200,177 @@ function homeApp(): HomeAppData {
190200
this.pendingDeleteName = name || ''
191201
},
192202
closeModal() {
193-
// The Hide button stays clickable while the upload bytes are
194-
// still going up — that just hides the modal. Once the bytes are
195-
// sent and the server is processing, dropping the modal would
196-
// strip the form HTMX is still waiting for, so the button is
197-
// disabled in the template at that point.
203+
// Hiding the modal during an in-flight upload (Hide button, Esc,
204+
// backdrop click) must NOT touch the upload state. uploadFiles()
205+
// owns uploadState for the whole batch and clears it when the
206+
// last file lands; wiping it here mid-batch would disarm the
207+
// re-entry guard (a reopened modal could start a second batch
208+
// that races the first over the shared progress/index fields)
209+
// and tear down the progress UI while files are still uploading.
210+
// Only reset when nothing is in flight — by then it's a no-op
211+
// anyway.
198212
this.mode = null
199213
this.editAsset = null
200-
if (this.uploadState !== 'processing') {
201-
this.uploadState = null
214+
if (!this.uploadState) {
202215
this.uploadProgress = 0
203216
this.uploadFileName = ''
217+
this.uploadIndex = 0
218+
this.uploadTotal = 0
204219
}
205220
},
206221
closePreview() {
207222
this.previewAsset = null
208223
},
209-
onUploadStart() {
210-
this.uploadState = 'sending'
211-
this.uploadProgress = 0
212-
},
213-
onUploadProgress(ev) {
214-
const detail = ev.detail
215-
if (!detail || !detail.lengthComputable || detail.total === 0) return
216-
const pct = Math.min(99, Math.round((detail.loaded / detail.total) * 100))
217-
this.uploadProgress = pct
218-
// Once bytes hit the server we flip to "processing" — the server
219-
// still has to write the file to disk and (for videos) shell out
220-
// to ffprobe, which is the longest part of the round-trip.
221-
if (detail.loaded >= detail.total) {
222-
this.uploadState = 'processing'
224+
225+
// Multi-file upload (issue #3045). The server's assets_upload
226+
// endpoint reads exactly one file per request
227+
// (request.FILES.get('file_upload')), so a single htmx form POST —
228+
// which would carry every selected file in the multipart body —
229+
// would still only create one asset. uploadFiles() instead uploads
230+
// the batch sequentially, one XHR (one file) per request. Driving
231+
// it from JS (instead of hx-post on the <form>) is also what makes
232+
// "X of N" progress and per-file failure handling possible. Mirrors
233+
// the pre-#2818 React behaviour added in #2778.
234+
async uploadFiles(input: HTMLInputElement) {
235+
const files = input.files ? Array.from(input.files) : []
236+
const form = input.form
237+
// Guard against re-entry: a drop/select while a batch is still
238+
// in flight would clobber the progress + index state.
239+
if (!files.length || !form || this.uploadState) {
240+
input.value = ''
241+
return
223242
}
224-
},
225-
onUploadDone(ev) {
226-
const ok = ev.detail?.successful
227-
// Success toast is fired by the server via HX-Trigger so we
228-
// don't double up here. The client only owns the failure path
229-
// (transport errors that never reach the server) and the
230-
// modal-close + state-reset bookkeeping.
243+
const url = form.getAttribute('action') || ''
244+
const csrf =
245+
form.querySelector<HTMLInputElement>(
246+
'input[name=csrfmiddlewaretoken]',
247+
)?.value || csrfToken()
248+
249+
this.uploadTotal = files.length
250+
let succeeded = 0
251+
let aborted = false
252+
for (let i = 0; i < files.length; i++) {
253+
this.uploadIndex = i + 1
254+
this.uploadFileName = files[i].name
255+
const result = await this.uploadOne(url, csrf, files[i])
256+
if (result === 'error') {
257+
// Transport failure — something's wrong with the request
258+
// itself, so stop the batch rather than hammering on.
259+
aborted = true
260+
break
261+
}
262+
// 'rejected' files already surfaced their own server toast;
263+
// skip them and keep uploading the rest of the selection.
264+
if (result === 'ok') succeeded += 1
265+
}
266+
267+
// Clear the input so re-selecting the same file(s) fires change
268+
// again, then drop the progress UI.
269+
input.value = ''
231270
this.uploadState = null
232271
this.uploadProgress = 0
233-
if (ok) {
234-
this.uploadFileName = ''
235-
this.mode = null
236-
} else {
272+
this.uploadFileName = ''
273+
this.uploadIndex = 0
274+
this.uploadTotal = 0
275+
276+
if (aborted) {
237277
const store = window.Alpine.store('toasts') as
238278
| ToastStoreLike
239279
| undefined
240280
store?.push('error', 'Upload failed — check the file and try again')
241281
}
282+
if (succeeded > 0) {
283+
this.mode = null
284+
// The per-file responses each fan out a WebSocket refresh
285+
// nudge, but force one final swap so the new rows land
286+
// immediately even when the socket is unavailable.
287+
const htmx = (
288+
window as unknown as {
289+
htmx?: { trigger: (target: string, event: string) => void }
290+
}
291+
).htmx
292+
htmx?.trigger('body', 'refresh-assets')
293+
}
242294
},
295+
296+
// POST a single file and resolve an UploadResult: 'ok' on a 2xx
297+
// with no error toast, 'rejected' on a 2xx carrying an error toast
298+
// (server refused the file), 'error' on a non-2xx or transport
299+
// failure. Server toasts ("reading metadata…" / "Uploaded X" /
300+
// "Invalid file type") ride back on the HX-Trigger header — we
301+
// replay them here since this isn't an htmx-managed request.
302+
// Progress flips to "processing" once the bytes are up (the server
303+
// still has to write to disk + ffprobe).
304+
uploadOne(
305+
this: HomeAppData,
306+
url: string,
307+
csrf: string,
308+
file: File,
309+
): Promise<UploadResult> {
310+
return new Promise<UploadResult>((resolve) => {
311+
const xhr = new XMLHttpRequest()
312+
xhr.open('POST', url)
313+
xhr.setRequestHeader('X-CSRFToken', csrf)
314+
// Mark as an htmx request so assets_upload returns the table
315+
// partial (+ HX-Trigger toast) instead of a full-page redirect.
316+
xhr.setRequestHeader('HX-Request', 'true')
317+
this.uploadState = 'sending'
318+
this.uploadProgress = 0
319+
xhr.upload.addEventListener('progress', (ev) => {
320+
if (!ev.lengthComputable || ev.total === 0) return
321+
this.uploadProgress = Math.min(
322+
99,
323+
Math.round((ev.loaded / ev.total) * 100),
324+
)
325+
if (ev.loaded >= ev.total) this.uploadState = 'processing'
326+
})
327+
xhr.addEventListener('load', () => {
328+
const kind = fireToastFromHeader(xhr.getResponseHeader('HX-Trigger'))
329+
if (xhr.status < 200 || xhr.status >= 300) {
330+
resolve('error')
331+
return
332+
}
333+
// The server validates and may refuse a file with a 200 +
334+
// error toast (invalid type, missing file). Treat that as a
335+
// rejected file, not a silent success.
336+
resolve(kind === 'error' ? 'rejected' : 'ok')
337+
})
338+
xhr.addEventListener('error', () => resolve('error'))
339+
xhr.addEventListener('abort', () => resolve('error'))
340+
const fd = new FormData()
341+
fd.append('csrfmiddlewaretoken', csrf)
342+
fd.append('file_upload', file)
343+
xhr.send(fd)
344+
})
345+
},
346+
}
347+
}
348+
349+
// Replay a server HX-Trigger toast payload through the global store
350+
// and return its kind. htmx does this automatically for hx-* requests;
351+
// the file-upload path uses raw XHR (see uploadFiles), so we parse it
352+
// by hand. The returned kind lets uploadOne treat a server-side
353+
// rejection (HTTP 200 + an error toast — e.g. invalid file type) as a
354+
// failed file rather than a silent success.
355+
function fireToastFromHeader(
356+
header: string | null,
357+
): 'success' | 'error' | 'info' | null {
358+
if (!header) return null
359+
try {
360+
const parsed = JSON.parse(header) as {
361+
toast?: { kind?: 'success' | 'error' | 'info'; message?: string }
362+
}
363+
const toast = parsed?.toast
364+
if (toast?.message) {
365+
const store = window.Alpine.store('toasts') as ToastStoreLike | undefined
366+
store?.push(toast.kind || 'info', toast.message)
367+
return toast.kind || 'info'
368+
}
369+
} catch {
370+
// Header was a bare event name, not JSON, or carried no toast —
371+
// nothing to surface.
243372
}
373+
return null
244374
}
245375

246376
// Tracks the assets that were `is_processing=true` on the previous

src/anthias_server/app/templates/_asset_modal.html

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +66,21 @@ <h2 x-text="mode === 'edit' ? 'Edit asset' : 'Add asset'"></h2>
6666
</div>
6767
</form>
6868

69+
{% comment %} The file tab is NOT htmx-managed. assets_upload reads
70+
one file per request (request.FILES.get('file_upload')), so a
71+
single htmx form POST — even though it would carry every
72+
selected file in the multipart body — would only ever get one
73+
asset created. uploadFiles() in home.ts instead uploads the
74+
batch sequentially, one XHR (one file) per request. The action
75+
/ enctype attributes stay so uploadFiles() can read the endpoint
76+
URL off the form and the CSRF token off its input.
77+
{% endcomment %}
6978
<form
7079
x-show="tab === 'file'"
7180
method="post"
7281
action="{% url 'anthias_app:assets_upload' %}"
7382
enctype="multipart/form-data"
74-
hx-post="{% url 'anthias_app:assets_upload' %}"
75-
hx-encoding="multipart/form-data"
76-
hx-target="#asset-table"
77-
hx-swap="outerHTML"
78-
@htmx:xhr:loadstart="onUploadStart()"
79-
@htmx:xhr:progress="onUploadProgress($event)"
80-
@htmx:after-request="onUploadDone($event)"
83+
@submit.prevent
8184
>
8285
{% csrf_token %}
8386
<div class="modal-card__body" style="padding-top: 0">
@@ -88,8 +91,8 @@ <h2 x-text="mode === 'edit' ? 'Edit asset' : 'Add asset'"></h2>
8891
x-show="!uploadState"
8992
>
9093
<i class="ti ti-cloud-upload" style="font-size: 2.25rem; opacity: 0.55"></i>
91-
<p class="mb-1 font-semibold mt-2">Click to choose a file</p>
92-
<p class="muted-label mb-0">Image or video. Upload starts immediately.</p>
94+
<p class="mb-1 font-semibold mt-2">Click to choose files</p>
95+
<p class="muted-label mb-0">Images or videos. Select one or many — upload starts immediately.</p>
9396
{% comment %}
9497
Browser MIME globs cover JPEG/PNG/WebP/GIF/MP4/WebM/MKV out of
9598
the box. iOS HEIC and TIFF still slip through `image/*` on
@@ -101,19 +104,19 @@ <h2 x-text="mode === 'edit' ? 'Edit asset' : 'Add asset'"></h2>
101104
H.264/HEVC MP4) makes the wider accept range safe to
102105
surface here.
103106
{% endcomment %}
104-
<input type="file" class="hidden" id="add-file" name="file_upload"
107+
<input type="file" class="hidden" id="add-file" name="file_upload" multiple
105108
accept="image/*,video/*,.heic,.heif,.tif,.tiff,.bmp,.ico,.tga,.jp2,.j2k,.jpx,.jpc,.jpf,.avif,.mov,.m4v,.mkv,.webm,.avi,.mpg,.mpeg,.ts,.flv"
106-
@change="if ($event.target.files.length) { uploadFileName = $event.target.files[0].name; $event.target.form.requestSubmit() }">
109+
@change="uploadFiles($event.target)">
107110
</label>
108111

109112
<div class="upload-progress" x-show="uploadState" x-cloak>
110113
<div class="upload-progress__head">
111114
<i class="ti" :class="uploadState === 'sending' ? 'ti-cloud-upload' : 'ti-refresh upload-progress__spin'"></i>
112115
<div class="upload-progress__text">
113116
<p class="m-0 font-semibold" x-text="uploadFileName || 'Uploading…'"></p>
114-
<p class="muted-label m-0" x-text="uploadState === 'sending'
117+
<p class="muted-label m-0" x-text="(uploadTotal > 1 ? `File ${uploadIndex} of ${uploadTotal} · ` : '') + (uploadState === 'sending'
115118
? `Uploading… ${uploadProgress}%`
116-
: 'Processing on server…'"></p>
119+
: 'Processing on server…')"></p>
117120
</div>
118121
</div>
119122
<div class="upload-progress__bar">

src/anthias_server/app/views.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,9 @@ def assets_upload(request: HttpRequest) -> HttpResponse:
293293

294294
file_upload = request.FILES.get('file_upload')
295295
if file_upload is None or not file_upload.name:
296-
messages.error(request, 'No file uploaded.')
297-
return _asset_table_response(request)
296+
return _asset_table_response(
297+
request, toast=('error', 'No file uploaded.')
298+
)
298299

299300
upload_name: str = file_upload.name
300301

@@ -378,8 +379,10 @@ def assets_upload(request: HttpRequest) -> HttpResponse:
378379
elif ext in video_exts:
379380
file_type = f'video/{ext.lstrip(".")}'
380381
if file_type.split('/')[0] not in ('image', 'video'):
381-
messages.error(request, 'Invalid file type. Expected image or video.')
382-
return _asset_table_response(request)
382+
return _asset_table_response(
383+
request,
384+
toast=('error', 'Invalid file type. Expected image or video.'),
385+
)
383386

384387
# Operator-friendly display name: 'My_day-2.mp4' → 'My Day 2'.
385388
# Drops the extension (the row already carries mimetype) and

0 commit comments

Comments
 (0)