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

1575 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <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);
/** Inline SVG icons for toolbar buttons (16×16, stroke-based, currentColor) */
const ICONS: Record<string, string> = {
bold: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5h5a2.5 2.5 0 0 1 0 5H4zM4 7.5h5.5a2.5 2.5 0 0 1 0 5H4z"/></svg>',
italic: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="2.5" x2="6" y2="13.5"/><line x1="6.5" y1="2.5" x2="11.5" y2="2.5"/><line x1="4.5" y1="13.5" x2="9.5" y2="13.5"/></svg>',
underline: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5v5a4 4 0 0 0 8 0v-5"/><line x1="3" y1="14" x2="13" y2="14"/></svg>',
strike: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4.5c-.5-1-1.8-2-3.5-2C5.5 2.5 4 3.8 4 5.3c0 1 .5 1.7 1.5 2.2"/><line x1="3" y1="8" x2="13" y2="8"/><path d="M5 11c.5 1.2 1.8 2.5 3.5 2.5 2 0 3.5-1.3 3.5-2.8 0-.7-.3-1.3-.8-1.7"/></svg>',
code: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="5.5 4 2 8 5.5 12"/><polyline points="10.5 4 14 8 10.5 12"/></svg>',
bulletList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="6" y1="4" x2="14" y2="4"/><line x1="6" y1="8" x2="14" y2="8"/><line x1="6" y1="12" x2="14" y2="12"/><circle cx="3" cy="4" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="8" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="12" r="1" fill="currentColor" stroke="none"/></svg>',
orderedList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="14" y2="12"/><text x="1.5" y="5.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">1</text><text x="1.5" y="9.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">2</text><text x="1.5" y="13.5" font-size="5" fill="currentColor" stroke="none" font-family="system-ui" font-weight="600">3</text></svg>',
taskList: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="5" height="5" rx="1"/><polyline points="3.5 4.5 4.5 5.5 6 3.5"/><line x1="9" y1="4.5" x2="14" y2="4.5"/><rect x="2" y="9" width="5" height="5" rx="1"/><line x1="9" y1="11.5" x2="14" y2="11.5"/></svg>',
blockquote: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="2" x2="3" y2="14"/><line x1="7" y1="4" x2="14" y2="4"/><line x1="7" y1="8" x2="14" y2="8"/><line x1="7" y1="12" x2="12" y2="12"/></svg>',
codeBlock: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="1.5" width="13" height="13" rx="2"/><polyline points="5 6 3.5 8 5 10"/><polyline points="11 6 12.5 8 11 10"/><line x1="9" y1="5" x2="7" y2="11"/></svg>',
horizontalRule: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="2" y1="8" x2="14" y2="8"/><circle cx="4" cy="8" r="0.5" fill="currentColor"/><circle cx="8" cy="8" r="0.5" fill="currentColor"/><circle cx="12" cy="8" r="0.5" fill="currentColor"/></svg>',
link: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 9.5a3 3 0 0 0 4.2.3l2-2a3 3 0 0 0-4.2-4.3L7 4.8"/><path d="M9.5 6.5a3 3 0 0 0-4.2-.3l-2 2a3 3 0 0 0 4.2 4.3L9 11.2"/></svg>',
image: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="11" rx="2"/><circle cx="5.5" cy="6" r="1.5"/><path d="M14.5 10.5l-3.5-3.5-5 5"/></svg>',
undo: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 6 2 8 4 10"/><path d="M2 8h8a4 4 0 0 1 0 8H8"/></svg>',
redo: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="12 6 14 8 12 10"/><path d="M14 8H6a4 4 0 0 0 0 8h2"/></svg>',
};
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;
// Listen for slash command image insert (custom event from slash-command.ts)
container.addEventListener('slash-insert-image', () => {
if (!this.editor) return;
const { from } = this.editor.view.state.selection;
const coords = this.editor.view.coordsAtPos(from);
const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
this.showUrlPopover(rect, 'Enter image URL...').then(url => {
if (url) this.editor!.chain().focus().setImage({ src: url }).run();
});
});
// 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 {
const btn = (cmd: string, title: string) =>
`<button class="toolbar-btn" data-cmd="${cmd}" title="${title}">${ICONS[cmd]}</button>`;
return `
<div class="editor-toolbar" id="editor-toolbar">
<div class="toolbar-group">
${btn('bold', 'Bold (Ctrl+B)')}
${btn('italic', 'Italic (Ctrl+I)')}
${btn('underline', 'Underline (Ctrl+U)')}
${btn('strike', 'Strikethrough')}
${btn('code', 'Inline Code')}
</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">
${btn('bulletList', 'Bullet List')}
${btn('orderedList', 'Numbered List')}
${btn('taskList', 'Task List')}
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
${btn('blockquote', 'Blockquote')}
${btn('codeBlock', 'Code Block')}
${btn('horizontalRule', 'Divider')}
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
${btn('link', 'Insert Link')}
${btn('image', 'Insert Image')}
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
${btn('undo', 'Undo (Ctrl+Z)')}
${btn('redo', 'Redo (Ctrl+Y)')}
</div>
</div>`;
}
private showUrlPopover(anchorRect: DOMRect, placeholder: string): Promise<string | null> {
return new Promise((resolve) => {
this.shadow.querySelector('.url-popover')?.remove();
const popover = document.createElement('div');
popover.className = 'url-popover';
const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect();
popover.style.left = `${anchorRect.left - hostRect.left}px`;
popover.style.top = `${anchorRect.bottom - hostRect.top + 4}px`;
const input = document.createElement('input');
input.type = 'url';
input.placeholder = placeholder;
input.className = 'url-popover__input';
const insertBtn = document.createElement('button');
insertBtn.textContent = 'Insert';
insertBtn.className = 'url-popover__btn url-popover__btn--insert';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.className = 'url-popover__btn url-popover__btn--cancel';
const btnRow = document.createElement('div');
btnRow.className = 'url-popover__actions';
btnRow.append(cancelBtn, insertBtn);
popover.append(input, btnRow);
this.shadow.appendChild(popover);
input.focus();
const cleanup = (value: string | null) => {
popover.remove();
resolve(value);
};
insertBtn.addEventListener('click', () => {
const val = input.value.trim();
cleanup(val || null);
});
cancelBtn.addEventListener('click', () => cleanup(null));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = input.value.trim();
cleanup(val || null);
}
if (e.key === 'Escape') {
e.preventDefault();
cleanup(null);
}
});
});
}
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 rect = btn.getBoundingClientRect();
this.showUrlPopover(rect, 'Enter link URL...').then(url => {
if (url) this.editor!.chain().focus().setLink({ href: url }).run();
else this.editor!.chain().focus().run();
});
break;
}
case 'image': {
const rect = btn.getBoundingClientRect();
this.showUrlPopover(rect, 'Enter image URL...').then(url => {
if (url) this.editor!.chain().focus().setImage({ src: url }).run();
else this.editor!.chain().focus().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 class="search-results-info">${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}">
<div class="notebook-card__accent" style="background:${nb.cover_color}"></div>
<div class="notebook-card__body">
<div class="notebook-card__title">${this.esc(nb.title)}</div>
<div class="notebook-card__desc">${this.esc(nb.description || "")}</div>
</div>
<div class="notebook-card__footer">
<span>${nb.note_count} notes</span>
<span>${this.formatDate(nb.updated_at)}</span>
</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 class="meta-live">Live</span>' : ""}
${isDemo ? '<span class="meta-demo">Demo</span>' : ""}
</div>`;
} else {
this.metaZone.innerHTML = '';
}
}
private renderNoteItem(n: Note): string {
return `
<div class="note-item" data-note="${n.id}">
<span class="note-item__icon">${this.getNoteIcon(n.type)}</span>
<div class="note-item__body">
<div class="note-item__title">${n.is_pinned ? '<span class="note-item__pin">\u{1F4CC}</span> ' : ""}${this.esc(n.title)}</div>
<div class="note-item__preview">${this.esc(n.content_plain || "")}</div>
<div class="note-item__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);