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>
100 lines
3.1 KiB
TypeScript
100 lines
3.1 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|
||
}
|