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>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user