The frontend had no JS/TS linter — only svelte-check. Add flat-config ESLint (typescript-eslint
+ eslint-plugin-svelte) and Prettier (tabs/single-quote, matching the existing style).
Rules encode "catch bugs, not enforce taste":
- svelte/require-each-key KEPT — it is the exact bug class as the feed mis-tap fix. Fixed every
flagged block: keyed activeFilters, filteredUsers, stagedFiles (by previewUrl), captionTags,
admin tabs/jobs/users, the export-viewer suggestions/filters/comments, and the static skeleton
loops.
- svelte/prefer-svelte-reactivity KEPT — inline-disabled only the verified-safe sites (a local
freq Map in a $derived.by, throwaway URLSearchParams query builders), with a reason each.
- svelte/no-navigation-without-resolve OFF — wants resolve() around every goto()/href; taste, not
a bug, and pure churn.
- svelte/no-unused-svelte-ignore OFF — those comments are consumed by svelte-check, which ESLint
can't see, so it wrongly calls them unused; removing them would reintroduce a11y warnings.
- no-explicit-any OFF for *.test.ts only (partial fixtures legitimately use any).
Real code fixes beyond keys: removed a dead jobLabel(), an unused ViewerComment import and unused
catch binding, an unused scroll-lock arg, and replaced an empty interface with a type alias.
Then `prettier --write` (54 files). Formatting only. Verified: eslint clean, svelte-check 0 errors,
vitest 46 passed, vite build succeeds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
2.4 KiB
TypeScript
90 lines
2.4 KiB
TypeScript
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;
|
||
}
|
||
}
|
||
|
||
const TIMEOUT_MS = 20_000;
|
||
|
||
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';
|
||
}
|
||
|
||
// Abort hung requests so a dead connection surfaces as a friendly error
|
||
// instead of a spinner that never resolves.
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(`${BASE}${path}`, {
|
||
method,
|
||
headers,
|
||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||
signal: controller.signal
|
||
});
|
||
} catch (e) {
|
||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||
throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.');
|
||
}
|
||
throw new ApiError(0, 'network', 'Netzwerkfehler – bitte Verbindung prüfen.');
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
|
||
if (res.status === 204) {
|
||
return undefined as T;
|
||
}
|
||
|
||
// A 5xx behind a proxy (or a crash page) can return HTML, not JSON — parsing
|
||
// it directly would throw an opaque SyntaxError. Read text, parse defensively.
|
||
const raw = await res.text();
|
||
let data: { error?: string; message?: string } | unknown = null;
|
||
if (raw) {
|
||
try {
|
||
data = JSON.parse(raw);
|
||
} catch {
|
||
data = null;
|
||
}
|
||
}
|
||
|
||
if (!res.ok) {
|
||
// An expired/invalid token (401) clears the dead session. Banned users are
|
||
// NOT logged out — they keep read access by design (USER_JOURNEYS §10) and
|
||
// simply get a 403 "gesperrt" toast on writes.
|
||
if (res.status === 401) {
|
||
clearAuth();
|
||
}
|
||
const d = (data ?? {}) as { error?: string; message?: string };
|
||
throw new ApiError(
|
||
res.status,
|
||
d.error ?? 'unknown',
|
||
d.message ?? `Serverfehler (${res.status}).`
|
||
);
|
||
}
|
||
|
||
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)
|
||
};
|