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>
137 lines
4.8 KiB
Svelte
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 />
|