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>
65 lines
1.6 KiB
TypeScript
65 lines
1.6 KiB
TypeScript
import { goto } from '$app/navigation';
|
|
import { browser } from '$app/environment';
|
|
import { getToken, clearAuth } from './auth';
|
|
|
|
const BASE = '/api/v1';
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
code: string;
|
|
|
|
constructor(status: number, code: string, message: string) {
|
|
super(message);
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
async function request<T>(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown
|
|
): Promise<T> {
|
|
const headers: Record<string, string> = {};
|
|
const token = getToken();
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
if (body !== undefined) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
});
|
|
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
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');
|
|
}
|
|
|
|
return data as T;
|
|
}
|
|
|
|
export const api = {
|
|
get: <T>(path: string) => request<T>('GET', path),
|
|
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
|
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
|
delete: <T>(path: string) => request<T>('DELETE', path)
|
|
};
|