Files
EventSnap/frontend/src/lib/components/UploadSheet.svelte
MechaCat02 641174717c
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Failing after 6m52s
E2E / Cross-UA smoke matrix (push) Failing after 3m50s
fix(user-flow): close offline-queue, export, session, ban & realtime gaps
Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:

- Offline upload queue auto-resumes on reconnect: network errors keep items
  pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
  returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
  re-release regenerates the keepsake; workers claim their job atomically so a
  reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
  exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
  revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
  is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
  not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
  live; feed_delta rate-limited; like returns {liked, like_count} to fix
  multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
  quota increment is transactional; operator floor.

Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:05:37 +02:00

216 lines
8.0 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { uploadSheetOpen } 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';
// 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 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;
}
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. -->
<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,
liken und kommentieren.
</p>
</div>
<button
onclick={close}
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Schließen
</button>
{: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>
<!-- Cancel -->
<button
onclick={close}
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Abbrechen
</button>
{/if}
</div>
</div>