Weboberflaeche mit Spielerkonto, Import und Verwaltung

Die Spieler-Detailseite beantwortet die Kernfrage direkt: drei grosse Zaehler,
rot sobald ein Topf im Minus ist, darunter ob ein neuer CC oder ein Redesign
gerade moeglich ist.

Inaktive CCs zeigen ihren Grund und bieten genau die passende Aktion an -
gratis reaktivieren oder bezahlt. Beim Redesign steht der Kulanz-Topf an
erster Stelle, damit er zuerst aufgebraucht wird.

Der Import fuehrt ueber Spaltenzuordnung und Vorschau; gebucht wird erst nach
Bestaetigung.
This commit is contained in:
2026-09-18 11:37:43 +02:00
parent a7228e63d1
commit 245666d363
17 changed files with 3628 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
const CSRF_COOKIE = 'ccm_csrf';
const CSRF_HEADER = 'x-csrf-token';
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
function readCookie(name: string): string | undefined {
const match = document.cookie
.split('; ')
.find((entry) => entry.startsWith(`${name}=`));
return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
}
export async function api<T>(path: string, options: RequestOptions = {}): Promise<T> {
const method = options.method ?? 'GET';
const headers: Record<string, string> = {};
if (options.body !== undefined) {
headers['content-type'] = 'application/json';
}
if (method !== 'GET') {
const token = readCookie(CSRF_COOKIE);
if (token) headers[CSRF_HEADER] = token;
}
const response = await fetch(path, {
method,
headers,
credentials: 'same-origin',
body: options.body === undefined ? undefined : JSON.stringify(options.body),
...(options.signal ? { signal: options.signal } : {}),
});
if (response.status === 204) return undefined as T;
const text = await response.text();
const payload = text ? safeParse(text) : null;
if (!response.ok) {
const error = payload as { error?: string; message?: string } | null;
throw new ApiError(
response.status,
error?.error ?? 'UNKNOWN',
error?.message ?? `Fehler ${response.status}`,
);
}
return payload as T;
}
/** Datei-Upload laeuft ohne JSON-Header, aber mit demselben CSRF-Schutz. */
export async function uploadFile<T>(path: string, file: File): Promise<T> {
const form = new FormData();
form.append('file', file);
const headers: Record<string, string> = {};
const token = readCookie(CSRF_COOKIE);
if (token) headers[CSRF_HEADER] = token;
const response = await fetch(path, {
method: 'POST',
headers,
credentials: 'same-origin',
body: form,
});
const text = await response.text();
const payload = text ? safeParse(text) : null;
if (!response.ok) {
const error = payload as { error?: string; message?: string } | null;
throw new ApiError(
response.status,
error?.error ?? 'UNKNOWN',
error?.message ?? `Fehler ${response.status}`,
);
}
return payload as T;
}
function safeParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return { message: text };
}
}