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,3 +1,5 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { getToken, clearAuth } from './auth';
const BASE = '/api/v1';
@@ -42,6 +44,11 @@ async function request<T>(
if (!res.ok) {
if (res.status === 401) {
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');
}

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
import { exportStatus, initExportStatus } from '$lib/export-status-store';
import { uploadsLocked } from '$lib/event-state-store';
function isActive(path: string): boolean {
return $page.url.pathname.startsWith(path);
@@ -43,8 +44,9 @@
<div class="relative -translate-y-3">
<button
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"
aria-label="Hochladen"
disabled={$uploadsLocked}
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 -->
<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 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);
@@ -15,6 +17,50 @@
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() {
fileInput?.click();
}
@@ -80,10 +126,17 @@
<!-- 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}
class:pointer-events-none={!open}
style="padding-bottom: env(safe-area-inset-bottom)"
role="dialog"
aria-modal="true"
aria-label="Hochladen"
tabindex="-1"
inert={!open}
>
<!-- Drag handle -->
<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;
quota_enabled: boolean;
storage_quota_enabled: boolean;
uploads_locked: boolean;
}
// mirrors backend/src/handlers/host.rs::PinResetResponse