export type User = { id: number; username: string }; export type Genre = { id: number; name: string }; export type ReadingStatus = "not_started" | "reading" | "finished"; export type Book = { id: number; title: string; author: string; genre: Genre; total_pages: number; current_page: number; status: ReadingStatus; created_at: string; updated_at: string; }; export type Note = { id: number; book_id: number; body: string; created_at: string; updated_at: string; }; export class ApiError extends Error { readonly status: number; readonly field?: string; constructor(status: number, message: string, field?: string) { super(message); this.status = status; this.field = field; } } let handleUnauthorized: () => void = () => {}; let authEpoch = 0; // A 401 only means "signed out" for the session that sent the request; one started before // a later login, logout or expiry must not end the session that replaced it. export function startAuthEpoch() { authEpoch += 1; } export function setUnauthorizedHandler(handler: () => void) { handleUnauthorized = handler; } export async function api( method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown, ): Promise { const epoch = authEpoch; const response = await fetch(`/api${path}`, { method, credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); if (response.status === 204) return undefined as T; const data = await response.json().catch(() => null); if (!response.ok) { if ( response.status === 401 && !path.startsWith("/auth/") && epoch === authEpoch ) { startAuthEpoch(); handleUnauthorized(); } throw new ApiError( response.status, data?.error ?? `Request failed with status ${response.status}`, data?.field, ); } return data as T; } export function errorMessage(error: unknown): string { return error instanceof ApiError ? error.message : "Could not reach the server; check your connection and try again"; }