Some checks failed
Audit / cargo audit (backend) (push) Failing after 12m25s
Audit / npm audit (frontend) (push) Failing after 23m21s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 55s
Checks / Frontend — vitest + svelte-check (push) Failing after 42m2s
Checks / E2E — typecheck + lint (push) Failing after 20m58s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 19m54s
E2E / Cross-UA smoke matrix (push) Failing after 20m27s
Tapping "Kamera" opened the viewfinder with the Galerie/Kamera sheet still sitting over the bottom of it, covering the capture controls. The sheet is `fixed`, so it could not be scrolled out of the way: the only route to the shutter was the phone's back button, which is not a discoverable step and is one most guests would read as "the camera is broken". Two independent causes, both fixed, because either one alone leaves a gap. The sheet never closed. It stays mounted for its translate-y animation and nothing told it the camera had taken over, so it kept its panel, its backdrop and its `aria-modal` while a full-screen overlay was up. `CameraCapture` now reports when its preview is live and the sheet dismisses itself on that signal. Deliberately on the preview, not on the tap. Closing when "Kamera" is pressed would dismiss the sheet before we know the camera works at all — and it often does not: a denied permission, no camera, or any non-secure context (where `navigator.mediaDevices` is simply absent) all end at the error panel. Closing early would leave the guest looking at that error with nothing behind it. Gated on `loadedmetadata`, the sheet is still there when the camera fails, so "Schließen" returns them to where they were. The signal is one-shot, because flipping the lens or switching photo/video re-acquires the stream and re-announcing "ready" would ask the caller to redo a dismissal it has already done. And the stacking was ambiguous. Both elements were `z-50` and the sheet is rendered after the camera, so it won on paint order. The overlay moves to `z-[60]` — the tier the Toaster already occupies, so toasts still surface above the viewfinder on DOM order. This is the part that holds regardless of timing: the controls are now reachable during the permission prompt and on the error panel, before anything has been dismissed. Focus follows the same reasoning. When the camera closes the sheet, restoring focus immediately would put it on the FAB *behind* the overlay, where a Tab could walk the page underneath; it is restored when the overlay goes away instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
337 lines
12 KiB
Svelte
337 lines
12 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}
|
|
/>
|
|
{/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>
|