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

@@ -55,6 +55,9 @@ pub struct MeContextDto {
pub privacy_note: String, pub privacy_note: String,
pub quota_enabled: bool, pub quota_enabled: bool,
pub storage_quota_enabled: bool, pub storage_quota_enabled: bool,
/// Whether uploads are currently locked for the event, so the client can show
/// a banner + disable the upload affordance on load (not just via SSE).
pub uploads_locked: bool,
} }
pub async fn get_context( pub async fn get_context(
@@ -68,6 +71,10 @@ pub async fn get_context(
let privacy_note = config::get_str(&state.pool, "privacy_note", "").await; let privacy_note = config::get_str(&state.pool, "privacy_note", "").await;
let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await; let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await;
let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await; let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
let uploads_locked = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.map(|e| e.uploads_locked_at.is_some())
.unwrap_or(false);
Ok(Json(MeContextDto { Ok(Json(MeContextDto {
user_id: user.id, user_id: user.id,
@@ -76,5 +83,6 @@ pub async fn get_context(
privacy_note, privacy_note,
quota_enabled, quota_enabled,
storage_quota_enabled, storage_quota_enabled,
uploads_locked,
})) }))
} }

View File

@@ -1,3 +1,5 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { getToken, clearAuth } from './auth'; import { getToken, clearAuth } from './auth';
const BASE = '/api/v1'; const BASE = '/api/v1';
@@ -42,6 +44,11 @@ async function request<T>(
if (!res.ok) { if (!res.ok) {
if (res.status === 401) { if (res.status === 401) {
clearAuth(); clearAuth();
// Don't strand the user on a now-unauthenticated page with no nav —
// send them to /join. Guard against loops on the auth screens.
if (browser && !/^\/(join|recover|admin\/login)/.test(window.location.pathname)) {
void goto('/join');
}
} }
throw new ApiError(res.status, data.error ?? 'unknown', data.message ?? 'Fehler'); throw new ApiError(res.status, data.error ?? 'unknown', data.message ?? 'Fehler');
} }

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store'; import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
import { exportStatus, initExportStatus } from '$lib/export-status-store'; import { exportStatus, initExportStatus } from '$lib/export-status-store';
import { uploadsLocked } from '$lib/event-state-store';
function isActive(path: string): boolean { function isActive(path: string): boolean {
return $page.url.pathname.startsWith(path); return $page.url.pathname.startsWith(path);
@@ -43,8 +44,9 @@
<div class="relative -translate-y-3"> <div class="relative -translate-y-3">
<button <button
onclick={() => ($uploadSheetOpen = true)} onclick={() => ($uploadSheetOpen = true)}
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700" disabled={$uploadsLocked}
aria-label="Hochladen" class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-blue-600 disabled:active:scale-100"
aria-label={$uploadsLocked ? 'Uploads gesperrt' : 'Hochladen'}
> >
<!-- Camera + plus icon --> <!-- Camera + plus icon -->
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">

View File

@@ -7,6 +7,8 @@
let showCamera = $state(false); let showCamera = $state(false);
let fileInput: HTMLInputElement; 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. // Keep the sheet and backdrop always in the DOM for smooth CSS transitions.
let open = $derived($uploadSheetOpen); let open = $derived($uploadSheetOpen);
@@ -15,6 +17,50 @@
uploadSheetOpen.set(false); uploadSheetOpen.set(false);
} }
// Modal contract (mirrors ContextSheet): trap Tab within the sheet, close on
// Escape, and restore focus on close. The sheet is permanently mounted (CSS
// translate animation), so this is wired via $effect — and `inert`/`aria-hidden`
// below keep its controls out of the tab order + AT tree while closed.
function onKeyDown(e: KeyboardEvent) {
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(() => {
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() { function openGallery() {
fileInput?.click(); fileInput?.click();
} }
@@ -80,10 +126,17 @@
<!-- Sheet --> <!-- Sheet -->
<div <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="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-full={!open}
class:translate-y-0={open} class:translate-y-0={open}
class:pointer-events-none={!open}
style="padding-bottom: env(safe-area-inset-bottom)" style="padding-bottom: env(safe-area-inset-bottom)"
role="dialog"
aria-modal="true"
aria-label="Hochladen"
tabindex="-1"
inert={!open}
> >
<!-- Drag handle --> <!-- Drag handle -->
<div class="flex justify-center pt-3 pb-1"> <div class="flex justify-center pt-3 pb-1">

View File

@@ -0,0 +1,10 @@
// Shared, reactive event state pushed from the backend.
//
// `uploadsLocked` is seeded from `/me/context` on boot and kept live by the
// `event-closed` / `event-opened` SSE events (wired in the root layout). Pages
// read it to show an "Uploads gesperrt" banner and disable the upload FAB, so a
// locked event stops inviting uploads that would only fail server-side.
import { writable } from 'svelte/store';
export const uploadsLocked = writable<boolean>(false);

View File

@@ -55,6 +55,7 @@ export interface MeContextDto {
privacy_note: string; privacy_note: string;
quota_enabled: boolean; quota_enabled: boolean;
storage_quota_enabled: boolean; storage_quota_enabled: boolean;
uploads_locked: boolean;
} }
// mirrors backend/src/handlers/host.rs::PinResetResponse // mirrors backend/src/handlers/host.rs::PinResetResponse

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import '../app.css'; 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 { initTheme } from '$lib/theme-store';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import BottomNav from '$lib/components/BottomNav.svelte'; import BottomNav from '$lib/components/BottomNav.svelte';
import UploadSheet from '$lib/components/UploadSheet.svelte'; import UploadSheet from '$lib/components/UploadSheet.svelte';
@@ -14,12 +15,28 @@
import { refreshQuota } from '$lib/quota-store'; import { refreshQuota } from '$lib/quota-store';
import { onSseEvent } from '$lib/sse'; import { onSseEvent } from '$lib/sse';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { toast } from '$lib/toast-store';
import { uploadsLocked } from '$lib/event-state-store';
import type { MeContextDto } from '$lib/types'; import type { MeContextDto } from '$lib/types';
let { children } = $props(); let { children } = $props();
let unsubs: Array<() => void> = []; 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. // Slim progress bar: ratio of completed items to total, shown while processing.
let progressPct = $derived.by(() => { let progressPct = $derived.by(() => {
const total = $queueItems.length; const total = $queueItems.length;
@@ -39,6 +56,7 @@
try { try {
const ctx = await api.get<MeContextDto>('/me/context'); const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note); privacyNote.set(ctx.privacy_note);
uploadsLocked.set(ctx.uploads_locked);
} catch { } catch {
// Cross-cutting hydration on boot — failure is non-fatal; users without // Cross-cutting hydration on boot — failure is non-fatal; users without
// a session land on /join anyway, and the per-page mount will retry. // a session land on /join anyway, and the per-page mount will retry.
@@ -61,7 +79,26 @@
} catch { } catch {
// Malformed payload — discard; nothing actionable for the user. // 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))
); );
}); });

View File

@@ -23,6 +23,7 @@
let status = $state<ExportStatus | null>(null); let status = $state<ExportStatus | null>(null);
let showHtmlGuide = $state(false); let showHtmlGuide = $state(false);
let loading = $state(true); let loading = $state(true);
let loadError = $state(false);
let unsubscribers: (() => void)[] = []; let unsubscribers: (() => void)[] = [];
@@ -53,9 +54,12 @@
async function loadStatus() { async function loadStatus() {
try { try {
status = await api.get<ExportStatus>('/export/status'); status = await api.get<ExportStatus>('/export/status');
loadError = false;
} catch { } catch {
// Background poll triggered by SSE — silent. The visible empty/loading state // M19: a fetch failure must not masquerade as "not yet released" — only
// will reflect the failure; the next event will retry. // flag an error when we have nothing to show; a background SSE poll that
// fails while we already have a status stays silent.
if (!status) loadError = true;
} finally { } finally {
loading = false; loading = false;
} }
@@ -174,6 +178,16 @@
<div class="mx-auto max-w-lg space-y-4 p-4"> <div class="mx-auto max-w-lg space-y-4 p-4">
{#if loading} {#if loading}
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div> <div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
{:else if loadError}
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="font-medium text-gray-700 dark:text-gray-300">Status konnte nicht geladen werden.</p>
<button
onclick={() => { loading = true; loadStatus(); }}
class="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
>
Erneut versuchen
</button>
</div>
{:else if !status?.released} {:else if !status?.released}
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800"> <div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">

View File

@@ -16,6 +16,7 @@
import { toast, toastError } from '$lib/toast-store'; import { toast, toastError } from '$lib/toast-store';
import { pullToRefresh } from '$lib/actions/pull-to-refresh'; import { pullToRefresh } from '$lib/actions/pull-to-refresh';
import { vibrate } from '$lib/haptics'; import { vibrate } from '$lib/haptics';
import { uploadsLocked } from '$lib/event-state-store';
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types'; import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
let uploads = $state<FeedUpload[]>([]); let uploads = $state<FeedUpload[]>([]);
@@ -24,6 +25,7 @@
let nextCursor = $state<string | null>(null); let nextCursor = $state<string | null>(null);
let loadingMore = $state(false); let loadingMore = $state(false);
let initialLoading = $state(true); let initialLoading = $state(true);
let loadError = $state(false);
let refreshing = $state(false); let refreshing = $state(false);
let pullProgress = $state(0); // 01+ during the drag, 0 when idle let pullProgress = $state(0); // 01+ during the drag, 0 when idle
let selectedUpload = $state<FeedUpload | null>(null); let selectedUpload = $state<FeedUpload | null>(null);
@@ -246,13 +248,18 @@
const res = await api.get<FeedResponse>(`/feed?${params}`); const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = res.uploads; uploads = res.uploads;
nextCursor = res.next_cursor; nextCursor = res.next_cursor;
loadError = false;
// Seed the SSE reconnect cursor from the newest server timestamp so the // Seed the SSE reconnect cursor from the newest server timestamp so the
// delta on the next reconnect is based on server time, not the client // delta on the next reconnect is based on server time, not the client
// clock (M9). // clock (M9).
if (uploads.length) setLastEventTime(uploads[0].created_at); if (uploads.length) setLastEventTime(uploads[0].created_at);
} catch (e) { } catch (e) {
// Initial / user-triggered refresh is worth surfacing — background SSE refetches are noisier and silenced below. // M19: distinguish a load failure from a genuinely empty gallery so the
if (!refresh) toastError(e); // template can offer a retry instead of showing "nobody posted yet".
if (!refresh) {
loadError = true;
toastError(e);
}
} finally { } finally {
initialLoading = false; initialLoading = false;
} }
@@ -524,6 +531,16 @@
{/if} {/if}
</div> </div>
<!-- Uploads-locked banner (M11) -->
{#if $uploadsLocked}
<div
class="mx-auto mb-2 max-w-2xl rounded-lg bg-amber-100 px-4 py-2 text-center text-sm font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
role="status"
>
Uploads sind aktuell gesperrt.
</div>
{/if}
<!-- Content --> <!-- Content -->
{#if initialLoading && uploads.length === 0} {#if initialLoading && uploads.length === 0}
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton"> <div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
@@ -539,10 +556,20 @@
</div> </div>
{/if} {/if}
</div> </div>
{:else if uploads.length === 0 && loadError}
<div class="py-20 text-center">
<p class="text-lg text-gray-600 dark:text-gray-300">Galerie konnte nicht geladen werden.</p>
<button
onclick={() => loadFeed()}
class="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
>
Erneut versuchen
</button>
</div>
{:else if uploads.length === 0} {:else if uploads.length === 0}
<div class="py-20 text-center"> <div class="py-20 text-center">
<p class="text-lg text-gray-400 dark:text-gray-500">Noch keine Fotos.</p> <p class="text-lg text-gray-500 dark:text-gray-400">Noch keine Fotos.</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Tippe auf den Plus-Button unten!</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Tippe auf den Plus-Button unten!</p>
</div> </div>
{:else if viewMode === 'list'} {:else if viewMode === 'list'}
<!-- List view: chronological full-width cards --> <!-- List view: chronological full-width cards -->