From eb2c43f1203a75d114f2442b78fbd45ed90cbbdf Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 1 Apr 2026 15:59:34 -0700 Subject: [PATCH 1/6] add link popover in editor --- .../Blocks/TextBlock/mountProsemirror.ts | 56 ++++- components/Blocks/TextBlock/schema.ts | 2 +- components/LinkPopover.tsx | 231 ++++++++++++++++++ components/Pages/Page.tsx | 2 + components/Toolbar/InlineLinkToolbar.tsx | 47 +--- src/utils/ensureProtocol.ts | 9 + src/utils/prosemirror/findMarkRange.ts | 28 +++ 7 files changed, 331 insertions(+), 44 deletions(-) create mode 100644 components/LinkPopover.tsx create mode 100644 src/utils/ensureProtocol.ts create mode 100644 src/utils/prosemirror/findMarkRange.ts diff --git a/components/Blocks/TextBlock/mountProsemirror.ts b/components/Blocks/TextBlock/mountProsemirror.ts index f7cc0171..7ce2707b 100644 --- a/components/Blocks/TextBlock/mountProsemirror.ts +++ b/components/Blocks/TextBlock/mountProsemirror.ts @@ -26,6 +26,11 @@ import { BlockProps } from "../Block"; import { useEntitySetContext } from "components/EntitySetProvider"; import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; +import { + useLinkPopoverStore, + scheduleLinkPopoverClose, + cancelLinkPopoverClose, +} from "components/LinkPopover"; export function useMountProsemirror({ props, @@ -142,21 +147,62 @@ export function useMountProsemirror({ window.open(url, "_blank", "noopener,noreferrer"); return; } - if (node.nodeSize - 2 <= _pos) return; - // Check for marks at the clicked position + if (node.nodeSize - 2 <= _pos) return; const nodeAt1 = node.nodeAt(_pos - 1); const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); - - // Check for link marks let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || nodeAt2?.marks.find((f) => f.type === schema.marks.link); if (linkMark) { - window.open(linkMark.attrs.href, "_blank", "noreferrer"); + let anchor = (_event.target as HTMLElement).closest("a") as HTMLElement | null; + if (anchor) { + cancelLinkPopoverClose(); + useLinkPopoverStore.getState().open( + linkMark.attrs.href, + anchor, + entityID, + ); + } return; } }, + handleDOMEvents: { + mouseover: (() => { + let activeTimer: number | null = null; + let activeAnchor: HTMLAnchorElement | null = null; + let activeLeaveHandler: (() => void) | null = null; + return (_view: EditorView, event: MouseEvent) => { + let target = event.target as HTMLElement; + let anchor = target.closest("a[href]") as HTMLAnchorElement | null; + if (!anchor) return false; + if (anchor === activeAnchor) return false; + if (activeTimer !== null) window.clearTimeout(activeTimer); + if (activeAnchor && activeLeaveHandler) { + activeAnchor.removeEventListener("mouseleave", activeLeaveHandler); + } + cancelLinkPopoverClose(); + activeAnchor = anchor; + activeTimer = window.setTimeout(() => { + useLinkPopoverStore.getState().open( + anchor.getAttribute("href") || "", + anchor, + entityID, + ); + activeTimer = null; + }, 300); + activeLeaveHandler = () => { + if (activeTimer !== null) window.clearTimeout(activeTimer); + activeTimer = null; + activeAnchor = null; + activeLeaveHandler = null; + scheduleLinkPopoverClose(); + }; + anchor.addEventListener("mouseleave", activeLeaveHandler, { once: true }); + return false; + }; + })(), + }, dispatchTransaction, }, ); diff --git a/components/Blocks/TextBlock/schema.ts b/components/Blocks/TextBlock/schema.ts index 29b39dee..df1ca5e5 100644 --- a/components/Blocks/TextBlock/schema.ts +++ b/components/Blocks/TextBlock/schema.ts @@ -105,7 +105,7 @@ let baseSchema = { ], toDOM(node) { let { href } = node.attrs; - return ["a", { href, target: "_blank", referrerpolicy: "no-referrer" }, 0]; + return ["a", { href, target: "_blank", referrerpolicy: "no-referrer", style: "cursor: text" }, 0]; }, } as MarkSpec, }, diff --git a/components/LinkPopover.tsx b/components/LinkPopover.tsx new file mode 100644 index 00000000..969c0346 --- /dev/null +++ b/components/LinkPopover.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useMemo, useState, useCallback } from "react"; +import { create } from "zustand"; +import { Transaction } from "prosemirror-state"; +import * as RadixPopover from "@radix-ui/react-popover"; +import { schema } from "components/Blocks/TextBlock/schema"; +import { useEditorStates, setEditorState } from "src/state/useEditorState"; +import { useReplicache } from "src/replicache"; +import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; +import { DeleteTiny } from "components/Icons/DeleteTiny"; +import { CheckTiny } from "components/Icons/CheckTiny"; +import { PopoverArrow } from "components/Icons/PopoverArrow"; +import { theme } from "tailwind.config"; +import { ensureProtocol } from "src/utils/ensureProtocol"; +import { findMarkRange } from "src/utils/prosemirror/findMarkRange"; + +type LinkPopoverState = { + href: string | null; + anchorElement: HTMLElement | null; + blockEntityID: string | null; + open: (href: string, anchor: HTMLElement, blockEntityID: string) => void; + close: () => void; +}; + +export const useLinkPopoverStore = create((set) => ({ + href: null, + anchorElement: null, + blockEntityID: null, + open: (href, anchor, blockEntityID) => + set({ href, anchorElement: anchor, blockEntityID }), + close: () => set({ href: null, anchorElement: null, blockEntityID: null }), +})); + +export function LinkPopover() { + let { href, anchorElement, blockEntityID, close } = useLinkPopoverStore(); + let { undoManager } = useReplicache(); + let [linkValue, setLinkValue] = useState(""); + + let isOpen = href !== null && anchorElement !== null; + let isDirty = href !== null && linkValue !== href; + + useEffect(() => { + if (href) { + setLinkValue(href); + } + }, [href]); + + // Close on scroll or resize, matching FootnotePopover behavior + useEffect(() => { + if (!isOpen || !anchorElement) return; + let handleScroll = () => close(); + let scrollWrapper = anchorElement.closest(".pageScrollWrapper"); + scrollWrapper?.addEventListener("scroll", handleScroll); + window.addEventListener("resize", close); + return () => { + scrollWrapper?.removeEventListener("scroll", handleScroll); + window.removeEventListener("resize", close); + }; + }, [isOpen, anchorElement, close]); + + let applyLinkTransaction = useCallback( + (buildTr: (tr: Transaction, linkStart: number, linkEnd: number) => void) => { + if (!blockEntityID || !anchorElement) return; + let editorEntry = useEditorStates.getState().editorStates[blockEntityID]; + if (!editorEntry?.editor || !editorEntry.view) return; + + let editor = editorEntry.editor; + let from = editorEntry.view.posAtDOM(anchorElement, 0); + let to = editorEntry.view.posAtDOM( + anchorElement, + anchorElement.childNodes.length, + ); + let { start, end } = findMarkRange( + editor.doc, + schema.marks.link, + from, + to, + ); + + let tr = editor.tr; + buildTr(tr, start, end); + + let oldState = editor; + let newState = editor.apply(tr); + undoManager.add({ + undo: () => setEditorState(blockEntityID, { editor: oldState }), + redo: () => setEditorState(blockEntityID, { editor: newState }), + }); + setEditorState(blockEntityID, { editor: newState }); + close(); + }, + [blockEntityID, anchorElement, undoManager, close], + ); + + let saveLink = useCallback(() => { + applyLinkTransaction((tr, linkStart, linkEnd) => { + if (linkValue.trim() === "") { + tr.removeMark(linkStart, linkEnd, schema.marks.link); + } else { + let newHref = ensureProtocol(linkValue); + tr.addMark( + linkStart, + linkEnd, + schema.marks.link.create({ href: newHref }), + ); + } + }); + }, [applyLinkTransaction, linkValue]); + + let deleteLink = useCallback(() => { + applyLinkTransaction((tr, linkStart, linkEnd) => { + tr.removeMark(linkStart, linkEnd, schema.marks.link); + }); + }, [applyLinkTransaction]); + + let anchorRect = useMemo( + () => anchorElement?.getBoundingClientRect(), + [anchorElement], + ); + + return ( + + + + e.preventDefault()} + onMouseEnter={() => cancelLinkPopoverClose()} + onMouseLeave={() => scheduleLinkPopoverClose()} + className="link-popover z-50 bg-bg-page border border-border rounded-lg shadow-md px-2 py-1 w-[min(calc(100vw-24px),320px)]" + > +
+ setLinkValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + saveLink(); + } + if (e.key === "Escape") { + e.preventDefault(); + close(); + } + }} + className="flex-1 min-w-0 bg-transparent border-none outline-none text-sm text-primary placeholder:text-tertiary" + placeholder="https://example.com" + /> + {isDirty ? ( + + ) : ( +
+ + +
+ )} +
+ + + +
+
+
+ ); +} + +let closeTimer: number | null = null; + +export function scheduleLinkPopoverClose() { + cancelLinkPopoverClose(); + closeTimer = window.setTimeout(() => { + useLinkPopoverStore.getState().close(); + closeTimer = null; + }, 300); +} + +export function cancelLinkPopoverClose() { + if (closeTimer !== null) { + window.clearTimeout(closeTimer); + closeTimer = null; + } +} diff --git a/components/Pages/Page.tsx b/components/Pages/Page.tsx index 30f74696..20bd9774 100644 --- a/components/Pages/Page.tsx +++ b/components/Pages/Page.tsx @@ -22,6 +22,7 @@ import { FootnoteContext } from "components/Footnotes/FootnoteContext"; import { FootnoteSection } from "components/Footnotes/FootnoteSection"; import { FootnoteSideColumn } from "components/Footnotes/FootnoteSideColumn"; import { FootnotePopover } from "components/Footnotes/FootnotePopover"; +import { LinkPopover } from "components/LinkPopover"; export function Page(props: { entityID: string; @@ -89,6 +90,7 @@ export function Page(props: { + ); diff --git a/components/Toolbar/InlineLinkToolbar.tsx b/components/Toolbar/InlineLinkToolbar.tsx index 6dd51c07..df33ca38 100644 --- a/components/Toolbar/InlineLinkToolbar.tsx +++ b/components/Toolbar/InlineLinkToolbar.tsx @@ -1,12 +1,13 @@ import { schema } from "components/Blocks/TextBlock/schema"; -import { EditorState, TextSelection } from "prosemirror-state"; +import { TextSelection } from "prosemirror-state"; import { useUIState } from "src/useUIState"; import { ToolbarButton } from "."; import { useEffect, useState } from "react"; import { Separator } from "components/Layout"; -import { MarkType } from "prosemirror-model"; import { setEditorState, useEditorStates } from "src/state/useEditorState"; import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; +import { findMarkRange } from "src/utils/prosemirror/findMarkRange"; +import { ensureProtocol } from "src/utils/ensureProtocol"; import { Input } from "components/Input"; import { useReplicache } from "src/replicache"; import { CheckTiny } from "components/Icons/CheckTiny"; @@ -81,7 +82,11 @@ export function InlineLinkToolbar(props: { onClose: () => void }) { start = from; end = to; } else { - let markRange = findMarkRange(focusedEditor.editor, schema.marks.link); + let markRange = findMarkRange( + focusedEditor.editor.doc, + schema.marks.link, + from, + ); start = markRange.start; end = markRange.end; } @@ -94,12 +99,7 @@ export function InlineLinkToolbar(props: { onClose: () => void }) { } let [linkValue, setLinkValue] = useState(content); let setLink = () => { - let href = - !linkValue.startsWith("http") && - !linkValue.startsWith("mailto") && - !linkValue.startsWith("tel:") - ? `https://${linkValue}` - : linkValue; + let href = ensureProtocol(linkValue); let editor = focusedEditor?.editor; if (!editor || start === null || !end || !focusedBlock) return; @@ -176,32 +176,3 @@ export function InlineLinkToolbar(props: { onClose: () => void }) { ); } -function findMarkRange(state: EditorState, markType: MarkType) { - const { from, $from } = state.selection; - - // Find the start of the mark - let start = from; - let startPos = $from; - while ( - startPos.parent.inlineContent && - startPos.nodeBefore && - startPos.nodeBefore.marks.some((mark) => mark.type === markType) - ) { - start -= startPos.nodeBefore.nodeSize; - startPos = state.doc.resolve(start); - } - - // Find the end of the mark - let end = from; - let endPos = $from; - while ( - endPos.parent.inlineContent && - endPos.nodeAfter && - endPos.nodeAfter.marks.some((mark) => mark.type === markType) - ) { - end += endPos.nodeAfter.nodeSize; - endPos = state.doc.resolve(end); - } - - return { start, end }; -} diff --git a/src/utils/ensureProtocol.ts b/src/utils/ensureProtocol.ts new file mode 100644 index 00000000..4be753c0 --- /dev/null +++ b/src/utils/ensureProtocol.ts @@ -0,0 +1,9 @@ +export function ensureProtocol(url: string) { + if ( + url.startsWith("http") || + url.startsWith("mailto") || + url.startsWith("tel:") + ) + return url; + return `https://${url}`; +} diff --git a/src/utils/prosemirror/findMarkRange.ts b/src/utils/prosemirror/findMarkRange.ts new file mode 100644 index 00000000..7c705cfe --- /dev/null +++ b/src/utils/prosemirror/findMarkRange.ts @@ -0,0 +1,28 @@ +import { MarkType, Node } from "prosemirror-model"; + +export function findMarkRange( + doc: Node, + markType: MarkType, + from: number, + to?: number, +) { + let start = from; + let end = to ?? from; + let $start = doc.resolve(start); + let $end = doc.resolve(end); + while ( + $start.nodeBefore && + $start.nodeBefore.marks.some((m) => m.type === markType) + ) { + start -= $start.nodeBefore.nodeSize; + $start = doc.resolve(start); + } + while ( + $end.nodeAfter && + $end.nodeAfter.marks.some((m) => m.type === markType) + ) { + end += $end.nodeAfter.nodeSize; + $end = doc.resolve(end); + } + return { start, end }; +} -- 2.51.2 From 24f90f36a070ddc260dbe3bfcb962ee0d3850af6 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 2 Apr 2026 22:52:42 -0700 Subject: [PATCH 2/6] add styles for side-note page bg --- app/globals.css | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app/globals.css b/app/globals.css index 9c75c91c..c86b2665 100644 --- a/app/globals.css +++ b/app/globals.css @@ -592,18 +592,28 @@ pre.shiki { } .footnote-side-item { - max-height: 4.5em; overflow: hidden; transition: max-height 200ms ease, - mask-image 200ms ease; -} -.footnote-side-item.has-overflow { - mask-image: linear-gradient(to bottom, white 50%, transparent 100%); + opacity 200ms ease; + background: rgba(var(--bg-page), var(--bg-page-alpha)); + border-radius: 4px; + padding: 4px 6px; +} +.footnote-side-item:not(:hover):not(:focus-within):not(.footnote-side-focused) { + max-height: calc(4.5em + 8px); + opacity: calc(var(--bg-page-alpha) * 0.7); +} +.footnote-side-item:not(:hover):not(:focus-within):not(.footnote-side-focused) .footnote-item > div:last-of-type { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; } .footnote-side-item:hover, .footnote-side-item:focus-within, .footnote-side-item.footnote-side-focused { max-height: 40em; - mask-image: none; + opacity: var(--bg-page-alpha); } -- 2.51.2 From 626f48c1c2f86da2ae345154fcaf66e8d250f4bd Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Mon, 6 Apr 2026 12:43:31 -0700 Subject: [PATCH 3/6] add super alpha ai api routes --- app/api/ai/blocks/[blockId]/route.ts | 375 +++++++++++++++++ app/api/ai/blocks/route.ts | 242 +++++++++++ app/api/ai/doc/route.ts | 138 ++++++ app/api/ai/lib.tsx | 609 +++++++++++++++++++++++++++ app/api/ai/search/route.ts | 87 ++++ 5 files changed, 1451 insertions(+) create mode 100644 app/api/ai/blocks/[blockId]/route.ts create mode 100644 app/api/ai/blocks/route.ts create mode 100644 app/api/ai/doc/route.ts create mode 100644 app/api/ai/lib.tsx create mode 100644 app/api/ai/search/route.ts diff --git a/app/api/ai/blocks/[blockId]/route.ts b/app/api/ai/blocks/[blockId]/route.ts new file mode 100644 index 00000000..e1bb42db --- /dev/null +++ b/app/api/ai/blocks/[blockId]/route.ts @@ -0,0 +1,375 @@ +import { NextRequest } from "next/server"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { sql, eq, and } from "drizzle-orm"; +import { pool } from "supabase/pool"; +import { facts, entities } from "drizzle/schema"; +import { + authenticateToken, + broadcastPoke, + tokenHash, + hasWriteAccess, + editYjsText, + EditOperation, +} from "../../lib"; + +type Params = { params: Promise<{ blockId: string }> }; + +// --- DELETE --- + +export async function DELETE(req: NextRequest, { params }: Params) { + let auth = await authenticateToken(req); + if (auth instanceof Response) return auth; + + if (!hasWriteAccess(auth)) { + return Response.json({ error: "No write access" }, { status: 403 }); + } + + let { blockId } = await params; + + let client = await pool.connect(); + try { + let db = drizzle(client); + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); + + // Verify the block entity exists + let [entity] = await tx + .select({ id: entities.id, set: entities.set }) + .from(entities) + .where(eq(entities.id, blockId)); + + if (!entity) { + throw Response.json({ error: "Block not found" }, { status: 404 }); + } + + // Verify permission + let hasAccess = auth.tokenRights.some( + (r) => r.entity_set === entity.set && r.write, + ); + if (!hasAccess) { + throw Response.json({ error: "Block not found" }, { status: 404 }); + } + + // Check for image to clean up + let [imageFact] = await tx + .select({ data: facts.data }) + .from(facts) + .where(and(eq(facts.entity, blockId), eq(facts.attribute, "block/image"))); + + if (imageFact) { + let { createClient } = await import("@supabase/supabase-js"); + let supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + ); + let src = (imageFact.data as any).src; + if (src) { + let paths = src.split("/"); + await supabase.storage + .from("minilink-user-assets") + .remove([paths[paths.length - 1]]); + } + } + + // Delete the entity (cascades to facts) + await tx.delete(entities).where(eq(entities.id, blockId)); + + // Also delete referencing facts (card/block pointing to this entity) + await tx.delete(facts).where( + and( + eq(facts.attribute, "card/block"), + sql`data->>'value' = ${blockId}`, + ), + ); + }); + + await broadcastPoke(auth.rootEntity); + return Response.json({ deleted: blockId }); + } catch (e) { + if (e instanceof Response) return e; + console.error("AI API delete error:", e); + return Response.json({ error: "Internal error" }, { status: 500 }); + } finally { + client.release(); + } +} + +// --- PATCH --- + +export async function PATCH(req: NextRequest, { params }: Params) { + let auth = await authenticateToken(req); + if (auth instanceof Response) return auth; + + if (!hasWriteAccess(auth)) { + return Response.json({ error: "No write access" }, { status: 403 }); + } + + let { blockId } = await params; + + let body: { + action?: "replace" | "insert"; + content?: string; + position?: "start" | "end" | { before: string } | { after: string }; + language?: string | null; + }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + let hasContentEdit = body.action !== undefined || body.content !== undefined; + if (hasContentEdit && (!body.action || body.content === undefined)) { + return Response.json( + { error: "action and content required together" }, + { status: 400 }, + ); + } + if (!hasContentEdit && body.language === undefined) { + return Response.json( + { error: "must provide action+content or language" }, + { status: 400 }, + ); + } + + let client = await pool.connect(); + try { + let db = drizzle(client); + let result: { blockId: string; newText: string } | null = null; + + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); + + // Verify the block entity exists + let [entity] = await tx + .select({ id: entities.id, set: entities.set }) + .from(entities) + .where(eq(entities.id, blockId)); + + if (!entity) { + throw Response.json({ error: "Block not found" }, { status: 404 }); + } + + let hasAccess = auth.tokenRights.some( + (r) => r.entity_set === entity.set && r.write, + ); + if (!hasAccess) { + throw Response.json({ error: "Block not found" }, { status: 404 }); + } + + // Get block type + let [typeFact] = await tx + .select({ id: facts.id, data: facts.data }) + .from(facts) + .where(and(eq(facts.entity, blockId), eq(facts.attribute, "block/type"))); + + if (!typeFact) { + throw Response.json({ error: "Block has no type" }, { status: 400 }); + } + + let blockType = (typeFact.data as any).value; + + if ( + blockType === "text" || + blockType === "heading" || + blockType === "blockquote" + ) { + if (body.language !== undefined) { + throw Response.json( + { error: "language only applies to code blocks" }, + { status: 400 }, + ); + } + if (!hasContentEdit) { + throw Response.json( + { error: "action and content required" }, + { status: 400 }, + ); + } + // YJS content + let [textFact] = await tx + .select({ id: facts.id, data: facts.data }) + .from(facts) + .where( + and(eq(facts.entity, blockId), eq(facts.attribute, "block/text")), + ); + + let existingBase64 = textFact ? (textFact.data as any).value : null; + let content = body.content as string; + + let operation: EditOperation; + if (body.action === "replace") { + operation = { type: "replace", content }; + } else { + operation = { + type: "insert", + position: body.position || "end", + content, + } as EditOperation; + } + + if (!existingBase64) { + // No existing text, create new + let { createYjsText } = await import("../../lib"); + let newBase64 = createYjsText(content); + if (textFact) { + await tx + .update(facts) + .set({ data: sql`jsonb_set(data, '{value}', ${JSON.stringify(newBase64)}::jsonb)` }) + .where(eq(facts.id, textFact.id)); + } else { + let { v7 } = await import("uuid"); + await tx.insert(facts).values({ + id: v7(), + entity: blockId, + attribute: "block/text", + data: sql`${JSON.stringify({ type: "text", value: newBase64 })}::jsonb`, + }); + } + result = { blockId, newText: content }; + } else { + let editResult = editYjsText(existingBase64, operation); + + if ("error" in editResult) { + throw Response.json( + { + error: "search_not_found", + blockText: editResult.fullText, + }, + { status: 400 }, + ); + } + + await tx + .update(facts) + .set({ + data: sql`jsonb_set(data, '{value}', ${JSON.stringify(editResult.result)}::jsonb)`, + }) + .where(eq(facts.id, textFact.id)); + + result = { blockId, newText: editResult.plaintext }; + } + } else if (blockType === "code") { + // Plain string content + let [codeFact] = await tx + .select({ id: facts.id, data: facts.data }) + .from(facts) + .where( + and(eq(facts.entity, blockId), eq(facts.attribute, "block/code")), + ); + + let existingCode = codeFact ? ((codeFact.data as any).value as string) : ""; + let newCode = existingCode; + + if (hasContentEdit) { + let content = body.content as string; + if (body.action === "replace") { + newCode = content; + } else { + let pos = body.position || "end"; + if (pos === "start") { + newCode = content + existingCode; + } else if (pos === "end") { + newCode = existingCode + content; + } else if (typeof pos === "object" && "before" in pos) { + let idx = existingCode.indexOf(pos.before); + if (idx === -1) { + throw Response.json( + { error: "search_not_found", blockText: existingCode }, + { status: 400 }, + ); + } + newCode = + existingCode.slice(0, idx) + + content + + existingCode.slice(idx); + } else if (typeof pos === "object" && "after" in pos) { + let idx = existingCode.indexOf(pos.after); + if (idx === -1) { + throw Response.json( + { error: "search_not_found", blockText: existingCode }, + { status: 400 }, + ); + } + newCode = + existingCode.slice(0, idx + pos.after.length) + + content + + existingCode.slice(idx + pos.after.length); + } else { + newCode = existingCode + content; + } + } + + if (codeFact) { + await tx + .update(facts) + .set({ + data: sql`jsonb_set(data, '{value}', ${JSON.stringify(newCode)}::jsonb)`, + }) + .where(eq(facts.id, codeFact.id)); + } else { + let { v7 } = await import("uuid"); + await tx.insert(facts).values({ + id: v7(), + entity: blockId, + attribute: "block/code", + data: sql`${JSON.stringify({ type: "string", value: newCode })}::jsonb`, + }); + } + } + + // Handle language update + if (body.language !== undefined) { + let [langFact] = await tx + .select({ id: facts.id }) + .from(facts) + .where( + and( + eq(facts.entity, blockId), + eq(facts.attribute, "block/code-language"), + ), + ); + + if (body.language === null || body.language === "") { + // Remove language + if (langFact) { + await tx.delete(facts).where(eq(facts.id, langFact.id)); + } + } else { + let langData = { type: "string", value: body.language }; + if (langFact) { + await tx + .update(facts) + .set({ data: sql`${JSON.stringify(langData)}::jsonb` }) + .where(eq(facts.id, langFact.id)); + } else { + let { v7 } = await import("uuid"); + await tx.insert(facts).values({ + id: v7(), + entity: blockId, + attribute: "block/code-language", + data: sql`${JSON.stringify(langData)}::jsonb`, + }); + } + } + } + + result = { blockId, newText: newCode }; + } else { + throw Response.json( + { error: `Cannot edit blocks of type '${blockType}'` }, + { status: 400 }, + ); + } + }); + + await broadcastPoke(auth.rootEntity); + return Response.json(result); + } catch (e) { + if (e instanceof Response) return e; + console.error("AI API patch error:", e); + return Response.json({ error: "Internal error" }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/app/api/ai/blocks/route.ts b/app/api/ai/blocks/route.ts new file mode 100644 index 00000000..df647ad2 --- /dev/null +++ b/app/api/ai/blocks/route.ts @@ -0,0 +1,242 @@ +import { NextRequest } from "next/server"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { sql, eq } from "drizzle-orm"; +import { pool } from "supabase/pool"; +import { permission_token_rights } from "drizzle/schema"; +import { cachedServerMutationContext } from "src/replicache/cachedServerMutationContext"; +import { generateKeyBetween } from "fractional-indexing"; +import { v7 } from "uuid"; +import { + authenticateToken, + resolvePageEntity, + getPageBlocks, + createYjsText, + broadcastPoke, + tokenHash, + hasWriteAccess, +} from "../lib"; + +type BlockInput = + | { type: "text"; content: string } + | { type: "heading"; content: string; level?: number } + | { type: "code"; content: string; language?: string } + | { type: "blockquote"; content: string } + | { type: "horizontal-rule" }; + +type PositionInput = + | "start" + | "end" + | { after: string } + | { before: string }; + +export async function POST(req: NextRequest) { + let auth = await authenticateToken(req); + if (auth instanceof Response) return auth; + + if (!hasWriteAccess(auth)) { + return Response.json({ error: "No write access" }, { status: 403 }); + } + + let body: { page?: string; position: PositionInput; blocks: BlockInput[] }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!body.blocks || !Array.isArray(body.blocks) || body.blocks.length === 0) { + return Response.json({ error: "blocks array required" }, { status: 400 }); + } + if (!body.position) { + return Response.json({ error: "position required" }, { status: 400 }); + } + + let client = await pool.connect(); + try { + let db = drizzle(client); + let createdBlocks: { blockId: string; type: string }[] = []; + + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(${tokenHash(auth.tokenId)})`); + + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, body.page); + if (pageEntity instanceof Response) throw pageEntity; + + let token_rights = await tx + .select() + .from(permission_token_rights) + .where(eq(permission_token_rights.token, auth.tokenId)); + + let { getContext, flush } = cachedServerMutationContext( + tx, + auth.tokenId, + token_rights, + ); + let ctx = getContext("ai-api", 0); + + let existingBlocks = await getPageBlocks(tx, pageEntity as string); + let sorted = existingBlocks.sort((a, b) => + a.position > b.position ? 1 : -1, + ); + + // Compute initial position based on body.position + let currentPosition: string; + let pos = body.position; + + if (pos === "start") { + currentPosition = generateKeyBetween( + null, + sorted[0]?.position || null, + ); + } else if (pos === "end") { + currentPosition = generateKeyBetween( + sorted[sorted.length - 1]?.position || null, + null, + ); + } else if ("after" in pos) { + let targetIdx = sorted.findIndex((b) => b.value === pos.after); + if (targetIdx === -1) { + throw Response.json({ error: "Block not found for 'after'" }, { status: 404 }); + } + currentPosition = generateKeyBetween( + sorted[targetIdx].position, + sorted[targetIdx + 1]?.position || null, + ); + } else if ("before" in pos) { + let targetIdx = sorted.findIndex((b) => b.value === pos.before); + if (targetIdx === -1) { + throw Response.json({ error: "Block not found for 'before'" }, { status: 404 }); + } + currentPosition = generateKeyBetween( + sorted[targetIdx - 1]?.position || null, + sorted[targetIdx].position, + ); + } else { + throw Response.json({ error: "Invalid position" }, { status: 400 }); + } + + // Track the next position boundary for chaining + let nextBound: string | null = null; + if (pos === "start" && sorted.length > 0) { + nextBound = sorted[0].position; + } else if (typeof pos === "object" && "before" in pos) { + let targetIdx = sorted.findIndex((b) => b.value === pos.before); + nextBound = sorted[targetIdx].position; + } + + for (let i = 0; i < body.blocks.length; i++) { + let block = body.blocks[i]; + let newEntityID = v7(); + let factID = v7(); + + // For subsequent blocks, chain after the previous position + if (i > 0) { + currentPosition = generateKeyBetween(currentPosition, nextBound); + } + + await ctx.createEntity({ + entityID: newEntityID, + permission_set: auth.permissionSet!, + }); + + await ctx.assertFact({ + entity: pageEntity as string, + id: factID, + data: { + type: "ordered-reference" as const, + value: newEntityID, + position: currentPosition, + }, + attribute: "card/block" as const, + }); + + let blockType: string; + + if (block.type === "text") { + blockType = "text"; + await ctx.assertFact({ + entity: newEntityID, + data: { type: "block-type-union" as const, value: "text" }, + attribute: "block/type" as const, + }); + await ctx.assertFact({ + entity: newEntityID, + data: { type: "text" as const, value: createYjsText(block.content) }, + attribute: "block/text" as const, + }); + } else if (block.type === "heading") { + blockType = "heading"; + await ctx.assertFact({ + entity: newEntityID, + data: { type: "block-type-union" as const, value: "heading" }, + attribute: "block/type" as const, + }); + await ctx.assertFact({ + entity: newEntityID, + data: { type: "text" as const, value: createYjsText(block.content) }, + attribute: "block/text" as const, + }); + await ctx.assertFact({ + entity: newEntityID, + data: { type: "number" as const, value: block.level || 1 }, + attribute: "block/heading-level" as const, + }); + } else if (block.type === "code") { + blockType = "code"; + await ctx.assertFact({ + entity: newEntityID, + data: { type: "block-type-union" as const, value: "code" }, + attribute: "block/type" as const, + }); + await ctx.assertFact({ + entity: newEntityID, + data: { type: "string" as const, value: block.content }, + attribute: "block/code" as const, + }); + if (block.language) { + await ctx.assertFact({ + entity: newEntityID, + data: { type: "string" as const, value: block.language }, + attribute: "block/code-language" as const, + }); + } + } else if (block.type === "blockquote") { + blockType = "blockquote"; + await ctx.assertFact({ + entity: newEntityID, + data: { type: "block-type-union" as const, value: "blockquote" }, + attribute: "block/type" as const, + }); + await ctx.assertFact({ + entity: newEntityID, + data: { type: "text" as const, value: createYjsText(block.content) }, + attribute: "block/text" as const, + }); + } else if (block.type === "horizontal-rule") { + blockType = "horizontal-rule"; + await ctx.assertFact({ + entity: newEntityID, + data: { type: "block-type-union" as const, value: "horizontal-rule" }, + attribute: "block/type" as const, + }); + } else { + continue; + } + + createdBlocks.push({ blockId: newEntityID, type: blockType }); + } + + await flush(); + }); + + await broadcastPoke(auth.rootEntity); + + return Response.json({ blocks: createdBlocks }); + } catch (e) { + if (e instanceof Response) return e; + console.error("AI API blocks error:", e); + return Response.json({ error: "Internal error" }, { status: 500 }); + } finally { + client.release(); + } +} diff --git a/app/api/ai/doc/route.ts b/app/api/ai/doc/route.ts new file mode 100644 index 00000000..f0c047ac --- /dev/null +++ b/app/api/ai/doc/route.ts @@ -0,0 +1,138 @@ +import { NextRequest } from "next/server"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { pool } from "supabase/pool"; +import { + authenticateToken, + resolvePageEntity, + getPageBlocks, + getAllFactsForEntities, + blocksToMarkdown, + extractPlaintext, +} from "../lib"; + +export async function GET(req: NextRequest) { + let auth = await authenticateToken(req); + if (auth instanceof Response) return auth; + + let pageParam = req.nextUrl.searchParams.get("page"); + + let client = await pool.connect(); + try { + let db = drizzle(client); + return await db.transaction(async (tx) => { + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, pageParam); + if (pageEntity instanceof Response) return pageEntity; + + let blocks = await getPageBlocks(tx, pageEntity); + + // Collect all entity IDs we need facts for + let entityIds = new Set(); + for (let b of blocks) { + entityIds.add(b.value); + } + let allFacts = await getAllFactsForEntities(tx, [...entityIds]); + + // For card blocks, also fetch subpage facts + let subpages: { id: string; title: string }[] = []; + for (let b of blocks) { + if (b.type === "card") { + let cardFacts = allFacts.filter( + (f) => f.entity === b.value && f.attribute === "block/card", + ); + if (cardFacts[0]) { + let cardEntityId = (cardFacts[0].data as any).value; + entityIds.add(cardEntityId); + } + } + } + + // Re-fetch with subpage entities included + allFacts = await getAllFactsForEntities(tx, [...entityIds]); + + // Also fetch subpage block entities for titles + let subpageBlockEntityIds = new Set(); + for (let b of blocks) { + if (b.type === "card") { + let cardFacts = allFacts.filter( + (f) => f.entity === b.value && f.attribute === "block/card", + ); + if (cardFacts[0]) { + let cardEntityId = (cardFacts[0].data as any).value; + let blockRefs = allFacts + .filter( + (f) => + f.entity === cardEntityId && f.attribute === "card/block", + ) + .sort( + (a, b) => + (a.data as any).position > (b.data as any).position ? 1 : -1, + ); + for (let ref of blockRefs) { + subpageBlockEntityIds.add((ref.data as any).value); + } + } + } + } + + if (subpageBlockEntityIds.size > 0) { + let subpageBlockFacts = await getAllFactsForEntities(tx, [ + ...subpageBlockEntityIds, + ]); + allFacts = [...allFacts, ...subpageBlockFacts]; + } + + // Build subpages list + for (let b of blocks) { + if (b.type === "card") { + let cardFacts = allFacts.filter( + (f) => f.entity === b.value && f.attribute === "block/card", + ); + if (cardFacts[0]) { + let cardEntityId = (cardFacts[0].data as any).value; + let blockRefs = allFacts + .filter( + (f) => + f.entity === cardEntityId && f.attribute === "card/block", + ) + .sort( + (a, b) => + (a.data as any).position > (b.data as any).position ? 1 : -1, + ); + let title = ""; + if (blockRefs[0]) { + let firstBlockId = (blockRefs[0].data as any).value; + let textFact = allFacts.find( + (f) => + f.entity === firstBlockId && f.attribute === "block/text", + ); + if (textFact) { + title = extractPlaintext((textFact.data as any).value); + } + } + subpages.push({ id: cardEntityId, title: title || "Untitled" }); + } + } + } + + let markdown = await blocksToMarkdown(blocks, allFacts); + + // Extract document title from first heading + let titleBlock = blocks.find( + (b) => b.type === "heading" || b.type === "text", + ); + let title = ""; + if (titleBlock) { + let textFact = allFacts.find( + (f) => f.entity === titleBlock.value && f.attribute === "block/text", + ); + if (textFact) { + title = extractPlaintext((textFact.data as any).value); + } + } + + return Response.json({ title, markdown, subpages }); + }); + } finally { + client.release(); + } +} diff --git a/app/api/ai/lib.tsx b/app/api/ai/lib.tsx new file mode 100644 index 00000000..b00b655a --- /dev/null +++ b/app/api/ai/lib.tsx @@ -0,0 +1,609 @@ +import { createClient } from "@supabase/supabase-js"; +import type { Database } from "supabase/database.types"; +import { permission_tokens, permission_token_rights } from "drizzle/schema"; +import { entities, facts } from "drizzle/schema"; +import * as driz from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import * as Y from "yjs"; +import * as base64 from "base64-js"; +import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; +import { Block } from "components/Blocks/Block"; +import { parseBlocksToList, List } from "src/utils/parseBlocksToList"; +import { htmlToMarkdown } from "src/htmlMarkdownParsers"; + +// --- Auth --- + +export type AuthResult = { + tokenId: string; + rootEntity: string; + tokenRights: { + token: string; + entity_set: string; + read: boolean; + write: boolean; + create_token: boolean; + change_entity_set: boolean; + }[]; + permissionSet: string | null; +}; + +export async function authenticateToken( + request: Request, +): Promise { + let auth = request.headers.get("Authorization"); + if (!auth || !auth.startsWith("Bearer ")) { + return Response.json({ error: "Missing Authorization header" }, { status: 401 }); + } + let tokenId = auth.slice("Bearer ".length).trim(); + if (!tokenId) { + return Response.json({ error: "Invalid token" }, { status: 401 }); + } + + let supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + ); + + let { data: token } = await supabase + .from("permission_tokens") + .select("id, root_entity, blocked_by_admin") + .eq("id", tokenId) + .single(); + + if (!token) { + return Response.json({ error: "Invalid token" }, { status: 401 }); + } + if (token.blocked_by_admin) { + return Response.json({ error: "Token blocked" }, { status: 403 }); + } + + let { data: rights } = await supabase + .from("permission_token_rights") + .select("token, entity_set, read, write, create_token, change_entity_set") + .eq("token", tokenId); + + let tokenRights = rights || []; + let permissionSet = + tokenRights.find((r) => r.write)?.entity_set ?? null; + + return { + tokenId, + rootEntity: token.root_entity, + tokenRights, + permissionSet, + }; +} + +// --- Page resolution --- + +export async function resolvePageEntity( + tx: PgTransaction, + rootEntity: string, + pageParam?: string | null, +): Promise { + let rootPageFacts = await tx + .select({ data: facts.data }) + .from(facts) + .where( + driz.and( + driz.eq(facts.entity, rootEntity), + driz.eq(facts.attribute, "root/page"), + ), + ); + + let mainPage = (rootPageFacts[0]?.data as any)?.value as string | undefined; + if (!mainPage) { + return Response.json({ error: "No main page found" }, { status: 404 }); + } + + if (!pageParam) return mainPage; + + // Verify the requested page exists as an entity in this document + let [pageEntity] = await tx + .select({ id: entities.id }) + .from(entities) + .where(driz.eq(entities.id, pageParam)); + + if (!pageEntity) { + return Response.json({ error: "Page not found" }, { status: 404 }); + } + + return pageParam; +} + +// --- Block fetching (server-side version of getBlocksWithTypeLocal) --- + +type FactRow = { + id: string; + entity: string; + attribute: string; + data: any; +}; + +export async function getPageBlocks( + tx: PgTransaction, + pageEntity: string, +): Promise { + // Get all facts for this page's blocks in bulk + let blockRefs = await tx + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) + .from(facts) + .where( + driz.and( + driz.eq(facts.entity, pageEntity), + driz.eq(facts.attribute, "card/block"), + ), + ); + + blockRefs.sort((a, b) => { + let posA = (a.data as any).position; + let posB = (b.data as any).position; + if (posA === posB) return a.id > b.id ? 1 : -1; + return posA > posB ? 1 : -1; + }); + + if (blockRefs.length === 0) return []; + + // Collect all block entity IDs + let blockEntityIds = blockRefs.map((r) => (r.data as any).value as string); + + // Fetch all facts for these block entities in one query + let allBlockFacts = await tx + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) + .from(facts) + .where(driz.inArray(facts.entity, blockEntityIds)); + + let factsByEntity = new Map(); + for (let f of allBlockFacts) { + let arr = factsByEntity.get(f.entity); + if (!arr) { + arr = []; + factsByEntity.set(f.entity, arr); + } + arr.push(f); + } + + let result: Block[] = []; + + for (let ref of blockRefs) { + let blockEntityId = (ref.data as any).value as string; + let blockFacts = factsByEntity.get(blockEntityId) || []; + let typeFact = blockFacts.find((f) => f.attribute === "block/type"); + if (!typeFact) continue; + + let isListFact = blockFacts.find((f) => f.attribute === "block/is-list"); + if (isListFact && (isListFact.data as any).value) { + let children = await getListChildren(tx, ref, pageEntity, 1, []); + result.push(...children); + } else { + result.push({ + value: blockEntityId, + position: (ref.data as any).position, + factID: ref.id, + type: (typeFact.data as any).value, + parent: pageEntity, + }); + } + } + + computeDisplayNumbers(result); + return result; +} + +async function getListChildren( + tx: PgTransaction, + root: FactRow, + pageParent: string, + depth: number, + path: { depth: number; entity: string }[], +): Promise { + let rootValue = (root.data as any).value as string; + + let childRefs = await tx + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) + .from(facts) + .where( + driz.and( + driz.eq(facts.entity, rootValue), + driz.eq(facts.attribute, "card/block"), + ), + ); + childRefs.sort((a, b) => + (a.data as any).position > (b.data as any).position ? 1 : -1, + ); + + let rootFacts = await tx + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) + .from(facts) + .where(driz.eq(facts.entity, rootValue)); + + let typeFact = rootFacts.find((f) => f.attribute === "block/type"); + if (!typeFact) return []; + + let listStyleFact = rootFacts.find((f) => f.attribute === "block/list-style"); + let listNumberFact = rootFacts.find((f) => f.attribute === "block/list-number"); + + let newPath = [...path, { entity: rootValue, depth }]; + + let childBlocks: Block[] = []; + for (let c of childRefs) { + let children = await getListChildren(tx, c, rootValue, depth + 1, newPath); + childBlocks.push(...children); + } + + return [ + { + value: rootValue, + position: (root.data as any).position, + factID: root.id, + type: (typeFact.data as any).value, + parent: pageParent, + listData: { + depth, + parent: root.entity, + path: newPath, + listStyle: listStyleFact ? (listStyleFact.data as any).value : undefined, + listStart: listNumberFact ? (listNumberFact.data as any).value : undefined, + }, + }, + ...childBlocks, + ]; +} + +function computeDisplayNumbers(blocks: Block[]): void { + let counters = new Map(); + for (let block of blocks) { + if (!block.listData) { + counters.clear(); + continue; + } + if (block.listData.listStyle !== "ordered") continue; + let parent = block.listData.parent; + if (block.listData.listStart !== undefined) { + counters.set(parent, block.listData.listStart); + } else if (!counters.has(parent)) { + counters.set(parent, 1); + } + block.listData.displayNumber = counters.get(parent)!; + counters.set(parent, counters.get(parent)! + 1); + } +} + +// --- Server-side YJS to HTML rendering --- + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderYjsToHTML( + base64Value: string, + wrapper: "p" | "h1" | "h2" | "h3" | "blockquote", + attrs?: Record, +): string { + let attrStr = attrs + ? Object.entries(attrs) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => ` ${k}="${escapeHtml(v)}"`) + .join("") + : ""; + + if (!base64Value) return `<${wrapper}${attrStr}>`; + + let doc = new Y.Doc(); + Y.applyUpdate(doc, base64.toByteArray(base64Value)); + let [node] = doc.getXmlElement("prosemirror").toArray(); + if (!node || node.constructor !== Y.XmlElement) return `<${wrapper}${attrStr}>`; + + let children = node.toArray(); + if (children.length === 0) return `<${wrapper}${attrStr}>
`; + + let inner = children + .map((child) => { + if (child.constructor === Y.XmlText) { + let deltas = child.toDelta() as { insert: string; attributes?: any }[]; + if (deltas.length === 0) return "
"; + return deltas + .map((d) => { + let text = escapeHtml(d.insert); + if (d.attributes?.link) return `${text}`; + if (d.attributes?.strong) text = `${text}`; + if (d.attributes?.em) text = `${text}`; + if (d.attributes?.code) text = `${text}`; + return text; + }) + .join(""); + } + if (child.constructor === Y.XmlElement) { + if (child.nodeName === "hard_break") return "
"; + if (child.nodeName === "didMention" || child.nodeName === "atMention") { + let text = child.getAttribute("text") || ""; + return escapeHtml(text); + } + } + return ""; + }) + .join(""); + + return `<${wrapper}${attrStr}>${inner}`; +} + +// --- Block-to-HTML (server-side, reads from pre-fetched facts) --- + +export async function getAllFactsForEntities( + tx: PgTransaction, + entityIds: string[], +): Promise { + if (entityIds.length === 0) return []; + return tx + .select({ id: facts.id, entity: facts.entity, attribute: facts.attribute, data: facts.data }) + .from(facts) + .where(driz.inArray(facts.entity, entityIds)); +} + +function factsLookup(allFacts: FactRow[], entity: string, attribute: string): FactRow[] { + return allFacts.filter((f) => f.entity === entity && f.attribute === attribute); +} + +async function renderBlockToHTML( + b: Block, + allFacts: FactRow[], +): Promise { + let [alignment] = factsLookup(allFacts, b.value, "block/text-alignment"); + let a = alignment ? (alignment.data as any).value : undefined; + + switch (b.type) { + case "text": { + let [value] = factsLookup(allFacts, b.value, "block/text"); + return renderYjsToHTML(value?.data.value, "p", a ? { "data-alignment": a } : undefined); + } + case "heading": { + let [value] = factsLookup(allFacts, b.value, "block/text"); + let [headingLevel] = factsLookup(allFacts, b.value, "block/heading-level"); + let wrapper = ("h" + ((headingLevel?.data as any)?.value || 1)) as "h1" | "h2" | "h3"; + return renderYjsToHTML(value?.data.value, wrapper, a ? { "data-alignment": a } : undefined); + } + case "blockquote": { + let [value] = factsLookup(allFacts, b.value, "block/text"); + return renderYjsToHTML(value?.data.value, "blockquote", a ? { "data-alignment": a } : undefined); + } + case "code": { + let [code] = factsLookup(allFacts, b.value, "block/code"); + let [lang] = factsLookup(allFacts, b.value, "block/code-language"); + let langValue = (lang?.data as any)?.value as string | undefined; + let codeAttr = langValue ? ` class="language-${escapeHtml(langValue)}"` : ""; + return `
${escapeHtml((code?.data as any)?.value || "")}
`; + } + case "image": { + let [src] = factsLookup(allFacts, b.value, "block/image"); + if (!src) return ""; + let alignAttr = a ? ` data-alignment="${escapeHtml(a)}"` : ""; + return ``; + } + case "horizontal-rule": + return "
"; + case "card": { + let [card] = factsLookup(allFacts, b.value, "block/card"); + if (!card) return ""; + let cardEntityId = (card.data as any).value; + let title = await getSubpageTitle(allFacts, cardEntityId); + return `${escapeHtml(title || "Untitled")}`; + } + case "link": { + let [url] = factsLookup(allFacts, b.value, "link/url"); + let [title] = factsLookup(allFacts, b.value, "link/title"); + if (!url) return ""; + return `${escapeHtml((title?.data as any)?.value || "")}`; + } + case "button": { + let [text] = factsLookup(allFacts, b.value, "button/text"); + let [url] = factsLookup(allFacts, b.value, "button/url"); + if (!text || !url) return ""; + return `${escapeHtml((text.data as any).value)}`; + } + case "math": { + let [math] = factsLookup(allFacts, b.value, "block/math"); + return `${escapeHtml((math?.data as any)?.value || "")}`; + } + default: + return ""; + } +} + +async function getSubpageTitle( + allFacts: FactRow[], + cardEntityId: string, +): Promise { + // Look for card/block children of the subpage to find first heading + let blockRefs = allFacts + .filter((f) => f.entity === cardEntityId && f.attribute === "card/block") + .sort((a, b) => ((a.data as any).position > (b.data as any).position ? 1 : -1)); + + if (blockRefs.length === 0) return ""; + + let firstBlockId = (blockRefs[0].data as any).value; + let [textFact] = allFacts.filter( + (f) => f.entity === firstBlockId && f.attribute === "block/text", + ); + + if (textFact) { + return extractPlaintext((textFact.data as any).value); + } + return ""; +} + +async function renderListToHTML(l: List, allFacts: FactRow[]): Promise { + let children = ( + await Promise.all(l.children.map((c) => renderListToHTML(c, allFacts))) + ).join("\n"); + + let checkedFacts = factsLookup(allFacts, l.block.value, "block/check-list"); + let checked = checkedFacts[0]; + + let isOrdered = l.children[0]?.block.listData?.listStyle === "ordered"; + let tag = isOrdered ? "ol" : "ul"; + + return `
  • ${await renderBlockToHTML(l.block, allFacts)} ${ + l.children.length > 0 ? `<${tag}>${children}` : "" + }
  • `; +} + +export async function blocksToHTML( + blocks: Block[], + allFacts: FactRow[], +): Promise { + let result: string[] = []; + let parsed = parseBlocksToList(blocks); + + for (let pb of parsed) { + if (pb.type === "block") { + result.push(await renderBlockToHTML(pb.block, allFacts)); + } else { + let isOrdered = pb.children[0]?.block.listData?.listStyle === "ordered"; + let tag = isOrdered ? "ol" : "ul"; + let listItems = ( + await Promise.all( + pb.children.map((c) => renderListToHTML(c, allFacts)), + ) + ).join("\n"); + result.push(`<${tag}>${listItems}`); + } + } + return result; +} + +// --- Blocks-to-markdown --- + +export async function blocksToMarkdown( + blocks: Block[], + allFacts: FactRow[], +): Promise { + let htmlParts = await blocksToHTML(blocks, allFacts); + let html = htmlParts.join("\n"); + return htmlToMarkdown(html); +} + +// --- YJS plaintext extraction --- + +export function extractPlaintext(base64Value: string): string { + if (!base64Value) return ""; + let doc = new Y.Doc(); + Y.applyUpdate(doc, base64.toByteArray(base64Value)); + let nodes = doc.getXmlElement("prosemirror").toArray(); + if (nodes.length === 0) return ""; + return YJSFragmentToString(nodes[0]); +} + +// --- YJS text creation --- + +export function createYjsText(plaintext: string): string { + let doc = new Y.Doc(); + let fragment = doc.getXmlFragment("prosemirror"); + let paragraph = new Y.XmlElement("paragraph"); + let textNode = new Y.XmlText(); + textNode.insert(0, plaintext); + paragraph.insert(0, [textNode]); + fragment.insert(0, [paragraph]); + return base64.fromByteArray(Y.encodeStateAsUpdate(doc)); +} + +// --- YJS text editing --- + +export type EditOperation = + | { type: "replace"; content: string } + | { type: "insert"; position: "start" | "end"; content: string } + | { type: "insert"; position: { before: string } | { after: string }; content: string }; + +export function editYjsText( + existingBase64: string, + operation: EditOperation, +): { result: string; plaintext: string } | { error: "search_not_found"; fullText: string } { + let doc = new Y.Doc(); + Y.applyUpdate(doc, base64.toByteArray(existingBase64)); + + let element = doc.getXmlElement("prosemirror"); + let paragraph = element.toArray()[0]; + if (!paragraph || paragraph.constructor !== Y.XmlElement) { + return { error: "search_not_found", fullText: "" }; + } + + // Find the XmlText child + let textNodes = paragraph.toArray(); + let xmlText: Y.XmlText | null = null; + for (let n of textNodes) { + if (n.constructor === Y.XmlText) { + xmlText = n; + break; + } + } + + if (!xmlText) { + // No text node exists yet, create one for replace/insert + xmlText = new Y.XmlText(); + paragraph.insert(0, [xmlText]); + } + + let currentText = (xmlText.toDelta() as { insert: string }[]) + .map((d) => d.insert) + .join(""); + + if (operation.type === "replace") { + xmlText.delete(0, currentText.length); + xmlText.insert(0, operation.content); + } else if (operation.type === "insert") { + let pos = operation.position; + if (pos === "start") { + xmlText.insert(0, operation.content); + } else if (pos === "end") { + xmlText.insert(currentText.length, operation.content); + } else if ("before" in pos) { + let idx = currentText.indexOf(pos.before); + if (idx === -1) return { error: "search_not_found", fullText: currentText }; + xmlText.insert(idx, operation.content); + } else if ("after" in pos) { + let idx = currentText.indexOf(pos.after); + if (idx === -1) return { error: "search_not_found", fullText: currentText }; + xmlText.insert(idx + pos.after.length, operation.content); + } + } + + let newText = (xmlText.toDelta() as { insert: string }[]) + .map((d) => d.insert) + .join(""); + + return { + result: base64.fromByteArray(Y.encodeStateAsUpdate(doc)), + plaintext: newText, + }; +} + +// --- Realtime poke --- + +export async function broadcastPoke(rootEntity: string) { + let supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + ); + let channel = supabase.channel(`rootEntity:${rootEntity}`); + await channel.send({ + type: "broadcast", + event: "poke", + payload: { message: "poke" }, + }); + await supabase.removeChannel(channel); +} + +// --- Helpers --- + +export function tokenHash(tokenId: string): number { + return tokenId.split("").reduce((acc, char) => { + return ((acc << 5) - acc + char.charCodeAt(0)) | 0; + }, 0); +} + +export function hasWriteAccess(auth: AuthResult): boolean { + return auth.tokenRights.some((r) => r.write); +} diff --git a/app/api/ai/search/route.ts b/app/api/ai/search/route.ts new file mode 100644 index 00000000..ed3c10a5 --- /dev/null +++ b/app/api/ai/search/route.ts @@ -0,0 +1,87 @@ +import { NextRequest } from "next/server"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { pool } from "supabase/pool"; +import { + authenticateToken, + resolvePageEntity, + getPageBlocks, + getAllFactsForEntities, + extractPlaintext, +} from "../lib"; + +export async function GET(req: NextRequest) { + let auth = await authenticateToken(req); + if (auth instanceof Response) return auth; + + let query = req.nextUrl.searchParams.get("q"); + if (!query) { + return Response.json({ error: "Missing q parameter" }, { status: 400 }); + } + + let pageParam = req.nextUrl.searchParams.get("page"); + let queryLower = query.toLowerCase(); + + let client = await pool.connect(); + try { + let db = drizzle(client); + return await db.transaction(async (tx) => { + let pageEntity = await resolvePageEntity(tx, auth.rootEntity, pageParam); + if (pageEntity instanceof Response) return pageEntity; + + let blocks = await getPageBlocks(tx, pageEntity); + let entityIds = blocks.map((b) => b.value); + let allFacts = await getAllFactsForEntities(tx, entityIds); + + let results: { + blockId: string; + type: string; + text: string; + language?: string; + }[] = []; + + for (let b of blocks) { + if ( + b.type === "text" || + b.type === "heading" || + b.type === "blockquote" + ) { + let textFact = allFacts.find( + (f) => f.entity === b.value && f.attribute === "block/text", + ); + if (textFact) { + let plaintext = extractPlaintext((textFact.data as any).value); + if (plaintext.toLowerCase().includes(queryLower)) { + results.push({ blockId: b.value, type: b.type, text: plaintext }); + } + } + } else if (b.type === "code") { + let codeFact = allFacts.find( + (f) => f.entity === b.value && f.attribute === "block/code", + ); + if (codeFact) { + let code = (codeFact.data as any).value as string; + if (code.toLowerCase().includes(queryLower)) { + let langFact = allFacts.find( + (f) => + f.entity === b.value && f.attribute === "block/code-language", + ); + let language = langFact + ? ((langFact.data as any).value as string) + : undefined; + results.push({ + blockId: b.value, + type: b.type, + text: code, + ...(language ? { language } : {}), + }); + } + } + } + } + + return Response.json({ results }); + }); + } finally { + client.release(); + } +} -- 2.51.2 From 497e74221fb235e379e7dd6b7b23fefc01e74379 Mon Sep 17 00:00:00 2001 From: celine Date: Mon, 6 Apr 2026 19:19:59 -0400 Subject: [PATCH 4/6] rip out twilio, replace rsvp block with deprecated placeholder --- actions/getRSVPData.ts | 61 --- actions/get_phone_rsvp_to_event_state.ts | 46 --- .../phone_auth/confirm_phone_auth_token.ts | 56 --- .../phone_auth/request_phone_auth_token.ts | 71 ---- actions/phone_rsvp_to_event.ts | 64 --- actions/sendUpdateToRSVPS.ts | 71 ---- app/[leaflet_id]/page.tsx | 11 +- components/Blocks/RSVPBlock/Atendees.tsx | 68 ---- .../Blocks/RSVPBlock/ContactDetailsForm.tsx | 366 ----------------- .../RSVPBlock/RSVPBackground.module.css | 7 - components/Blocks/RSVPBlock/SendUpdate.tsx | 194 --------- components/Blocks/RSVPBlock/index.tsx | 252 +----------- components/PageSWRDataProvider.tsx | 35 +- components/ThemeManager/PageThemeSetter.tsx | 1 - components/ViewportSizeLayout.tsx | 2 +- package-lock.json | 384 ++++++------------ package.json | 1 - 17 files changed, 162 insertions(+), 1528 deletions(-) delete mode 100644 actions/getRSVPData.ts delete mode 100644 actions/get_phone_rsvp_to_event_state.ts delete mode 100644 actions/phone_auth/confirm_phone_auth_token.ts delete mode 100644 actions/phone_auth/request_phone_auth_token.ts delete mode 100644 actions/phone_rsvp_to_event.ts delete mode 100644 actions/sendUpdateToRSVPS.ts delete mode 100644 components/Blocks/RSVPBlock/Atendees.tsx delete mode 100644 components/Blocks/RSVPBlock/ContactDetailsForm.tsx delete mode 100644 components/Blocks/RSVPBlock/RSVPBackground.module.css delete mode 100644 components/Blocks/RSVPBlock/SendUpdate.tsx diff --git a/actions/getRSVPData.ts b/actions/getRSVPData.ts deleted file mode 100644 index a00b3576..00000000 --- a/actions/getRSVPData.ts +++ /dev/null @@ -1,61 +0,0 @@ -"use server"; - -import { cookies } from "next/headers"; -import { supabaseServerClient } from "supabase/serverClient"; - -export async function getRSVPData(entity_sets: string[]) { - const token = (await cookies()).get("phone_auth_token"); - - let authToken: { - id: string; - created_at: string; - confirmed: boolean; - confirmation_code: string; - phone_number: string; - country_code: string; - } | null = null; - if (token) { - let { data } = await supabaseServerClient - .from("phone_number_auth_tokens") - .select("*") - .eq("id", token.value) - .single(); - authToken = data; - } - - const { data: rsvps } = await supabaseServerClient - .from("phone_rsvps_to_entity") - .select( - ` - *, - entities!inner(*) - `, - ) - .in("entities.set", entity_sets); - - return { - authToken, - rsvps: - rsvps?.map((rsvp) => { - if ( - rsvp.phone_number === authToken?.phone_number && - rsvp.country_code === authToken.country_code - ) - return { - phone_number: rsvp.phone_number, - country_code: rsvp.country_code, - name: rsvp.name, - entity: rsvp.entities.id, - status: rsvp.status, - plus_ones: rsvp.plus_ones, - }; - else - return { - name: rsvp.name, - entity: rsvp.entities.id, - status: rsvp.status, - plus_ones: rsvp.plus_ones, - }; - }) || [], - }; -} diff --git a/actions/get_phone_rsvp_to_event_state.ts b/actions/get_phone_rsvp_to_event_state.ts deleted file mode 100644 index 3f96c37f..00000000 --- a/actions/get_phone_rsvp_to_event_state.ts +++ /dev/null @@ -1,46 +0,0 @@ -"use server"; - -import { drizzle } from "drizzle-orm/node-postgres"; -import { and, eq } from "drizzle-orm"; -import postgres from "postgres"; -import { - phone_number_auth_tokens, - phone_rsvps_to_entity, -} from "drizzle/schema"; -import { cookies } from "next/headers"; -import { Database } from "supabase/database.types"; -import { pool } from "supabase/pool"; - -export async function getPhoneRSVPToEventState(entityId: string) { - const token = (await cookies()).get("phone_auth_token"); - - if (!token) { - return null; - } - - const client = await pool.connect(); - const db = drizzle(client); - - const [authToken] = await db - .select() - .from(phone_number_auth_tokens) - .where(eq(phone_number_auth_tokens.id, token.value)); - - if (!authToken || !authToken.confirmed) { - client.release(); - return null; - } - - const [rsvp] = await db - .select() - .from(phone_rsvps_to_entity) - .where( - and( - eq(phone_rsvps_to_entity.phone_number, authToken.phone_number), - eq(phone_rsvps_to_entity.entity, entityId), - ), - ); - - client.release(); - return rsvp; -} diff --git a/actions/phone_auth/confirm_phone_auth_token.ts b/actions/phone_auth/confirm_phone_auth_token.ts deleted file mode 100644 index 21a42816..00000000 --- a/actions/phone_auth/confirm_phone_auth_token.ts +++ /dev/null @@ -1,56 +0,0 @@ -"use server"; - -import { drizzle } from "drizzle-orm/node-postgres"; -import { and, eq } from "drizzle-orm"; -import postgres from "postgres"; -import { phone_number_auth_tokens } from "drizzle/schema"; -import { cookies } from "next/headers"; -import { pool } from "supabase/pool"; - -export async function confirmPhoneAuthToken(tokenId: string, code: string) { - const client = await pool.connect(); - const db = drizzle(client); - - const [token] = await db - .select() - .from(phone_number_auth_tokens) - .where(eq(phone_number_auth_tokens.id, tokenId)); - - if (!token) { - client.release(); - throw new Error("Invalid token"); - } - - if (token.confirmation_code !== code) { - client.release(); - throw new Error("Invalid confirmation code"); - } - - if (token.confirmed) { - client.release(); - throw new Error("Token already confirmed"); - } - - const [confirmedToken] = await db - .update(phone_number_auth_tokens) - .set({ - confirmed: true, - }) - .where( - and( - eq(phone_number_auth_tokens.id, tokenId), - eq(phone_number_auth_tokens.confirmation_code, code), - ), - ) - .returning(); - - (await cookies()).set("phone_auth_token", confirmedToken.id, { - maxAge: 60 * 60 * 24 * 30, - secure: process.env.NODE_ENV === "production", - httpOnly: true, - sameSite: "strict", - }); - - client.release(); - return confirmedToken; -} diff --git a/actions/phone_auth/request_phone_auth_token.ts b/actions/phone_auth/request_phone_auth_token.ts deleted file mode 100644 index 49ccc33b..00000000 --- a/actions/phone_auth/request_phone_auth_token.ts +++ /dev/null @@ -1,71 +0,0 @@ -"use server"; - -import { randomBytes } from "crypto"; -import { drizzle } from "drizzle-orm/node-postgres"; -import postgres from "postgres"; -import { phone_number_auth_tokens } from "drizzle/schema"; -import twilio from "twilio"; -import { pool } from "supabase/pool"; - -async function sendAuthCode({ - country_code, - phone_number, - code, -}: { - country_code: string; - phone_number: string; - code: string; -}) { - let phoneNumber = `+${country_code}${phone_number}`; - const accountSid = process.env.TWILIO_ACCOUNT_SID; - const authToken = process.env.TWILIO_AUTH_TOKEN; - const client = twilio(accountSid, authToken); - if (country_code === "1") { - const message = await client.messages.create({ - body: `${code} is your verification code - -@leaflet.pub #${code}`, - from: `+18449523391`, - to: phoneNumber, - }); - console.log(message); - } else { - const message = await client.messages.create({ - contentSid: "HX5ebfae4d2a423808486e773e8a22488d", - contentVariables: JSON.stringify({ 1: code }), - from: "whatsapp:+18449523391", - messagingServiceSid: "MGffbf9a66770350b25caf3b80b9aac481", - to: `whatsapp:${phoneNumber}`, - }); - } -} - -export async function createPhoneAuthToken({ - phone_number, - country_code, -}: { - phone_number: string; - country_code: string; -}) { - const client = await pool.connect(); - const db = drizzle(client); - - const code = randomBytes(3).toString("hex").toUpperCase(); - - const [token] = await db - .insert(phone_number_auth_tokens) - .values({ - phone_number, - country_code, - confirmation_code: code, - confirmed: false, - }) - .returning({ - id: phone_number_auth_tokens.id, - }); - - await sendAuthCode({ country_code, phone_number, code }); - - client.release(); - return token.id; -} diff --git a/actions/phone_rsvp_to_event.ts b/actions/phone_rsvp_to_event.ts deleted file mode 100644 index 17d6e2bd..00000000 --- a/actions/phone_rsvp_to_event.ts +++ /dev/null @@ -1,64 +0,0 @@ -"use server"; - -import { drizzle } from "drizzle-orm/node-postgres"; -import { - entities, - phone_number_auth_tokens, - phone_rsvps_to_entity, -} from "drizzle/schema"; -import { redirect } from "next/navigation"; -import postgres from "postgres"; -import { v7 } from "uuid"; -import { eq, sql } from "drizzle-orm"; -import { Database } from "supabase/database.types"; -import { createServerClient } from "@supabase/ssr"; -import { cookies } from "next/headers"; -import { pool } from "supabase/pool"; - -export async function submitRSVP(args: { - entity: string; - status: Database["public"]["Enums"]["rsvp_status"]; - name: string; - plus_ones: number; -}) { - const client = await pool.connect(); - const db = drizzle(client); - let token = (await cookies()).get("phone_auth_token"); - if (!token) throw new Error("No auth token found"); - - let [auth_token] = await db - .select() - .from(phone_number_auth_tokens) - .where(eq(phone_number_auth_tokens.id, token.value)); - if (!auth_token) throw new Error("Invalid auth token"); - if (!auth_token.confirmed) throw new Error("Auth token not confirmed"); - - await db.transaction(async (tx) => { - await tx - .insert(phone_rsvps_to_entity) - .values([ - { - status: args.status, - entity: args.entity, - phone_number: auth_token.phone_number, - country_code: auth_token.country_code, - name: args.name, - plus_ones: args.plus_ones, - }, - ]) - .onConflictDoUpdate({ - target: [ - phone_rsvps_to_entity.entity, - phone_rsvps_to_entity.phone_number, - ], - set: { - name: args.name, - status: args.status, - plus_ones: args.plus_ones, - }, - }); - }); - - client.release(); - return { success: true }; -} diff --git a/actions/sendUpdateToRSVPS.ts b/actions/sendUpdateToRSVPS.ts deleted file mode 100644 index 2b1d429b..00000000 --- a/actions/sendUpdateToRSVPS.ts +++ /dev/null @@ -1,71 +0,0 @@ -"use server"; -import { drizzle } from "drizzle-orm/node-postgres"; -import { eq } from "drizzle-orm"; -import { - entities, - permission_token_rights, - phone_rsvps_to_entity, -} from "drizzle/schema"; -import twilio from "twilio"; -import { pool } from "supabase/pool"; - -export async function sendUpdateToRSVPS( - token: { id: string }, - { - entity, - message, - eventName, - sendto, - publicLeafletID, - }: { - entity: string; - message: string; - eventName: string; - publicLeafletID: string; - sendto: { GOING: boolean; MAYBE: boolean; NOT_GOING: boolean }; - }, -) { - let dbclient = await pool.connect(); - const db = drizzle(dbclient); - let token_rights = await db - .select() - .from(permission_token_rights) - .where(eq(permission_token_rights.token, token.id)); - - let RSVPS = db - .select() - .from(phone_rsvps_to_entity) - .innerJoin(entities, eq(phone_rsvps_to_entity.entity, entities.id)) - .where(eq(phone_rsvps_to_entity.entity, entity)); - - dbclient.release(); - - if (!token_rights[0]?.write) return; - let rsvps = await RSVPS; - let entity_set = rsvps[0]?.entities.set; - if (!token_rights.find((r) => r.entity_set === entity_set)) { - return; - } - - const accountSid = process.env.TWILIO_ACCOUNT_SID; - const authToken = process.env.TWILIO_AUTH_TOKEN; - const client = twilio(accountSid, authToken); - - for (let rsvp of rsvps) { - if (sendto[rsvp.phone_rsvps_to_entity.status]) { - let { country_code, phone_number } = rsvp.phone_rsvps_to_entity; - let number = `+${country_code}${phone_number}`; - await client.messages.create({ - contentSid: "HX8e1217f791d38fa4cf7b7b24a02fe10c", - contentVariables: JSON.stringify({ - 1: eventName, - 2: message, - 3: `https://leaflet.pub/${publicLeafletID}`, - }), - from: `${country_code === "1" ? "" : "whatsapp:"}+18449523391`, - messagingServiceSid: "MGffbf9a66770350b25caf3b80b9aac481", - to: country_code === "1" ? number : `whatsapp:${number}`, - }); - } - } -} diff --git a/app/[leaflet_id]/page.tsx b/app/[leaflet_id]/page.tsx index 851ac894..5a90a3b5 100644 --- a/app/[leaflet_id]/page.tsx +++ b/app/[leaflet_id]/page.tsx @@ -7,7 +7,7 @@ import type { Attribute } from "src/replicache/attributes"; import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; import { Leaflet } from "./Leaflet"; import { scanIndexLocal } from "src/replicache/utils"; -import { getRSVPData } from "actions/getRSVPData"; + import { PageSWRDataProvider } from "components/PageSWRDataProvider"; import { getPollData } from "actions/pollActions"; import { supabaseServerClient } from "supabase/serverClient"; @@ -41,24 +41,25 @@ export default async function LeafletPage(props: Props) { ); - let [{ data }, rsvp_data, poll_data] = await Promise.all([ + let [{ data }, poll_data] = await Promise.all([ supabaseServerClient.rpc("get_facts", { root: rootEntity, }), - getRSVPData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), ]); let initialFacts = (data as unknown as Fact[]) || []; // Extract font settings from facts for server-side font loading - const { headingFontId, bodyFontId } = extractFontsFromFacts(initialFacts as any, rootEntity); + const { headingFontId, bodyFontId } = extractFontsFromFacts( + initialFacts as any, + rootEntity, + ); return ( <> {/* Server-side font loading with preload and @font-face */} rsvp.entity === props.entityID) || []; - let going = attendees.filter((rsvp) => rsvp.status === "GOING"); - let maybe = attendees.filter((rsvp) => rsvp.status === "MAYBE"); - let notGoing = attendees.filter((rsvp) => rsvp.status === "NOT_GOING"); - - return ( - - No RSVPs yet - - ) : ( - - {going.length > 0 && - `${going.reduce((acc, g) => acc + 1 + g.plus_ones, 0)} Going`} - {maybe.length > 0 && - `${going.length > 0 ? ", " : ""}${maybe.reduce((acc, m) => acc + 1 + m.plus_ones, 0)} Maybe`} - - ) - } - > - {going.length === 0 && maybe.length === 0 && notGoing.length === 0 && ( -
    No RSVPs yet
    - )} - - - -
    - ); -} - -function AttendeeStatusList(props: { - rsvps: Array<{ - name: string; - phone_number?: string; - plus_ones: number; - status: string; - }>; - title: string; -}) { - if (props.rsvps.length === 0) return null; - return ( -
    -
    - {props.title} ({props.rsvps.length}) -
    - {props.rsvps.map((rsvp) => ( -
    - {rsvp.name} {rsvp.plus_ones > 0 ? `+${rsvp.plus_ones}` : ""} -
    - ))} -
    - ); -} diff --git a/components/Blocks/RSVPBlock/ContactDetailsForm.tsx b/components/Blocks/RSVPBlock/ContactDetailsForm.tsx deleted file mode 100644 index a1a01222..00000000 --- a/components/Blocks/RSVPBlock/ContactDetailsForm.tsx +++ /dev/null @@ -1,366 +0,0 @@ -"use client"; -import { useSmoker, useToaster } from "components/Toast"; -import { RSVP_Status, RSVPButtons, State, useRSVPNameState } from "."; -import { createContext, useContext, useState } from "react"; -import { useRSVPData } from "components/PageSWRDataProvider"; -import { confirmPhoneAuthToken } from "actions/phone_auth/confirm_phone_auth_token"; -import { submitRSVP } from "actions/phone_rsvp_to_event"; - -import { countryCodes } from "src/constants/countryCodes"; -import { Checkbox } from "components/Checkbox"; -import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; -import { Separator } from "components/Layout"; -import { createPhoneAuthToken } from "actions/phone_auth/request_phone_auth_token"; -import { Input, InputWithLabel } from "components/Input"; -import { RequestHeadersContext } from "components/Providers/RequestHeadersProvider"; -import { Popover } from "components/Popover"; -import { theme } from "tailwind.config"; -import { InfoSmall } from "components/Icons/InfoSmall"; - -export function ContactDetailsForm(props: { - status: RSVP_Status; - entityID: string; - setState: (s: State) => void; - setStatus: (s: RSVP_Status) => void; -}) { - let { status, entityID, setState, setStatus } = props; - let focusWithinStyles = - "focus-within:border-tertiary focus-within:outline-solid focus-within:outline-2 focus-within:outline-tertiary focus-within:outline-offset-1"; - let toaster = useToaster(); - let { data, mutate } = useRSVPData(); - let [contactFormState, setContactFormState] = useState< - { state: "details" } | { state: "confirm"; token: string } - >({ state: "details" }); - let { name, setName } = useRSVPNameState(); - let [plus_ones, setPlusOnes] = useState( - data?.rsvps?.find( - (rsvp) => - data.authToken && - rsvp.entity === props.entityID && - data.authToken.country_code === rsvp.country_code && - data.authToken.phone_number === rsvp.phone_number, - )?.plus_ones || 0, - ); - let requestHeaders = useContext(RequestHeadersContext); - const [formState, setFormState] = useState({ - country_code: - countryCodes.find((c) => c[1].toUpperCase() === (requestHeaders.country || "US"))?.[2] || "1", - phone_number: "", - confirmationCode: "", - }); - - let submit = async ( - token: Awaited>, - ) => { - try { - await submitRSVP({ - status, - name: name, - entity: entityID, - plus_ones, - }); - } catch (e) { - //handle failed confirm - return false; - } - - mutate({ - authToken: token, - rsvps: [ - ...(data?.rsvps || []).filter((r) => r.entity !== entityID), - { - name: name, - status, - plus_ones, - entity: entityID, - phone_number: token.phone_number, - country_code: token.country_code, - }, - ], - }); - props.setState({ state: "default" }); - return true; - }; - return contactFormState.state === "details" ? ( - <> -
    { - e.preventDefault(); - if (data?.authToken) { - submit(data.authToken); - toaster({ - content: ( -
    - {status === "GOING" - ? "Yay! You're Going!" - : status === "MAYBE" - ? "You're a Maybe" - : "Sorry you can't make it D:"} -
    - ), - type: "success", - }); - } else { - let tokenId = await createPhoneAuthToken(formState); - setContactFormState({ state: "confirm", token: tokenId }); - } - }} - > - - -
    - -
    - -
    - {formState.country_code !== "1" ? ( - <> - Messages to non-US/Canada numbers will be sent via{" "} - WhatsApp - - ) : null} -
    -
    -
    - setPlusOnes(parseInt(e.currentTarget.value))} - onKeyDown={(e) => { - if (e.key === "Backspace" && !e.currentTarget.value) - e.preventDefault(); - }} - /> -
    -
    - -
    -
    - - { - setState({ state: "default" }); - }} - > - Back - - - RSVP as{" "} - {status === "GOING" - ? "Going" - : status === "MAYBE" - ? "Maybe" - : "Can't Go"} - -
    - - - ) : ( - - setFormState((state) => ({ ...state, confirmationCode: value })) - } - /> - ); -} - -const ConfirmationForm = (props: { - country_code: string; - phoneNumber: string; - value: string; - token: string; - status: RSVP_Status; - submit: ( - token: Awaited>, - ) => Promise; - onChange: (v: string) => void; -}) => { - let smoker = useSmoker(); - let toaster = useToaster(); - return ( -
    { - e.preventDefault(); - let rect = document - .getElementById("rsvp-code-confirm-button") - ?.getBoundingClientRect(); - try { - let token = await confirmPhoneAuthToken(props.token, props.value); - props.submit(token); - toaster({ - content: ( -
    - {props.status === "GOING" - ? "Yay! You're Going!" - : props.status === "MAYBE" - ? "You're a Maybe" - : "Sorry you can't make it D:"} -
    - ), - type: "success", - }); - } catch (error) { - smoker({ - alignOnMobile: "left", - error: true, - text: "invalid code!", - position: { - x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, - y: rect ? rect.top + 26 : 0, - }, - }); - return; - } - }} - > - - - - Confirm - -
    - ); -}; - -const ConsentPopover = (props: { country_code: string }) => { - return ( - }> -
    - By RSVPing I to consent to receive - {props.country_code === "1" ? "" : " WhatsApp"} messages from the event - host, via Leaflet! -
    -
    - ); -}; diff --git a/components/Blocks/RSVPBlock/RSVPBackground.module.css b/components/Blocks/RSVPBlock/RSVPBackground.module.css deleted file mode 100644 index 93dcd918..00000000 --- a/components/Blocks/RSVPBlock/RSVPBackground.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.RSVPWavyBG { - opacity: calc(1 - (var(--accent-1-is-contrast) * 0.6)); - mask-image: url("/RSVPBackground/wavy.svg"); - mask-repeat: repeat repeat; - mask-position: center; - mask-size: 64px; -} diff --git a/components/Blocks/RSVPBlock/SendUpdate.tsx b/components/Blocks/RSVPBlock/SendUpdate.tsx deleted file mode 100644 index fb13b7f6..00000000 --- a/components/Blocks/RSVPBlock/SendUpdate.tsx +++ /dev/null @@ -1,194 +0,0 @@ -"use client"; -import { useState } from "react"; -import { useRSVPData } from "components/PageSWRDataProvider"; -import { useEntitySetContext } from "components/EntitySetProvider"; -import { ButtonPrimary } from "components/Buttons"; -import { Popover } from "components/Popover"; -import { theme } from "tailwind.config"; -import { useToaster } from "components/Toast"; -import { sendUpdateToRSVPS } from "actions/sendUpdateToRSVPS"; -import { useReplicache } from "src/replicache"; -import { Checkbox } from "components/Checkbox"; -import { useReadOnlyShareLink } from "app/[leaflet_id]/actions/ShareOptions"; - -export function SendUpdateButton(props: { entityID: string }) { - let publishLink = useReadOnlyShareLink(); - let { permissions } = useEntitySetContext(); - let { permission_token } = useReplicache(); - let [input, setInput] = useState(""); - let toaster = useToaster(); - let [open, setOpen] = useState(false); - let [checkedRecipients, setCheckedRecipients] = useState({ - GOING: true, - MAYBE: true, - NOT_GOING: false, - }); - - let { data, mutate } = useRSVPData(); - let attendees = - data?.rsvps?.filter((rsvp) => rsvp.entity === props.entityID) || []; - let going = attendees.filter((rsvp) => rsvp.status === "GOING"); - let maybe = attendees.filter((rsvp) => rsvp.status === "MAYBE"); - let notGoing = attendees.filter((rsvp) => rsvp.status === "NOT_GOING"); - - let allRecipients = - ((checkedRecipients.GOING && going.length) || 0) + - ((checkedRecipients.MAYBE && maybe.length) || 0) + - ((checkedRecipients.NOT_GOING && notGoing.length) || 0); - - if (!!!permissions.write) return; - return ( - setOpen(open)} - trigger={ - - Send a Text Blast - - } - > -
    -
    -

    Send a Text Blast to

    - - -