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:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
10
frontend/src/lib/event-state-store.ts
Normal file
10
frontend/src/lib/event-state-store.ts
Normal 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);
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user