/** * — notebook and note management. * * 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; description: string; cover_color: string; note_count: string; updated_at: string; } interface Note { id: string; title: string; content: string; content_plain: string; type: string; tags: string[] | null; is_pinned: boolean; created_at: string; 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 = ""; private view: "notebooks" | "notebook" | "note" = "notebooks"; private notebooks: Notebook[] = []; private selectedNotebook: (Notebook & { notes: Note[] }) | null = null; private selectedNote: Note | null = null; private searchQuery = ""; private searchResults: Note[] = []; 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" }); } 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/); return match ? `/${match[1]}/notes` : ""; } private async loadNotebooks() { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notebooks`); const data = await res.json(); this.notebooks = data.notebooks || []; } catch { this.error = "Failed to load notebooks"; } this.loading = false; this.render(); } 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}`); this.selectedNotebook = await res.json(); } catch { this.error = "Failed to load notebook"; } this.loading = false; this.render(); } 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.render(); } private async searchNotes(query: string) { if (!query.trim()) { this.searchResults = []; this.render(); return; } try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notes?q=${encodeURIComponent(query)}`); const data = await res.json(); this.searchResults = data.notes || []; } catch { this.searchResults = []; } this.render(); } private async createNotebook() { const title = prompt("Notebook name:"); if (!title?.trim()) return; try { const base = this.getApiBase(); await fetch(`${base}/api/notebooks`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }); await this.loadNotebooks(); } catch { this.error = "Failed to create notebook"; this.render(); } } private getNoteIcon(type: string): string { switch (type) { case "NOTE": return "\u{1F4DD}"; case "CODE": return "\u{1F4BB}"; case "BOOKMARK": return "\u{1F517}"; case "IMAGE": return "\u{1F5BC}"; case "AUDIO": return "\u{1F3A4}"; case "FILE": return "\u{1F4CE}"; case "CLIP": return "\u2702\uFE0F"; default: return "\u{1F4C4}"; } } private formatDate(dateStr: string): string { const d = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return "Today"; if (diffDays === 1) return "Yesterday"; if (diffDays < 7) return `${diffDays}d ago`; return d.toLocaleDateString(); } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Loading...
' : ""} ${!this.loading ? this.renderView() : ""} `; this.attachListeners(); } private renderView(): string { if (this.view === "note" && this.selectedNote) return this.renderNote(); if (this.view === "notebook" && this.selectedNotebook) return this.renderNotebook(); return this.renderNotebooks(); } private renderNotebooks(): string { return `
Notebooks
${this.searchQuery && this.searchResults.length > 0 ? `
${this.searchResults.length} results for "${this.esc(this.searchQuery)}"
${this.searchResults.map((n) => this.renderNoteItem(n)).join("")} ` : ""} ${!this.searchQuery ? `
${this.notebooks.map((nb) => `
${this.esc(nb.title)}
${this.esc(nb.description || "")}
${nb.note_count} notes · ${this.formatDate(nb.updated_at)}
`).join("")}
${this.notebooks.length === 0 ? '
No notebooks yet. Create one to get started.
' : ""} ` : ""} `; } private renderNotebook(): string { const nb = this.selectedNotebook!; const syncBadge = this.subscribedDocId ? `` : ""; return `
${this.esc(nb.title)}${syncBadge}
${nb.notes && nb.notes.length > 0 ? nb.notes.map((n) => this.renderNoteItem(n)).join("") : '
No notes in this notebook.
' } `; } private renderNoteItem(n: Note): string { return `
${this.getNoteIcon(n.type)}
${n.is_pinned ? '📌 ' : ""}${this.esc(n.title)}
${this.esc(n.content_plain || "")}
${this.formatDate(n.updated_at)} ${n.type} ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""}
`; } private renderNote(): string { const n = this.selectedNote!; const isAutomerge = !!(this.doc?.items?.[n.id]); return `
${isAutomerge ? `` : `${this.getNoteIcon(n.type)} ${this.esc(n.title)}` }
${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' : ""}
`; } private attachListeners() { // 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; searchInput?.addEventListener("input", () => { clearTimeout(searchTimeout); this.searchQuery = searchInput.value; searchTimeout = setTimeout(() => this.searchNotes(this.searchQuery), 300); }); // Notebook cards this.shadow.querySelectorAll("[data-notebook]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.notebook!; this.view = "notebook"; this.loadNotebook(id); }); }); // Note items this.shadow.querySelectorAll("[data-note]").forEach((el) => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.note!; this.view = "note"; this.loadNote(id); }); }); // Back buttons this.shadow.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const target = (el as HTMLElement).dataset.back; 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 { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-notes-app", FolkNotesApp);