/** * The numolus server API (Rails, /api). JSON in and out, the session cookie * for the signed-in user, the CSRF token from the page's meta tag, and * X-Share-Token (a share link's token, for access via link). */ export class ApiError extends Error { constructor( public status: number, public body: Record, ) { super(ApiError.describe(status, body)); } static describe(status: number, body: Record): string { if (status === 0) return 'Keine Verbindung zum Server.'; if (status === 401) return 'Nicht angemeldet.'; if (status === 404) return 'Nicht gefunden oder kein Zugriff.'; if (status === 429) return 'Zu viele Versuche – bitte später noch einmal.'; const msgs = body.messages; if (Array.isArray(msgs) && msgs.length) return msgs.join(', '); return `Serverfehler (${status}).`; } } export type Role = 'owner' | 'editor' | 'viewer'; export interface User { id: number; email: string; name: string; display_name: string | null; color: string; } export interface ServerDocMeta { id: string; title: string; role: Role; owner_name: string; updated_at: string; updated_by_name: string | null; } export interface ServerDoc extends ServerDocMeta { /** JSON snapshot (the live content comes through DocumentChannel) */ content?: string; } export interface ShareInfo { links: { id: number; role: 'viewer' | 'editor'; token: string; created_at: string }[]; members: { user_id: number; name: string; email: string; role: 'viewer' | 'editor'; source: 'invite' | 'link' }[]; invitations: { email: string; role: 'viewer' | 'editor'; expires_at: string }[]; } function csrfToken(): string { return document.querySelector('meta[name="csrf-token"]')?.content ?? ''; } interface RequestOpts { body?: unknown; shareToken?: string | null; /** survives the page being closed (small bodies only, < 64 KB) */ keepalive?: boolean; } export async function api>(method: string, path: string, opts: RequestOpts = {}): Promise { const headers: Record = { Accept: 'application/json', 'X-CSRF-Token': csrfToken(), }; if (opts.body !== undefined) headers['Content-Type'] = 'application/json'; if (opts.shareToken) headers['X-Share-Token'] = opts.shareToken; let res: Response; try { res = await fetch(`/api${path}`, { method, headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), credentials: 'same-origin', keepalive: opts.keepalive, }); } catch { throw new ApiError(0, {}); } if (res.status === 204) return {} as T; const body = (await res.json().catch(() => ({}))) as Record; if (!res.ok) throw new ApiError(res.status, body); return body as T; } /** Data the server put into the page (see app/views/spa/show.html.erb). */ export function bootData(): { user: User | null } { try { const el = document.getElementById('numolus-boot'); return el ? JSON.parse(el.textContent || '{}') : { user: null }; } catch { return { user: null }; } }