1706 lines
75 KiB
TypeScript
1706 lines
75 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';
|
||
import type { ImportExportDialog } from './import-export-dialog';
|
||
|
||
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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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">${this.esc(nb.title)}${syncBadge}</span>
|
||
<button class="rapp-nav__btn rapp-nav__btn--secondary" id="btn-export-notebook" title="Export this notebook">
|
||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 10V2M5 5l3-3 3 3"/><path d="M2 12v2h12v-2"/></svg>
|
||
Export
|
||
</button>
|
||
<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 rapp-nav__btn--secondary" id="btn-import-export" title="Import / Export">
|
||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v8M5 7l3 3 3-3"/><path d="M2 12v2h12v-2"/></svg>
|
||
Import / Export
|
||
</button>
|
||
<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";
|
||
|
||
// Import / Export button
|
||
this.shadow.getElementById("btn-import-export")?.addEventListener("click", () => {
|
||
this.openImportExportDialog();
|
||
});
|
||
|
||
// Create notebook
|
||
this.shadow.getElementById("create-notebook")?.addEventListener("click", () => {
|
||
isDemo ? this.demoCreateNotebook() : this.createNotebook();
|
||
});
|
||
|
||
// Export notebook button (in notebook detail view)
|
||
this.shadow.getElementById("btn-export-notebook")?.addEventListener("click", () => {
|
||
this.openImportExportDialog('export');
|
||
});
|
||
|
||
// 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();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Import/Export Dialog ──
|
||
|
||
private importExportDialog: ImportExportDialog | null = null;
|
||
|
||
private openImportExportDialog(tab: 'import' | 'export' = 'import') {
|
||
if (!this.importExportDialog) {
|
||
// Dynamically import the dialog component
|
||
import('./import-export-dialog').then(() => {
|
||
this.importExportDialog = document.createElement('import-export-dialog') as unknown as ImportExportDialog;
|
||
this.importExportDialog.setAttribute('space', this.space);
|
||
this.shadow.appendChild(this.importExportDialog);
|
||
|
||
this.importExportDialog.addEventListener('import-complete', () => {
|
||
// Refresh notebooks list after import
|
||
if (this.space === 'demo') {
|
||
this.loadDemoData();
|
||
} else {
|
||
this.loadNotebooks();
|
||
}
|
||
});
|
||
|
||
this.showDialog(tab);
|
||
});
|
||
} else {
|
||
this.showDialog(tab);
|
||
}
|
||
}
|
||
|
||
private showDialog(tab: 'import' | 'export') {
|
||
if (!this.importExportDialog) return;
|
||
|
||
// Gather notebook list for the dialog
|
||
const notebooks = this.notebooks.map(nb => ({
|
||
id: nb.id,
|
||
title: nb.title,
|
||
}));
|
||
|
||
this.importExportDialog.open(notebooks, tab);
|
||
}
|
||
|
||
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: var(--rs-text-primary); }
|
||
* { box-sizing: border-box; }
|
||
|
||
/* ── Navigation ── */
|
||
.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 var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 13px; transition: all 0.15s; }
|
||
.rapp-nav__back:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
|
||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; display: flex; align-items: center; gap: 4px; }
|
||
.rapp-nav__btn:hover { background: var(--rs-primary-hover); }
|
||
.rapp-nav__btn--secondary { background: transparent; border: 1px solid var(--rs-border); color: var(--rs-text-secondary); font-weight: 500; }
|
||
.rapp-nav__btn--secondary:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); background: transparent; }
|
||
|
||
/* ── Search ── */
|
||
.search-bar {
|
||
width: 100%; padding: 10px 14px; border-radius: 8px;
|
||
border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text);
|
||
font-size: 14px; margin-bottom: 16px; transition: border-color 0.15s;
|
||
}
|
||
.search-bar:focus { border-color: var(--rs-primary); outline: none; }
|
||
.search-results-info { margin-bottom: 12px; font-size: 13px; color: var(--rs-text-secondary); }
|
||
|
||
/* ── Notebook Grid ── */
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||
.notebook-card {
|
||
position: relative; overflow: hidden;
|
||
border-radius: 12px; padding: 16px 16px 16px 20px; cursor: pointer;
|
||
border: 1px solid var(--rs-card-border); background: var(--rs-card-bg);
|
||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||
min-height: 120px; display: flex; flex-direction: column; justify-content: space-between;
|
||
}
|
||
.notebook-card:hover { border-color: var(--rs-border); box-shadow: var(--rs-shadow-sm); transform: translateY(-1px); }
|
||
.notebook-card__accent { position: absolute; top: 0; left: 0; width: 4px; height: 100%; border-radius: 12px 0 0 12px; }
|
||
.notebook-card__body { flex: 1; }
|
||
.notebook-card__title { font-size: 15px; font-weight: 600; margin-bottom: 4px; color: var(--rs-text-primary); }
|
||
.notebook-card__desc { font-size: 12px; color: var(--rs-text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||
.notebook-card__footer { display: flex; justify-content: space-between; font-size: 11px; color: var(--rs-text-muted); margin-top: 8px; }
|
||
|
||
/* ── Note Items ── */
|
||
.note-item {
|
||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle); border-radius: 10px;
|
||
padding: 14px 16px; margin-bottom: 8px; cursor: pointer;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
display: flex; gap: 12px; align-items: flex-start;
|
||
}
|
||
.note-item:hover { border-color: var(--rs-border); box-shadow: var(--rs-shadow-sm); }
|
||
.note-item__icon {
|
||
font-size: 18px; flex-shrink: 0; width: 32px; height: 32px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: var(--rs-bg-surface-raised); border-radius: 8px;
|
||
}
|
||
.note-item__body { flex: 1; min-width: 0; }
|
||
.note-item__title { font-size: 14px; font-weight: 600; color: var(--rs-text-primary); }
|
||
.note-item__pin { color: var(--rs-warning); }
|
||
.note-item__preview {
|
||
font-size: 12px; color: var(--rs-text-muted); margin-top: 3px; line-height: 1.4;
|
||
overflow: hidden; text-overflow: ellipsis;
|
||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||
}
|
||
.note-item__meta { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; display: flex; gap: 8px; align-items: center; }
|
||
.tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: var(--rs-bg-surface-raised); color: var(--rs-text-secondary); font-size: 10px; }
|
||
|
||
/* ── Editor Title ── */
|
||
.editable-title {
|
||
background: transparent; border: none; border-bottom: 2px solid transparent;
|
||
color: var(--rs-text-primary); font-family: inherit;
|
||
font-size: 22px; font-weight: 700; width: 100%; outline: none;
|
||
padding: 8px 0; margin-bottom: 4px; transition: border-color 0.15s;
|
||
}
|
||
.editable-title:focus { border-bottom-color: var(--rs-primary); }
|
||
.editable-title::placeholder { color: var(--rs-text-muted); }
|
||
|
||
/* ── Sync Badge ── */
|
||
.sync-badge {
|
||
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
|
||
margin-left: 8px; vertical-align: middle;
|
||
}
|
||
.sync-badge.connected { background: var(--rs-success); }
|
||
.sync-badge.disconnected { background: var(--rs-error); }
|
||
|
||
/* ── State Messages ── */
|
||
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; }
|
||
.loading { text-align: center; color: var(--rs-text-secondary); padding: 40px; }
|
||
.error { text-align: center; color: var(--rs-error); padding: 20px; }
|
||
|
||
/* ── Meta Bar ── */
|
||
.note-meta-bar {
|
||
margin-top: 12px; font-size: 12px; color: var(--rs-text-muted);
|
||
display: flex; gap: 12px; padding: 8px 0; align-items: center;
|
||
}
|
||
.meta-live { color: var(--rs-success); font-weight: 500; }
|
||
.meta-demo { color: var(--rs-warning); font-weight: 500; }
|
||
|
||
/* ── Editor Toolbar ── */
|
||
.editor-toolbar {
|
||
display: flex; flex-wrap: wrap; gap: 2px; align-items: center;
|
||
background: var(--rs-toolbar-bg); border: 1px solid var(--rs-toolbar-panel-border);
|
||
border-radius: 8px; padding: 4px 6px; margin-bottom: 2px;
|
||
}
|
||
.toolbar-group { display: flex; gap: 1px; }
|
||
.toolbar-sep { width: 1px; height: 20px; background: var(--rs-toolbar-sep); 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: var(--rs-text-secondary); cursor: pointer;
|
||
font-size: 13px; font-family: inherit; transition: all 0.15s;
|
||
}
|
||
.toolbar-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||
.toolbar-btn:hover { background: var(--rs-toolbar-btn-hover); color: var(--rs-toolbar-btn-text); }
|
||
.toolbar-btn.active { background: var(--rs-primary); color: #fff; }
|
||
.toolbar-select {
|
||
padding: 2px 4px; border-radius: 4px; border: 1px solid var(--rs-toolbar-panel-border);
|
||
background: var(--rs-toolbar-bg); color: var(--rs-text-secondary); font-size: 12px; cursor: pointer;
|
||
font-family: inherit; transition: border-color 0.15s;
|
||
}
|
||
.toolbar-select:focus { outline: none; border-color: var(--rs-primary); }
|
||
|
||
/* ── Tiptap Editor ── */
|
||
.editor-wrapper {
|
||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-subtle);
|
||
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: 20px 24px; outline: none;
|
||
font-size: 15px; line-height: 1.75; color: var(--rs-text-primary);
|
||
}
|
||
.tiptap-container .tiptap:focus { outline: none; }
|
||
|
||
/* ── Prose Styles ── */
|
||
.tiptap-container .tiptap h1 {
|
||
font-size: 1.75em; font-weight: 700; margin: 1.2em 0 0.5em; color: var(--rs-text-primary);
|
||
padding-bottom: 0.3em; border-bottom: 1px solid var(--rs-border-subtle);
|
||
}
|
||
.tiptap-container .tiptap h2 { font-size: 1.35em; font-weight: 600; margin: 1em 0 0.4em; color: var(--rs-text-primary); }
|
||
.tiptap-container .tiptap h3 { font-size: 1.1em; font-weight: 600; margin: 0.8em 0 0.3em; color: var(--rs-text-secondary); }
|
||
.tiptap-container .tiptap h4 {
|
||
font-size: 0.95em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
||
margin: 0.7em 0 0.25em; color: var(--rs-text-muted);
|
||
}
|
||
.tiptap-container .tiptap p { margin: 0.5em 0; }
|
||
.tiptap-container .tiptap blockquote {
|
||
border-left: 3px solid var(--rs-primary); padding: 4px 0 4px 16px; margin: 0.8em 0;
|
||
color: var(--rs-text-secondary); font-style: italic;
|
||
background: var(--rs-bg-surface-raised); border-radius: 0 6px 6px 0;
|
||
}
|
||
.tiptap-container .tiptap code {
|
||
background: var(--rs-bg-surface-raised); padding: 2px 6px; border-radius: 4px;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.88em; color: var(--rs-accent);
|
||
}
|
||
.tiptap-container .tiptap pre {
|
||
background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border-subtle);
|
||
border-radius: 8px; padding: 14px 16px; margin: 1em 0; overflow-x: auto;
|
||
}
|
||
.tiptap-container .tiptap pre code {
|
||
background: none; padding: 0; border-radius: 0; color: var(--rs-text-primary);
|
||
font-size: 13px; line-height: 1.6;
|
||
}
|
||
.tiptap-container .tiptap ul, .tiptap-container .tiptap ol { padding-left: 24px; margin: 0.5em 0; }
|
||
.tiptap-container .tiptap li { margin: 0.2em 0; }
|
||
.tiptap-container .tiptap li p { margin: 0.15em 0; }
|
||
.tiptap-container .tiptap li::marker { color: var(--rs-text-muted); }
|
||
|
||
/* 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: var(--rs-text-muted);
|
||
}
|
||
.tiptap-container .tiptap ul[data-type="taskList"] li label input[type="checkbox"] {
|
||
accent-color: var(--rs-primary); width: 15px; height: 15px;
|
||
}
|
||
|
||
.tiptap-container .tiptap img {
|
||
max-width: 100%; border-radius: 8px; margin: 0.75em 0;
|
||
border: 1px solid var(--rs-border-subtle);
|
||
}
|
||
.tiptap-container .tiptap a {
|
||
color: var(--rs-primary-hover); text-decoration: underline;
|
||
text-underline-offset: 2px; text-decoration-color: rgba(99, 102, 241, 0.4);
|
||
}
|
||
.tiptap-container .tiptap a:hover { text-decoration-color: var(--rs-primary-hover); }
|
||
.tiptap-container .tiptap hr { border: none; border-top: 1px solid var(--rs-border); margin: 1.5em 0; }
|
||
.tiptap-container .tiptap strong { color: var(--rs-text-primary); font-weight: 600; }
|
||
.tiptap-container .tiptap em { color: inherit; }
|
||
.tiptap-container .tiptap s { color: var(--rs-text-muted); }
|
||
.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: var(--rs-text-muted); pointer-events: none; height: 0;
|
||
font-style: italic;
|
||
}
|
||
|
||
/* ── URL Popover ── */
|
||
.url-popover {
|
||
position: absolute; z-index: 110;
|
||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
|
||
border-radius: 10px; box-shadow: var(--rs-shadow-md);
|
||
padding: 8px; min-width: 300px;
|
||
animation: popover-in 0.15s ease-out;
|
||
}
|
||
@keyframes popover-in {
|
||
from { opacity: 0; transform: translateY(-4px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.url-popover__input {
|
||
width: 100%; padding: 8px 10px; border-radius: 6px;
|
||
border: 1px solid var(--rs-input-border); background: var(--rs-input-bg);
|
||
color: var(--rs-input-text); font-size: 13px; font-family: inherit;
|
||
outline: none; margin-bottom: 6px; transition: border-color 0.15s;
|
||
}
|
||
.url-popover__input:focus { border-color: var(--rs-primary); }
|
||
.url-popover__actions { display: flex; gap: 6px; justify-content: flex-end; }
|
||
.url-popover__btn {
|
||
padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600;
|
||
cursor: pointer; border: none; transition: all 0.15s;
|
||
}
|
||
.url-popover__btn--insert { background: var(--rs-primary); color: #fff; }
|
||
.url-popover__btn--insert:hover { background: var(--rs-primary-hover); }
|
||
.url-popover__btn--cancel {
|
||
background: transparent; color: var(--rs-text-secondary);
|
||
border: 1px solid var(--rs-border);
|
||
}
|
||
.url-popover__btn--cancel:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
|
||
|
||
/* ── Slash Menu ── */
|
||
.slash-menu {
|
||
position: absolute; z-index: 100;
|
||
background: var(--rs-bg-surface); border: 1px solid var(--rs-border);
|
||
border-radius: 10px; box-shadow: var(--rs-shadow-lg);
|
||
max-height: 360px; overflow-y: auto; min-width: 240px;
|
||
display: none;
|
||
}
|
||
.slash-menu__header {
|
||
padding: 8px 12px 6px; font-size: 11px; font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: 0.05em;
|
||
color: var(--rs-text-muted); border-bottom: 1px solid var(--rs-border-subtle);
|
||
}
|
||
.slash-menu-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 12px; cursor: pointer; transition: background 0.1s;
|
||
}
|
||
.slash-menu-item:last-child { border-radius: 0 0 10px 10px; }
|
||
.slash-menu-item:hover, .slash-menu-item.selected { background: var(--rs-bg-hover); }
|
||
.slash-menu-icon {
|
||
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
|
||
background: var(--rs-bg-surface-raised); border-radius: 6px;
|
||
font-size: 13px; font-weight: 600; color: var(--rs-primary);
|
||
flex-shrink: 0;
|
||
}
|
||
.slash-menu-icon svg { width: 16px; height: 16px; }
|
||
.slash-menu-text { flex: 1; }
|
||
.slash-menu-title { font-size: 13px; font-weight: 500; color: var(--rs-text-primary); }
|
||
.slash-menu-desc { font-size: 11px; color: var(--rs-text-muted); }
|
||
.slash-menu-hint {
|
||
font-size: 10px; color: var(--rs-text-muted); padding: 1px 6px;
|
||
background: var(--rs-bg-surface-raised); border-radius: 3px; margin-left: auto;
|
||
}
|
||
|
||
/* ── 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);
|