/** * — notebook and note management. * * Browse notebooks, create/edit notes with rich text, * search, tag management. * * Notebook list: REST (GET /api/notebooks) * Notebook detail + notes: Automerge sync via WebSocket * Search: REST (GET /api/notes?q=...) */ import * as Automerge from '@automerge/automerge'; interface Notebook { id: string; title: string; description: string; cover_color: string; note_count: string; updated_at: string; } interface Note { id: string; title: string; content: string; content_plain: string; type: string; tags: string[] | null; is_pinned: boolean; created_at: string; updated_at: string; } /** Shape of Automerge notebook doc (matches PG→Automerge migration) */ interface NotebookDoc { meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; notebook: { id: string; title: string; slug: string; description: string; coverColor: string; isPublic: boolean; createdAt: number; updatedAt: number; }; items: Record; } class FolkNotesApp extends HTMLElement { private shadow: ShadowRoot; private space = ""; private view: "notebooks" | "notebook" | "note" = "notebooks"; private notebooks: Notebook[] = []; private selectedNotebook: (Notebook & { notes: Note[] }) | null = null; private selectedNote: Note | null = null; private searchQuery = ""; private searchResults: Note[] = []; private loading = false; private error = ""; // Automerge sync state private ws: WebSocket | null = null; private doc: Automerge.Doc | null = null; private syncState: Automerge.SyncState = Automerge.initSyncState(); private subscribedDocId: string | null = null; private syncConnected = false; private pingInterval: ReturnType | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } // ── Demo data ── private demoNotebooks: (Notebook & { notes: Note[] })[] = []; connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.connectSync(); this.loadNotebooks(); } private loadDemoData() { const now = Date.now(); const hour = 3600000; const day = 86400000; const tripPlanningNotes: Note[] = [ { id: "demo-note-1", title: "Pre-trip Preparation", content: `## Pre-trip Preparation ### Flights & Transfers - **Jul 6**: Fly Geneva, shuttle to Chamonix (~1.5h) - **Jul 14**: Train Zermatt to Dolomites (Bernina Express, ~6h scenic route) - **Jul 20**: Fly home from Innsbruck > Book the Aiguille du Midi cable car tickets at least 2 weeks in advance -- they sell out fast in July. ### Travel Documents 1. Passports (valid 6+ months) 2. EU health insurance cards (EHIC) 3. Travel insurance policy (ref: WA-2026-7891) 4. Hut reservation confirmations (printed copies) 5. Drone registration for Italy ### Budget Overview Total budget: **EUR 4,000** across 4 travelers. \`\`\` Transport: EUR 800 (20%) Accommodation: EUR 1200 (30%) Activities: EUR 1000 (25%) Food: EUR 600 (15%) Gear: EUR 400 (10%) \`\`\` *Maya is tracking expenses in rFunds. Current spend: EUR 1,203.*`, content_plain: "Pre-trip preparation checklist covering flights, transfers, travel documents, and budget overview for the Alpine Explorer 2026 trip.", 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: `## Accommodation Research ### Chamonix (Jul 6-10) - **Refuge du Lac Blanc** -- Jul 7, 4 beds, conf #LB2026-234 - Airbnb in town for other nights (~EUR 120/night for 4 pax) - Consider Hotel Le Morgane if Airbnb falls through ### Zermatt (Jul 10-14) - **Hornlihutte** (Matterhorn base) -- Waitlisted for Jul 12 - Main accommodation: Apartment near Bahnhofstrasse - Car-free village, arrive by Glacier Express > Zermatt is expensive. Budget EUR 80-100pp/night minimum. The apartment saves us about 40% vs hotels. ### Dolomites (Jul 14-20) - **Rifugio Locatelli** -- Jul 15, 4 beds, conf #TRE2026-089 - Val Gardena base: Ortisei area - Look for agriturismo options for authentic experience ### Booking Status | Location | Status | Cost/Night | Notes | |----------|--------|-----------|-------| | Lac Blanc | Confirmed | EUR 65pp | Half-board included | | Chamonix Airbnb | Confirmed | EUR 120 total | 2-bed apartment | | Zermatt apartment | Confirmed | EUR 180 total | Near station | | Hornlihutte | Waitlisted | EUR 85pp | Fingers crossed | | Locatelli | Confirmed | EUR 55pp | Half-board | | Val Gardena | Searching | ~EUR 100 total | Need 4+ beds |`, content_plain: "Accommodation research for all three destinations: Chamonix, Zermatt, and Dolomites. Includes confirmed bookings, waitlists, and budget estimates.", 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: `## Activity Planning ### Hiking Routes - **Lac Blanc** (Jul 7) -- Acclimatization hike, ~6h round trip, 1000m elevation gain. Stunning Mont Blanc reflection at sunrise. - **Gornergrat Sunrise** (Jul 11) -- Take the first train up at 7am, hike down. Matterhorn panorama. - **Matterhorn Base Camp** (Jul 12) -- Full day trek to Hornlihutte. 1500m gain. Only if weather permits. - **Tre Cime di Lavaredo** (Jul 15) -- Classic loop, ~4h. Stay at Rifugio Locatelli for golden hour photos. - **Seceda Ridgeline** (Jul 17) -- Gondola up, ridge walk, hike down to Ortisei. Best drone location. ### Adventure Activities 1. **Via Ferrata at Aiguille du Midi** (Jul 8) -- Rent harness + lanyard + helmet in Chamonix, ~EUR 25/day 2. **Paragliding over Zermatt** (Jul 13) -- Tandem flights ~EUR 180pp. Book with Paragliding Zermatt (best reviews) 3. **Kayaking at Lago di Braies** (Jul 16) -- Turquoise glacial lake, kayak rental ~EUR 15/hour ### Rest Days - Jul 9: Explore Chamonix town, gear shopping - Jul 19: Free day before flying home, packing > Omar suggested we vote on the Day 5 alternative activity -- Via Ferrata is winning 7-3 over kayaking on Lac d'Annecy. ### Difficulty Ratings \`\`\` Lac Blanc: Moderate (T2) Gornergrat: Easy (T1) Matterhorn Base: Difficult (T3) Tre Cime Loop: Moderate (T2) Seceda Ridge: Easy (T1) Via Ferrata: Difficult (K3) \`\`\``, content_plain: "Detailed activity planning including hiking routes with difficulty ratings, adventure activities with costs, and rest day plans.", 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: `## Gear Research ### Via Ferrata Kit Need harness + lanyard + helmet. Can rent in Chamonix for ~EUR 25/day per person. Renting is better than buying for a one-time activity. ### Paragliding Tandem flights in Zermatt: ~EUR 180pp. Book at **Paragliding Zermatt** (best reviews on TripAdvisor). They offer morning flights with better thermals. ### Camera & Drone - Bring the **DJI Mini 4 Pro** for Tre Cime and Seceda - Check Italian drone regulations! Need ENAC registration for flights over 250g - ND filters for long exposure water shots at Lago di Braies - Extra batteries (3x) -- cold altitude drains them fast > Liam scored the DJI Mini 4 Pro over GoPro Hero 12 and Sony A7C II in our decision matrix. Best weight-to-quality ratio for hiking. ### Group Gear (Shared) \`\`\` Item Cost Status Owner -------------------------- ------- ---------- ----- Adventure First-Aid Kit EUR 85 Funded Omar Water Filter (Sawyer) EUR 45 Funded Maya Bear Canisters 2x (BV500) EUR 120 In Cart Liam Camp Stove + Fuel EUR 65 Funded Priya DJI Mini 4 Pro Rental EUR 350 Needs Fund Liam Starlink Mini Rental EUR 200 Needs Fund Omar \`\`\` ### Personal Gear Checklist - Hiking boots (broken in!) - Rain jacket (waterproof, not just resistant) - Headlamp + spare batteries - Trekking poles (collapsible for flights) - Sunscreen SPF 50 + lip balm - Wool base layers for hut nights`, content_plain: "Gear research including Via Ferrata rental, paragliding booking, camera and drone regulations, shared group gear status, and personal gear checklist.", 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: `## Emergency Contacts & Safety ### Emergency Numbers - **France**: 112 (EU general), PGHM Mountain Rescue: +33 4 50 53 16 89 - **Switzerland**: 1414 (REGA air rescue), 144 (ambulance) - **Italy**: 118 (medical), 112 (general emergency) ### Insurance - Policy #: **WA-2026-7891** - Emergency line: +1-800-555-0199 - Covers: mountain rescue, helicopter evacuation, medical repatriation - Deductible: EUR 150 per incident ### Altitude Sickness Protocol 1. Acclimatize in Chamonix (1,035m) for 2 days before going high 2. Stay hydrated -- minimum 3L water per day above 2,500m 3. Watch for symptoms: headache, nausea, dizziness 4. Descend immediately if symptoms worsen 5. Omar packed altitude sickness medication (Diamox) in the first-aid kit ### Weather Contingency > If the Matterhorn trek gets rained out, Omar found a cheese museum in Zermatt as backup. Priya suggests the Glacier Paradise instead. - Check MeteoSwiss and Meteotrentino daily - Alpine weather changes fast -- always carry rain gear - Lightning protocol: descend ridgelines immediately, avoid isolated trees ### Emergency Meeting Points \`\`\` Chamonix: Place Balmat (town center) Zermatt: Bahnhofplatz (train station) Dolomites: Rifugio Auronzo parking lot \`\`\``, content_plain: "Emergency contacts for France, Switzerland, and Italy. Insurance details, altitude sickness protocol, weather contingency plans, and emergency meeting points.", 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: `## Photo Spots & Creative Plan ### Must-Capture Locations 1. **Lac Blanc** -- Reflection of Mont Blanc at sunrise. Arrive by 5:30am. Tripod essential. 2. **Gornergrat Panorama** -- 360-degree view with Matterhorn. Golden hour is best. 3. **Tre Cime from Rifugio Locatelli** -- The iconic three peaks at golden hour. Drone shots here. 4. **Seceda Ridgeline** -- Dramatic Dolomite spires. Best drone footage location. 5. **Lago di Braies** -- Turquoise water, use ND filters for long exposure reflections. ### Drone Shot List (Liam) - Tre Cime circular orbit (check wind < 20km/h) - Seceda ridge reveal shot (low to high) - Lago di Braies top-down turquoise pattern - Matterhorn time-lapse from Gornergrat (if weather permits) ### Zine Plan (Maya) We are making an **Alpine Explorer Zine** after the trip: - Format: A5 risograph, 50 copies - Print at Chamonix Print Collective - Content: best photos, trail notes, hand-drawn maps - Price: EUR 12 per copy on rCart > Bring ND filters for long exposure water shots. Pack the circular polarizer for cutting glare on alpine lakes. ### Video Plan - Daily 30-second clips for trip recap - Lac Blanc sunrise test footage already uploaded to rTube (3:42) - Final edit: 5-8 minute highlight reel`, content_plain: "Photography and creative plan including must-capture locations, drone shot list, zine production details, and video plan.", 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: `## Packing Checklist ### Footwear - [x] Hiking boots (broken in!) - [ ] Camp sandals / flip-flops - [ ] Extra laces ### Clothing - [x] Rain jacket (Gore-Tex) - [ ] Down jacket for hut nights - [ ] 3x wool base layers - [ ] 2x hiking pants - [ ] Sun hat + warm beanie - [ ] Gloves (lightweight) ### Gear - [x] Headlamp + spare batteries - [ ] Trekking poles (collapsible) - [x] First aid kit - [ ] Sunscreen SPF 50 - [ ] Water filter (Sawyer Squeeze) - [ ] Dry bags (2x) - [ ] Repair kit (duct tape, zip ties) ### Electronics - [ ] Camera + 3 batteries - [ ] Drone + 3 batteries (Liam) - [x] Power bank (20,000mAh) - [ ] Universal adapter (EU plugs) ### Documents - [x] Passports - [ ] Travel insurance printout - [x] Hut reservation confirmations - [ ] Italian drone registration - [ ] Emergency contacts card ### Food & Water - [ ] Reusable water bottles (1L each) - [ ] Trail snacks (energy bars, nuts) - [ ] Electrolyte tablets - [ ] Coffee/tea for hut mornings`, content_plain: "Complete packing checklist organized by category: footwear, clothing, gear, electronics, documents, and food. Includes checked-off items.", 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: `## Food & Cooking Plan ### Hut Meals (Half-Board) Lac Blanc and Locatelli include dinner + breakfast. Budget EUR 0 for those nights. ### Self-Catering Days We have a kitchen in the Chamonix Airbnb and Zermatt apartment. **Chamonix Grocery Run (Omar)** - Pasta, rice, couscous - Cheese, bread, cured meats - Fresh vegetables - Coffee, tea, milk - Trail mix ingredients > Omar already spent EUR 93 at Chamonix Carrefour. Receipts in rFunds. ### Trail Lunches Pack these the night before each hike: - Sandwiches (baguette + cheese + ham) - Energy bars (2 per person) - Nuts and dried fruit - Chocolate (the altitude calls for it) - 1.5L water minimum ### Special Meals - **Jul 10**: Fondue at Chez Vrony, Zermatt (won the dinner vote 5-4 over pizza) - **Jul 18**: Cooking class in Bolzano (South Tyrolean cuisine) ### Dietary Notes \`\`\` Maya: Vegetarian (eggs & dairy OK) Priya: No shellfish allergy Others: No restrictions \`\`\``, content_plain: "Food and cooking plan covering hut meals, self-catering, trail lunches, special restaurant meals, and dietary notes for all travelers.", 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: `## Transport & Logistics ### Getting There - **Jul 6**: Fly to Geneva (everyone arrives by 14:00) - Geneva to Chamonix shuttle: EUR 186 for 4 pax (Maya paid, tracked in rFunds) ### Between Destinations - **Jul 10**: Chamonix to Zermatt - Option A: Train via Martigny (~3.5h, scenic) - Option B: Drive via Grand St Bernard tunnel (~2.5h) - *Decision: Train (Liam prefers scenic route)* - **Jul 14**: Zermatt to Dolomites - Bernina Express is 6 hours but spectacular - Rental car is 3.5 hours - *Under discussion in rForum -- Priya prefers the train* ### Local Transport - **Chamonix**: Free local bus with guest card - **Zermatt**: Car-free! Electric taxis + Gornergrat railway - **Dolomites**: Need rental car or local bus (limited schedule) ### Getting Home - **Jul 20**: Drive to Innsbruck airport (~2.5h from Val Gardena) - Return flights booked separately ### Important Timetables \`\`\` Gornergrat Railway (first): 07:00 from Zermatt Lac Blanc trailhead shuttle: 06:30 from Chamonix Seceda gondola (first): 08:30 from Ortisei \`\`\` > Sam is researching rail passes -- the Swiss Travel Pass might save us money for the Zermatt segment.`, content_plain: "Transport and logistics plan covering flights, inter-city transfers, local transport options, return journey, and important timetables.", 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: `## Full Itinerary -- Alpine Explorer 2026 **Jul 6-20 | France, Switzerland, Italy** **Travelers: Maya, Liam, Priya, Omar, Alex, Sam** ### Week 1: Chamonix, France (Jul 6-10) | Date | Activity | Category | |------|----------|----------| | Jul 6 | Fly Geneva, shuttle to Chamonix | Travel | | Jul 7 | Acclimatization hike -- Lac Blanc | Hike | | Jul 8 | Via Ferrata -- Aiguille du Midi | Adventure | | Jul 9 | Rest day / Chamonix town | Rest | | Jul 10 | Train to Zermatt | Travel | ### Week 2: Zermatt, Switzerland (Jul 10-14) | Date | Activity | Category | |------|----------|----------| | Jul 10 | Arrive Zermatt, settle in | Travel | | Jul 11 | Gornergrat sunrise hike | Hike | | Jul 12 | Matterhorn base camp trek | Hike | | Jul 13 | Paragliding over Zermatt | Adventure | | Jul 14 | Transfer to Dolomites | Travel | ### Week 3: Dolomites, Italy (Jul 14-20) | Date | Activity | Category | |------|----------|----------| | Jul 14 | Arrive Val Gardena | Travel | | Jul 15 | Tre Cime di Lavaredo loop | Hike | | Jul 16 | Lago di Braies kayaking | Adventure | | Jul 17 | Seceda ridgeline hike | Hike | | Jul 18 | Cooking class in Bolzano | Culture | | Jul 19 | Free day -- shopping & packing | Rest | | Jul 20 | Fly home from Innsbruck | Travel | > This itinerary is also tracked in rTrips and synced to the shared rCal calendar.`, content_plain: "Complete day-by-day itinerary for the Alpine Explorer 2026 trip covering three weeks across Chamonix, Zermatt, and the Dolomites.", 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: `## Mountain Hut Reservations ### Confirmed - **Refuge du Lac Blanc** (Jul 7) - 4 beds reserved, half-board - Confirmation: #LB2026-234 - Check-in after 15:00, dinner at 19:00 - Bring sleeping bag liner (required) - **Rifugio Locatelli** (Jul 15) - 4 beds reserved, half-board - Confirmation: #TRE2026-089 - Famous for Tre Cime sunset views - Cash only! Bring EUR 55pp ### Waitlisted - **Hornlihutte** (Matterhorn base, Jul 12) - Waitlisted -- will know by Jul 1 - If no luck, camp at Zermatt and do a long day hike instead - Alex is monitoring the cancellation list ### Hut Etiquette Reminders 1. Arrive before 17:00 if possible 2. Remove boots at entrance (bring hut shoes or thick socks) 3. Lights out by 22:00 4. Pack out all trash 5. Tip is appreciated but not required > Priya handled all the hut bookings. She has the confirmations printed and digital copies in rFiles.`, content_plain: "Mountain hut reservations with confirmation numbers, check-in details, and hut etiquette reminders. Two confirmed, one waitlisted.", 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: `## Group Decisions & Votes ### Decided - **Camera Gear**: DJI Mini 4 Pro (Liam's decision matrix: 8.5/10) - **First Night Dinner in Zermatt**: Fondue at Chez Vrony (won 5-4 over pizza) - **Day 5 Activity**: Via Ferrata at Aiguille du Midi (won 7-3 over kayaking) ### Active Votes (in rVote) - **Zermatt to Dolomites transfer**: Train vs rental car - Train: 3 votes (Liam, Priya, Sam) - Car: 2 votes (Maya, Omar) - Alex: undecided ### Pending Decisions - Val Gardena accommodation (agriturismo vs apartment) - Whether to rent the Starlink Mini (EUR 200, needs funding) - Trip zine print run size (50 vs 100 copies) ### Decision Framework We are using a simple majority vote for group activities. For expenses over EUR 100, we need consensus (all 6 agree). Individual expenses are each person's choice. > All votes are tracked in rVote and synced to the canvas.`, content_plain: "Summary of group decisions made and active votes. Covers camera gear, dining, activities, and pending decisions with the group's voting framework.", 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, space: "demo", } 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, space: "demo", } 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, space: "demo", } 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; this.render(); } 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[], space: "demo", } 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: "", type: "NOTE", tags: null, is_pinned: false, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }; // Add to the matching demoNotebook 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.render(); } disconnectedCallback() { this.disconnectSync(); } // ── WebSocket Sync ── private connectSync() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${proto}//${location.host}/ws/${this.space}`; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { this.syncConnected = true; // Keepalive ping every 30s this.pingInterval = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "ping", timestamp: Date.now() })); } }, 30000); // If we had a pending subscription, re-subscribe if (this.subscribedDocId && this.doc) { this.subscribeNotebook(this.subscribedDocId.split(":").pop()!); } }; this.ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.type === "sync" && msg.docId === this.subscribedDocId) { this.handleSyncMessage(new Uint8Array(msg.data)); } // pong and other messages are ignored } catch { // ignore parse errors } }; this.ws.onclose = () => { this.syncConnected = false; if (this.pingInterval) clearInterval(this.pingInterval); // Reconnect after 3s setTimeout(() => { if (this.isConnected) this.connectSync(); }, 3000); }; this.ws.onerror = () => { // onclose will fire after this }; } private disconnectSync() { if (this.pingInterval) clearInterval(this.pingInterval); if (this.ws) { this.ws.onclose = null; // prevent reconnect this.ws.close(); this.ws = null; } this.syncConnected = false; } private handleSyncMessage(syncMsg: Uint8Array) { if (!this.doc) return; const [newDoc, newSyncState] = Automerge.receiveSyncMessage( this.doc, this.syncState, syncMsg ); this.doc = newDoc; this.syncState = newSyncState; // Send reply if needed const [nextState, reply] = Automerge.generateSyncMessage(this.doc, this.syncState); this.syncState = nextState; if (reply && this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "sync", docId: this.subscribedDocId, data: Array.from(reply), })); } this.renderFromDoc(); } private subscribeNotebook(notebookId: string) { this.subscribedDocId = `${this.space}:notes:notebooks:${notebookId}`; this.doc = Automerge.init(); this.syncState = Automerge.initSyncState(); if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; // Send subscribe this.ws.send(JSON.stringify({ type: "subscribe", docIds: [this.subscribedDocId] })); // Send initial sync message to kick off handshake const [s, m] = Automerge.generateSyncMessage(this.doc, this.syncState); this.syncState = s; if (m) { this.ws.send(JSON.stringify({ type: "sync", docId: this.subscribedDocId, data: Array.from(m), })); } } private unsubscribeNotebook() { if (this.subscribedDocId && this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "unsubscribe", docIds: [this.subscribedDocId] })); } this.subscribedDocId = null; this.doc = null; this.syncState = Automerge.initSyncState(); } /** Extract notebook + notes from Automerge doc into component state */ private renderFromDoc() { if (!this.doc) return; const nb = this.doc.notebook; const items = this.doc.items; if (!nb) return; // doc not yet synced // Build notebook data from doc const notes: Note[] = []; if (items) { for (const [, item] of Object.entries(items)) { notes.push({ id: item.id, title: item.title || "Untitled", content: item.content || "", content_plain: item.contentPlain || "", type: item.type || "NOTE", tags: item.tags?.length ? Array.from(item.tags) : null, is_pinned: item.isPinned || false, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), }); } } // Sort: pinned first, then by sort order, then by updated_at desc notes.sort((a, b) => { if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); }); this.selectedNotebook = { id: nb.id, title: nb.title, description: nb.description || "", cover_color: nb.coverColor || "#3b82f6", note_count: String(notes.length), updated_at: nb.updatedAt ? new Date(nb.updatedAt).toISOString() : new Date().toISOString(), notes, }; // If viewing a specific note, update it from doc too if (this.view === "note" && this.selectedNote) { const noteItem = items?.[this.selectedNote.id]; if (noteItem) { this.selectedNote = { id: noteItem.id, title: noteItem.title || "Untitled", content: noteItem.content || "", content_plain: noteItem.contentPlain || "", type: noteItem.type || "NOTE", tags: noteItem.tags?.length ? Array.from(noteItem.tags) : null, is_pinned: noteItem.isPinned || false, created_at: noteItem.createdAt ? new Date(noteItem.createdAt).toISOString() : new Date().toISOString(), updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(), }; } } this.loading = false; this.render(); } // ── Automerge mutations ── private createNoteViaSync() { if (!this.doc || !this.selectedNotebook) return; const noteId = crypto.randomUUID(); const now = Date.now(); this.doc = Automerge.change(this.doc, "Create note", (d: NotebookDoc) => { if (!d.items) (d as any).items = {}; d.items[noteId] = { id: noteId, notebookId: this.selectedNotebook!.id, title: "Untitled Note", content: "", contentPlain: "", type: "NOTE", tags: [], isPinned: false, sortOrder: 0, createdAt: now, updatedAt: now, }; }); this.sendSyncAfterChange(); this.renderFromDoc(); // Open the new note for editing this.selectedNote = { id: noteId, title: "Untitled Note", content: "", content_plain: "", type: "NOTE", tags: null, is_pinned: false, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }; this.view = "note"; this.render(); } private updateNoteField(noteId: string, field: string, value: string) { if (!this.doc || !this.doc.items?.[noteId]) return; this.doc = Automerge.change(this.doc, `Update ${field}`, (d: NotebookDoc) => { (d.items[noteId] as any)[field] = value; d.items[noteId].updatedAt = Date.now(); }); this.sendSyncAfterChange(); } private sendSyncAfterChange() { if (!this.doc || !this.ws || this.ws.readyState !== WebSocket.OPEN) return; const [newState, msg] = Automerge.generateSyncMessage(this.doc, this.syncState); this.syncState = newState; if (msg) { this.ws.send(JSON.stringify({ type: "sync", docId: this.subscribedDocId, data: Array.from(msg), })); } } // ── REST (notebook list + search) ── private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/notes/); return match ? `/${match[1]}/notes` : ""; } private async loadNotebooks() { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notebooks`); const data = await res.json(); this.notebooks = data.notebooks || []; } catch { this.error = "Failed to load notebooks"; } this.loading = false; this.render(); } private async loadNotebook(id: string) { this.loading = true; this.render(); // Unsubscribe from any previous notebook this.unsubscribeNotebook(); // Subscribe to the new notebook via Automerge this.subscribeNotebook(id); // Set a timeout — if doc doesn't arrive in 5s, fall back to REST setTimeout(() => { if (this.loading && this.view === "notebook") { this.loadNotebookREST(id); } }, 5000); } /** REST fallback for notebook detail */ private async loadNotebookREST(id: string) { try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notebooks/${id}`); this.selectedNotebook = await res.json(); } catch { this.error = "Failed to load notebook"; } this.loading = false; this.render(); } private loadNote(id: string) { // Note is already in the Automerge doc — just select it if (this.doc?.items?.[id]) { const item = this.doc.items[id]; this.selectedNote = { id: item.id, title: item.title || "Untitled", content: item.content || "", content_plain: item.contentPlain || "", type: item.type || "NOTE", tags: item.tags?.length ? Array.from(item.tags) : null, is_pinned: item.isPinned || false, created_at: item.createdAt ? new Date(item.createdAt).toISOString() : new Date().toISOString(), updated_at: item.updatedAt ? new Date(item.updatedAt).toISOString() : new Date().toISOString(), }; } else if (this.selectedNotebook?.notes) { // Fallback: find in REST-loaded data this.selectedNote = this.selectedNotebook.notes.find(n => n.id === id) || null; } this.render(); } private async searchNotes(query: string) { if (!query.trim()) { this.searchResults = []; this.render(); return; } try { const base = this.getApiBase(); const res = await fetch(`${base}/api/notes?q=${encodeURIComponent(query)}`); const data = await res.json(); this.searchResults = data.notes || []; } catch { this.searchResults = []; } this.render(); } private async createNotebook() { const title = prompt("Notebook name:"); if (!title?.trim()) return; try { const base = this.getApiBase(); await fetch(`${base}/api/notebooks`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }); await this.loadNotebooks(); } catch { this.error = "Failed to create notebook"; this.render(); } } private getNoteIcon(type: string): string { switch (type) { case "NOTE": return "📝"; case "CODE": return "💻"; case "BOOKMARK": return "🔗"; case "IMAGE": return "🖼"; case "AUDIO": return "🎤"; case "FILE": return "📎"; case "CLIP": return "✂️"; default: return "📄"; } } private formatDate(dateStr: string): string { const d = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return "Today"; if (diffDays === 1) return "Yesterday"; if (diffDays < 7) return `${diffDays}d ago`; return d.toLocaleDateString(); } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading ? '
Loading...
' : ""} ${!this.loading ? this.renderView() : ""} `; this.attachListeners(); } private renderView(): string { if (this.view === "note" && this.selectedNote) return this.renderNote(); if (this.view === "notebook" && this.selectedNotebook) return this.renderNotebook(); return this.renderNotebooks(); } private renderNotebooks(): string { return `
Notebooks
${this.searchQuery && this.searchResults.length > 0 ? `
${this.searchResults.length} results for "${this.esc(this.searchQuery)}"
${this.searchResults.map((n) => this.renderNoteItem(n)).join("")} ` : ""} ${!this.searchQuery ? `
${this.notebooks.map((nb) => `
${this.esc(nb.title)}
${this.esc(nb.description || "")}
${nb.note_count} notes · ${this.formatDate(nb.updated_at)}
`).join("")}
${this.notebooks.length === 0 ? '
No notebooks yet. Create one to get started.
' : ""} ` : ""} `; } private renderNotebook(): string { const nb = this.selectedNotebook!; const syncBadge = this.subscribedDocId ? `` : ""; return `
${this.esc(nb.title)}${syncBadge}
${nb.notes && nb.notes.length > 0 ? nb.notes.map((n) => this.renderNoteItem(n)).join("") : '
No notes in this notebook.
' } `; } private renderNoteItem(n: Note): string { return `
${this.getNoteIcon(n.type)}
${n.is_pinned ? '📌 ' : ""}${this.esc(n.title)}
${this.esc(n.content_plain || "")}
${this.formatDate(n.updated_at)} ${n.type} ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""}
`; } private renderNote(): string { const n = this.selectedNote!; const isDemo = this.space === "demo"; const isAutomerge = !!(this.doc?.items?.[n.id]); const isEditable = isAutomerge || isDemo; return `
${isEditable ? `` : `${this.getNoteIcon(n.type)} ${this.esc(n.title)}` }
${n.content || 'Empty note'}
Type: ${n.type} Created: ${this.formatDate(n.created_at)} Updated: ${this.formatDate(n.updated_at)} ${n.tags ? n.tags.map((t) => `${this.esc(t)}`).join("") : ""} ${isAutomerge ? 'Live' : ""} ${isDemo ? 'Demo' : ""}
`; } private attachListeners() { const isDemo = this.space === "demo"; // Create notebook this.shadow.getElementById("create-notebook")?.addEventListener("click", () => { isDemo ? this.demoCreateNotebook() : this.createNotebook(); }); // Create note (Automerge or demo) 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 this.shadow.querySelectorAll("[data-back]").forEach((el) => { el.addEventListener("click", (e) => { e.stopPropagation(); const target = (el as HTMLElement).dataset.back; if (target === "notebooks") { this.view = "notebooks"; if (!isDemo) this.unsubscribeNotebook(); this.selectedNotebook = null; this.selectedNote = null; this.render(); } else if (target === "notebook") { this.view = "notebook"; this.render(); } }); }); // Editable note title (debounced) — demo: update local data; live: Automerge const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement; if (titleInput && this.selectedNote) { let titleTimeout: any; const noteId = this.selectedNote.id; titleInput.addEventListener("input", () => { clearTimeout(titleTimeout); titleTimeout = setTimeout(() => { if (isDemo) { this.demoUpdateNoteField(noteId, "title", titleInput.value); } else { this.updateNoteField(noteId, "title", titleInput.value); } }, 500); }); } // Editable note content (debounced) — demo: update local data; live: Automerge const contentEl = this.shadow.getElementById("note-content-editable"); if (contentEl && this.selectedNote) { let contentTimeout: any; const noteId = this.selectedNote.id; contentEl.addEventListener("input", () => { clearTimeout(contentTimeout); contentTimeout = setTimeout(() => { const html = contentEl.innerHTML; const plain = contentEl.textContent?.trim() || ""; if (isDemo) { this.demoUpdateNoteField(noteId, "content", html); this.demoUpdateNoteField(noteId, "content_plain", plain); } else { this.updateNoteField(noteId, "content", html); this.updateNoteField(noteId, "contentPlain", plain); } }, 800); }); } } private demoUpdateNoteField(noteId: string, field: string, value: string) { // Update in the selectedNote if (this.selectedNote && this.selectedNote.id === noteId) { (this.selectedNote as any)[field] = value; this.selectedNote.updated_at = new Date().toISOString(); } // Update in the matching demoNotebook 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; } } // Update in selectedNotebook notes 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; } } customElements.define("folk-notes-app", FolkNotesApp);