715 lines
23 KiB
TypeScript
715 lines
23 KiB
TypeScript
/**
|
|
* <folk-notes-app> — 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<string, {
|
|
id: string; notebookId: string; title: string; content: string;
|
|
contentPlain: string; type: string; tags: string[]; isPinned: boolean;
|
|
sortOrder: number; createdAt: number; updatedAt: number;
|
|
}>;
|
|
}
|
|
|
|
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<NotebookDoc> | null = null;
|
|
private syncState: Automerge.SyncState = Automerge.initSyncState();
|
|
private subscribedDocId: string | null = null;
|
|
private syncConnected = false;
|
|
private pingInterval: ReturnType<typeof setInterval> | 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<NotebookDoc>();
|
|
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 = `
|
|
<style>
|
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
|
* { box-sizing: border-box; }
|
|
|
|
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
|
|
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
|
|
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
|
|
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
|
.rapp-nav__btn:hover { background: #6366f1; }
|
|
|
|
.search-bar {
|
|
width: 100%; padding: 10px 14px; border-radius: 8px;
|
|
border: 1px solid #444; background: #2a2a3e; color: #e0e0e0;
|
|
font-size: 14px; margin-bottom: 16px;
|
|
}
|
|
.search-bar:focus { border-color: #6366f1; outline: none; }
|
|
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
|
.notebook-card {
|
|
border-radius: 10px; padding: 16px; cursor: pointer;
|
|
border: 2px solid transparent; transition: border-color 0.2s;
|
|
min-height: 120px; display: flex; flex-direction: column; justify-content: space-between;
|
|
}
|
|
.notebook-card:hover { border-color: rgba(255,255,255,0.2); }
|
|
.notebook-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
|
.notebook-meta { font-size: 12px; opacity: 0.7; }
|
|
|
|
.note-item {
|
|
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
|
|
padding: 12px 16px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.2s;
|
|
display: flex; gap: 12px; align-items: flex-start;
|
|
}
|
|
.note-item:hover { border-color: #555; }
|
|
.note-icon { font-size: 20px; flex-shrink: 0; }
|
|
.note-body { flex: 1; min-width: 0; }
|
|
.note-title { font-size: 14px; font-weight: 600; }
|
|
.note-preview { font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.note-meta { font-size: 11px; color: #666; margin-top: 4px; display: flex; gap: 8px; }
|
|
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #333; color: #aaa; font-size: 10px; }
|
|
.pinned { color: #f59e0b; }
|
|
|
|
.note-content {
|
|
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; }
|
|
.error { text-align: center; color: #ef5350; padding: 20px; }
|
|
</style>
|
|
|
|
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
|
|
${this.loading ? '<div class="loading">Loading...</div>' : ""}
|
|
${!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 `
|
|
<div class="rapp-nav">
|
|
<span class="rapp-nav__title">Notebooks</span>
|
|
<button class="rapp-nav__btn" id="create-notebook">+ New Notebook</button>
|
|
</div>
|
|
<input class="search-bar" type="text" placeholder="Search notes..." id="search-input" value="${this.esc(this.searchQuery)}">
|
|
|
|
${this.searchQuery && this.searchResults.length > 0 ? `
|
|
<div style="margin-bottom:16px;font-size:13px;color:#888">${this.searchResults.length} results for "${this.esc(this.searchQuery)}"</div>
|
|
${this.searchResults.map((n) => this.renderNoteItem(n)).join("")}
|
|
` : ""}
|
|
|
|
${!this.searchQuery ? `
|
|
<div class="grid">
|
|
${this.notebooks.map((nb) => `
|
|
<div class="notebook-card" data-notebook="${nb.id}"
|
|
style="background:${nb.cover_color}33;border-color:${nb.cover_color}55">
|
|
<div>
|
|
<div class="notebook-title">${this.esc(nb.title)}</div>
|
|
<div class="notebook-meta">${this.esc(nb.description || "")}</div>
|
|
</div>
|
|
<div class="notebook-meta">${nb.note_count} notes · ${this.formatDate(nb.updated_at)}</div>
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
${this.notebooks.length === 0 ? '<div class="empty">No notebooks yet. Create one to get started.</div>' : ""}
|
|
` : ""}
|
|
`;
|
|
}
|
|
|
|
private renderNotebook(): string {
|
|
const nb = this.selectedNotebook!;
|
|
const syncBadge = this.subscribedDocId
|
|
? `<span class="sync-badge ${this.syncConnected ? "connected" : "disconnected"}" title="${this.syncConnected ? "Live sync" : "Reconnecting..."}"></span>`
|
|
: "";
|
|
return `
|
|
<div class="rapp-nav">
|
|
<button class="rapp-nav__back" data-back="notebooks">\u2190 Notebooks</button>
|
|
<span class="rapp-nav__title" style="color:${nb.cover_color}">${this.esc(nb.title)}${syncBadge}</span>
|
|
<button class="rapp-nav__btn" id="create-note">+ New Note</button>
|
|
</div>
|
|
${nb.notes && nb.notes.length > 0
|
|
? nb.notes.map((n) => this.renderNoteItem(n)).join("")
|
|
: '<div class="empty">No notes in this notebook.</div>'
|
|
}
|
|
`;
|
|
}
|
|
|
|
private renderNoteItem(n: Note): string {
|
|
return `
|
|
<div class="note-item" data-note="${n.id}">
|
|
<span class="note-icon">${this.getNoteIcon(n.type)}</span>
|
|
<div class="note-body">
|
|
<div class="note-title">${n.is_pinned ? '<span class="pinned">📌</span> ' : ""}${this.esc(n.title)}</div>
|
|
<div class="note-preview">${this.esc(n.content_plain || "")}</div>
|
|
<div class="note-meta">
|
|
<span>${this.formatDate(n.updated_at)}</span>
|
|
<span>${n.type}</span>
|
|
${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderNote(): string {
|
|
const n = this.selectedNote!;
|
|
const isAutomerge = !!(this.doc?.items?.[n.id]);
|
|
return `
|
|
<div class="rapp-nav">
|
|
<button class="rapp-nav__back" data-back="${this.selectedNotebook ? "notebook" : "notebooks"}">\u2190 ${this.selectedNotebook ? this.esc(this.selectedNotebook.title) : "Notebooks"}</button>
|
|
${isAutomerge
|
|
? `<input class="editable-title" id="note-title-input" value="${this.esc(n.title)}" placeholder="Note title...">`
|
|
: `<span class="rapp-nav__title">${this.getNoteIcon(n.type)} ${this.esc(n.title)}</span>`
|
|
}
|
|
</div>
|
|
<div class="note-content" ${isAutomerge ? 'contenteditable="true" id="note-content-editable"' : ""}>${n.content || '<em style="color:#666">Empty note</em>'}</div>
|
|
<div style="margin-top:12px;font-size:12px;color:#666;display:flex;gap:12px">
|
|
<span>Type: ${n.type}</span>
|
|
<span>Created: ${this.formatDate(n.created_at)}</span>
|
|
<span>Updated: ${this.formatDate(n.updated_at)}</span>
|
|
${n.tags ? n.tags.map((t) => `<span class="tag">${this.esc(t)}</span>`).join("") : ""}
|
|
${isAutomerge ? '<span style="color:#10b981">Live</span>' : ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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);
|