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>
This commit is contained in:
MechaCat02
2026-06-27 16:16:01 +02:00
parent cf428725b9
commit 5bd008591b
9 changed files with 169 additions and 10 deletions

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
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';
@@ -14,12 +15,28 @@
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;
@@ -39,6 +56,7 @@
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.
@@ -61,7 +79,26 @@
} 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))
);
});