Files
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

114 lines
4.1 KiB
TypeScript

/**
* The browser's document library: every numolus document the user has in
* this browser, stored in IndexedDB. Two object stores, so listing documents
* never has to read their (possibly large) contents:
*
* meta { id, title, createdAt, updatedAt } -- the document list
* content { id, json } -- the document itself
*
* `json` is the serialised DocumentState (the same JSON a .num file holds).
* Plain promise wrapper over the IndexedDB API, no library; `factory` is
* injectable so tests can run against an in-memory implementation.
*/
export interface DocMeta {
id: string;
title: string;
/** epoch ms */
createdAt: number;
/** epoch ms of the last *content* change (not cursor moves) */
updatedAt: number;
}
interface ContentRecord {
id: string;
json: string;
}
const DB_NAME = 'numolus';
const DB_VERSION = 1;
function req<T>(r: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
}
function done(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error('Transaktion abgebrochen.'));
});
}
export class DocumentLibrary {
private constructor(private db: IDBDatabase) {}
static async open(factory: IDBFactory = indexedDB): Promise<DocumentLibrary> {
const r = factory.open(DB_NAME, DB_VERSION);
r.onupgradeneeded = () => {
const db = r.result;
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'id' });
if (!db.objectStoreNames.contains('content')) db.createObjectStore('content', { keyPath: 'id' });
};
const db = await req(r);
// another tab upgrading the schema later must not be blocked by us
db.onversionchange = () => db.close();
return new DocumentLibrary(db);
}
/** All documents, most recently changed first. */
async list(): Promise<DocMeta[]> {
const tx = this.db.transaction('meta', 'readonly');
const all = await req(tx.objectStore('meta').getAll() as IDBRequest<DocMeta[]>);
return all.sort((a, b) => b.updatedAt - a.updatedAt);
}
async getMeta(id: string): Promise<DocMeta | undefined> {
const tx = this.db.transaction('meta', 'readonly');
return req(tx.objectStore('meta').get(id) as IDBRequest<DocMeta | undefined>);
}
async getContent(id: string): Promise<string | undefined> {
const tx = this.db.transaction('content', 'readonly');
const rec = await req(tx.objectStore('content').get(id) as IDBRequest<ContentRecord | undefined>);
return rec?.json;
}
/** Create or overwrite a document -- meta and content in one transaction. */
async put(meta: DocMeta, json: string): Promise<void> {
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
tx.objectStore('meta').put(meta);
tx.objectStore('content').put({ id: meta.id, json } satisfies ContentRecord);
await done(tx);
}
/** Update only the listing data (rename). No-op for an unknown id. */
async patchMeta(id: string, patch: Partial<Omit<DocMeta, 'id'>>): Promise<void> {
const tx = this.db.transaction('meta', 'readwrite');
const store = tx.objectStore('meta');
const cur = await req(store.get(id) as IDBRequest<DocMeta | undefined>);
if (cur) store.put({ ...cur, ...patch, id });
await done(tx);
}
async remove(id: string): Promise<void> {
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
tx.objectStore('meta').delete(id);
tx.objectStore('content').delete(id);
await done(tx);
}
}
export function newDocId(): string {
// randomUUID exists only in secure contexts (https, localhost)
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}