Files
EventSnap/frontend/src/routes/+layout.svelte
MechaCat02 5bd008591b fix(frontend): surface upload errors, push ban/lock, route guards (WS8)
H11: UploadSheet now honors the modal contract — role=dialog/aria-modal +
accessible name, a focus-trap/Escape/focus-restore $effect mirroring
ContextSheet, and inert + pointer-events-none when closed so its controls leave
the tab order and AT tree.

H12: a layout-level $effect toasts each upload-queue item the first time it
transitions to 'error', so a banned/locked/over-quota guest is actually told
why their photo didn't post instead of the composer silently closing.

M10: api.ts now redirects to /join after clearAuth() on a 401 (guarded against
loops on the auth screens), so a 401 no longer strands the user on a dead page.

M11: ban and event-lock are pushed to guests. A new uploadsLocked store is
seeded from /me/context (now exposes uploads_locked) and kept live by
event-closed/opened SSE; the feed shows a banner and the FAB is disabled when
locked. A user-banned SSE event force-logs-out the targeted user.

M19: feed and export pages track a distinct loadError and render an "Erneut
versuchen" retry card instead of collapsing a fetch failure into "empty" /
"not released". Light-mode empty-state text bumped to gray-500 for contrast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:16:01 +02:00

137 lines
4.8 KiB
Svelte

<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
import { initAuth, getToken, getUserId, clearPin, clearAuth } from '$lib/auth';
import { initTheme } from '$lib/theme-store';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
import UploadSheet from '$lib/components/UploadSheet.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import { showBottomNav } from '$lib/ui-store';
import { isAuthenticated } from '$lib/auth';
import { queueItems, isProcessing } from '$lib/upload-queue';
import { privacyNote } from '$lib/privacy-note-store';
import { refreshQuota } from '$lib/quota-store';
import { onSseEvent } from '$lib/sse';
import { api } from '$lib/api';
import { toast } from '$lib/toast-store';
import { uploadsLocked } from '$lib/event-state-store';
import type { MeContextDto } from '$lib/types';
let { children } = $props();
let unsubs: Array<() => void> = [];
// H12: surface upload failures. The queue marks failed items 'error' in
// IndexedDB but nothing rendered them, so a banned/locked/over-quota guest saw
// the composer close to a normal feed and assumed success. Toast each item the
// first time it transitions to 'error'.
let toastedErrors = new Set<string>();
$effect(() => {
for (const item of $queueItems) {
if (item.status === 'error' && !toastedErrors.has(item.id)) {
toastedErrors.add(item.id);
toast(item.error ?? 'Upload fehlgeschlagen.', 'error');
}
}
});
// Slim progress bar: ratio of completed items to total, shown while processing.
let progressPct = $derived.by(() => {
const total = $queueItems.length;
if (total === 0) return 0;
const done = $queueItems.filter((i) => i.status === 'done').length;
return Math.round((done / total) * 100);
});
onMount(async () => {
initAuth();
// Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the
// first paint after hydration matches the saved preference.
initTheme();
// Hydrate cross-cutting stores once on boot if the user is already authenticated.
// Page-level mounts will refresh again as needed.
if (getToken()) {
try {
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
uploadsLocked.set(ctx.uploads_locked);
} catch {
// Cross-cutting hydration on boot — failure is non-fatal; users without
// a session land on /join anyway, and the per-page mount will retry.
}
void refreshQuota();
}
// Global pin-reset listener — clears the now-invalid plaintext PIN from
// localStorage no matter which route the user is currently on. The reactive
// `currentPin` store carries the change into any page that reads it (My
// Account in particular).
unsubs.push(
// Server contract: `data` is a JSON string of the shape `{ user_id: UUID }`.
// We clear the cached PIN only for our own user; admin resets for other guests
// arrive on the same channel but aren't ours to act on.
onSseEvent('pin-reset', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
if (payload.user_id === getUserId()) clearPin();
} catch {
// Malformed payload — discard; nothing actionable for the user.
}
}),
// M11: a host banned someone. If it's us, the session has already been
// revoked server-side — drop local auth and send us to /join so the UI
// doesn't keep pretending we're signed in.
onSseEvent('user-banned', (data) => {
try {
const payload = JSON.parse(data) as { user_id: string };
if (payload.user_id === getUserId()) {
clearAuth();
toast('Du wurdest vom Event entfernt.', 'error');
void goto('/join');
}
} catch {
/* malformed — ignore */
}
}),
// M11: reflect event lock state live so the feed/upload pages can toggle
// their "Uploads gesperrt" banner and disable the FAB.
onSseEvent('event-closed', () => uploadsLocked.set(true)),
onSseEvent('event-opened', () => uploadsLocked.set(false))
);
});
onDestroy(() => {
for (const unsub of unsubs) unsub();
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}
<!-- Slim upload progress bar — sits just above the bottom nav -->
{#if $isProcessing && $isAuthenticated && $showBottomNav}
<div
class="fixed z-30 h-0.5 bg-gray-200 transition-all"
style="bottom: calc(3.5rem + env(safe-area-inset-bottom)); left: 0; right: 0"
>
<div
class="h-full bg-blue-500 transition-all duration-500"
style="width: {progressPct}%"
></div>
</div>
{/if}
<!-- UploadSheet is always mounted for smooth enter/exit animation -->
<UploadSheet />
{#if $showBottomNav && $isAuthenticated}
<BottomNav />
{/if}
<Toaster />