Files
EventSnap/frontend/src/lib/components/UploadSheet.svelte
MechaCat02 5b705317ef fix: close the last three guest-facing dead ends (items 7-9)
C1 — a failed page-append silently ended infinite scroll

`loadMore`'s catch showed a toast and changed no state, unlike every sibling error
path in the file. `nextCursor` survived so the feed was still technically paginable,
but the IntersectionObserver only fires on a CHANGE: after a failed append nothing
scrolls and no rows are added, so it never re-fires. One 429 or wifi blip and the
guest concluded the gallery was 20 photos. Now leaves a retry control at the sentinel
— a toast that fades in 5s is not an affordance — resuming from the untouched cursor.

C2 — the export page reported downloads that never happened

`downloadFile` toasted 'Download gestartet' the instant it assigned the iframe's src,
before a single byte existed. Since the iframe swallows errors BY DESIGN (a top-level
navigation to a 404 would unload the PWA), a failure produced a green success message,
a consumed single-use ticket, and one of only three daily slots spent — repeatable
until the day's allowance was gone, on the screen that is the whole point of the app.

Root cause is two sources of truth: `export_status` reports `done` from `export_job`
and enables the button, while the download resolves through `export_current.file_path`
plus a `Path::exists()`. They can legitimately disagree. `export_ticket` now takes a
`kind` and calls the existing `resolve_export_file` BEFORE charging the rate slot, so
a missing archive fails honestly on a plain fetch that `toastError` already renders.
Not the HEAD probe ruled out elsewhere: it reads the same indexed row the download
will read and touches no ticket, so it cannot consume anything. The parameter is
optional, so an older client degrades to today's behaviour rather than breaking.

C3 — the WhatsApp journey could dead-end with no error at all

The join link travels through guest group chats, and a link tapped inside one opens in
that app's browser, where the file picker and getUserMedia both depend on the host app
having wired them up. When they aren't, the buttons do nothing — no error, nothing to
act on. Two targeted changes rather than a UI rebuild: the camera error panel now
offers "Aus Galerie wählen" (its advice to change "Browsereinstellungen" refers to
settings that do not exist in a webview, so retrying could never help those guests),
and the sheet carries a standing one-line hint to open the link in Safari or Chrome.

Deliberately no user-agent sniffing: a sniff list is wrong for every browser it has
not heard of, while a quiet standing hint costs one line and is never wrong. The hint
lives in UploadSheet rather than the root layout because both layout banners are gated
on `$showBottomNav`, which `/upload` turns off — one there would never render on the
composer.

Verified: 151/151 backend tests against a live Postgres, clippy clean, 58/58 vitest,
svelte-check 0 errors, eslint clean, both builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 22:38:07 +02:00

357 lines
14 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte';
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();
}
}
function restoreFocus() {
if (!returnFocus) return;
try {
returnFocus.focus({ preventScroll: true });
} catch {
/* element gone */
}
returnFocus = null;
}
$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 (untrack(() => showCamera)) {
// The camera closed the sheet from under us. Restoring focus now would put it on the
// FAB *behind* a full-screen overlay, where a Tab could then walk the page underneath;
// `handleCameraClose` does it once the overlay is gone. `untrack` because this branch
// must not resubscribe the effect to `showCamera` — that would re-run the open branch
// when the camera mounts and re-capture `returnFocus` as the sheet's own Kamera button.
} else {
restoreFocus();
}
});
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');
}
// The camera preview is up, so the sheet has done its job — dismiss it. Leaving it open put
// the Galerie/Kamera panel over the shutter button, and since it is `fixed` it could not be
// scrolled away: the only escape was the phone's back button.
//
// Deliberately driven by the camera's own ready signal rather than by the tap on "Kamera".
// Closing on the tap would dismiss the sheet before we know the camera will work at all, so a
// denied permission — or an insecure context, where `getUserMedia` does not exist — would
// leave the guest looking at an error panel with nothing behind it. This way the sheet is
// still there if the camera never opens, and `handleCameraClose` returns them to it.
function handleCameraReady() {
close();
}
function handleCameraClose() {
showCamera = false;
// `close()` normally restores focus to whatever opened the sheet, but when the camera
// dismissed it that element was behind a full-screen overlay and the restore was skipped.
// Do it now that the overlay is gone, so focus never ends up on <body>.
restoreFocus();
}
</script>
<!-- Camera (rendered outside sheet so it gets full viewport) -->
{#if showCamera}
<CameraCapture
oncapture={handleCapture}
onclose={handleCameraClose}
onready={handleCameraReady}
onpickfile={() => {
// Close the camera first, then open the OS picker — the sheet is still mounted
// behind it, so this returns the guest to a working path instead of a dead end.
showCamera = false;
openGallery();
}}
/>
{/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>
<!-- The in-app-browser escape hatch.
The join link travels through WhatsApp groups, and a link tapped inside one opens
in that app's own browser rather than Safari or Chrome. There, the file picker and
`getUserMedia` both depend on the host app having wired them up, and when they are
not wired up the buttons above simply do nothing — no error, nothing to act on,
with no operator to ask. Deliberately NOT user-agent sniffing: a sniff list is
wrong for browsers it has never heard of, whereas a quiet standing hint costs a
line of text and is never wrong. It lives here rather than in the root layout
because both layout banners are gated on `$showBottomNav`, which `/upload` turns
off — a banner there would never render on the composer. -->
<p class="px-1 pt-1 text-center text-xs text-gray-500 dark:text-gray-400">
Nichts passiert beim Tippen? Öffne den Link in Safari oder Chrome.
</p>
{/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>