From 34c96b5a457ddfef8ee3f1c48aed7e71a24302ec Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 18:04:53 -0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Transak=20credit=20card=20=E2=86=92?= =?UTF-8?q?=20USDC=20fiat=20on-ramp=20for=20rFunds=20TBFF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Transak widget integration so users can fund flows with a credit card. Server receives webhook on order completion and deposits USDC into the flow via the existing Flow Service API. Includes HMAC signature verification when TRANSAK_WEBHOOK_SECRET is configured. Co-Authored-By: Claude Opus 4.6 --- modules/rfunds/components/folk-funds-app.ts | 76 +++++++++++++++++++++ modules/rfunds/lib/types.ts | 1 + modules/rfunds/mod.ts | 64 +++++++++++++++++ scripts/seed-infisical.sh | 3 + 4 files changed, 144 insertions(+) diff --git a/modules/rfunds/components/folk-funds-app.ts b/modules/rfunds/components/folk-funds-app.ts index 7c50627d..cf9a7a9a 100644 --- a/modules/rfunds/components/folk-funds-app.ts +++ b/modules/rfunds/components/folk-funds-app.ts @@ -1405,6 +1405,14 @@ class FolkFundsApp extends HTMLElement { `; + if (d.sourceType === "card") { + html += `
+ +
`; + } html += this.renderAllocEditor("Target Allocations", d.targetAllocations); return html; } @@ -1476,6 +1484,62 @@ class FolkFundsApp extends HTMLElement { return html; } + private async openTransakWidget(flowId: string, walletAddress: string) { + // Fetch Transak config from server + let apiKey = "STAGING_KEY"; + let env = "STAGING"; + try { + const base = this.space ? `/s/${this.space}/rfunds` : "/rfunds"; + const res = await fetch(`${base}/api/transak/config`); + if (res.ok) { + const cfg = await res.json(); + apiKey = cfg.apiKey || apiKey; + env = cfg.environment || env; + } + } catch { /* use defaults */ } + + const baseUrl = env === "PRODUCTION" + ? "https://global.transak.com" + : "https://global-stg.transak.com"; + + const params = new URLSearchParams({ + apiKey, + environment: env, + cryptoCurrencyCode: "USDC", + network: "base", + defaultCryptoCurrency: "USDC", + walletAddress, + partnerOrderId: flowId, + themeColor: "6366f1", + hideMenu: "true", + }); + + const modal = document.createElement("div"); + modal.id = "transak-modal"; + modal.style.cssText = `position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;`; + modal.innerHTML = ` +
+ + +
`; + + document.body.appendChild(modal); + + modal.querySelector("#transak-close")!.addEventListener("click", () => modal.remove()); + modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); }); + + const handler = (e: MessageEvent) => { + if (e.data?.event_id === "TRANSAK_ORDER_SUCCESSFUL") { + console.log("[Transak] Order successful:", e.data.data); + modal.remove(); + window.removeEventListener("message", handler); + } + }; + window.addEventListener("message", handler); + } + private attachEditorListeners(panel: HTMLElement, node: FlowNode) { // Close button panel.querySelector('[data-editor-action="close"]')?.addEventListener("click", () => this.closeEditor()); @@ -1499,6 +1563,18 @@ class FolkFundsApp extends HTMLElement { this.updateSufficiencyBadge(); }); }); + + // Fund with Card button (source nodes with sourceType "card") + panel.querySelector('[data-action="fund-with-card"]')?.addEventListener("click", () => { + const flowId = this.flowId || this.getAttribute("flow-id") || ""; + const sourceData = node.data as SourceNodeData; + const walletAddress = sourceData.walletAddress || ""; + if (!walletAddress) { + alert("Configure a wallet address first (use rIdentity passkey or enter manually)"); + return; + } + this.openTransakWidget(flowId, walletAddress); + }); } // ─── Node CRUD ──────────────────────────────────────── diff --git a/modules/rfunds/lib/types.ts b/modules/rfunds/lib/types.ts index 29f85e23..f5875acc 100644 --- a/modules/rfunds/lib/types.ts +++ b/modules/rfunds/lib/types.ts @@ -82,6 +82,7 @@ export interface SourceNodeData { walletAddress?: string; chainId?: number; safeAddress?: string; + transakOrderId?: string; [key: string]: unknown; } diff --git a/modules/rfunds/mod.ts b/modules/rfunds/mod.ts index ffb626a3..aafe5b6d 100644 --- a/modules/rfunds/mod.ts +++ b/modules/rfunds/mod.ts @@ -145,6 +145,70 @@ routes.get("/api/flows/:flowId/transactions", async (c) => { return c.json(await res.json(), res.status as any); }); +// ─── Transak fiat on-ramp ──────────────────────────────── + +routes.get("/api/transak/config", (c) => { + return c.json({ + apiKey: process.env.TRANSAK_API_KEY || "STAGING_KEY", + environment: process.env.TRANSAK_ENV || "STAGING", + }); +}); + +routes.post("/api/transak/webhook", async (c) => { + const rawBody = await c.req.text(); + + // HMAC verification — if TRANSAK_WEBHOOK_SECRET is set, validate signature + const webhookSecret = process.env.TRANSAK_WEBHOOK_SECRET; + if (webhookSecret) { + const signature = c.req.header("x-transak-signature") || ""; + const { createHmac } = await import("crypto"); + const expected = createHmac("sha256", webhookSecret).update(rawBody).digest("hex"); + if (signature !== expected) { + console.error("[Transak] Invalid webhook signature"); + return c.json({ error: "Invalid signature" }, 401); + } + } + + const body = JSON.parse(rawBody); + const { webhookData } = body; + + // Ack non-completion events (Transak sends multiple status updates) + if (!webhookData || webhookData.status !== "COMPLETED") { + return c.json({ ok: true }); + } + + const { partnerOrderId, cryptoAmount, cryptocurrency, network } = webhookData; + if (!partnerOrderId || cryptocurrency !== "USDC" || !network?.toLowerCase().includes("base")) { + return c.json({ error: "Invalid webhook data" }, 400); + } + + // partnerOrderId format: "flowId" or "flowId:funnelId" + const [flowId, funnelId] = partnerOrderId.split(":"); + if (!flowId) return c.json({ error: "Missing flowId in partnerOrderId" }, 400); + + // Convert crypto amount to USDC units (6 decimals) + const amountUnits = Math.round(parseFloat(cryptoAmount) * 1e6).toString(); + + const depositUrl = `${FLOW_SERVICE_URL}/api/flows/${flowId}/deposit`; + const res = await fetch(depositUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + amount: amountUnits, + source: "transak", + ...(funnelId ? { funnelId } : {}), + }), + }); + + if (!res.ok) { + console.error(`[Transak] Deposit failed: ${await res.text()}`); + return c.json({ error: "Deposit failed" }, 500); + } + + console.log(`[Transak] Deposit OK: flow=${flowId} amount=${amountUnits} USDC`); + return c.json({ ok: true }); +}); + // ─── Space-flow association endpoints ──────────────────── routes.post("/api/space-flows", async (c) => { diff --git a/scripts/seed-infisical.sh b/scripts/seed-infisical.sh index 545df8de..a349e398 100755 --- a/scripts/seed-infisical.sh +++ b/scripts/seed-infisical.sh @@ -69,6 +69,9 @@ RUNPOD_API_KEY|AI/MI|RunPod API key X402_PAY_TO|payments|Payment recipient address MAILCOW_API_KEY|EncryptID|Mailcow admin API key ENCRYPTID_DEMO_SPACES|EncryptID|Comma-separated demo space slugs +TRANSAK_API_KEY|rFunds|Transak widget API key (public, scoped to app) +TRANSAK_WEBHOOK_SECRET|rFunds|Transak webhook HMAC secret for signature verification +TRANSAK_ENV|rFunds|Transak environment: STAGING or PRODUCTION (default: STAGING) " ADDED=0 From 4bca76cf4524b130bdf3070d7aae3c2a66d4c1cf Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 18:08:24 -0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20handle=20body=20read=20in=20Transak?= =?UTF-8?q?=20webhook=20=E2=80=94=20clone=20request=20for=20HMAC=20+=20JSO?= =?UTF-8?q?N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- modules/rfunds/mod.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/rfunds/mod.ts b/modules/rfunds/mod.ts index aafe5b6d..d5c0c4b7 100644 --- a/modules/rfunds/mod.ts +++ b/modules/rfunds/mod.ts @@ -155,7 +155,8 @@ routes.get("/api/transak/config", (c) => { }); routes.post("/api/transak/webhook", async (c) => { - const rawBody = await c.req.text(); + // Clone request so we can read body twice (once for HMAC, once for JSON) + const rawBody = await c.req.raw.clone().text(); // HMAC verification — if TRANSAK_WEBHOOK_SECRET is set, validate signature const webhookSecret = process.env.TRANSAK_WEBHOOK_SECRET; @@ -169,7 +170,8 @@ routes.post("/api/transak/webhook", async (c) => { } } - const body = JSON.parse(rawBody); + let body: any; + try { body = rawBody ? JSON.parse(rawBody) : await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); } const { webhookData } = body; // Ack non-completion events (Transak sends multiple status updates) From a70d5fc1a21e1f6ceda509dfcac51d0848a42350 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 18:10:04 -0800 Subject: [PATCH 3/4] fix: use c.req.json() for Transak webhook body parsing Hono consumes the request body upstream, so c.req.raw.clone().text() returns empty. Use c.req.json() directly and re-serialize for HMAC. Co-Authored-By: Claude Opus 4.6 --- modules/rfunds/mod.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/modules/rfunds/mod.ts b/modules/rfunds/mod.ts index d5c0c4b7..1490f382 100644 --- a/modules/rfunds/mod.ts +++ b/modules/rfunds/mod.ts @@ -155,23 +155,22 @@ routes.get("/api/transak/config", (c) => { }); routes.post("/api/transak/webhook", async (c) => { - // Clone request so we can read body twice (once for HMAC, once for JSON) - const rawBody = await c.req.raw.clone().text(); + let body: any; + try { body = await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); } // HMAC verification — if TRANSAK_WEBHOOK_SECRET is set, validate signature const webhookSecret = process.env.TRANSAK_WEBHOOK_SECRET; if (webhookSecret) { const signature = c.req.header("x-transak-signature") || ""; const { createHmac } = await import("crypto"); - const expected = createHmac("sha256", webhookSecret).update(rawBody).digest("hex"); + // Re-serialize for HMAC (Transak signs the raw JSON body) + const expected = createHmac("sha256", webhookSecret).update(JSON.stringify(body)).digest("hex"); if (signature !== expected) { console.error("[Transak] Invalid webhook signature"); return c.json({ error: "Invalid signature" }, 401); } } - let body: any; - try { body = rawBody ? JSON.parse(rawBody) : await c.req.json(); } catch { return c.json({ error: "Invalid JSON" }, 400); } const { webhookData } = body; // Ack non-completion events (Transak sends multiple status updates) From 1af7df41bb7e1714d6fbc7a6e61fba9902df90c2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 2 Mar 2026 18:20:33 -0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20Tiptap=20rich=20text=20editor=20for?= =?UTF-8?q?=20rNotes=20=E2=80=94=20toolbar,=20slash=20commands,=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace bare contenteditable divs with a full Tiptap editor (vanilla JS, no React) inside the web component. Adds formatting toolbar (bold/italic/underline/strike/code, heading dropdown, lists, blockquote, code block, link/image insert, undo/redo), slash command menu (/ at start of empty block), syntax-highlighted code blocks via lowlight, and task list checkboxes. Zone-based rendering keeps the editor DOM persistent across re-renders. Content stored as Tiptap JSON in the existing Automerge content field with a new contentFormat discriminator. Legacy HTML notes auto-migrate on first edit. Remote sync updates applied without cursor disruption. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 148 ++ modules/rnotes/components/folk-notes-app.ts | 1492 ++++++++++--------- modules/rnotes/components/notes.css | 3 +- modules/rnotes/components/slash-command.ts | 269 ++++ modules/rnotes/mod.ts | 32 +- modules/rnotes/schemas.ts | 14 +- package.json | 32 +- 7 files changed, 1304 insertions(+), 686 deletions(-) create mode 100644 modules/rnotes/components/slash-command.ts diff --git a/bun.lock b/bun.lock index 1fa8752b..cc3777a1 100644 --- a/bun.lock +++ b/bun.lock @@ -11,10 +11,22 @@ "@lit/reactive-element": "^2.0.4", "@noble/curves": "^1.8.0", "@noble/hashes": "^1.7.0", + "@tiptap/core": "^3.20.0", + "@tiptap/extension-code-block-lowlight": "^3.20.0", + "@tiptap/extension-image": "^3.20.0", + "@tiptap/extension-link": "^3.20.0", + "@tiptap/extension-placeholder": "^3.20.0", + "@tiptap/extension-task-item": "^3.20.0", + "@tiptap/extension-task-list": "^3.20.0", + "@tiptap/extension-typography": "^3.20.0", + "@tiptap/extension-underline": "^3.20.0", + "@tiptap/pm": "^3.20.0", + "@tiptap/starter-kit": "^3.20.0", "@x402/core": "^2.3.1", "@x402/evm": "^2.5.0", "hono": "^4.11.7", "imapflow": "^1.0.170", + "lowlight": "^3.3.0", "mailparser": "^3.7.2", "nodemailer": "^6.9.0", "perfect-arrows": "^0.3.7", @@ -227,6 +239,8 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="], + "@rollup/plugin-virtual": ["@rollup/plugin-virtual@3.0.2", "", { "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="], @@ -421,16 +435,88 @@ "@swc/wasm": ["@swc/wasm@1.15.8", "", {}, "sha512-RG2BxGbbsjtddFCo1ghKH6A/BMXbY1eMBfpysV0lJMCpI4DZOjW1BNBnxvBt7YsYmlJtmy5UXIg9/4ekBTFFaQ=="], + "@tiptap/core": ["@tiptap/core@3.20.0", "", { "peerDependencies": { "@tiptap/pm": "^3.20.0" } }, "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ=="], + + "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA=="], + + "@tiptap/extension-bold": ["@tiptap/extension-bold@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg=="], + + "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.20.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.20.0" } }, "sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw=="], + + "@tiptap/extension-code": ["@tiptap/extension-code@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ=="], + + "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ=="], + + "@tiptap/extension-code-block-lowlight": ["@tiptap/extension-code-block-lowlight@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/extension-code-block": "^3.20.0", "@tiptap/pm": "^3.20.0", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "sha512-9lN9rn07lOWkLnByT5C1axtq56MHpOI7MpLaCmX3p+x1bDl6Uvixm6AoBdTLfZUmUYeEFBsf7t5cR+QepMbkiA=="], + + "@tiptap/extension-document": ["@tiptap/extension-document@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w=="], + + "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.20.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.20.0" } }, "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ=="], + + "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.20.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.20.0" } }, "sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w=="], + + "@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA=="], + + "@tiptap/extension-heading": ["@tiptap/extension-heading@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA=="], + + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA=="], + + "@tiptap/extension-image": ["@tiptap/extension-image@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-0t7HYncV0kYEQS79NFczxdlZoZ8zu8X4VavDqt+mbSAUKRq3gCvgtZ5Zyd778sNmtmbz3arxkEYMIVou2swD0g=="], + + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw=="], + + "@tiptap/extension-link": ["@tiptap/extension-link@3.20.0", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA=="], + + "@tiptap/extension-list": ["@tiptap/extension-list@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ=="], + + "@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.20.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.20.0" } }, "sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA=="], + + "@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.20.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.20.0" } }, "sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA=="], + + "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.20.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.20.0" } }, "sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA=="], + + "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ=="], + + "@tiptap/extension-placeholder": ["@tiptap/extension-placeholder@3.20.0", "", { "peerDependencies": { "@tiptap/extensions": "^3.20.0" } }, "sha512-ZhYD3L5m16ydSe2z8vqz+RdtAG/iOQaFHHedFct70tKRoLqi2ajF5kgpemu8DwpaRTcyiCN4G99J/+MqehKNjQ=="], + + "@tiptap/extension-strike": ["@tiptap/extension-strike@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ=="], + + "@tiptap/extension-task-item": ["@tiptap/extension-task-item@3.20.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.20.0" } }, "sha512-UArl5ffRNKyiOGJYNtjF50+A0qgYF2DXIEFGyMgJsbp1KYQpDj+UrezWvnGOHZfX64eBWDIO6Bum5jS7o32dYQ=="], + + "@tiptap/extension-task-list": ["@tiptap/extension-task-list@3.20.0", "", { "peerDependencies": { "@tiptap/extension-list": "^3.20.0" } }, "sha512-Ab2hfun+5xMG6kQhRjL7s16zTe3XaQRXe3/deGo9hnZrAzTQpWVwDN7H36BX8MemrWnAw3PiuwBH5iOrwNRGjA=="], + + "@tiptap/extension-text": ["@tiptap/extension-text@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ=="], + + "@tiptap/extension-typography": ["@tiptap/extension-typography@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-XpstIJ/hCK6+N87DdzjaYie1iH1gunbDK0UCsqsLemd8rMR/WAlF04M/yeJ6F2QtNzNh83bkoTxyNAHLDdUm2A=="], + + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0" } }, "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ=="], + + "@tiptap/extensions": ["@tiptap/extensions@3.20.0", "", { "peerDependencies": { "@tiptap/core": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg=="], + + "@tiptap/pm": ["@tiptap/pm@3.20.0", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.2", "prosemirror-markdown": "^1.13.1", "prosemirror-menu": "^1.2.4", "prosemirror-model": "^1.24.1", "prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-trailing-node": "^3.0.0", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" } }, "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A=="], + + "@tiptap/starter-kit": ["@tiptap/starter-kit@3.20.0", "", { "dependencies": { "@tiptap/core": "^3.20.0", "@tiptap/extension-blockquote": "^3.20.0", "@tiptap/extension-bold": "^3.20.0", "@tiptap/extension-bullet-list": "^3.20.0", "@tiptap/extension-code": "^3.20.0", "@tiptap/extension-code-block": "^3.20.0", "@tiptap/extension-document": "^3.20.0", "@tiptap/extension-dropcursor": "^3.20.0", "@tiptap/extension-gapcursor": "^3.20.0", "@tiptap/extension-hard-break": "^3.20.0", "@tiptap/extension-heading": "^3.20.0", "@tiptap/extension-horizontal-rule": "^3.20.0", "@tiptap/extension-italic": "^3.20.0", "@tiptap/extension-link": "^3.20.0", "@tiptap/extension-list": "^3.20.0", "@tiptap/extension-list-item": "^3.20.0", "@tiptap/extension-list-keymap": "^3.20.0", "@tiptap/extension-ordered-list": "^3.20.0", "@tiptap/extension-paragraph": "^3.20.0", "@tiptap/extension-strike": "^3.20.0", "@tiptap/extension-text": "^3.20.0", "@tiptap/extension-underline": "^3.20.0", "@tiptap/extensions": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], + "@types/mailparser": ["@types/mailparser@3.4.6", "", { "dependencies": { "@types/node": "*", "iconv-lite": "^0.6.3" } }, "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q=="], + "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], + + "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], + "@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="], "@types/nodemailer": ["@types/nodemailer@6.4.22", "", { "dependencies": { "@types/node": "*" } }, "sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@x402/core": ["@x402/core@2.5.0", "", { "dependencies": { "zod": "^3.24.2" } }, "sha512-nUr8HW8WhkU1DvrpUfsRvALy5NF8UWKoFezZOtX61mohxp2lWZpJ2GnvscxDM8nmBAbtIollmksd5z5pj8InXw=="], "@x402/evm": ["@x402/evm@2.5.0", "", { "dependencies": { "@x402/core": "~2.5.0", "@x402/extensions": "~2.5.0", "viem": "^2.39.3", "zod": "^3.24.2" } }, "sha512-MBSTQZwLobMVcmYO7itOMJRkxfHstsDyr7F94o9Rk/Oinz0kjvCe4DFgZmFXyz3nQUgQFmDVgTK5KIzfYR5uIA=="], @@ -449,6 +535,8 @@ "apg-js": ["apg-js@4.4.0", "", {}, "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], @@ -463,12 +551,18 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -483,6 +577,8 @@ "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "ethers": ["ethers@6.16.0", "", { "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.2", "@types/node": "22.7.5", "aes-js": "4.0.0-beta.5", "tslib": "2.7.0", "ws": "8.17.1" } }, "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], @@ -499,6 +595,8 @@ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], @@ -527,14 +625,24 @@ "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], + + "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], + "mailparser": ["mailparser@3.9.3", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.2", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.13", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ=="], + "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nodemailer": ["nodemailer@6.10.1", "", {}, "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "ox": ["ox@0.12.4", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q=="], "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], @@ -561,6 +669,42 @@ "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "prosemirror-changeset": ["prosemirror-changeset@2.4.0", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng=="], + + "prosemirror-collab": ["prosemirror-collab@1.3.1", "", { "dependencies": { "prosemirror-state": "^1.0.0" } }, "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ=="], + + "prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="], + + "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="], + + "prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.0", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ=="], + + "prosemirror-history": ["prosemirror-history@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="], + + "prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="], + + "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], + + "prosemirror-markdown": ["prosemirror-markdown@1.13.4", "", { "dependencies": { "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", "prosemirror-model": "^1.25.0" } }, "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw=="], + + "prosemirror-menu": ["prosemirror-menu@1.3.0", "", { "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", "prosemirror-history": "^1.0.0", "prosemirror-state": "^1.0.0" } }, "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg=="], + + "prosemirror-model": ["prosemirror-model@1.25.4", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA=="], + + "prosemirror-schema-basic": ["prosemirror-schema-basic@1.2.4", "", { "dependencies": { "prosemirror-model": "^1.25.0" } }, "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ=="], + + "prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="], + + "prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="], + + "prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="], + + "prosemirror-trailing-node": ["prosemirror-trailing-node@3.0.0", "", { "dependencies": { "@remirror/core-constants": "3.0.0", "escape-string-regexp": "^4.0.0" }, "peerDependencies": { "prosemirror-model": "^1.22.1", "prosemirror-state": "^1.4.2", "prosemirror-view": "^1.33.8" } }, "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ=="], + + "prosemirror-transform": ["prosemirror-transform@1.11.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw=="], + + "prosemirror-view": ["prosemirror-view@1.41.6", "", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], @@ -573,6 +717,8 @@ "rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="], + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], @@ -629,6 +775,8 @@ "vite-plugin-wasm": ["vite-plugin-wasm@3.5.0", "", { "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" } }, "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 516023fb..83356e5b 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -1,7 +1,7 @@ /** * — notebook and note management. * - * Browse notebooks, create/edit notes with rich text, + * Browse notebooks, create/edit notes with rich text (Tiptap), * search, tag management. * * Notebook list: REST (GET /api/notebooks) @@ -10,6 +10,20 @@ */ import * as Automerge from '@automerge/automerge'; +import { Editor } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import Link from '@tiptap/extension-link'; +import Image from '@tiptap/extension-image'; +import TaskList from '@tiptap/extension-task-list'; +import TaskItem from '@tiptap/extension-task-item'; +import Placeholder from '@tiptap/extension-placeholder'; +import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; +import Typography from '@tiptap/extension-typography'; +import Underline from '@tiptap/extension-underline'; +import { common, createLowlight } from 'lowlight'; +import { createSlashCommandPlugin } from './slash-command'; + +const lowlight = createLowlight(common); interface Notebook { id: string; @@ -25,6 +39,7 @@ interface Note { title: string; content: string; content_plain: string; + content_format?: 'html' | 'tiptap-json'; type: string; tags: string[] | null; is_pinned: boolean; @@ -41,13 +56,13 @@ interface NotebookDoc { }; items: Record; } class FolkNotesApp extends HTMLElement { - private shadow: ShadowRoot; + private shadow!: ShadowRoot; private space = ""; private view: "notebooks" | "notebook" | "note" = "notebooks"; private notebooks: Notebook[] = []; @@ -58,6 +73,17 @@ class FolkNotesApp extends HTMLElement { 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 | null = null; + // Automerge sync state private ws: WebSocket | null = null; private doc: Automerge.Doc | null = null; @@ -66,21 +92,39 @@ class FolkNotesApp extends HTMLElement { private syncConnected = false; private pingInterval: ReturnType | null = null; - constructor() { - super(); - this.shadow = this.attachShadow({ mode: "open" }); - } - // ── 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; @@ -89,218 +133,53 @@ class FolkNotesApp extends HTMLElement { 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%) + 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.*`,
+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.", + 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: `## 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: `

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
`, 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: `## 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: `

Activity Planning

Hiking Routes

  • Lac Blanc (Jul 7) -- Acclimatization hike, ~6h round trip, 1000m elevation gain.
  • Gornergrat Sunrise (Jul 11) -- Take the first train up at 7am, hike down.
  • Matterhorn Base Camp (Jul 12) -- Full day trek to Hornlihutte. 1500m gain.
  • Tre Cime di Lavaredo (Jul 15) -- Classic loop, ~4h.
  • Seceda Ridgeline (Jul 17) -- Gondola up, ridge walk, hike down to Ortisei.

Adventure Activities

  1. Via Ferrata at Aiguille du Midi (Jul 8) -- Rent harness + lanyard + helmet, ~EUR 25/day
  2. Paragliding over Zermatt (Jul 13) -- Tandem flights ~EUR 180pp
  3. Kayaking at Lago di Braies (Jul 16) -- Turquoise glacial lake, ~EUR 15/hour

Rest Days

  • Jul 9: Explore Chamonix town, gear shopping
  • Jul 19: Free day before flying home, packing
`, 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: `## 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.", + content: `

Gear Research

Via Ferrata Kit

Need harness + lanyard + helmet. Can rent in Chamonix for ~EUR 25/day per person.

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

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, 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: `## 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.", + 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

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
`, + 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: `## 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: `

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.

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
`, 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(), }, @@ -309,130 +188,25 @@ We are making an **Alpine Explorer Zine** after the trip: 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.", + content: `

Packing Checklist

Footwear

  • Hiking boots (broken in!)
  • Camp sandals / flip-flops
  • Extra laces

Clothing

  • Rain jacket (Gore-Tex)
  • Down jacket for hut nights
  • 3x wool base layers
  • 2x hiking pants
  • Sun hat + warm beanie

Gear

  • Headlamp + spare batteries
  • Trekking poles (collapsible)
  • First aid kit
  • Sunscreen SPF 50
  • Water filter (Sawyer Squeeze)
`, + 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: `## 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.", + 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.

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
`, + 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: `## 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.", + content: `

Transport & Logistics

Getting There

  • Jul 6: Fly to Geneva (everyone arrives by 14:00)
  • Geneva to Chamonix shuttle: EUR 186 for 4 pax

Between Destinations

  • Jul 10: Chamonix to Zermatt -- Train via Martigny (~3.5h, scenic)
  • Jul 14: Zermatt to Dolomites -- Bernina Express (6 hours but spectacular)

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)
`, + 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(), }, @@ -441,104 +215,25 @@ Seceda gondola (first): 08:30 from Ortisei 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: `

Full Itinerary -- Alpine Explorer 2026

Jul 6-20 | France, Switzerland, Italy

Week 1: Chamonix, France (Jul 6-10)

  • Jul 6: Fly Geneva, shuttle to Chamonix
  • Jul 7: Acclimatization hike -- Lac Blanc
  • Jul 8: Via Ferrata -- Aiguille du Midi
  • Jul 9: Rest day / Chamonix town
  • Jul 10: Train to Zermatt

Week 2: Zermatt, Switzerland (Jul 10-14)

  • Jul 10: Arrive Zermatt, settle in
  • Jul 11: Gornergrat sunrise hike
  • Jul 12: Matterhorn base camp trek
  • Jul 13: Paragliding over Zermatt
  • Jul 14: Transfer to Dolomites

Week 3: Dolomites, Italy (Jul 14-20)

  • Jul 14: Arrive Val Gardena
  • Jul 15: Tre Cime di Lavaredo loop
  • Jul 16: Lago di Braies kayaking
  • Jul 17: Seceda ridgeline hike
  • Jul 18: Cooking class in Bolzano
  • Jul 19: Free day -- shopping & packing
  • Jul 20: Fly home from Innsbruck
`, 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: `## 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.", + content: `

Mountain Hut Reservations

Confirmed

  • Refuge du Lac Blanc (Jul 7) -- 4 beds, half-board, conf #LB2026-234
  • Rifugio Locatelli (Jul 15) -- 4 beds, half-board, conf #TRE2026-089

Waitlisted

  • Hornlihutte (Matterhorn base, Jul 12) -- will know by Jul 1

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
`, + 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: `## 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.", + 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 leading 3-2

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)
`, + 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(), }, @@ -548,17 +243,17 @@ We are using a simple majority vote for group activities. For expenses over EUR { 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", + 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, space: "demo", + 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, space: "demo", + notes: itineraryNotes, } as any, ]; @@ -597,7 +292,12 @@ We are using a simple majority vote for group activities. For expenses over EUR private demoLoadNote(id: string) { const allNotes = this.demoNotebooks.flatMap(nb => nb.notes); this.selectedNote = allNotes.find(n => n.id === id) || null; - this.render(); + if (this.selectedNote) { + this.view = "note"; + this.renderNav(); + this.renderMeta(); + this.mountEditor(this.selectedNote); + } } private demoCreateNotebook() { @@ -608,7 +308,6 @@ We are using a simple majority vote for group activities. For expenses over EUR 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); @@ -621,10 +320,10 @@ We are using a simple majority vote for group activities. For expenses over EUR 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(), }; - // Add to the matching demoNotebook const demoNb = this.demoNotebooks.find(n => n.id === this.selectedNotebook!.id); if (demoNb) { demoNb.notes.push(newNote); @@ -634,10 +333,13 @@ We are using a simple majority vote for group activities. For expenses over EUR this.selectedNotebook.note_count = String(this.selectedNotebook.notes.length); this.selectedNote = newNote; this.view = "note"; - this.render(); + this.renderNav(); + this.renderMeta(); + this.mountEditor(newNote); } disconnectedCallback() { + this.destroyEditor(); this.disconnectSync(); } @@ -650,13 +352,11 @@ We are using a simple majority vote for group activities. For expenses over EUR 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()!); } @@ -668,7 +368,6 @@ We are using a simple majority vote for group activities. For expenses over EUR if (msg.type === "sync" && msg.docId === this.subscribedDocId) { this.handleSyncMessage(new Uint8Array(msg.data)); } - // pong and other messages are ignored } catch { // ignore parse errors } @@ -677,21 +376,18 @@ We are using a simple majority vote for group activities. For expenses over EUR 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 - }; + this.ws.onerror = () => {}; } private disconnectSync() { if (this.pingInterval) clearInterval(this.pingInterval); if (this.ws) { - this.ws.onclose = null; // prevent reconnect + this.ws.onclose = null; this.ws.close(); this.ws = null; } @@ -707,7 +403,6 @@ We are using a simple majority vote for group activities. For expenses over EUR 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) { @@ -727,10 +422,8 @@ We are using a simple majority vote for group activities. For expenses over EUR 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) { @@ -757,7 +450,7 @@ We are using a simple majority vote for group activities. For expenses over EUR const nb = this.doc.notebook; const items = this.doc.items; - if (!nb) return; // doc not yet synced + if (!nb) return; // Build notebook data from doc const notes: Note[] = []; @@ -768,6 +461,7 @@ We are using a simple majority vote for group activities. For expenses over EUR 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, @@ -777,7 +471,6 @@ We are using a simple majority vote for group activities. For expenses over EUR } } - // 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(); @@ -793,7 +486,61 @@ We are using a simple majority vote for group activities. For expenses over EUR notes, }; - // If viewing a specific note, update it from doc too + // 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) { @@ -802,6 +549,7 @@ We are using a simple majority vote for group activities. For expenses over EUR 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, @@ -831,6 +579,7 @@ We are using a simple majority vote for group activities. For expenses over EUR title: "Untitled Note", content: "", contentPlain: "", + contentFormat: "tiptap-json", type: "NOTE", tags: [], isPinned: false, @@ -843,14 +592,17 @@ We are using a simple majority vote for group activities. For expenses over EUR this.sendSyncAfterChange(); this.renderFromDoc(); - // Open the new note for editing + // 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.render(); + this.renderNav(); + this.renderMeta(); + this.mountEditor(this.selectedNote); } private updateNoteField(noteId: string, field: string, value: string) { @@ -904,13 +656,9 @@ We are using a simple majority vote for group activities. For expenses over EUR 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); @@ -918,7 +666,6 @@ We are using a simple majority vote for group activities. For expenses over EUR }, 5000); } - /** REST fallback for notebook detail */ private async loadNotebookREST(id: string) { try { const base = this.getApiBase(); @@ -932,7 +679,7 @@ We are using a simple majority vote for group activities. For expenses over EUR } private loadNote(id: string) { - // Note is already in the Automerge doc — just select it + // Note is already in the Automerge doc if (this.doc?.items?.[id]) { const item = this.doc.items[id]; this.selectedNote = { @@ -940,6 +687,7 @@ We are using a simple majority vote for group activities. For expenses over EUR 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, @@ -947,10 +695,14 @@ We are using a simple majority vote for group activities. For expenses over EUR 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(); + + if (this.selectedNote) { + this.renderNav(); + this.renderMeta(); + this.mountEditor(this.selectedNote); + } } private async searchNotes(query: string) { @@ -987,16 +739,275 @@ We are using a simple majority vote for group activities. For expenses over EUR } } + // ── 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 = ` +
+ + ${isEditable ? this.renderToolbar() : ''} +
+
+ `; + + const container = this.shadow.getElementById('tiptap-container'); + if (!container) return; + + // Determine content to load + let content: any = ''; + if (note.content) { + if (note.content_format === 'tiptap-json') { + try { + content = JSON.parse(note.content); + } catch { + content = note.content; + } + } else { + // HTML content (legacy or explicit) + content = note.content; + } + } + + const slashPlugin = createSlashCommandPlugin( + null as any, // Will be set after editor creation + this.shadow + ); + + this.editor = new Editor({ + element: container, + editable: isEditable, + extensions: [ + StarterKit.configure({ + codeBlock: false, + heading: { levels: [1, 2, 3, 4] }, + }), + Link.configure({ openOnClick: false }), + Image, + TaskList, + TaskItem.configure({ nested: true }), + Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }), + CodeBlockLowlight.configure({ lowlight }), + Typography, + Underline, + ], + content, + onUpdate: ({ editor }) => { + if (this.isRemoteUpdate) return; + if (this.editorUpdateTimer) clearTimeout(this.editorUpdateTimer); + this.editorUpdateTimer = setTimeout(() => { + const json = JSON.stringify(editor.getJSON()); + const plain = editor.getText(); + const noteId = this.editorNoteId; + if (!noteId) return; + + if (isDemo) { + this.demoUpdateNoteField(noteId, "content", json); + this.demoUpdateNoteField(noteId, "content_plain", plain); + this.demoUpdateNoteField(noteId, "content_format", 'tiptap-json'); + } else { + this.updateNoteField(noteId, "content", json); + this.updateNoteField(noteId, "contentPlain", plain); + this.updateNoteField(noteId, "contentFormat", 'tiptap-json'); + } + }, 800); + }, + onSelectionUpdate: () => { + this.updateToolbarState(); + }, + }); + + // Now register the slash command plugin with the actual editor + this.editor.registerPlugin( + createSlashCommandPlugin(this.editor, this.shadow) + ); + + this.editorNoteId = note.id; + + // Wire up title input + const titleInput = this.shadow.getElementById("note-title-input") as HTMLInputElement; + if (titleInput) { + let titleTimeout: any; + titleInput.addEventListener("input", () => { + clearTimeout(titleTimeout); + titleTimeout = setTimeout(() => { + if (isDemo) { + this.demoUpdateNoteField(note.id, "title", titleInput.value); + } else { + this.updateNoteField(note.id, "title", titleInput.value); + } + }, 500); + }); + } + + // Wire up toolbar + this.attachToolbarListeners(); + } + + private destroyEditor() { + if (this.editorUpdateTimer) { + clearTimeout(this.editorUpdateTimer); + this.editorUpdateTimer = null; + } + if (this.editor) { + this.editor.destroy(); + this.editor = null; + } + this.editorNoteId = null; + } + + private renderToolbar(): string { + return ` +
+
+ + + + + +
+
+
+ +
+
+
+ + + +
+
+
+ + + +
+
+
+ + +
+
+
+ + +
+
`; + } + + private attachToolbarListeners() { + const toolbar = this.shadow.getElementById('editor-toolbar'); + if (!toolbar || !this.editor) return; + + // Button clicks via event delegation + toolbar.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement).closest('[data-cmd]') as HTMLElement; + if (!btn || btn.tagName === 'SELECT') return; + e.preventDefault(); + const cmd = btn.dataset.cmd; + if (!this.editor) return; + + switch (cmd) { + case 'bold': this.editor.chain().focus().toggleBold().run(); break; + case 'italic': this.editor.chain().focus().toggleItalic().run(); break; + case 'underline': this.editor.chain().focus().toggleUnderline().run(); break; + case 'strike': this.editor.chain().focus().toggleStrike().run(); break; + case 'code': this.editor.chain().focus().toggleCode().run(); break; + case 'bulletList': this.editor.chain().focus().toggleBulletList().run(); break; + case 'orderedList': this.editor.chain().focus().toggleOrderedList().run(); break; + case 'taskList': this.editor.chain().focus().toggleTaskList().run(); break; + case 'blockquote': this.editor.chain().focus().toggleBlockquote().run(); break; + case 'codeBlock': this.editor.chain().focus().toggleCodeBlock().run(); break; + case 'horizontalRule': this.editor.chain().focus().setHorizontalRule().run(); break; + case 'link': { + const url = prompt('Link URL:'); + if (url) this.editor.chain().focus().setLink({ href: url }).run(); + break; + } + case 'image': { + const url = prompt('Image URL:'); + if (url) this.editor.chain().focus().setImage({ src: url }).run(); + break; + } + case 'undo': this.editor.chain().focus().undo().run(); break; + case 'redo': this.editor.chain().focus().redo().run(); break; + } + }); + + // Heading select + const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement; + if (headingSelect) { + headingSelect.addEventListener('change', () => { + if (!this.editor) return; + const val = headingSelect.value; + if (val === 'paragraph') { + this.editor.chain().focus().setParagraph().run(); + } else { + this.editor.chain().focus().setHeading({ level: parseInt(val) as 1 | 2 | 3 | 4 }).run(); + } + }); + } + } + + private updateToolbarState() { + if (!this.editor) return; + const toolbar = this.shadow.getElementById('editor-toolbar'); + if (!toolbar) return; + + // Toggle active class on buttons + toolbar.querySelectorAll('.toolbar-btn[data-cmd]').forEach((btn) => { + const cmd = (btn as HTMLElement).dataset.cmd!; + let isActive = false; + switch (cmd) { + case 'bold': isActive = this.editor!.isActive('bold'); break; + case 'italic': isActive = this.editor!.isActive('italic'); break; + case 'underline': isActive = this.editor!.isActive('underline'); break; + case 'strike': isActive = this.editor!.isActive('strike'); break; + case 'code': isActive = this.editor!.isActive('code'); break; + case 'bulletList': isActive = this.editor!.isActive('bulletList'); break; + case 'orderedList': isActive = this.editor!.isActive('orderedList'); break; + case 'taskList': isActive = this.editor!.isActive('taskList'); break; + case 'blockquote': isActive = this.editor!.isActive('blockquote'); break; + case 'codeBlock': isActive = this.editor!.isActive('codeBlock'); break; + } + btn.classList.toggle('active', isActive); + }); + + // Update heading select + const headingSelect = toolbar.querySelector('[data-cmd="heading"]') as HTMLSelectElement; + if (headingSelect) { + if (this.editor.isActive('heading', { level: 1 })) headingSelect.value = '1'; + else if (this.editor.isActive('heading', { level: 2 })) headingSelect.value = '2'; + else if (this.editor.isActive('heading', { level: 3 })) headingSelect.value = '3'; + else if (this.editor.isActive('heading', { level: 4 })) headingSelect.value = '4'; + else headingSelect.value = 'paragraph'; + } + } + + // ── Helpers ── + private getNoteIcon(type: string): string { switch (type) { - case "NOTE": return "📝"; - case "CODE": return "💻"; - case "BOOKMARK": return "🔗"; - case "IMAGE": return "🖼"; - case "AUDIO": return "🎤"; - case "FILE": return "📎"; - case "CLIP": return "✂️"; - default: return "📄"; + 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}"; } } @@ -1011,9 +1022,248 @@ We are using a simple majority vote for group activities. For expenses over EUR return d.toLocaleDateString(); } + // ── Rendering ── + 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.
' + .note-meta-bar { + margin-top: 12px; font-size: 12px; color: #666; display: flex; gap: 12px; padding: 8px 0; } - `; - } - 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; + /* ── Editor Toolbar ── */ + .editor-toolbar { + display: flex; flex-wrap: wrap; gap: 2px; align-items: center; + background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; + padding: 4px 6px; margin-bottom: 2px; } - } - // 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(); + .toolbar-group { display: flex; gap: 1px; } + .toolbar-sep { width: 1px; height: 20px; background: #1e293b; margin: 0 4px; } + .toolbar-btn { + display: flex; align-items: center; justify-content: center; + width: 30px; height: 28px; border: none; border-radius: 4px; + background: transparent; color: #94a3b8; cursor: pointer; + font-size: 13px; font-family: inherit; transition: all 0.15s; } - } - } + .toolbar-btn:hover { background: #1e293b; color: #e2e8f0; } + .toolbar-btn.active { background: #312e81; color: #a5b4fc; } + .toolbar-select { + padding: 2px 4px; border-radius: 4px; border: 1px solid #1e293b; + background: #0f172a; color: #94a3b8; font-size: 12px; cursor: pointer; + font-family: inherit; + } + .toolbar-select:focus { outline: none; border-color: #4f46e5; } - private esc(s: string): string { - const d = document.createElement("div"); - d.textContent = s || ""; - return d.innerHTML; + /* ── Tiptap Editor ── */ + .editor-wrapper { + background: #1e1e2e; border: 1px solid #333; border-radius: 10px; + overflow: hidden; + } + .editor-wrapper .editable-title { + padding: 16px 20px 0; + } + .editor-wrapper .editor-toolbar { + margin: 4px 8px; border-radius: 6px; + } + + .tiptap-container .tiptap { + min-height: 300px; padding: 16px 20px; outline: none; + font-size: 15px; line-height: 1.7; color: #e0e0e0; + } + .tiptap-container .tiptap:focus { outline: none; } + + /* Prose styles */ + .tiptap-container .tiptap h1 { font-size: 1.8em; font-weight: 700; margin: 1em 0 0.4em; color: #f1f5f9; } + .tiptap-container .tiptap h2 { font-size: 1.4em; font-weight: 600; margin: 0.8em 0 0.3em; color: #e2e8f0; } + .tiptap-container .tiptap h3 { font-size: 1.15em; font-weight: 600; margin: 0.7em 0 0.25em; color: #cbd5e1; } + .tiptap-container .tiptap h4 { font-size: 1em; font-weight: 600; margin: 0.6em 0 0.2em; color: #94a3b8; } + .tiptap-container .tiptap p { margin: 0.4em 0; } + .tiptap-container .tiptap blockquote { + border-left: 3px solid #4f46e5; padding-left: 16px; margin: 0.8em 0; + color: #94a3b8; font-style: italic; + } + .tiptap-container .tiptap code { + background: #2a2a3e; padding: 2px 6px; border-radius: 4px; + font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.9em; color: #a5b4fc; + } + .tiptap-container .tiptap pre { + background: #0f172a; border: 1px solid #1e293b; border-radius: 8px; + padding: 12px 16px; margin: 0.8em 0; overflow-x: auto; + } + .tiptap-container .tiptap pre code { + background: none; padding: 0; border-radius: 0; color: #e0e0e0; + font-size: 13px; line-height: 1.5; + } + .tiptap-container .tiptap ul, .tiptap-container .tiptap ol { + padding-left: 24px; margin: 0.4em 0; + } + .tiptap-container .tiptap li { margin: 0.15em 0; } + .tiptap-container .tiptap li p { margin: 0.1em 0; } + + /* Task list */ + .tiptap-container .tiptap ul[data-type="taskList"] { + list-style: none; padding-left: 4px; + } + .tiptap-container .tiptap ul[data-type="taskList"] li { + display: flex; align-items: flex-start; gap: 8px; + } + .tiptap-container .tiptap ul[data-type="taskList"] li label { + margin-top: 3px; + } + .tiptap-container .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p { + text-decoration: line-through; color: #666; + } + + .tiptap-container .tiptap img { + max-width: 100%; border-radius: 8px; margin: 0.5em 0; + } + .tiptap-container .tiptap a { + color: #818cf8; text-decoration: underline; text-underline-offset: 2px; + } + .tiptap-container .tiptap a:hover { color: #a5b4fc; } + .tiptap-container .tiptap hr { + border: none; border-top: 1px solid #333; margin: 1.5em 0; + } + .tiptap-container .tiptap strong { color: #f1f5f9; } + .tiptap-container .tiptap em { color: inherit; } + .tiptap-container .tiptap s { color: #666; } + .tiptap-container .tiptap u { text-underline-offset: 3px; } + + /* Placeholder */ + .tiptap-container .tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; color: #555; pointer-events: none; height: 0; + } + + /* ── Slash Menu ── */ + .slash-menu { + position: absolute; z-index: 100; + background: #1e1e2e; border: 1px solid #333; border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + max-height: 320px; overflow-y: auto; min-width: 220px; + display: none; + } + .slash-menu-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 12px; cursor: pointer; transition: background 0.1s; + } + .slash-menu-item:hover, .slash-menu-item.selected { + background: #312e81; + } + .slash-menu-icon { + width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; + background: #2a2a3e; border-radius: 4px; font-size: 13px; font-weight: 600; color: #a5b4fc; + flex-shrink: 0; + } + .slash-menu-text { flex: 1; } + .slash-menu-title { font-size: 13px; font-weight: 500; color: #e2e8f0; } + .slash-menu-desc { font-size: 11px; color: #666; } + + /* ── Code highlighting (lowlight) ── */ + .tiptap-container .tiptap .hljs-keyword { color: #c792ea; } + .tiptap-container .tiptap .hljs-string { color: #c3e88d; } + .tiptap-container .tiptap .hljs-number { color: #f78c6c; } + .tiptap-container .tiptap .hljs-comment { color: #546e7a; font-style: italic; } + .tiptap-container .tiptap .hljs-built_in { color: #82aaff; } + .tiptap-container .tiptap .hljs-function { color: #82aaff; } + .tiptap-container .tiptap .hljs-title { color: #82aaff; } + .tiptap-container .tiptap .hljs-attr { color: #ffcb6b; } + .tiptap-container .tiptap .hljs-tag { color: #f07178; } + .tiptap-container .tiptap .hljs-type { color: #ffcb6b; } + `; } } diff --git a/modules/rnotes/components/notes.css b/modules/rnotes/components/notes.css index 9bd25745..afdc266e 100644 --- a/modules/rnotes/components/notes.css +++ b/modules/rnotes/components/notes.css @@ -1,6 +1,7 @@ -/* Notes module — dark theme */ +/* Notes module — dark theme (host-level styles) */ folk-notes-app { display: block; min-height: 400px; padding: 20px; + position: relative; } diff --git a/modules/rnotes/components/slash-command.ts b/modules/rnotes/components/slash-command.ts new file mode 100644 index 00000000..4ff6f2f6 --- /dev/null +++ b/modules/rnotes/components/slash-command.ts @@ -0,0 +1,269 @@ +/** + * Slash command ProseMirror plugin for Tiptap. + * + * Detects '/' typed at the start of an empty block and shows a floating menu + * with block type options. Keyboard navigation: arrow keys + Enter + Escape. + */ + +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import type { Editor } from '@tiptap/core'; + +export interface SlashMenuItem { + title: string; + icon: string; + description: string; + command: (editor: Editor) => void; +} + +export const SLASH_ITEMS: SlashMenuItem[] = [ + { + title: 'Text', + icon: 'Aa', + description: 'Plain paragraph text', + command: (e) => e.chain().focus().setParagraph().run(), + }, + { + title: 'Heading 1', + icon: 'H1', + description: 'Large section heading', + command: (e) => e.chain().focus().setHeading({ level: 1 }).run(), + }, + { + title: 'Heading 2', + icon: 'H2', + description: 'Medium section heading', + command: (e) => e.chain().focus().setHeading({ level: 2 }).run(), + }, + { + title: 'Heading 3', + icon: 'H3', + description: 'Small section heading', + command: (e) => e.chain().focus().setHeading({ level: 3 }).run(), + }, + { + title: 'Bullet List', + icon: '•', + description: 'Unordered bullet list', + command: (e) => e.chain().focus().toggleBulletList().run(), + }, + { + title: 'Numbered List', + icon: '1.', + description: 'Ordered numbered list', + command: (e) => e.chain().focus().toggleOrderedList().run(), + }, + { + title: 'Task List', + icon: '☑', + description: 'Checklist with checkboxes', + command: (e) => e.chain().focus().toggleTaskList().run(), + }, + { + title: 'Code Block', + icon: '</>', + description: 'Syntax-highlighted code block', + command: (e) => e.chain().focus().toggleCodeBlock().run(), + }, + { + title: 'Blockquote', + icon: '“', + description: 'Indented quote block', + command: (e) => e.chain().focus().toggleBlockquote().run(), + }, + { + title: 'Horizontal Rule', + icon: '—', + description: 'Visual divider line', + command: (e) => e.chain().focus().setHorizontalRule().run(), + }, + { + title: 'Image', + icon: '📷', + description: 'Insert an image from URL', + command: (e) => { + const url = prompt('Image URL:'); + if (url) e.chain().focus().setImage({ src: url }).run(); + }, + }, +]; + +const pluginKey = new PluginKey('slashCommand'); + +export function createSlashCommandPlugin(editor: Editor, shadowRoot: ShadowRoot): Plugin { + let menuEl: HTMLDivElement | null = null; + let selectedIndex = 0; + let filteredItems: SlashMenuItem[] = []; + let query = ''; + let active = false; + let triggerPos = -1; + + function show(view: EditorView) { + if (!menuEl) { + menuEl = document.createElement('div'); + menuEl.className = 'slash-menu'; + shadowRoot.appendChild(menuEl); + } + active = true; + selectedIndex = 0; + query = ''; + filteredItems = SLASH_ITEMS; + updateMenuContent(); + positionMenu(view); + menuEl.style.display = 'block'; + } + + function hide() { + active = false; + query = ''; + triggerPos = -1; + if (menuEl) menuEl.style.display = 'none'; + } + + function updateMenuContent() { + if (!menuEl) return; + menuEl.innerHTML = filteredItems + .map( + (item, i) => + `
+ ${item.icon} +
+
${item.title}
+
${item.description}
+
+
`, + ) + .join(''); + + // Click handlers + menuEl.querySelectorAll('.slash-menu-item').forEach((el) => { + el.addEventListener('mousedown', (e) => { + e.preventDefault(); + const idx = parseInt((el as HTMLElement).dataset.index || '0'); + executeItem(idx); + }); + el.addEventListener('mouseenter', () => { + selectedIndex = parseInt((el as HTMLElement).dataset.index || '0'); + updateMenuContent(); + }); + }); + } + + function positionMenu(view: EditorView) { + if (!menuEl) return; + const { from } = view.state.selection; + const coords = view.coordsAtPos(from); + const shadowHost = shadowRoot.host as HTMLElement; + const hostRect = shadowHost.getBoundingClientRect(); + + menuEl.style.left = `${coords.left - hostRect.left}px`; + menuEl.style.top = `${coords.bottom - hostRect.top + 4}px`; + } + + function filterItems() { + const q = query.toLowerCase(); + filteredItems = q + ? SLASH_ITEMS.filter( + (item) => + item.title.toLowerCase().includes(q) || item.description.toLowerCase().includes(q), + ) + : SLASH_ITEMS; + selectedIndex = Math.min(selectedIndex, Math.max(0, filteredItems.length - 1)); + updateMenuContent(); + } + + function executeItem(index: number) { + const item = filteredItems[index]; + if (!item) return; + + // Delete the slash + query text + const { state } = editor.view; + const tr = state.tr.delete(triggerPos, state.selection.from); + editor.view.dispatch(tr); + + item.command(editor); + hide(); + } + + return new Plugin({ + key: pluginKey, + props: { + handleKeyDown(view, event) { + if (active) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + selectedIndex = (selectedIndex + 1) % filteredItems.length; + updateMenuContent(); + return true; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + selectedIndex = (selectedIndex - 1 + filteredItems.length) % filteredItems.length; + updateMenuContent(); + return true; + } + if (event.key === 'Enter') { + event.preventDefault(); + executeItem(selectedIndex); + return true; + } + if (event.key === 'Escape') { + event.preventDefault(); + hide(); + return true; + } + if (event.key === 'Backspace') { + if (query.length === 0) { + // Backspace deletes the '/', close menu + hide(); + return false; // let ProseMirror handle the deletion + } + query = query.slice(0, -1); + filterItems(); + return false; // let ProseMirror handle the deletion + } + if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) { + query += event.key; + filterItems(); + if (filteredItems.length === 0) { + hide(); + } + return false; // let ProseMirror insert the character + } + } + return false; + }, + + handleTextInput(view, from, to, text) { + if (text === '/' && !active) { + // Check if cursor is at start of an empty block + const { $from } = view.state.selection; + const isAtStart = $from.parentOffset === 0; + const isEmpty = $from.parent.textContent === ''; + if (isAtStart && isEmpty) { + triggerPos = from; + // Defer show to after the '/' is inserted + setTimeout(() => show(view), 0); + } + } + return false; + }, + }, + + view() { + return { + update(view) { + if (active && menuEl) { + positionMenu(view); + } + }, + destroy() { + if (menuEl) { + menuEl.remove(); + menuEl = null; + } + }, + }; + }, + }); +} diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 00063b85..dda80541 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -95,6 +95,7 @@ function noteToRest(item: NoteItem) { title: item.title, content: item.content, content_plain: item.contentPlain, + content_format: item.contentFormat || undefined, type: item.type, tags: item.tags.length > 0 ? item.tags : null, is_pinned: item.isPinned, @@ -216,6 +217,27 @@ function seedDemoIfEmpty(space: string) { console.log("[Notes] Demo data seeded: 3 notebooks, 7 notes"); } +// ── Content extraction helpers ── + +/** Recursively extract plain text from a Tiptap JSON node tree. */ +function walkTiptapNodes(node: any): string { + if (node.text) return node.text; + if (!node.content) return ''; + return node.content.map(walkTiptapNodes).join(node.type === 'paragraph' ? '\n' : ''); +} + +/** Extract plain text from content, handling both HTML and tiptap-json formats. */ +function extractPlainText(content: string, format?: string): string { + if (format === 'tiptap-json') { + try { + const doc = JSON.parse(content); + return walkTiptapNodes(doc).trim(); + } catch { return ''; } + } + // Legacy HTML stripping + return content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); +} + // ── Notebooks API ── // GET /api/notebooks — list notebooks @@ -383,13 +405,12 @@ routes.post("/api/notes", async (c) => { try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const body = await c.req.json(); - const { notebook_id, title, content, type, url, language, file_url, mime_type, file_size, duration, tags } = body; + const { notebook_id, title, content, content_format, type, url, language, file_url, mime_type, file_size, duration, tags } = body; if (!title?.trim()) return c.json({ error: "Title is required" }, 400); if (!notebook_id) return c.json({ error: "notebook_id is required" }, 400); - // Strip HTML/markdown for plain text search - const contentPlain = content ? content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() : ""; + const contentPlain = content ? extractPlainText(content, content_format) : ""; // Normalize tags const tagNames: string[] = []; @@ -442,7 +463,7 @@ routes.put("/api/notes/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const body = await c.req.json(); - const { title, content, type, url, language, is_pinned, sort_order } = body; + const { title, content, content_format, type, url, language, is_pinned, sort_order } = body; if (title === undefined && content === undefined && type === undefined && url === undefined && language === undefined && is_pinned === undefined && sort_order === undefined) { @@ -453,7 +474,7 @@ routes.put("/api/notes/:id", async (c) => { if (!found) return c.json({ error: "Note not found" }, 404); const contentPlain = content !== undefined - ? content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() + ? extractPlainText(content, content_format || found.item.contentFormat) : undefined; _syncServer!.changeDoc(found.docId, `Update note ${id}`, (d) => { @@ -462,6 +483,7 @@ routes.put("/api/notes/:id", async (c) => { if (title !== undefined) item.title = title; if (content !== undefined) item.content = content; if (contentPlain !== undefined) item.contentPlain = contentPlain; + if (content_format !== undefined) (item as any).contentFormat = content_format; if (type !== undefined) item.type = type; if (url !== undefined) item.url = url; if (language !== undefined) item.language = language; diff --git a/modules/rnotes/schemas.ts b/modules/rnotes/schemas.ts index a1914c79..f3a050c9 100644 --- a/modules/rnotes/schemas.ts +++ b/modules/rnotes/schemas.ts @@ -20,6 +20,7 @@ export interface NoteItem { title: string; content: string; contentPlain: string; + contentFormat?: 'html' | 'tiptap-json'; type: 'NOTE' | 'CLIP' | 'BOOKMARK' | 'CODE' | 'IMAGE' | 'FILE' | 'AUDIO'; url: string | null; language: string | null; @@ -62,12 +63,12 @@ export interface NotebookDoc { export const notebookSchema: DocSchema = { module: 'notes', collection: 'notebooks', - version: 1, + version: 2, init: (): NotebookDoc => ({ meta: { module: 'notes', collection: 'notebooks', - version: 1, + version: 2, spaceSlug: '', createdAt: Date.now(), }, @@ -83,6 +84,14 @@ export const notebookSchema: DocSchema = { }, items: {}, }), + migrate: (doc: NotebookDoc, fromVersion: number): NotebookDoc => { + if (fromVersion < 2) { + for (const item of Object.values(doc.items)) { + if (!(item as any).contentFormat) (item as any).contentFormat = 'html'; + } + } + return doc; + }, }; // ── Helpers ── @@ -107,6 +116,7 @@ export function createNoteItem( title, content: '', contentPlain: '', + contentFormat: 'tiptap-json', type: 'NOTE', url: null, language: null, diff --git a/package.json b/package.json index af933e0b..3b7b8f36 100644 --- a/package.json +++ b/package.json @@ -14,21 +14,33 @@ }, "dependencies": { "@automerge/automerge": "^2.2.8", + "@aws-sdk/client-s3": "^3.700.0", "@encryptid/sdk": "file:../encryptid-sdk", "@lit/reactive-element": "^2.0.4", - "hono": "^4.11.7", - "postgres": "^3.4.5", - "nodemailer": "^6.9.0", - "sharp": "^0.33.0", - "perfect-arrows": "^0.3.7", - "perfect-freehand": "^1.2.2", - "@aws-sdk/client-s3": "^3.700.0", - "imapflow": "^1.0.170", - "mailparser": "^3.7.2", "@noble/curves": "^1.8.0", "@noble/hashes": "^1.7.0", + "@tiptap/core": "^3.20.0", + "@tiptap/extension-code-block-lowlight": "^3.20.0", + "@tiptap/extension-image": "^3.20.0", + "@tiptap/extension-link": "^3.20.0", + "@tiptap/extension-placeholder": "^3.20.0", + "@tiptap/extension-task-item": "^3.20.0", + "@tiptap/extension-task-list": "^3.20.0", + "@tiptap/extension-typography": "^3.20.0", + "@tiptap/extension-underline": "^3.20.0", + "@tiptap/pm": "^3.20.0", + "@tiptap/starter-kit": "^3.20.0", "@x402/core": "^2.3.1", - "@x402/evm": "^2.5.0" + "@x402/evm": "^2.5.0", + "hono": "^4.11.7", + "imapflow": "^1.0.170", + "lowlight": "^3.3.0", + "mailparser": "^3.7.2", + "nodemailer": "^6.9.0", + "perfect-arrows": "^0.3.7", + "perfect-freehand": "^1.2.2", + "postgres": "^3.4.5", + "sharp": "^0.33.0" }, "devDependencies": { "@types/mailparser": "^3.4.0",