rspace-online/modules/rnotes/components/folk-notes-app.ts

1475 lines
61 KiB
TypeScript

/**
* <folk-notes-app> — notebook and note management.
*
* Browse notebooks, create/edit notes with rich text (Tiptap),
* 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';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Placeholder from '@tiptap/extension-placeholder';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import Typography from '@tiptap/extension-typography';
import Underline from '@tiptap/extension-underline';
import { common, createLowlight } from 'lowlight';
import { createSlashCommandPlugin } from './slash-command';
const lowlight = createLowlight(common);
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;
content_format?: 'html' | 'tiptap-json';
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; contentFormat?: 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 = "";
// Zone-based rendering
private navZone!: HTMLDivElement;
private contentZone!: HTMLDivElement;
private metaZone!: HTMLDivElement;
// Tiptap editor
private editor: Editor | null = null;
private editorNoteId: string | null = null;
private isRemoteUpdate = false;
private editorUpdateTimer: ReturnType<typeof setTimeout> | null = null;
// 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;
// ── Demo data ──
private demoNotebooks: (Notebook & { notes: Note[] })[] = [];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open", delegatesFocus: true });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.setupShadow();
if (this.space === "demo") { this.loadDemoData(); return; }
this.connectSync();
this.loadNotebooks();
}
private setupShadow() {
const style = document.createElement('style');
style.textContent = this.getStyles();
this.navZone = document.createElement('div');
this.navZone.id = 'nav-zone';
this.contentZone = document.createElement('div');
this.contentZone.id = 'content-zone';
this.metaZone = document.createElement('div');
this.metaZone.id = 'meta-zone';
this.shadow.appendChild(style);
this.shadow.appendChild(this.navZone);
this.shadow.appendChild(this.contentZone);
this.shadow.appendChild(this.metaZone);
}
// ── Demo data ──
private loadDemoData() {
const now = Date.now();
const hour = 3600000;
const day = 86400000;
const tripPlanningNotes: Note[] = [
{
id: "demo-note-1", title: "Pre-trip Preparation",
content: `<h2>Pre-trip Preparation</h2><h3>Flights &amp; Transfers</h3><ul><li><strong>Jul 6</strong>: Fly Geneva, shuttle to Chamonix (~1.5h)</li><li><strong>Jul 14</strong>: Train Zermatt to Dolomites (Bernina Express, ~6h scenic route)</li><li><strong>Jul 20</strong>: Fly home from Innsbruck</li></ul><blockquote><p>Book the Aiguille du Midi cable car tickets at least 2 weeks in advance -- they sell out fast in July.</p></blockquote><h3>Travel Documents</h3><ol><li>Passports (valid 6+ months)</li><li>EU health insurance cards (EHIC)</li><li>Travel insurance policy (ref: WA-2026-7891)</li><li>Hut reservation confirmations (printed copies)</li><li>Drone registration for Italy</li></ol><h3>Budget Overview</h3><p>Total budget: <strong>EUR 4,000</strong> across 4 travelers.</p><pre><code>Transport: EUR 800 (20%)
Accommodation: EUR 1200 (30%)
Activities: EUR 1000 (25%)
Food: EUR 600 (15%)
Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rFunds. Current spend: EUR 1,203.</em></p>`,
content_plain: "Pre-trip preparation checklist covering flights, transfers, travel documents, and budget overview for the Alpine Explorer 2026 trip.",
content_format: 'html',
type: "NOTE", tags: ["planning", "budget", "transport"], is_pinned: true,
created_at: new Date(now - 14 * day).toISOString(), updated_at: new Date(now - hour).toISOString(),
},
{
id: "demo-note-2", title: "Accommodation Research",
content: `<h2>Accommodation Research</h2><h3>Chamonix (Jul 6-10)</h3><ul><li><strong>Refuge du Lac Blanc</strong> -- Jul 7, 4 beds, conf #LB2026-234</li><li>Airbnb in town for other nights (~EUR 120/night for 4 pax)</li><li>Consider Hotel Le Morgane if Airbnb falls through</li></ul><h3>Zermatt (Jul 10-14)</h3><ul><li><strong>Hornlihutte</strong> (Matterhorn base) -- Waitlisted for Jul 12</li><li>Main accommodation: Apartment near Bahnhofstrasse</li><li>Car-free village, arrive by Glacier Express</li></ul><blockquote><p>Zermatt is expensive. Budget EUR 80-100pp/night minimum. The apartment saves us about 40% vs hotels.</p></blockquote><h3>Dolomites (Jul 14-20)</h3><ul><li><strong>Rifugio Locatelli</strong> -- Jul 15, 4 beds, conf #TRE2026-089</li><li>Val Gardena base: Ortisei area</li><li>Look for agriturismo options for authentic experience</li></ul>`,
content_plain: "Accommodation research for all three destinations: Chamonix, Zermatt, and Dolomites. Includes confirmed bookings, waitlists, and budget estimates.",
content_format: 'html',
type: "NOTE", tags: ["accommodation", "budget"], is_pinned: false,
created_at: new Date(now - 12 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(),
},
{
id: "demo-note-3", title: "Activity Planning",
content: `<h2>Activity Planning</h2><h3>Hiking Routes</h3><ul><li><strong>Lac Blanc</strong> (Jul 7) -- Acclimatization hike, ~6h round trip, 1000m elevation gain.</li><li><strong>Gornergrat Sunrise</strong> (Jul 11) -- Take the first train up at 7am, hike down.</li><li><strong>Matterhorn Base Camp</strong> (Jul 12) -- Full day trek to Hornlihutte. 1500m gain.</li><li><strong>Tre Cime di Lavaredo</strong> (Jul 15) -- Classic loop, ~4h.</li><li><strong>Seceda Ridgeline</strong> (Jul 17) -- Gondola up, ridge walk, hike down to Ortisei.</li></ul><h3>Adventure Activities</h3><ol><li><strong>Via Ferrata at Aiguille du Midi</strong> (Jul 8) -- Rent harness + lanyard + helmet, ~EUR 25/day</li><li><strong>Paragliding over Zermatt</strong> (Jul 13) -- Tandem flights ~EUR 180pp</li><li><strong>Kayaking at Lago di Braies</strong> (Jul 16) -- Turquoise glacial lake, ~EUR 15/hour</li></ol><h3>Rest Days</h3><ul><li>Jul 9: Explore Chamonix town, gear shopping</li><li>Jul 19: Free day before flying home, packing</li></ul>`,
content_plain: "Detailed activity planning including hiking routes with difficulty ratings, adventure activities with costs, and rest day plans.",
content_format: 'html',
type: "NOTE", tags: ["hiking", "activities", "adventure"], is_pinned: false,
created_at: new Date(now - 10 * day).toISOString(), updated_at: new Date(now - 5 * hour).toISOString(),
},
{
id: "demo-note-4", title: "Gear Research",
content: `<h2>Gear Research</h2><h3>Via Ferrata Kit</h3><p>Need harness + lanyard + helmet. Can rent in Chamonix for ~EUR 25/day per person.</p><h3>Camera &amp; Drone</h3><ul><li>Bring the <strong>DJI Mini 4 Pro</strong> for Tre Cime and Seceda</li><li>Check Italian drone regulations! Need ENAC registration for flights over 250g</li><li>ND filters for long exposure water shots at Lago di Braies</li><li>Extra batteries (3x) -- cold altitude drains them fast</li></ul><h3>Personal Gear Checklist</h3><ul><li>Hiking boots (broken in!)</li><li>Rain jacket (waterproof, not just resistant)</li><li>Headlamp + spare batteries</li><li>Trekking poles (collapsible for flights)</li><li>Sunscreen SPF 50 + lip balm</li><li>Wool base layers for hut nights</li></ul>`,
content_plain: "Gear research including Via Ferrata rental, camera and drone regulations, shared group gear status, and personal gear checklist.",
content_format: 'html',
type: "NOTE", tags: ["gear", "equipment", "budget"], is_pinned: false,
created_at: new Date(now - 8 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(),
},
{
id: "demo-note-5", title: "Emergency Contacts & Safety",
content: `<h2>Emergency Contacts &amp; Safety</h2><h3>Emergency Numbers</h3><ul><li><strong>France</strong>: 112 (EU general), PGHM Mountain Rescue: +33 4 50 53 16 89</li><li><strong>Switzerland</strong>: 1414 (REGA air rescue), 144 (ambulance)</li><li><strong>Italy</strong>: 118 (medical), 112 (general emergency)</li></ul><h3>Insurance</h3><ul><li>Policy #: <strong>WA-2026-7891</strong></li><li>Emergency line: +1-800-555-0199</li><li>Covers: mountain rescue, helicopter evacuation, medical repatriation</li></ul><h3>Altitude Sickness Protocol</h3><ol><li>Acclimatize in Chamonix (1,035m) for 2 days before going high</li><li>Stay hydrated -- minimum 3L water per day above 2,500m</li><li>Watch for symptoms: headache, nausea, dizziness</li><li>Descend immediately if symptoms worsen</li></ol>`,
content_plain: "Emergency contacts for France, Switzerland, and Italy. Insurance details, altitude sickness protocol, weather contingency plans.",
content_format: 'html',
type: "NOTE", tags: ["safety", "emergency", "contacts"], is_pinned: false,
created_at: new Date(now - 7 * day).toISOString(), updated_at: new Date(now - 6 * hour).toISOString(),
},
{
id: "demo-note-6", title: "Photo Spots & Creative Plan",
content: `<h2>Photo Spots &amp; Creative Plan</h2><h3>Must-Capture Locations</h3><ol><li><strong>Lac Blanc</strong> -- Reflection of Mont Blanc at sunrise. Arrive by 5:30am. Tripod essential.</li><li><strong>Gornergrat Panorama</strong> -- 360-degree view with Matterhorn. Golden hour is best.</li><li><strong>Tre Cime from Rifugio Locatelli</strong> -- The iconic three peaks at golden hour. Drone shots here.</li><li><strong>Seceda Ridgeline</strong> -- Dramatic Dolomite spires. Best drone footage location.</li><li><strong>Lago di Braies</strong> -- Turquoise water, use ND filters for long exposure reflections.</li></ol><h3>Zine Plan (Maya)</h3><p>We are making an <strong>Alpine Explorer Zine</strong> after the trip:</p><ul><li>Format: A5 risograph, 50 copies</li><li>Print at Chamonix Print Collective</li><li>Content: best photos, trail notes, hand-drawn maps</li><li>Price: EUR 12 per copy on rCart</li></ul>`,
content_plain: "Photography and creative plan including must-capture locations, drone shot list, zine production details, and video plan.",
content_format: 'html',
type: "NOTE", tags: ["photography", "creative", "planning"], is_pinned: false,
created_at: new Date(now - 5 * day).toISOString(), updated_at: new Date(now - 4 * hour).toISOString(),
},
];
const packingNotes: Note[] = [
{
id: "demo-note-7", title: "Packing Checklist",
content: `<h2>Packing Checklist</h2><h3>Footwear</h3><ul data-type="taskList"><li data-type="taskItem" data-checked="true">Hiking boots (broken in!)</li><li data-type="taskItem" data-checked="false">Camp sandals / flip-flops</li><li data-type="taskItem" data-checked="false">Extra laces</li></ul><h3>Clothing</h3><ul data-type="taskList"><li data-type="taskItem" data-checked="true">Rain jacket (Gore-Tex)</li><li data-type="taskItem" data-checked="false">Down jacket for hut nights</li><li data-type="taskItem" data-checked="false">3x wool base layers</li><li data-type="taskItem" data-checked="false">2x hiking pants</li><li data-type="taskItem" data-checked="false">Sun hat + warm beanie</li></ul><h3>Gear</h3><ul data-type="taskList"><li data-type="taskItem" data-checked="true">Headlamp + spare batteries</li><li data-type="taskItem" data-checked="false">Trekking poles (collapsible)</li><li data-type="taskItem" data-checked="true">First aid kit</li><li data-type="taskItem" data-checked="false">Sunscreen SPF 50</li><li data-type="taskItem" data-checked="false">Water filter (Sawyer Squeeze)</li></ul>`,
content_plain: "Complete packing checklist organized by category: footwear, clothing, gear, electronics, documents, and food.",
content_format: 'html',
type: "NOTE", tags: ["packing", "gear", "checklist"], is_pinned: true,
created_at: new Date(now - 6 * day).toISOString(), updated_at: new Date(now - hour).toISOString(),
},
{
id: "demo-note-8", title: "Food & Cooking Plan",
content: `<h2>Food &amp; Cooking Plan</h2><h3>Hut Meals (Half-Board)</h3><p>Lac Blanc and Locatelli include dinner + breakfast. Budget EUR 0 for those nights.</p><h3>Self-Catering Days</h3><p>We have a kitchen in the Chamonix Airbnb and Zermatt apartment.</p><h3>Trail Lunches</h3><p>Pack these the night before each hike:</p><ul><li>Sandwiches (baguette + cheese + ham)</li><li>Energy bars (2 per person)</li><li>Nuts and dried fruit</li><li>Chocolate (the altitude calls for it)</li><li>1.5L water minimum</li></ul>`,
content_plain: "Food and cooking plan covering hut meals, self-catering, trail lunches, special restaurant meals, and dietary notes.",
content_format: 'html',
type: "NOTE", tags: ["food", "planning", "budget"], is_pinned: false,
created_at: new Date(now - 4 * day).toISOString(), updated_at: new Date(now - 8 * hour).toISOString(),
},
{
id: "demo-note-9", title: "Transport & Logistics",
content: `<h2>Transport &amp; Logistics</h2><h3>Getting There</h3><ul><li><strong>Jul 6</strong>: Fly to Geneva (everyone arrives by 14:00)</li><li>Geneva to Chamonix shuttle: EUR 186 for 4 pax</li></ul><h3>Between Destinations</h3><ul><li><strong>Jul 10</strong>: Chamonix to Zermatt -- Train via Martigny (~3.5h, scenic)</li><li><strong>Jul 14</strong>: Zermatt to Dolomites -- Bernina Express (6 hours but spectacular)</li></ul><h3>Local Transport</h3><ul><li><strong>Chamonix</strong>: Free local bus with guest card</li><li><strong>Zermatt</strong>: Car-free! Electric taxis + Gornergrat railway</li><li><strong>Dolomites</strong>: Need rental car or local bus (limited schedule)</li></ul>`,
content_plain: "Transport and logistics plan covering flights, inter-city transfers, local transport options, return journey, and timetables.",
content_format: 'html',
type: "NOTE", tags: ["transport", "logistics"], is_pinned: false,
created_at: new Date(now - 9 * day).toISOString(), updated_at: new Date(now - 5 * hour).toISOString(),
},
];
const itineraryNotes: Note[] = [
{
id: "demo-note-10", title: "Full Itinerary -- Alpine Explorer 2026",
content: `<h2>Full Itinerary -- Alpine Explorer 2026</h2><p><strong>Jul 6-20 | France, Switzerland, Italy</strong></p><h3>Week 1: Chamonix, France (Jul 6-10)</h3><ul><li>Jul 6: Fly Geneva, shuttle to Chamonix</li><li>Jul 7: Acclimatization hike -- Lac Blanc</li><li>Jul 8: Via Ferrata -- Aiguille du Midi</li><li>Jul 9: Rest day / Chamonix town</li><li>Jul 10: Train to Zermatt</li></ul><h3>Week 2: Zermatt, Switzerland (Jul 10-14)</h3><ul><li>Jul 10: Arrive Zermatt, settle in</li><li>Jul 11: Gornergrat sunrise hike</li><li>Jul 12: Matterhorn base camp trek</li><li>Jul 13: Paragliding over Zermatt</li><li>Jul 14: Transfer to Dolomites</li></ul><h3>Week 3: Dolomites, Italy (Jul 14-20)</h3><ul><li>Jul 14: Arrive Val Gardena</li><li>Jul 15: Tre Cime di Lavaredo loop</li><li>Jul 16: Lago di Braies kayaking</li><li>Jul 17: Seceda ridgeline hike</li><li>Jul 18: Cooking class in Bolzano</li><li>Jul 19: Free day -- shopping &amp; packing</li><li>Jul 20: Fly home from Innsbruck</li></ul>`,
content_plain: "Complete day-by-day itinerary for the Alpine Explorer 2026 trip covering three weeks across Chamonix, Zermatt, and the Dolomites.",
content_format: 'html',
type: "NOTE", tags: ["itinerary", "planning"], is_pinned: true,
created_at: new Date(now - 15 * day).toISOString(), updated_at: new Date(now - 2 * hour).toISOString(),
},
{
id: "demo-note-11", title: "Mountain Hut Reservations",
content: `<h2>Mountain Hut Reservations</h2><h3>Confirmed</h3><ul><li><strong>Refuge du Lac Blanc</strong> (Jul 7) -- 4 beds, half-board, conf #LB2026-234</li><li><strong>Rifugio Locatelli</strong> (Jul 15) -- 4 beds, half-board, conf #TRE2026-089</li></ul><h3>Waitlisted</h3><ul><li><strong>Hornlihutte</strong> (Matterhorn base, Jul 12) -- will know by Jul 1</li></ul><h3>Hut Etiquette Reminders</h3><ol><li>Arrive before 17:00 if possible</li><li>Remove boots at entrance (bring hut shoes or thick socks)</li><li>Lights out by 22:00</li><li>Pack out all trash</li><li>Tip is appreciated but not required</li></ol>`,
content_plain: "Mountain hut reservations with confirmation numbers, check-in details, and hut etiquette reminders.",
content_format: 'html',
type: "NOTE", tags: ["accommodation", "hiking"], is_pinned: false,
created_at: new Date(now - 11 * day).toISOString(), updated_at: new Date(now - day).toISOString(),
},
{
id: "demo-note-12", title: "Group Decisions & Votes",
content: `<h2>Group Decisions &amp; Votes</h2><h3>Decided</h3><ul><li><strong>Camera Gear</strong>: DJI Mini 4 Pro (Liam's decision matrix: 8.5/10)</li><li><strong>First Night Dinner in Zermatt</strong>: Fondue at Chez Vrony (won 5-4 over pizza)</li><li><strong>Day 5 Activity</strong>: Via Ferrata at Aiguille du Midi (won 7-3 over kayaking)</li></ul><h3>Active Votes (in rVote)</h3><ul><li><strong>Zermatt to Dolomites transfer</strong>: Train vs rental car -- Train leading 3-2</li></ul><h3>Pending Decisions</h3><ul><li>Val Gardena accommodation (agriturismo vs apartment)</li><li>Whether to rent the Starlink Mini (EUR 200, needs funding)</li><li>Trip zine print run size (50 vs 100 copies)</li></ul>`,
content_plain: "Summary of group decisions made and active votes. Covers camera gear, dining, activities, and pending decisions.",
content_format: 'html',
type: "NOTE", tags: ["decisions", "planning"], is_pinned: false,
created_at: new Date(now - 3 * day).toISOString(), updated_at: new Date(now - 3 * hour).toISOString(),
},
];
this.demoNotebooks = [
{
id: "demo-nb-1", title: "Alpine Explorer Planning", description: "Shared knowledge base for our July 2026 trip across France, Switzerland, and Italy",
cover_color: "#f59e0b", note_count: "6", updated_at: new Date(now - hour).toISOString(),
notes: tripPlanningNotes,
} as any,
{
id: "demo-nb-2", title: "Packing & Logistics", description: "Checklists, food plans, and transport details",
cover_color: "#22c55e", note_count: "3", updated_at: new Date(now - hour).toISOString(),
notes: packingNotes,
} as any,
{
id: "demo-nb-3", title: "Itinerary & Decisions", description: "Day-by-day schedule, hut reservations, and group votes",
cover_color: "#6366f1", note_count: "3", updated_at: new Date(now - 2 * hour).toISOString(),
notes: itineraryNotes,
} as any,
];
this.notebooks = this.demoNotebooks.map(({ notes, ...nb }) => nb as Notebook);
this.loading = false;
this.render();
}
private demoSearchNotes(query: string) {
if (!query.trim()) {
this.searchResults = [];
this.render();
return;
}
const q = query.toLowerCase();
const all = this.demoNotebooks.flatMap(nb => nb.notes);
this.searchResults = all.filter(n =>
n.title.toLowerCase().includes(q) ||
n.content_plain.toLowerCase().includes(q) ||
(n.tags && n.tags.some(t => t.toLowerCase().includes(q)))
);
this.render();
}
private demoLoadNotebook(id: string) {
const nb = this.demoNotebooks.find(n => n.id === id);
if (nb) {
this.selectedNotebook = { ...nb };
} else {
this.error = "Notebook not found";
}
this.loading = false;
this.render();
}
private demoLoadNote(id: string) {
const allNotes = this.demoNotebooks.flatMap(nb => nb.notes);
this.selectedNote = allNotes.find(n => n.id === id) || null;
if (this.selectedNote) {
this.view = "note";
this.renderNav();
this.renderMeta();
this.mountEditor(this.selectedNote);
}
}
private demoCreateNotebook() {
const title = prompt("Notebook name:");
if (!title?.trim()) return;
const now = Date.now();
const nb = {
id: `demo-nb-${now}`, title, description: "",
cover_color: "#8b5cf6", note_count: "0",
updated_at: new Date(now).toISOString(), notes: [] as Note[],
} as any;
this.demoNotebooks.push(nb);
this.notebooks = this.demoNotebooks.map(({ notes, ...rest }) => rest as Notebook);
this.render();
}
private demoCreateNote() {
if (!this.selectedNotebook) return;
const now = Date.now();
const noteId = `demo-note-${now}`;
const newNote: Note = {
id: noteId, title: "Untitled Note", content: "", content_plain: "",
content_format: 'tiptap-json',
type: "NOTE", tags: null, is_pinned: false,
created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
};
const demoNb = this.demoNotebooks.find(n => n.id === this.selectedNotebook!.id);
if (demoNb) {
demoNb.notes.push(newNote);
demoNb.note_count = String(demoNb.notes.length);
}
this.selectedNotebook.notes.push(newNote);
this.selectedNotebook.note_count = String(this.selectedNotebook.notes.length);
this.selectedNote = newNote;
this.view = "note";
this.renderNav();
this.renderMeta();
this.mountEditor(newNote);
}
disconnectedCallback() {
this.destroyEditor();
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;
this.pingInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() }));
}
}, 30000);
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));
}
} catch {
// ignore parse errors
}
};
this.ws.onclose = () => {
this.syncConnected = false;
if (this.pingInterval) clearInterval(this.pingInterval);
setTimeout(() => {
if (this.isConnected) this.connectSync();
}, 3000);
};
this.ws.onerror = () => {};
}
private disconnectSync() {
if (this.pingInterval) clearInterval(this.pingInterval);
if (this.ws) {
this.ws.onclose = null;
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;
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;
this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] }));
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;
// 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 || "",
content_format: (item.contentFormat as Note['content_format']) || undefined,
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(),
});
}
}
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 note and editor is mounted, update editor content from remote
if (this.view === "note" && this.selectedNote && this.editor && this.editorNoteId === this.selectedNote.id) {
const noteItem = items?.[this.selectedNote.id];
if (noteItem) {
this.selectedNote = {
id: noteItem.id,
title: noteItem.title || "Untitled",
content: noteItem.content || "",
content_plain: noteItem.contentPlain || "",
content_format: (noteItem.contentFormat as Note['content_format']) || undefined,
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(),
};
// Update editor content if different (remote change)
const remoteContent = noteItem.content || "";
const currentContent = noteItem.contentFormat === 'tiptap-json'
? JSON.stringify(this.editor.getJSON())
: this.editor.getHTML();
if (remoteContent !== currentContent) {
this.isRemoteUpdate = true;
try {
if (noteItem.contentFormat === 'tiptap-json') {
try {
this.editor.commands.setContent(JSON.parse(remoteContent), { emitUpdate: false });
} catch {
this.editor.commands.setContent(remoteContent, { emitUpdate: false });
}
} else {
this.editor.commands.setContent(remoteContent, { emitUpdate: false });
}
} finally {
this.isRemoteUpdate = false;
}
}
// Update title input if it exists
const titleInput = this.shadow.querySelector('#note-title-input') as HTMLInputElement;
if (titleInput && document.activeElement !== titleInput && titleInput !== this.shadow.activeElement) {
titleInput.value = noteItem.title || "Untitled";
}
// Only update nav/meta, skip contentZone
this.renderNav();
this.renderMeta();
this.loading = false;
return;
}
}
// If viewing a specific note without editor mounted, update selectedNote
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 || "",
content_format: (noteItem.contentFormat as Note['content_format']) || undefined,
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: "",
contentFormat: "tiptap-json",
type: "NOTE",
tags: [],
isPinned: false,
sortOrder: 0,
createdAt: now,
updatedAt: now,
};
});
this.sendSyncAfterChange();
this.renderFromDoc();
// Open the new note
this.selectedNote = {
id: noteId, title: "Untitled Note", content: "", content_plain: "",
content_format: 'tiptap-json',
type: "NOTE", tags: null, is_pinned: false,
created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(),
};
this.view = "note";
this.renderNav();
this.renderMeta();
this.mountEditor(this.selectedNote);
}
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(/^(\/[^/]+)?\/rnotes/);
return match ? match[0] : "";
}
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();
this.unsubscribeNotebook();
this.subscribeNotebook(id);
setTimeout(() => {
if (this.loading && this.view === "notebook") {
this.loadNotebookREST(id);
}
}, 5000);
}
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
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 || "",
content_format: (item.contentFormat as Note['content_format']) || undefined,
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) {
this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null;
}
if (this.selectedNote) {
this.renderNav();
this.renderMeta();
this.mountEditor(this.selectedNote);
}
}
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();
}
}
// ── Tiptap Editor ──
private mountEditor(note: Note) {
this.destroyEditor();
// Build content zone
const isDemo = this.space === "demo";
const isAutomerge = !!(this.doc?.items?.[note.id]);
const isEditable = isAutomerge || isDemo;
this.contentZone.innerHTML = `
<div class="editor-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
${isEditable ? this.renderToolbar() : ''}
<div class="tiptap-container" id="tiptap-container"></div>
</div>
`;
const container = this.shadow.getElementById('tiptap-container');
if (!container) return;
// Determine content to load
let content: any = '';
if (note.content) {
if (note.content_format === 'tiptap-json') {
try {
content = JSON.parse(note.content);
} catch {
content = note.content;
}
} else {
// HTML content (legacy or explicit)
content = note.content;
}
}
const slashPlugin = createSlashCommandPlugin(
null as any, // Will be set after editor creation
this.shadow
);
this.editor = new Editor({
element: container,
editable: isEditable,
extensions: [
StarterKit.configure({
codeBlock: false,
heading: { levels: [1, 2, 3, 4] },
}),
Link.configure({ openOnClick: false }),
Image,
TaskList,
TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
CodeBlockLowlight.configure({ lowlight }),
Typography,
Underline,
],
content,
onUpdate: ({ editor }) => {
if (this.isRemoteUpdate) return;
if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer);
this.editorUpdateTimer = setTimeout(() => {
const json = JSON.stringify(editor.getJSON());
const plain = editor.getText();
const noteId = this.editorNoteId;
if (!noteId) return;
if (isDemo) {
this.demoUpdateNoteField(noteId, "content", json);
this.demoUpdateNoteField(noteId, "content_plain", plain);
this.demoUpdateNoteField(noteId, "content_format", 'tiptap-json');
} else {
this.updateNoteField(noteId, "content", json);
this.updateNoteField(noteId, "contentPlain", plain);
this.updateNoteField(noteId, "contentFormat", 'tiptap-json');
}
}, 800);
},
onSelectionUpdate: () => {
this.updateToolbarState();
},
});
// Now register the slash command plugin with the actual editor
this.editor.registerPlugin(
createSlashCommandPlugin(this.editor, this.shadow)
);
this.editorNoteId = note.id;
// Wire up title input
const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement;
if (titleInput) {
let titleTimeout: any;
titleInput.addEventListener("input", () => {
clearTimeout(titleTimeout);
titleTimeout = setTimeout(() => {
if (isDemo) {
this.demoUpdateNoteField(note.id, "title", titleInput.value);
} else {
this.updateNoteField(note.id, "title", titleInput.value);
}
}, 500);
});
}
// Wire up toolbar
this.attachToolbarListeners();
}
private destroyEditor() {
if (this.editorUpdateTimer) {
clearTimeout(this.editorUpdateTimer);
this.editorUpdateTimer = null;
}
if (this.editor) {
this.editor.destroy();
this.editor = null;
}
this.editorNoteId = null;
}
private renderToolbar(): string {
return `
<div class="editor-toolbar" id="editor-toolbar">
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="bold" title="Bold (Ctrl+B)"><strong>B</strong></button>
<button class="toolbar-btn" data-cmd="italic" title="Italic (Ctrl+I)"><em>I</em></button>
<button class="toolbar-btn" data-cmd="underline" title="Underline (Ctrl+U)"><u>U</u></button>
<button class="toolbar-btn" data-cmd="strike" title="Strikethrough"><s>S</s></button>
<button class="toolbar-btn" data-cmd="code" title="Inline Code">&lt;/&gt;</button>
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<select class="toolbar-select" data-cmd="heading" title="Block type">
<option value="paragraph">Paragraph</option>
<option value="1">Heading 1</option>
<option value="2">Heading 2</option>
<option value="3">Heading 3</option>
<option value="4">Heading 4</option>
</select>
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="bulletList" title="Bullet List">&#8226;</button>
<button class="toolbar-btn" data-cmd="orderedList" title="Numbered List">1.</button>
<button class="toolbar-btn" data-cmd="taskList" title="Task List">&#9745;</button>
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="blockquote" title="Blockquote">&#8220;</button>
<button class="toolbar-btn" data-cmd="codeBlock" title="Code Block">{}</button>
<button class="toolbar-btn" data-cmd="horizontalRule" title="Divider">&#8212;</button>
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="link" title="Insert Link">&#128279;</button>
<button class="toolbar-btn" data-cmd="image" title="Insert Image">&#128247;</button>
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<button class="toolbar-btn" data-cmd="undo" title="Undo (Ctrl+Z)">&#8617;</button>
<button class="toolbar-btn" data-cmd="redo" title="Redo (Ctrl+Y)">&#8618;</button>
</div>
</div>`;
}
private attachToolbarListeners() {
const toolbar = this.shadow.getElementById('editor-toolbar');
if (!toolbar || !this.editor) return;
// Button clicks via event delegation
toolbar.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('[data-cmd]') as HTMLElement;
if (!btn || btn.tagName === 'SELECT') return;
e.preventDefault();
const cmd = btn.dataset.cmd;
if (!this.editor) return;
switch (cmd) {
case 'bold': this.editor.chain().focus().toggleBold().run(); break;
case 'italic': this.editor.chain().focus().toggleItalic().run(); break;
case 'underline': this.editor.chain().focus().toggleUnderline().run(); break;
case 'strike': this.editor.chain().focus().toggleStrike().run(); break;
case 'code': this.editor.chain().focus().toggleCode().run(); break;
case 'bulletList': this.editor.chain().focus().toggleBulletList().run(); break;
case 'orderedList': this.editor.chain().focus().toggleOrderedList().run(); break;
case 'taskList': this.editor.chain().focus().toggleTaskList().run(); break;
case 'blockquote': this.editor.chain().focus().toggleBlockquote().run(); break;
case 'codeBlock': this.editor.chain().focus().toggleCodeBlock().run(); break;
case 'horizontalRule': this.editor.chain().focus().setHorizontalRule().run(); break;
case 'link': {
const url = prompt('Link URL:');
if (url) this.editor.chain().focus().setLink({ href: url }).run();
break;
}
case 'image': {
const url = prompt('Image URL:');
if (url) this.editor.chain().focus().setImage({ src: url }).run();
break;
}
case 'undo': this.editor.chain().focus().undo().run(); break;
case 'redo': this.editor.chain().focus().redo().run(); break;
}
});
// Heading select
const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement;
if (headingSelect) {
headingSelect.addEventListener('change', () => {
if (!this.editor) return;
const val = headingSelect.value;
if (val === 'paragraph') {
this.editor.chain().focus().setParagraph().run();
} else {
this.editor.chain().focus().setHeading({ level: parseInt(val) as 1 | 2 | 3 | 4 }).run();
}
});
}
}
private updateToolbarState() {
if (!this.editor) return;
const toolbar = this.shadow.getElementById('editor-toolbar');
if (!toolbar) return;
// Toggle active class on buttons
toolbar.querySelectorAll('.toolbar-btn[data-cmd]').forEach((btn) => {
const cmd = (btn as HTMLElement).dataset.cmd!;
let isActive = false;
switch (cmd) {
case 'bold': isActive = this.editor!.isActive('bold'); break;
case 'italic': isActive = this.editor!.isActive('italic'); break;
case 'underline': isActive = this.editor!.isActive('underline'); break;
case 'strike': isActive = this.editor!.isActive('strike'); break;
case 'code': isActive = this.editor!.isActive('code'); break;
case 'bulletList': isActive = this.editor!.isActive('bulletList'); break;
case 'orderedList': isActive = this.editor!.isActive('orderedList'); break;
case 'taskList': isActive = this.editor!.isActive('taskList'); break;
case 'blockquote': isActive = this.editor!.isActive('blockquote'); break;
case 'codeBlock': isActive = this.editor!.isActive('codeBlock'); break;
}
btn.classList.toggle('active', isActive);
});
// Update heading select
const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement;
if (headingSelect) {
if (this.editor.isActive('heading', { level: 1 })) headingSelect.value = '1';
else if (this.editor.isActive('heading', { level: 2 })) headingSelect.value = '2';
else if (this.editor.isActive('heading', { level: 3 })) headingSelect.value = '3';
else if (this.editor.isActive('heading', { level: 4 })) headingSelect.value = '4';
else headingSelect.value = 'paragraph';
}
}
// ── Helpers ──
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();
}
// ── Rendering ──
private render() {
this.renderNav();
if (this.view === 'note' && this.editor && this.editorNoteId) {
// Editor already mounted — don't touch contentZone
} else {
this.renderContent();
}
this.renderMeta();
this.attachListeners();
}
private renderNav() {
const isDemo = this.space === "demo";
if (this.view === "note" && this.selectedNote) {
// Nav is handled by mountEditor's content, or we just show back button
this.navZone.innerHTML = `
<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>
<span style="flex:1"></span>
</div>`;
// Re-attach back listener
this.navZone.querySelectorAll('[data-back]').forEach((el) => {
el.addEventListener('click', (e) => {
e.stopPropagation();
const target = (el as HTMLElement).dataset.back;
this.destroyEditor();
if (target === 'notebooks') {
this.view = 'notebooks';
if (!isDemo) this.unsubscribeNotebook();
this.selectedNotebook = null;
this.selectedNote = null;
this.render();
} else if (target === 'notebook') {
this.view = 'notebook';
this.selectedNote = null;
this.render();
}
});
});
return;
}
if (this.view === "notebook" && this.selectedNotebook) {
const nb = this.selectedNotebook;
const syncBadge = this.subscribedDocId
? `<span class="sync-badge ${this.syncConnected ? "connected" : "disconnected"}" title="${this.syncConnected ? "Live sync" : "Reconnecting..."}"></span>`
: "";
this.navZone.innerHTML = `
<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>`;
return;
}
// Notebooks view
this.navZone.innerHTML = `
<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)}">`;
}
private renderContent() {
if (this.error) {
this.contentZone.innerHTML = `<div class="error">${this.esc(this.error)}</div>`;
return;
}
if (this.loading) {
this.contentZone.innerHTML = '<div class="loading">Loading...</div>';
return;
}
if (this.view === "notebook" && this.selectedNotebook) {
const nb = this.selectedNotebook;
this.contentZone.innerHTML = nb.notes && nb.notes.length > 0
? nb.notes.map((n) => this.renderNoteItem(n)).join("")
: '<div class="empty">No notes in this notebook.</div>';
return;
}
// Notebooks view
let html = '';
if (this.searchQuery && this.searchResults.length > 0) {
html += `<div style="margin-bottom:16px;font-size:13px;color:#888">${this.searchResults.length} results for "${this.esc(this.searchQuery)}"</div>`;
html += this.searchResults.map((n) => this.renderNoteItem(n)).join("");
}
if (!this.searchQuery) {
html += `<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 &middot; ${this.formatDate(nb.updated_at)}</div>
</div>
`).join("")}</div>`;
if (this.notebooks.length === 0) {
html += '<div class="empty">No notebooks yet. Create one to get started.</div>';
}
}
this.contentZone.innerHTML = html;
}
private renderMeta() {
if (this.view === "note" && this.selectedNote) {
const n = this.selectedNote;
const isAutomerge = !!(this.doc?.items?.[n.id]);
const isDemo = this.space === "demo";
this.metaZone.innerHTML = `
<div class="note-meta-bar">
<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>' : ""}
${isDemo ? '<span style="color:#f59e0b">Demo</span>' : ""}
</div>`;
} else {
this.metaZone.innerHTML = '';
}
}
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">&#x1F4CC;</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 attachListeners() {
const isDemo = this.space === "demo";
// Create notebook
this.shadow.getElementById("create-notebook")?.addEventListener("click", () => {
isDemo ? this.demoCreateNotebook() : this.createNotebook();
});
// Create note
this.shadow.getElementById("create-note")?.addEventListener("click", () => {
isDemo ? this.demoCreateNote() : 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(() => {
isDemo ? this.demoSearchNotes(this.searchQuery) : 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";
isDemo ? this.demoLoadNotebook(id) : 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";
isDemo ? this.demoLoadNote(id) : this.loadNote(id);
});
});
// Back buttons (for notebook view)
this.navZone.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const target = (el as HTMLElement).dataset.back;
if (target === "notebooks") {
this.destroyEditor();
this.view = "notebooks";
if (!isDemo) this.unsubscribeNotebook();
this.selectedNotebook = null;
this.selectedNote = null;
this.render();
}
else if (target === "notebook") {
this.destroyEditor();
this.view = "notebook";
this.selectedNote = null;
this.render();
}
});
});
}
private demoUpdateNoteField(noteId: string, field: string, value: string) {
if (this.selectedNote && this.selectedNote.id === noteId) {
(this.selectedNote as any)[field] = value;
this.selectedNote.updated_at = new Date().toISOString();
}
for (const nb of this.demoNotebooks) {
const note = nb.notes.find(n => n.id === noteId);
if (note) {
(note as any)[field] = value;
note.updated_at = new Date().toISOString();
break;
}
}
if (this.selectedNotebook?.notes) {
const note = this.selectedNotebook.notes.find(n => n.id === noteId);
if (note) {
(note as any)[field] = value;
note.updated_at = new Date().toISOString();
}
}
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
private getStyles(): string {
return `
: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; }
.editable-title {
background: transparent; border: none; color: #e2e8f0; font-family: inherit;
font-size: 22px; font-weight: 700; width: 100%; outline: none;
padding: 8px 0; margin-bottom: 4px;
}
.editable-title:focus { border-bottom: 2px solid #6366f1; }
.editable-title::placeholder { color: #555; }
.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; }
.note-meta-bar {
margin-top: 12px; font-size: 12px; color: #666; display: flex; gap: 12px; padding: 8px 0;
}
/* ── Editor Toolbar ── */
.editor-toolbar {
display: flex; flex-wrap: wrap; gap: 2px; align-items: center;
background: #0f172a; border: 1px solid #1e293b; border-radius: 8px;
padding: 4px 6px; margin-bottom: 2px;
}
.toolbar-group { display: flex; gap: 1px; }
.toolbar-sep { width: 1px; height: 20px; background: #1e293b; margin: 0 4px; }
.toolbar-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 28px; border: none; border-radius: 4px;
background: transparent; color: #94a3b8; cursor: pointer;
font-size: 13px; font-family: inherit; transition: all 0.15s;
}
.toolbar-btn:hover { background: #1e293b; color: #e2e8f0; }
.toolbar-btn.active { background: #312e81; color: #a5b4fc; }
.toolbar-select {
padding: 2px 4px; border-radius: 4px; border: 1px solid #1e293b;
background: #0f172a; color: #94a3b8; font-size: 12px; cursor: pointer;
font-family: inherit;
}
.toolbar-select:focus { outline: none; border-color: #4f46e5; }
/* ── Tiptap Editor ── */
.editor-wrapper {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
overflow: hidden;
}
.editor-wrapper .editable-title {
padding: 16px 20px 0;
}
.editor-wrapper .editor-toolbar {
margin: 4px 8px; border-radius: 6px;
}
.tiptap-container .tiptap {
min-height: 300px; padding: 16px 20px; outline: none;
font-size: 15px; line-height: 1.7; color: #e0e0e0;
}
.tiptap-container .tiptap:focus { outline: none; }
/* Prose styles */
.tiptap-container .tiptap h1 { font-size: 1.8em; font-weight: 700; margin: 1em 0 0.4em; color: #f1f5f9; }
.tiptap-container .tiptap h2 { font-size: 1.4em; font-weight: 600; margin: 0.8em 0 0.3em; color: #e2e8f0; }
.tiptap-container .tiptap h3 { font-size: 1.15em; font-weight: 600; margin: 0.7em 0 0.25em; color: #cbd5e1; }
.tiptap-container .tiptap h4 { font-size: 1em; font-weight: 600; margin: 0.6em 0 0.2em; color: #94a3b8; }
.tiptap-container .tiptap p { margin: 0.4em 0; }
.tiptap-container .tiptap blockquote {
border-left: 3px solid #4f46e5; padding-left: 16px; margin: 0.8em 0;
color: #94a3b8; font-style: italic;
}
.tiptap-container .tiptap code {
background: #2a2a3e; padding: 2px 6px; border-radius: 4px;
font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.9em; color: #a5b4fc;
}
.tiptap-container .tiptap pre {
background: #0f172a; border: 1px solid #1e293b; border-radius: 8px;
padding: 12px 16px; margin: 0.8em 0; overflow-x: auto;
}
.tiptap-container .tiptap pre code {
background: none; padding: 0; border-radius: 0; color: #e0e0e0;
font-size: 13px; line-height: 1.5;
}
.tiptap-container .tiptap ul, .tiptap-container .tiptap ol {
padding-left: 24px; margin: 0.4em 0;
}
.tiptap-container .tiptap li { margin: 0.15em 0; }
.tiptap-container .tiptap li p { margin: 0.1em 0; }
/* Task list */
.tiptap-container .tiptap ul[data-type="taskList"] {
list-style: none; padding-left: 4px;
}
.tiptap-container .tiptap ul[data-type="taskList"] li {
display: flex; align-items: flex-start; gap: 8px;
}
.tiptap-container .tiptap ul[data-type="taskList"] li label {
margin-top: 3px;
}
.tiptap-container .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p {
text-decoration: line-through; color: #666;
}
.tiptap-container .tiptap img {
max-width: 100%; border-radius: 8px; margin: 0.5em 0;
}
.tiptap-container .tiptap a {
color: #818cf8; text-decoration: underline; text-underline-offset: 2px;
}
.tiptap-container .tiptap a:hover { color: #a5b4fc; }
.tiptap-container .tiptap hr {
border: none; border-top: 1px solid #333; margin: 1.5em 0;
}
.tiptap-container .tiptap strong { color: #f1f5f9; }
.tiptap-container .tiptap em { color: inherit; }
.tiptap-container .tiptap s { color: #666; }
.tiptap-container .tiptap u { text-underline-offset: 3px; }
/* Placeholder */
.tiptap-container .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left; color: #555; pointer-events: none; height: 0;
}
/* ── Slash Menu ── */
.slash-menu {
position: absolute; z-index: 100;
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
max-height: 320px; overflow-y: auto; min-width: 220px;
display: none;
}
.slash-menu-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; cursor: pointer; transition: background 0.1s;
}
.slash-menu-item:hover, .slash-menu-item.selected {
background: #312e81;
}
.slash-menu-icon {
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
background: #2a2a3e; border-radius: 4px; font-size: 13px; font-weight: 600; color: #a5b4fc;
flex-shrink: 0;
}
.slash-menu-text { flex: 1; }
.slash-menu-title { font-size: 13px; font-weight: 500; color: #e2e8f0; }
.slash-menu-desc { font-size: 11px; color: #666; }
/* ── Code highlighting (lowlight) ── */
.tiptap-container .tiptap .hljs-keyword { color: #c792ea; }
.tiptap-container .tiptap .hljs-string { color: #c3e88d; }
.tiptap-container .tiptap .hljs-number { color: #f78c6c; }
.tiptap-container .tiptap .hljs-comment { color: #546e7a; font-style: italic; }
.tiptap-container .tiptap .hljs-built_in { color: #82aaff; }
.tiptap-container .tiptap .hljs-function { color: #82aaff; }
.tiptap-container .tiptap .hljs-title { color: #82aaff; }
.tiptap-container .tiptap .hljs-attr { color: #ffcb6b; }
.tiptap-container .tiptap .hljs-tag { color: #f07178; }
.tiptap-container .tiptap .hljs-type { color: #ffcb6b; }
`;
}
}
customElements.define("folk-notes-app", FolkNotesApp);