fix(upload): stop the queue from wedging, and tell the guest when it fails

A STALLED UPLOAD BLOCKED EVERYTHING, FOREVER. The XHR set no timeout and had no stall
detection, so a half-open connection from an AP roam left the item `uploading`
indefinitely — which kept `processQueue`'s `processing` flag set, so the whole rest of
the queue stopped draining. The UI offered no control at all for an `uploading` item.
The guest saw "Wird hochgeladen 43%" all evening with four photos stuck behind it and
no button to press; the only escape was force-quitting the PWA, which nobody guesses.
Now: a watchdog aborts when no bytes move for 90s, disarmed on `loadend` so the server
may take its time storing a file it already has; a size-scaled timeout as a generous
backstop that will not kill slow-but-progressing LTE; and a cancel button.

FAILURES WERE INVISIBLE. `handleSubmit` navigates to /feed immediately, and the queue
component is mounted only on /upload — so a 5xx, a captive-portal error or an
uploads-locked 403 wrote a German message into an item that nothing ever rendered. The
guest believed the photo was uploading; it never appeared. Same for the documented
rate-limit countdown banner, which lives in that same unreachable component and is now
also rendered from the layout.

RETRIES WERE UNCAPPED. `requeueRetriable` flipped every errored item back to pending on
the `online` event AND on every `feed-delta` — i.e. every SSE reconnect — with no
attempt counter and no backoff. On a flapping network a large failing video was
re-uploaded from byte zero all evening, saturating the AP for everyone. Now a persisted
attempt count, exponential backoff and a cap of five.

INDEXEDDB COULD STRAND THE COMPOSER. `openDB` had no `blocked` handler, so a second tab
holding an older version made it never settle, and it rejects outright on iOS private
mode; `handleSubmit` had no try/catch and never reset `submitting`, so both buttons
stayed disabled reading "Wird hochgeladen…" permanently, with no error and nothing
queued. There is now a `blocked` handler plus a settle timeout, an in-memory fallback
so uploading still works when persistence is unavailable, and a `finally`.

A 401 during a background upload cleared the session without redirecting — and api.ts
documents exactly why that strands a guest: the nav and FAB are gated on
`isAuthenticated` so they vanish, route guards only run on mount, and a standalone PWA
has no URL bar. Three early-return paths wrote status only to memory and never to
IndexedDB, leaving blob-less error rows that could never be evicted and held the red
FAB badge lit all night.

A banned guest was offered the entire upload flow — FAB, camera, staging — and only the
POST 403'd, while the new read-only banner told them uploading was disabled. The sheet
now consults the ban, the layout subscribes to `user-hidden` so a live ban reaches the
UI instead of arriving as a stream of 403 toasts, and the banner clears
`env(safe-area-inset-bottom)` so the bottom nav stops covering it on notched iPhones.
The /upload submit bar gets the same inset — it sat in the home-indicator zone, where
the system swallows the first tap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Fabian Hamm (Privat)
2026-08-03 18:36:30 +02:00
parent 51e55b1ace
commit fffa2d556c
8 changed files with 915 additions and 121 deletions

View File

@@ -1,16 +1,23 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { uploadSheetOpen } from '$lib/ui-store';
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
import { pendingFiles } from '$lib/pending-upload-store';
import { scrollLock } from '$lib/actions/scroll-lock';
import CameraCapture from '$lib/components/CameraCapture.svelte';
import type { PendingFile } from '$lib/pending-upload-store';
import { eventState, uploadsClosed } from '$lib/event-state-store';
import { commentsEnabled } from '$lib/event-config-store';
import { isBanned } from '$lib/ban-store';
// A ban closes uploads just as hard as an event lock does — the backend refuses every
// write from a banned user. Without this the read-only banner said uploading was off while
// the FAB still opened the sheet, the camera still opened, files still staged, and only the
// final POST 403'd: the guest burns their photo and their upload allowance on a rejection
// nobody is around to explain.
let banned = $derived($isBanned);
// Uploads closed (event locked or gallery released) — show a lock notice instead of
// the capture options, so a guest can't stage a photo that would just be rejected.
let closed = $derived(uploadsClosed($eventState));
let closed = $derived(banned || uploadsClosed($eventState));
let showCamera = $state(false);
let fileInput: HTMLInputElement;
@@ -77,6 +84,13 @@
showCamera = true;
}
// Close the sheet before navigating: it stays mounted for its translate-y animation,
// so leaving it open would keep the backdrop and scroll lock over /upload.
function openQueue() {
close();
void goto('/upload');
}
async function handleFiles() {
const files = fileInput?.files;
if (!files || files.length === 0) return;
@@ -160,16 +174,26 @@
<div class="space-y-3 px-4 pb-4 pt-2">
{#if closed}
<!-- Uploads closed: no capture options, just an explanation + dismiss. -->
<!-- Uploads closed: no capture options, just an explanation + dismiss. A ban and an
event lock both land here, but they need different copy — a banned guest keeps
read access only, so promising them likes and comments would just set up the
next 403. -->
<div class="rounded-xl bg-amber-50 px-5 py-4 text-center dark:bg-amber-950/30">
<p class="font-semibold text-amber-800 dark:text-amber-300">Uploads geschlossen</p>
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
Der Host hat die Uploads für dieses Event beendet. Du kannst weiterhin Fotos ansehen{$commentsEnabled
? ', liken und kommentieren'
: ' und liken'}.
</p>
{#if banned}
<p class="font-semibold text-amber-800 dark:text-amber-300">Nur-Lese-Modus</p>
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
Hochladen ist für dich deaktiviert. Du kannst weiterhin alle Fotos ansehen und die
Galerie später herunterladen.
</p>
{:else}
<p class="font-semibold text-amber-800 dark:text-amber-300">Uploads geschlossen</p>
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
Der Host hat die Uploads für dieses Event beendet. Du kannst weiterhin Fotos ansehen{$commentsEnabled
? ', liken und kommentieren'
: ' und liken'}.
</p>
{/if}
</div>
<button onclick={close} class="btn btn-secondary btn-block"> Schließen </button>
{:else}
<!-- Gallery option -->
<button
@@ -231,9 +255,49 @@
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
</div>
</button>
<!-- Cancel -->
<button onclick={close} class="btn btn-secondary btn-block"> Abbrechen </button>
{/if}
<!-- Queue access. The FAB badge is the ONLY signal a guest gets that an upload is
pending or failed, and tapping it opens this sheet — not the queue. Without this
entry the queue view (and with it the only retry button in the app) is reachable
only by staging a NEW file, so a guest with a failed upload sees a red badge,
three capture options, and no way to act on it. Shown in the closed/banned state
too: that guest is the MOST likely to have items parked in the queue, and it is
the only place they can clear the badge. -->
{#if $uploadBadgeCount > 0}
<button
onclick={openQueue}
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
>
<span
class="flex h-11 w-11 items-center justify-center rounded-full bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300"
>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</span>
<div>
<p class="font-semibold text-gray-900 dark:text-gray-100">Warteschlange</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{$uploadBadgeCount}
{$uploadBadgeCount === 1 ? 'Foto wartet' : 'Fotos warten'}
</p>
</div>
</button>
{/if}
<button onclick={close} class="btn btn-secondary btn-block">
{closed ? 'Schließen' : 'Abbrechen'}
</button>
</div>
</div>