/** * 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 two * per-request extras: X-Client-Id (this browser tab, the edit-lock holder) * 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; version: number; role: Role; owner_name: string; updated_at: string; updated_by_name: string | null; } export interface ServerDoc extends ServerDocMeta { content?: string; lock?: { locked_by_other: boolean; holder_name: string | null }; } 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 }[]; } /** One id per browser tab, kept across reloads of that tab. */ export const clientId: string = (() => { const key = 'numolus:client-id'; try { const existing = sessionStorage.getItem(key); if (existing) return existing; const id = Math.random().toString(36).slice(2) + Date.now().toString(36); sessionStorage.setItem(key, id); return id; } catch { return Math.random().toString(36).slice(2); } })(); 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(), 'X-Client-Id': clientId, }; 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 }; } }