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>
304 lines
11 KiB
Svelte
304 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
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(banned || uploadsClosed($eventState));
|
|
|
|
let showCamera = $state(false);
|
|
let fileInput: HTMLInputElement;
|
|
let sheet = $state<HTMLDivElement | null>(null);
|
|
let returnFocus: HTMLElement | null = null;
|
|
|
|
// Keep the sheet and backdrop always in the DOM for smooth CSS transitions.
|
|
let open = $derived($uploadSheetOpen);
|
|
|
|
function close() {
|
|
uploadSheetOpen.set(false);
|
|
}
|
|
|
|
// Focus-trap + Escape, wired manually because the sheet stays mounted for its
|
|
// translate-y animation (so use:focusTrap, which activates on mount, won't do).
|
|
// Mirrors ContextSheet. Suspended while the camera overlay owns the screen.
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (showCamera) return;
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
close();
|
|
return;
|
|
}
|
|
if (e.key !== 'Tab' || !sheet) return;
|
|
const list = Array.from(sheet.querySelectorAll<HTMLElement>('button:not([disabled])'));
|
|
if (list.length === 0) return;
|
|
const first = list[0];
|
|
const last = list[list.length - 1];
|
|
const active = document.activeElement as HTMLElement | null;
|
|
if (e.shiftKey && (active === first || !sheet.contains(active))) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && active === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (open) {
|
|
returnFocus = (document.activeElement as HTMLElement | null) ?? null;
|
|
requestAnimationFrame(() => {
|
|
if (showCamera) return;
|
|
const first = sheet?.querySelector<HTMLButtonElement>('button:not([disabled])');
|
|
first?.focus({ preventScroll: true });
|
|
});
|
|
window.addEventListener('keydown', onKeyDown);
|
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
} else if (returnFocus) {
|
|
try {
|
|
returnFocus.focus({ preventScroll: true });
|
|
} catch {
|
|
/* element gone */
|
|
}
|
|
returnFocus = null;
|
|
}
|
|
});
|
|
|
|
function openGallery() {
|
|
fileInput?.click();
|
|
}
|
|
|
|
function openCamera() {
|
|
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;
|
|
|
|
const staged: PendingFile[] = [];
|
|
for (const file of files) {
|
|
staged.push({ file, previewUrl: URL.createObjectURL(file) });
|
|
}
|
|
pendingFiles.set(staged);
|
|
fileInput.value = '';
|
|
close();
|
|
await goto('/upload');
|
|
}
|
|
|
|
async function handleCapture(blob: Blob, type: 'photo' | 'video') {
|
|
const ext = type === 'photo' ? 'jpg' : blob.type.includes('mp4') ? 'mp4' : 'webm';
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const fileName = `${type}_${timestamp}.${ext}`;
|
|
const file = new File([blob], fileName, { type: blob.type });
|
|
pendingFiles.set([{ file, previewUrl: URL.createObjectURL(file) }]);
|
|
showCamera = false;
|
|
close();
|
|
await goto('/upload');
|
|
}
|
|
|
|
function handleCameraClose() {
|
|
showCamera = false;
|
|
}
|
|
</script>
|
|
|
|
<!-- Camera (rendered outside sheet so it gets full viewport) -->
|
|
{#if showCamera}
|
|
<CameraCapture oncapture={handleCapture} onclose={handleCameraClose} />
|
|
{/if}
|
|
|
|
<!-- Hidden file input -->
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
accept="image/*,video/*"
|
|
multiple
|
|
class="hidden"
|
|
onchange={handleFiles}
|
|
/>
|
|
|
|
<!-- Lock body scroll only while open (sheet stays mounted for its animation). -->
|
|
{#if open && !showCamera}
|
|
<div use:scrollLock class="hidden"></div>
|
|
{/if}
|
|
|
|
<!-- Backdrop — real <button> so keyboard / switch-control users get parity. -->
|
|
<button
|
|
type="button"
|
|
class="fixed inset-0 z-40 bg-black/50 transition-opacity duration-300"
|
|
class:opacity-0={!open}
|
|
class:pointer-events-none={!open}
|
|
class:opacity-100={open}
|
|
onclick={close}
|
|
tabindex="-1"
|
|
aria-label="Schließen"
|
|
></button>
|
|
|
|
<!-- Sheet -->
|
|
<div
|
|
bind:this={sheet}
|
|
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-300 dark:bg-gray-900"
|
|
class:translate-y-full={!open}
|
|
class:translate-y-0={open}
|
|
style="padding-bottom: env(safe-area-inset-bottom)"
|
|
role={open ? 'dialog' : undefined}
|
|
aria-modal={open ? 'true' : undefined}
|
|
aria-label="Hochladen"
|
|
aria-hidden={!open}
|
|
inert={!open}
|
|
tabindex="-1"
|
|
>
|
|
<!-- Drag handle -->
|
|
<div class="flex justify-center pt-3 pb-1">
|
|
<div class="h-1 w-10 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
|
</div>
|
|
|
|
<div class="space-y-3 px-4 pb-4 pt-2">
|
|
{#if closed}
|
|
<!-- 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">
|
|
{#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>
|
|
{:else}
|
|
<!-- Gallery option -->
|
|
<button
|
|
onclick={openGallery}
|
|
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-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-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="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
<div>
|
|
<p class="font-semibold text-gray-900 dark:text-gray-100">Galerie</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Foto oder Video wählen</p>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- Camera option -->
|
|
<button
|
|
onclick={openCamera}
|
|
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-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-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="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"
|
|
/>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
<div>
|
|
<p class="font-semibold text-gray-900 dark:text-gray-100">Kamera</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
|
</div>
|
|
</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>
|