Files
numolus/app/frontend/io/api.ts
T
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

117 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>,
) {
super(ApiError.describe(status, body));
}
static describe(status: number, body: Record<string, unknown>): 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<HTMLMetaElement>('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<T = Record<string, unknown>>(method: string, path: string, opts: RequestOpts = {}): Promise<T> {
const headers: Record<string, string> = {
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<string, unknown>;
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 };
}
}