Files
numolus/app/frontend/io/api.ts
T
wkhandClaude Opus 5.5 7fce972fdc Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of
taking turns behind an edit lock.

- The document's content is a sequence of Yjs updates (document_updates);
  DocumentChannel stores and relays them immediately, checks access on
  subscribe and again whenever links or memberships change.
- app/frontend/collab/: the store stays position-based; a binding maps it
  to a Yjs form with stable row/column ids, so a row inserted by one
  person doesn't misplace what another is typing. Remote changes are
  applied only after pending local ones went into Yjs.
- Per-person undo (Y.UndoManager), presence via awareness: the others'
  selections as coloured boxes with names, avatars in the toolbar.
- Existing JSON documents convert on first open, deterministically, so
  two people opening one at once end up with the same Yjs state.
- Editors compact the stored updates after 200 and keep a JSON snapshot
  current for export and duplicating. Edit lock and polling are gone.

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

100 lines
3.1 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
* 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;
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<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(),
};
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 };
}
}