- SvelteKit SPA with adapter-static, prerender, SSR disabled - Catppuccin Mocha/Latte theme CSS with semantic color tokens - WebSocket client with auto-reconnect and exponential backoff - HTTP API client with JWT auth and 401 handling - Auth state store with localStorage persistence (Svelte 5 runes) - Tournament state store handling all WS message types (Svelte 5 runes) - PIN login page with numpad, 48px touch targets - Updated Makefile frontend target for real SvelteKit build Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
124 lines
3 KiB
TypeScript
124 lines
3 KiB
TypeScript
/**
|
|
* HTTP API client for the Felt backend.
|
|
*
|
|
* Auto-detects base URL from current host, attaches JWT from auth store,
|
|
* handles 401 responses by clearing auth state and redirecting to login.
|
|
*/
|
|
|
|
import { auth } from '$lib/stores/auth.svelte';
|
|
import { goto } from '$app/navigation';
|
|
|
|
/** Typed API error with status code and message. */
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public readonly status: number,
|
|
public readonly statusText: string,
|
|
public readonly body: unknown
|
|
) {
|
|
const msg = typeof body === 'object' && body !== null && 'error' in body
|
|
? (body as { error: string }).error
|
|
: statusText;
|
|
super(msg);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
/** Base URL for API requests — auto-detected from current host. */
|
|
function getBaseUrl(): string {
|
|
return `${window.location.origin}/api/v1`;
|
|
}
|
|
|
|
/** Build headers with JWT auth and content type. */
|
|
function buildHeaders(hasBody: boolean): HeadersInit {
|
|
const headers: Record<string, string> = {
|
|
'Accept': 'application/json'
|
|
};
|
|
|
|
if (hasBody) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
const token = auth.token;
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
/** Handle API response — parse JSON, handle errors. */
|
|
async function handleResponse<T>(response: Response): Promise<T> {
|
|
if (response.status === 401) {
|
|
// Token expired or invalid — clear auth and redirect to login
|
|
auth.logout();
|
|
await goto('/login');
|
|
throw new ApiError(401, 'Unauthorized', { error: 'Session expired' });
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let body: unknown;
|
|
try {
|
|
body = await response.json();
|
|
} catch {
|
|
body = { error: response.statusText };
|
|
}
|
|
throw new ApiError(response.status, response.statusText, body);
|
|
}
|
|
|
|
// Handle 204 No Content
|
|
if (response.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
/** Perform an API request. */
|
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const url = `${getBaseUrl()}${path}`;
|
|
|
|
const init: RequestInit = {
|
|
method,
|
|
headers: buildHeaders(body !== undefined),
|
|
credentials: 'same-origin'
|
|
};
|
|
|
|
if (body !== undefined) {
|
|
init.body = JSON.stringify(body);
|
|
}
|
|
|
|
const response = await fetch(url, init);
|
|
return handleResponse<T>(response);
|
|
}
|
|
|
|
/**
|
|
* HTTP API client.
|
|
*
|
|
* All methods auto-attach JWT from auth store and handle 401 responses.
|
|
*/
|
|
export const api = {
|
|
/** GET request. */
|
|
get<T>(path: string): Promise<T> {
|
|
return request<T>('GET', path);
|
|
},
|
|
|
|
/** POST request with JSON body. */
|
|
post<T>(path: string, body?: unknown): Promise<T> {
|
|
return request<T>('POST', path, body);
|
|
},
|
|
|
|
/** PUT request with JSON body. */
|
|
put<T>(path: string, body?: unknown): Promise<T> {
|
|
return request<T>('PUT', path, body);
|
|
},
|
|
|
|
/** PATCH request with JSON body. */
|
|
patch<T>(path: string, body?: unknown): Promise<T> {
|
|
return request<T>('PATCH', path, body);
|
|
},
|
|
|
|
/** DELETE request. */
|
|
delete<T>(path: string): Promise<T> {
|
|
return request<T>('DELETE', path);
|
|
}
|
|
};
|