diff --git a/modules/notes/components/folk-notes-app.ts b/modules/notes/components/folk-notes-app.ts index 22bd3fc..f365f0f 100644 --- a/modules/notes/components/folk-notes-app.ts +++ b/modules/notes/components/folk-notes-app.ts @@ -3,8 +3,14 @@ * * Browse notebooks, create/edit notes with rich text, * search, tag management. + * + * Notebook list: REST (GET /api/notebooks) + * Notebook detail + notes: Automerge sync via WebSocket + * Search: REST (GET /api/notes?q=...) */ +import * as Automerge from '@automerge/automerge'; + interface Notebook { id: string; title: string; @@ -26,6 +32,20 @@ interface Note { updated_at: string; } +/** Shape of Automerge notebook doc (matches PG→Automerge migration) */ +interface NotebookDoc { + meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; + notebook: { + id: string; title: string; slug: string; description: string; + coverColor: string; isPublic: boolean; createdAt: number; updatedAt: number; + }; + items: Record; +} + class FolkNotesApp extends HTMLElement { private shadow: ShadowRoot; private space = ""; @@ -38,6 +58,14 @@ class FolkNotesApp extends HTMLElement { private loading = false; private error = ""; + // Automerge sync state + private ws: WebSocket | null = null; + private doc: Automerge.Doc | null = null; + private syncState: Automerge.SyncState = Automerge.initSyncState(); + private subscribedDocId: string | null = null; + private syncConnected = false; + private pingInterval: ReturnType | null = null; + constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); @@ -45,9 +73,252 @@ class FolkNotesApp extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; + this.connectSync(); this.loadNotebooks(); } + disconnectedCallback() { + this.disconnectSync(); + } + + // ── WebSocket Sync ── + + private connectSync() { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${proto}//${location.host}/ws/${this.space}`; + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.syncConnected = true; + // Keepalive ping every 30s + this.pingInterval = setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() })); + } + }, 30000); + // If we had a pending subscription, re-subscribe + if (this.subscribedDocId && this.doc) { + this.subscribeNotebook(this.subscribedDocId.split(":").pop()!); + } + }; + + this.ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (msg.type === "sync" && msg.docId === this.subscribedDocId) { + this.handleSyncMessage(new Uint8Array(msg.data)); + } + // pong and other messages are ignored + } catch { + // ignore parse errors + } + }; + + this.ws.onclose = () => { + this.syncConnected = false; + if (this.pingInterval) clearInterval(this.pingInterval); + // Reconnect after 3s + setTimeout(() => { + if (this.isConnected) this.connectSync(); + }, 3000); + }; + + this.ws.onerror = () => { + // onclose will fire after this + }; + } + + private disconnectSync() { + if (this.pingInterval) clearInterval(this.pingInterval); + if (this.ws) { + this.ws.onclose = null; // prevent reconnect + this.ws.close(); + this.ws = null; + } + this.syncConnected = false; + } + + private handleSyncMessage(syncMsg: Uint8Array) { + if (!this.doc) return; + + const [newDoc, newSyncState] = Automerge.receiveSyncMessage( + this.doc, this.syncState, syncMsg + ); + this.doc = newDoc; + this.syncState = newSyncState; + + // Send reply if needed + const [nextState, reply] = Automerge.generateSyncMessage(this.doc, this.syncState); + this.syncState = nextState; + if (reply && this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ + type: "sync", docId: this.subscribedDocId, + data: Array.from(reply), + })); + } + + this.renderFromDoc(); + } + + private subscribeNotebook(notebookId: string) { + this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`; + this.doc = Automerge.init(); + this.syncState = Automerge.initSyncState(); + + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + + // Send subscribe + this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] })); + + // Send initial sync message to kick off handshake + const [s, m] = Automerge.generateSyncMessage(this.doc, this.syncState); + this.syncState = s; + if (m) { + this.ws.send(JSON.stringify({ + type: "sync", docId: this.subscribedDocId, + data: Array.from(m), + })); + } + } + + private unsubscribeNotebook() { + if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] })); + } + this.subscribedDocId = null; + this.doc = null; + this.syncState = Automerge.initSyncState(); + } + + /** Extract notebook + notes from Automerge doc into component state */ + private renderFromDoc() { + if (!this.doc) return; + + const nb = this.doc.notebook; + const items = this.doc.items; + + if (!nb) return; // doc not yet synced + + // Build notebook data from doc + const notes: Note[] = []; + if (items) { + for (const [, item] of Object.entries(items)) { + notes.push({ + id: item.id, + title: item.title || "Untitled", + content: item.content || "", + content_plain: item.contentPlain || "", + type: item.type || "NOTE", + tags: item.tags?.length ? Array.from(item.tags) : null, + is_pinned: item.isPinned || false, + created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), + updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), + }); + } + } + + // Sort: pinned first, then by sort order, then by updated_at desc + notes.sort((a, b) => { + if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; + return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); + }); + + this.selectedNotebook = { + id: nb.id, + title: nb.title, + description: nb.description || "", + cover_color: nb.coverColor || "#3b82f6", + note_count: String(notes.length), + updated_at: nb.updatedAt ? new Date(nb.updatedAt).toISOString() : new Date().toISOString(), + notes, + }; + + // If viewing a specific note, update it from doc too + if (this.view === "note" && this.selectedNote) { + const noteItem = items?.[this.selectedNote.id]; + if (noteItem) { + this.selectedNote = { + id: noteItem.id, + title: noteItem.title || "Untitled", + content: noteItem.content || "", + content_plain: noteItem.contentPlain || "", + type: noteItem.type || "NOTE", + tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null, + is_pinned: noteItem.isPinned || false, + created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), + updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), + }; + } + } + + this.loading = false; + this.render(); + } + + // ── Automerge mutations ── + + private createNoteViaSync() { + if (!this.doc || !this.selectedNotebook) return; + + const noteId = crypto.randomUUID(); + const now = Date.now(); + + this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => { + if (!d.items) (d as any).items = {}; + d.items[noteId] = { + id: noteId, + notebookId: this.selectedNotebook!.id, + title: "Untitled Note", + content: "", + contentPlain: "", + type: "NOTE", + tags: [], + isPinned: false, + sortOrder: 0, + createdAt: now, + updatedAt: now, + }; + }); + + this.sendSyncAfterChange(); + this.renderFromDoc(); + + // Open the new note for editing + this.selectedNote = { + id: noteId, title: "Untitled Note", content: "", content_plain: "", + type: "NOTE", tags: null, is_pinned: false, + created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), + }; + this.view = "note"; + this.render(); + } + + private updateNoteField(noteId: string, field: string, value: string) { + if (!this.doc || !this.doc.items?.[noteId]) return; + + this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => { + (d.items[noteId] as any)[field] = value; + d.items[noteId].updatedAt = Date.now(); + }); + + this.sendSyncAfterChange(); + } + + private sendSyncAfterChange() { + if (!this.doc || !this.ws || this.ws.readyState !== WebSocket.OPEN) return; + + const [newState, msg] = Automerge.generateSyncMessage(this.doc, this.syncState); + this.syncState = newState; + if (msg) { + this.ws.send(JSON.stringify({ + type: "sync", docId: this.subscribedDocId, + data: Array.from(msg), + })); + } + } + + // ── REST (notebook list + search) ── + private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/notes/); @@ -72,6 +343,23 @@ class FolkNotesApp extends HTMLElement { private async loadNotebook(id: string) { this.loading = true; this.render(); + + // Unsubscribe from any previous notebook + this.unsubscribeNotebook(); + + // Subscribe to the new notebook via Automerge + this.subscribeNotebook(id); + + // Set a timeout — if doc doesn't arrive in 5s, fall back to REST + setTimeout(() => { + if (this.loading && this.view === "notebook") { + this.loadNotebookREST(id); + } + }, 5000); + } + + /** REST fallback for notebook detail */ + private async loadNotebookREST(id: string) { try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notebooks/${id}`); @@ -83,17 +371,25 @@ class FolkNotesApp extends HTMLElement { this.render(); } - private async loadNote(id: string) { - this.loading = true; - this.render(); - try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/notes/${id}`); - this.selectedNote = await res.json(); - } catch { - this.error = "Failed to load note"; + private loadNote(id: string) { + // Note is already in the Automerge doc — just select it + if (this.doc?.items?.[id]) { + const item = this.doc.items[id]; + this.selectedNote = { + id: item.id, + title: item.title || "Untitled", + content: item.content || "", + content_plain: item.contentPlain || "", + type: item.type || "NOTE", + tags: item.tags?.length ? Array.from(item.tags) : null, + is_pinned: item.isPinned || false, + created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), + updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), + }; + } else if (this.selectedNotebook?.notes) { + // Fallback: find in REST-loaded data + this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null; } - this.loading = false; this.render(); } @@ -209,6 +505,22 @@ class FolkNotesApp extends HTMLElement { background: #1e1e2e; border: 1px solid #333; border-radius: 10px; padding: 20px; font-size: 14px; line-height: 1.6; } + .note-content[contenteditable] { outline: none; min-height: 100px; cursor: text; } + .note-content[contenteditable]:focus { border-color: #6366f1; } + + .editable-title { + background: transparent; border: none; color: inherit; font: inherit; + font-size: 18px; font-weight: 600; width: 100%; outline: none; + padding: 0; flex: 1; + } + .editable-title:focus { border-bottom: 1px solid #6366f1; } + + .sync-badge { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + margin-left: 8px; vertical-align: middle; + } + .sync-badge.connected { background: #10b981; } + .sync-badge.disconnected { background: #ef4444; } .empty { text-align: center; color: #666; padding: 40px; } .loading { text-align: center; color: #888; padding: 40px; } @@ -262,10 +574,14 @@ class FolkNotesApp extends HTMLElement { private renderNotebook(): string { const nb = this.selectedNotebook!; + const syncBadge = this.subscribedDocId + ? `` + : ""; return `
- ${this.esc(nb.title)} + ${this.esc(nb.title)}${syncBadge} +
${nb.notes && nb.notes.length > 0 ? nb.notes.map((n) => this.renderNoteItem(n)).join("") @@ -293,17 +609,22 @@ class FolkNotesApp extends HTMLElement { private renderNote(): string { const n = this.selectedNote!; + const isAutomerge = !!(this.doc?.items?.[n.id]); return `
- ${this.getNoteIcon(n.type)} ${this.esc(n.title)} + ${isAutomerge + ? `` + : `${this.getNoteIcon(n.type)} ${this.esc(n.title)}` + }
-
${n.content || 'Empty note'}
+
${n.content || 'Empty note'}
Type: ${n.type} Created: ${this.formatDate(n.created_at)} Updated: ${this.formatDate(n.updated_at)} ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""} + ${isAutomerge ? 'Live' : ""}
`; } @@ -312,6 +633,9 @@ class FolkNotesApp extends HTMLElement { // Create notebook this.shadow.getElementById("create-notebook")?.addEventListener("click", () => this.createNotebook()); + // Create note (Automerge) + this.shadow.getElementById("create-note")?.addEventListener("click", () => this.createNoteViaSync()); + // Search const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement; let searchTimeout: any; @@ -344,10 +668,46 @@ class FolkNotesApp extends HTMLElement { el.addEventListener("click", (e) => { e.stopPropagation(); const target = (el as HTMLElement).dataset.back; - if (target === "notebooks") { this.view = "notebooks"; this.render(); } + if (target === "notebooks") { + this.view = "notebooks"; + this.unsubscribeNotebook(); + this.selectedNotebook = null; + this.selectedNote = null; + this.render(); + } else if (target === "notebook") { this.view = "notebook"; this.render(); } }); }); + + // Editable note title (debounced) + const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement; + if (titleInput && this.selectedNote) { + let titleTimeout: any; + const noteId = this.selectedNote.id; + titleInput.addEventListener("input", () => { + clearTimeout(titleTimeout); + titleTimeout = setTimeout(() => { + this.updateNoteField(noteId, "title", titleInput.value); + }, 500); + }); + } + + // Editable note content (debounced) + const contentEl = this.shadow.getElementById("note-content-editable"); + if (contentEl && this.selectedNote) { + let contentTimeout: any; + const noteId = this.selectedNote.id; + contentEl.addEventListener("input", () => { + clearTimeout(contentTimeout); + contentTimeout = setTimeout(() => { + const html = contentEl.innerHTML; + this.updateNoteField(noteId, "content", html); + // Also update plain text + const plain = contentEl.textContent?.trim() || ""; + this.updateNoteField(noteId, "contentPlain", plain); + }, 800); + }); + } } private esc(s: string): string { diff --git a/vite.config.ts b/vite.config.ts index 8904afb..5e9ba91 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -396,11 +396,18 @@ export default defineConfig({ resolve(__dirname, "dist/modules/vote/vote.css"), ); - // Build notes module component + // Build notes module component (with Automerge WASM support) await build({ configFile: false, root: resolve(__dirname, "modules/notes/components"), + plugins: [wasm()], + resolve: { + alias: { + '@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'), + }, + }, build: { + target: "esnext", emptyOutDir: false, outDir: resolve(__dirname, "dist/modules/notes"), lib: {