From 7a7415b0ed87340c404fb128eb9f824b25ee84df Mon Sep 17 00:00:00 2001 From: Robert Molina Date: Thu, 21 Aug 2025 02:11:53 -0400 Subject: [PATCH 1/5] feat(block-editor): new block editor and page editor --- apps/web/package.json | 7 +- apps/web/src/ai/mastra/agents/journl-agent.ts | 5 +- .../_components/app-sidebar-page-item.tsx | 2 +- .../_components/create-page-button.tsx | 4 +- .../_components/delete-page-button.tsx | 16 +- apps/web/src/app/(app)/pages/[id]/page.tsx | 39 +- .../(app)/pages/_components/page-blocks.tsx | 12 - .../pages/_components/page-editor.dynamic.tsx | 10 + .../(app)/pages/_components/page-editor.tsx | 71 ++- .../pages/_components/page-title-input.tsx | 110 +++++ .../app/(app)/test-blocknote/component.tsx | 43 -- .../web/src/app/(app)/test-blocknote/page.tsx | 7 - .../app/api/webhooks/journal-entries/route.ts | 1 - .../src/components/editor/block-editor.tsx | 401 ++++++++++++++--- .../web/src/components/editor/block-schema.ts | 26 ++ .../src/components/editor/editor-title.tsx | 139 ------ .../editor/hooks/use-block-changes.ts | 128 ------ .../editor/hooks/use-block-editor.ts | 405 ------------------ .../editor/hooks/use-nested-blocks.ts | 161 ------- .../components/editor/lazy-block-editor.tsx | 135 ------ apps/web/src/components/editor/types.ts | 45 -- .../editor/utils/block-transforms.ts | 66 --- .../editor/utils/document-processor.ts | 132 ------ apps/web/src/components/utils/default-map.ts | 21 + cspell-words.txt | 1 + packages/api/package.json | 2 + packages/api/src/index.ts | 3 + packages/api/src/root.ts | 3 +- packages/api/src/router/blocks.ts | 340 ++++----------- packages/api/src/router/document.ts | 21 + packages/api/src/router/journal.ts | 112 +---- packages/api/src/router/notes.ts | 1 - packages/api/src/router/pages.ts | 258 +++-------- packages/api/src/shared/block-note-schema.ts | 6 + packages/api/src/shared/block-note-tree.ts | 208 +++++++++ packages/api/tsconfig.json | 3 +- packages/db/src/core/block-node.schema.ts | 99 +++++ packages/db/src/core/block.schema.ts | 347 --------------- packages/db/src/core/document.schema.ts | 28 ++ packages/db/src/core/page.schema.ts | 9 +- packages/db/src/schema.ts | 3 +- pnpm-lock.yaml | 59 ++- 42 files changed, 1151 insertions(+), 2338 deletions(-) delete mode 100644 apps/web/src/app/(app)/pages/_components/page-blocks.tsx create mode 100644 apps/web/src/app/(app)/pages/_components/page-editor.dynamic.tsx create mode 100644 apps/web/src/app/(app)/pages/_components/page-title-input.tsx delete mode 100644 apps/web/src/app/(app)/test-blocknote/component.tsx delete mode 100644 apps/web/src/app/(app)/test-blocknote/page.tsx create mode 100644 apps/web/src/components/editor/block-schema.ts delete mode 100644 apps/web/src/components/editor/editor-title.tsx delete mode 100644 apps/web/src/components/editor/hooks/use-block-changes.ts delete mode 100644 apps/web/src/components/editor/hooks/use-block-editor.ts delete mode 100644 apps/web/src/components/editor/hooks/use-nested-blocks.ts delete mode 100644 apps/web/src/components/editor/lazy-block-editor.tsx delete mode 100644 apps/web/src/components/editor/types.ts delete mode 100644 apps/web/src/components/editor/utils/block-transforms.ts delete mode 100644 apps/web/src/components/editor/utils/document-processor.ts create mode 100644 apps/web/src/components/utils/default-map.ts create mode 100644 packages/api/src/router/document.ts create mode 100644 packages/api/src/shared/block-note-schema.ts create mode 100644 packages/api/src/shared/block-note-tree.ts create mode 100644 packages/db/src/core/block-node.schema.ts delete mode 100644 packages/db/src/core/block.schema.ts create mode 100644 packages/db/src/core/document.schema.ts diff --git a/apps/web/package.json b/apps/web/package.json index 0d93ec5..4531a99 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,13 +22,12 @@ "@assistant-ui/react-ai-sdk": "^0.10.16", "@assistant-ui/react-markdown": "^0.10.6", "@assistant-ui/styles": "^0.1.14", - "@blocknote/core": "^0.33.0", - "@blocknote/mantine": "^0.33.0", - "@blocknote/react": "^0.33.0", + "@blocknote/core": "^0.35.0", + "@blocknote/mantine": "^0.35.0", + "@blocknote/react": "^0.35.0", "@daveyplate/better-auth-ui": "^2.1.0", "@hookform/resolvers": "^5.0.1", "@mantine/core": "^8.1.3", - "@mantine/hooks": "^8.1.3", "@mastra/core": "^0.12.1", "@mastra/libsql": "^0.12.0", "@mastra/loggers": "^0.10.5", diff --git a/apps/web/src/ai/mastra/agents/journl-agent.ts b/apps/web/src/ai/mastra/agents/journl-agent.ts index d98715a..0c90fef 100644 --- a/apps/web/src/ai/mastra/agents/journl-agent.ts +++ b/apps/web/src/ai/mastra/agents/journl-agent.ts @@ -16,7 +16,7 @@ const AGENT_INSTRUCTIONS = () => { return ` You are Journl, a deeply curious companion for personal reflection and self-discovery. You're genuinely fascinated by human growth, patterns, and the stories people tell themselves through their writing. -**Today's date is ${today}.** +Current date: ${today} ## Your Personality @@ -36,8 +36,6 @@ When users mention their thoughts, experiences, or ask about patterns, you intui You always link what you find naturally: [brief description](/journal/YYYY-MM-DD) for journal entries or [title](/pages/uuid) for pages. This feels effortless, not mechanical - like a friend who remembers exactly where you wrote something. -**The links to the journal entries and pages are always relative to the current page**. - ## Your Approach for Different Needs **For emotional or personal queries:** Be genuinely empathetic and cite their own insights back to them. Quote their exact words when it's meaningful. Never add external advice - stay within their own reflections. @@ -53,6 +51,7 @@ You always link what you find naturally: [brief description](/journal/YYYY-MM-DD You naturally structure your responses with: 1. Acknowledging what they're asking about 2. Sharing what you found (with links to sources) + - NOTE: **The links to the journal entries and pages are always relative to the current page.** 3. Pointing out patterns or insights that stand out 4. Ending with a thoughtful question or suggestion diff --git a/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-page-item.tsx b/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-page-item.tsx index 4021f22..452a202 100644 --- a/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-page-item.tsx +++ b/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-page-item.tsx @@ -34,7 +34,7 @@ export function AppSidebarPageItem(props: AppSidebarPageItemProps) { href={`/pages/${page?.id}`} className="line-clamp-1 min-w-0 flex-1 truncate hover:underline" > - {page?.title || "New Page"} + {page?.title || "New page"} {!!page && ( { @@ -64,7 +64,7 @@ export function CreatePageButton() { disabled={showLoading} > - {showLoading ? "Creating..." : "New Page"} + {showLoading ? "Creating..." : "New page"} diff --git a/apps/web/src/app/(app)/@appSidebar/_components/delete-page-button.tsx b/apps/web/src/app/(app)/@appSidebar/_components/delete-page-button.tsx index a9dfa76..77ff8b8 100644 --- a/apps/web/src/app/(app)/@appSidebar/_components/delete-page-button.tsx +++ b/apps/web/src/app/(app)/@appSidebar/_components/delete-page-button.tsx @@ -32,7 +32,7 @@ export function DeletePageButton({ page, className }: DeletePageButtonProps) { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const { mutate: deletePage, isPending: isDeleting } = useMutation( - trpc.pages.delete.mutationOptions({}), + trpc.document.delete.mutationOptions({}), ); const handleDelete = () => { @@ -42,16 +42,14 @@ export function DeletePageButton({ page, className }: DeletePageButtonProps) { const confirmDelete = () => { startTransition(() => { deletePage( - { id: page.id }, + { id: page.document_id }, { onError: (error) => { console.error("Failed to delete page:", error); }, - onSuccess: (result) => { - const deletedPageId = result.deletedPage.id; - + onSuccess: () => { // Only navigate away if we're currently on the deleted page - if (pathname === `/pages/${deletedPageId}`) { + if (pathname === `/pages/${page.id}`) { router.push("/journal"); } @@ -60,18 +58,18 @@ export function DeletePageButton({ page, className }: DeletePageButtonProps) { trpc.pages.getAll.queryOptions().queryKey, (oldPages: Page[] | undefined) => { if (!oldPages) return []; - return oldPages.filter((p) => p.id !== deletedPageId); + return oldPages.filter((p) => p.id !== page.id); }, ); // Remove the specific page from cache queryClient.removeQueries({ - queryKey: trpc.pages.getById.queryKey({ id: deletedPageId }), + queryKey: trpc.pages.getById.queryKey({ id: page.id }), }); // Cancel any in-flight queries for this page queryClient.cancelQueries({ - queryKey: trpc.pages.getById.queryKey({ id: deletedPageId }), + queryKey: trpc.pages.getById.queryKey({ id: page.id }), }); // Close the dialog after successful deletion diff --git a/apps/web/src/app/(app)/pages/[id]/page.tsx b/apps/web/src/app/(app)/pages/[id]/page.tsx index 0aa4e07..ee36b8f 100644 --- a/apps/web/src/app/(app)/pages/[id]/page.tsx +++ b/apps/web/src/app/(app)/pages/[id]/page.tsx @@ -1,6 +1,8 @@ import { notFound } from "next/navigation"; -import { api, prefetch, trpc } from "~/trpc/server"; -import { PageEditor } from "../_components/page-editor"; +import { Suspense } from "react"; +import { api } from "~/trpc/server"; +import { DynamicPageEditor } from "../_components/page-editor.dynamic"; +import { PageTitleInput } from "../_components/page-title-input"; export default async function Page({ params, @@ -15,14 +17,27 @@ export default async function Page({ notFound(); } - if (page?.children && page.children.length > 0) { - prefetch( - trpc.blocks.loadPageChunk.queryOptions({ - limit: 100, - parentChildren: page.children, - }), - ); - } - - return ; + return ( +
+
+ + + + +
+
+ ); } diff --git a/apps/web/src/app/(app)/pages/_components/page-blocks.tsx b/apps/web/src/app/(app)/pages/_components/page-blocks.tsx deleted file mode 100644 index eb7f1ae..0000000 --- a/apps/web/src/app/(app)/pages/_components/page-blocks.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { BlockEditor } from "~/components/editor/block-editor"; - -type PageBlocksProps = { - parentId: string; - parentType: "page" | "block"; -}; - -export function PageBlocks({ parentId, parentType }: PageBlocksProps) { - return ; -} diff --git a/apps/web/src/app/(app)/pages/_components/page-editor.dynamic.tsx b/apps/web/src/app/(app)/pages/_components/page-editor.dynamic.tsx new file mode 100644 index 0000000..308c211 --- /dev/null +++ b/apps/web/src/app/(app)/pages/_components/page-editor.dynamic.tsx @@ -0,0 +1,10 @@ +"use client"; + +import dynamic from "next/dynamic"; + +export const DynamicPageEditor = dynamic( + () => import("./page-editor").then((mod) => mod.PageEditor), + { + ssr: false, + }, +); diff --git a/apps/web/src/app/(app)/pages/_components/page-editor.tsx b/apps/web/src/app/(app)/pages/_components/page-editor.tsx index 66198b5..b0e894d 100644 --- a/apps/web/src/app/(app)/pages/_components/page-editor.tsx +++ b/apps/web/src/app/(app)/pages/_components/page-editor.tsx @@ -1,17 +1,72 @@ "use client"; -import { LazyBlockEditor } from "~/components/editor/lazy-block-editor"; +import type { BlockTransaction } from "@acme/api"; +import type { Page } from "@acme/db/schema"; +import type { PartialBlock } from "@blocknote/core"; +import { useMutation } from "@tanstack/react-query"; +import { useRef } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { BlockEditor } from "~/components/editor/block-editor"; +import { env } from "~/env"; +import { useTRPC } from "~/trpc/react"; type PageEditorProps = { - id: string; + page: Pick; + initialBlocks: [PartialBlock, ...PartialBlock[]] | undefined; + debounceTime?: number; }; -export function PageEditor({ id }: PageEditorProps) { +export function PageEditor({ + page, + initialBlocks, + debounceTime = 200, +}: PageEditorProps) { + const trpc = useTRPC(); + const { mutate, isPending } = useMutation({ + ...trpc.blocks.saveTransactions.mutationOptions({}), + onSuccess: () => { + if (pendingChangesRef.current.length > 0) { + debouncedMutate(); + } + }, + }); + const pendingChangesRef = useRef([]); + const debouncedMutate = useDebouncedCallback(() => { + if (isPending) return; + const transactions = pendingChangesRef.current; + pendingChangesRef.current = []; + mutate({ document_id: page.document_id, transactions }); + }, debounceTime); + + function handleEditorChange(transactions: BlockTransaction[]) { + pendingChangesRef.current.push(...transactions); + + // Leaving this here for debugging purposes because this logic is a fucking mess. + if (env.NODE_ENV === "development") { + console.debug("saveTransactions 👀", { + transactions: pendingChangesRef.current.map((t) => + t.type === "block_remove" || t.type === "block_upsert" + ? { + ...t, + element: document.querySelector(`[data-id="${t.args.id}"]`), + } + : { + ...t, + from_element: document.querySelector( + `[data-id="${t.args.from_id}"]`, + ), + to_element: document.querySelector( + `[data-id="${t.args.to_id}"]`, + ), + }, + ), + }); + } + + debouncedMutate(); + } + return ( -
-
- -
-
+ ); } diff --git a/apps/web/src/app/(app)/pages/_components/page-title-input.tsx b/apps/web/src/app/(app)/pages/_components/page-title-input.tsx new file mode 100644 index 0000000..adbf176 --- /dev/null +++ b/apps/web/src/app/(app)/pages/_components/page-title-input.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { Page } from "@acme/db/schema"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { Input } from "~/components/ui/input"; +import { cn } from "~/components/utils"; +import { useTRPC } from "~/trpc/react"; + +type PageEditorTitleProps = { + page: Pick; + placeholder?: string; + className?: string; + debounceTime?: number; + onTitleChange?: (title: string) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; +} & Omit, "value" | "onChange" | "onKeyDown">; + +export function PageTitleInput({ + page, + placeholder = "New page", + className, + debounceTime = 150, + onTitleChange, + onKeyDown, + ref, + ...rest +}: PageEditorTitleProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { mutate: updatePageTitle } = useMutation( + trpc.pages.updateTitle.mutationOptions({}), + ); + const [title, setTitle] = useState(page.title); + + // Debounced API call for page title updates + const debouncedUpdate = useDebouncedCallback((newTitle: string) => { + // Optimistically update the cache + queryClient.setQueryData( + trpc.pages.getById.queryOptions({ id: page.id }).queryKey, + (old) => { + if (!old) return old; + return { + ...old, + title: newTitle, + updatedAt: new Date().toISOString(), + }; + }, + ); + + // Update the pages.getAll query cache + queryClient.setQueryData( + trpc.pages.getAll.queryOptions().queryKey, + (old) => { + if (!old) return old; + return old.map((p) => + p.id === page.id + ? { + ...p, + title: newTitle, + } + : p, + ); + }, + ); + + // Execute the mutation + updatePageTitle( + { id: page.id, title: newTitle }, + { + onError: () => { + // If the mutation fails, invalidate queries to refetch correct data + queryClient.invalidateQueries({ + queryKey: trpc.pages.getById.queryOptions({ id: page.id }).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.pages.getAll.queryOptions().queryKey, + }); + }, + }, + ); + }, debounceTime); + + function handleChange(e: React.ChangeEvent) { + const newTitle = e.target.value; + setTitle(newTitle); + onTitleChange?.(newTitle); + debouncedUpdate(newTitle); + } + + function handleKeyDown(e: React.KeyboardEvent) { + onKeyDown?.(e); + } + + return ( + + ); +} diff --git a/apps/web/src/app/(app)/test-blocknote/component.tsx b/apps/web/src/app/(app)/test-blocknote/component.tsx deleted file mode 100644 index 82ec7d5..0000000 --- a/apps/web/src/app/(app)/test-blocknote/component.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; -import { BlockNoteView } from "@blocknote/mantine"; -import { useCreateBlockNote } from "@blocknote/react"; -import { AlertBlock } from "~/components/ui/custom-blocks/alert-block"; - -// Minimal schema with NO custom blocks -const schema = BlockNoteSchema.create({ - blockSpecs: { - ...defaultBlockSpecs, - alert: AlertBlock, - }, -}); - -export default function TestComponent() { - const editor = useCreateBlockNote({ - initialContent: [ - { - content: "Test paragraph", - type: "paragraph", - }, - { - content: "This is an example alert", - type: "alert", - }, - { - content: "Click the '!' icon to change the alert type", - type: "paragraph", - }, - ], - schema, - }); - - return ( -
-

BlockNote Test Page

-
- -
-
- ); -} diff --git a/apps/web/src/app/(app)/test-blocknote/page.tsx b/apps/web/src/app/(app)/test-blocknote/page.tsx deleted file mode 100644 index 862b380..0000000 --- a/apps/web/src/app/(app)/test-blocknote/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import TestComponent from "./component"; - -export default function TestBlockNotePage() { - return ; -} diff --git a/apps/web/src/app/api/webhooks/journal-entries/route.ts b/apps/web/src/app/api/webhooks/journal-entries/route.ts index 9e0ffcf..14756da 100644 --- a/apps/web/src/app/api/webhooks/journal-entries/route.ts +++ b/apps/web/src/app/api/webhooks/journal-entries/route.ts @@ -17,7 +17,6 @@ export const POST = handler(zJournalEntry, async (payload) => { ); } - // TODO: Chunk the journal entries. const { embedding } = await embed({ maxRetries: 5, model: openai.embedding("text-embedding-3-small"), diff --git a/apps/web/src/components/editor/block-editor.tsx b/apps/web/src/components/editor/block-editor.tsx index 06efe7a..c4ed5dd 100644 --- a/apps/web/src/components/editor/block-editor.tsx +++ b/apps/web/src/components/editor/block-editor.tsx @@ -1,81 +1,356 @@ "use client"; -import type { BlockIdentifier } from "@blocknote/core"; +import type { BlockTransaction } from "@acme/api"; +import type { Block, PartialBlock } from "@blocknote/core"; import { BlockNoteView } from "@blocknote/mantine"; +import { useCreateBlockNote } from "@blocknote/react"; import { useTheme } from "next-themes"; -import { useMemo, useRef } from "react"; -import { EditorTitle } from "~/components/editor/editor-title"; -import { useBlockEditor } from "./hooks/use-block-editor"; -import type { BlockNoteEditorProps } from "./types"; +import { type ComponentProps, useRef } from "react"; +import { env } from "~/env"; +import { DefaultMap } from "../utils/default-map"; +import { + type BlockPrimitive, + type EditorPrimitive, + schema, +} from "./block-schema"; + +type EditorPrimitiveOnChangeParams = Parameters< + Parameters[0] +>; + +type EditorBlock = BlockPrimitive & { + previous?: BlockPrimitive; + next?: BlockPrimitive; + parent?: BlockPrimitive; + index: number; +}; + +type BlockChange = { + type: "upsert" | "delete" | "dentation" | "parent"; + block: EditorBlock; + oldBlock: EditorBlock | undefined; +}; + +type BlockTransactionMap = DefaultMap< + Extract, + Map +>; + +type EdgeTransactionMap = DefaultMap< + Extract, + DefaultMap> +>; + +type BlockEditorProps = Omit< + ComponentProps, + "editor" | "theme" | "onChange" +> & { + /** + * The initial blocks to render in the editor. + * @note The initial blocks must be a non-empty array. + */ + initialBlocks?: [PartialBlock, ...PartialBlock[]] | undefined; + /** + * The function to call when the editor changes. + */ + onChange?: (transactions: BlockTransaction[]) => void; +}; export function BlockEditor({ - blocks, - parentId, - parentType, - isFullyLoaded, - title, - titlePlaceholder = "New page", -}: BlockNoteEditorProps) { + initialBlocks, + onChange, + ...rest +}: BlockEditorProps) { const { theme, systemTheme } = useTheme(); - const titleRef = useRef(null); - - // Use the main editor hook that contains all the logic - const { editor, handleEditorChange } = useBlockEditor( - blocks, - parentId, - parentType, - isFullyLoaded, - ); + const editor = useCreateBlockNote({ + animations: false, + initialContent: initialBlocks, + schema, + }); + const previousEditorRef = useRef(editor.document); - const firstBlock = useMemo(() => { - return editor.document[0]; - }, [editor.document]); + /** + * Change handler for the editor. + * + * @privateRemarks + * + * The biggest challenge is the computation of the edges rather than the blocks. + * An intuitive approach is to compute the edges for the previous state and the current state and then compute the diff. + * For example, a brute force approach would be to loop over all blocks and detect if their adjacent blocks are different. + * If they are we need to remove the previous edge and insert the new edge. + * However we can take this a step further and reduce the amount of blocks we need to check by keeping track of the affected blocks while handling the block changes. + * After we are done handling block changes we can loop over the affected blocks and compare the previous and current state of the edges. + * If the edges are different we need to remove the previous ones and (if it's not a deleted block) insert the new ones. + * + * 1. Deletes: + * - We must create `block_remove` transactions for all deleted blocks (we get these from BlockNote). + * - The backend will handle `edge_remove` transactions for removed blocks. + * - We need to compute `edge_insert` transactions for all edges that need to be reconnected. + * - To do this, we need to detect the siblings of the previous and next block of the deleted block that are not being deleted. + * - For example, it's not necessarily the siblings right next to the deleted block because multiple adjacent blocks can be deleted. + * + * 2. Inserts: + * - We must create `block_upsert` transactions for all inserted blocks (we get these from BlockNote). + * - We need to compute `edge_remove` transactions for the previous edges of the adjacent blocks. + * - Remember that we can insert multiple adjacent blocks at once, so we need to check the previous state of the adjacent blocks. + * - We will check the previous siblings of the previous and next block of the inserted block and delete the edges between them. + * - We need to compute `edge_insert` transactions for the next edges of the adjacent blocks. + * - We will connect the inserted blocks to their adjacent blocks. + * + * 3. Moves: + * - We must create `block_upsert` transactions for all moved blocks (we need to detect these manually). + * - A block is considered moved if its parent or position has changed (a changed position means at least one of the adjacent blocks are different). + * - Moves are more complex than inserts and deletes because we need to compute edges for the blocks that moved and the blocks that are adjacent to the moved blocks (assuming they didn't move). + */ + function handleEditorChange( + currentEditor: EditorPrimitiveOnChangeParams[0], + context: EditorPrimitiveOnChangeParams[1], + ) { + if (!onChange) return; - const resolvedTheme = theme === "system" ? systemTheme : theme; + const oldBlocks = getEditorBlocks(previousEditorRef.current); + const currentBlocks = getEditorBlocks(currentEditor.document); + + const blockChanges: BlockChange[] = []; + + for (const change of context.getChanges()) { + if (change.type === "move") continue; + + const oldBlock = oldBlocks.get(change.block.id); + const currentBlock = currentBlocks.get(change.block.id); - const handleTitleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" || e.key === "ArrowDown") { - e.preventDefault(); - if (editor && firstBlock) { - editor.setTextCursorPosition(firstBlock.id as BlockIdentifier, "end"); - editor.focus(); + if ( + currentBlock && + (change.type === "update" || change.type === "insert") + ) { + blockChanges.push({ + block: currentBlock, + oldBlock, + type: "upsert", + }); + } else if (oldBlock && change.type === "delete") { + blockChanges.push({ + block: oldBlock, + oldBlock: undefined, + type: "delete", + }); } } - }; - - const handleEditorKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "ArrowUp" && parentType === "page") { - const cursorPos = editor.getTextCursorPosition(); - if (cursorPos.block.id === firstBlock?.id) { - titleRef.current?.focus(); - setTimeout(() => { - const length = titleRef.current?.value.length || 0; - titleRef.current?.setSelectionRange(length, length); - }, 0); + + // We need to manually detect moves because BlockNote doesn't provide a way to do this. + for (const block of currentBlocks.values()) { + const oldBlock = oldBlocks.get(block.id); + + // We skip insertions. + if (!oldBlock) continue; + + const isParentDifferent = oldBlock.parent?.id !== block.parent?.id; + const isPositionDifferent = + oldBlock.next?.id !== block.next?.id || + oldBlock.previous?.id !== block.previous?.id; + + if (isParentDifferent || isPositionDifferent) { + blockChanges.push({ + block, + oldBlock, + type: isParentDifferent ? "parent" : "dentation", + }); } } - }; + + const blockTransactions: BlockTransactionMap = new DefaultMap( + () => new Map(), + ); + + const edgeTransactions: EdgeTransactionMap = new DefaultMap( + () => new DefaultMap(() => new Map()), + ); + + for (const { block, oldBlock, type } of blockChanges) { + if (type === "delete") { + blockTransactions.get("block_remove").set(block.id, { + id: block.id, + }); + } else if (type === "upsert" || type === "parent") { + blockTransactions.get("block_upsert").set(block.id, { + data: { + content: block.content, + props: block.props, + type: block.type, + }, + id: block.id, + parent_id: block.parent?.id ?? null, + }); + } + + const isPreviousEdgeDifferent = + oldBlock?.previous?.id !== block.previous?.id; + const isNextEdgeDifferent = oldBlock?.next?.id !== block.next?.id; + + if (isPreviousEdgeDifferent) { + if (oldBlock?.previous) { + edgeTransactions + .get("edge_remove") + .get(oldBlock.previous.id) + .set(oldBlock.id, { + from_id: oldBlock.previous.id, + to_id: oldBlock.id, + }); + } + if (type !== "delete" && block.previous) { + edgeTransactions + .get("edge_insert") + .get(block.previous.id) + .set(block.id, { + from_id: block.previous.id, + to_id: block.id, + }); + } + } + + if (isNextEdgeDifferent) { + if (oldBlock?.next) { + edgeTransactions + .get("edge_remove") + .get(oldBlock.id) + .set(oldBlock.next.id, { + from_id: oldBlock.id, + to_id: oldBlock.next.id, + }); + } + if (type !== "delete" && block.next) { + edgeTransactions.get("edge_insert").get(block.id).set(block.next.id, { + from_id: block.id, + to_id: block.next.id, + }); + } + } + } + + previousEditorRef.current = currentEditor.document; + + const transactions = [ + ...flattenBlockTransactions(blockTransactions), + ...flattenEdgeTransactions(edgeTransactions), + ]; + + // Leaving this here for debugging purposes because this logic is a fucking mess. + if (env.NODE_ENV === "development") { + console.debug("saveTransactions 👀", { + transactions: transactions.map((t) => + t.type === "block_remove" || t.type === "block_upsert" + ? { + ...t, + element: document.querySelector(`[data-id="${t.args.id}"]`), + } + : { + ...t, + from_element: document.querySelector( + `[data-id="${t.args.from_id}"]`, + ), + to_element: document.querySelector( + `[data-id="${t.args.to_id}"]`, + ), + }, + ), + }); + } + + onChange(transactions); + } + + const resolvedTheme = theme === "system" ? systemTheme : theme; return ( - <> - -
- -
- + ); } + +/** + * Flattens a tree of blocks into a single array of blocks. + * @param blocks - The blocks to flatten. + * @param parent - The parent block of the current block list. + * @returns A flattened array of blocks. + */ +function getEditorBlocks(blocks: BlockPrimitive[], parent?: BlockPrimitive) { + const flattened = new Map(); + + for (const [index, block] of blocks.entries()) { + const editorBlock: EditorBlock = { + ...block, + index, + next: blocks[index + 1], + parent, + previous: blocks[index - 1], + }; + + flattened.set(block.id, editorBlock); + + if (block.children) { + getEditorBlocks(block.children, block).forEach((editorBlock) => + flattened.set(editorBlock.id, editorBlock), + ); + } + } + + return flattened; +} + +/** + * Flattens BlockTransactions map into an array of BlockTransaction objects. + */ +function flattenBlockTransactions( + blockTransactions: BlockTransactionMap, +): BlockTransaction[] { + const transactions: BlockTransaction[] = []; + + for (const [type, blockMap] of blockTransactions.entries()) { + for (const args of blockMap.values()) { + transactions.push({ + args, + type, + } as BlockTransaction); + } + } + + return transactions; +} + +/** + * Flattens EdgeTransactions map into an array of BlockTransaction objects. + */ +function flattenEdgeTransactions( + edgeTransactions: EdgeTransactionMap, +): BlockTransaction[] { + const transactions: BlockTransaction[] = []; + + // Removing redundant edge remove/insert pairs + const removeMap = edgeTransactions.get("edge_remove"); + const insertMap = edgeTransactions.get("edge_insert"); + for (const [fromId, toMap] of removeMap.entries()) { + for (const toId of toMap.keys()) { + if (insertMap.get(fromId).has(toId)) { + insertMap.get(fromId).delete(toId); + removeMap.get(fromId).delete(toId); + } + } + } + + for (const [type, fromMap] of edgeTransactions.entries()) { + for (const toMap of fromMap.values()) { + for (const args of toMap.values()) { + transactions.push({ + args, + type, + } as BlockTransaction); + } + } + } + + return transactions; +} diff --git a/apps/web/src/components/editor/block-schema.ts b/apps/web/src/components/editor/block-schema.ts new file mode 100644 index 0000000..5aa8169 --- /dev/null +++ b/apps/web/src/components/editor/block-schema.ts @@ -0,0 +1,26 @@ +"use client"; + +import { + type Block, + type BlockNoteEditor, + BlockNoteSchema, + defaultBlockSpecs, +} from "@blocknote/core"; + +export const schema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + }, +}); + +export type EditorPrimitive = BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; + +export type BlockPrimitive = Block< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; diff --git a/apps/web/src/components/editor/editor-title.tsx b/apps/web/src/components/editor/editor-title.tsx deleted file mode 100644 index 97ba01e..0000000 --- a/apps/web/src/components/editor/editor-title.tsx +++ /dev/null @@ -1,139 +0,0 @@ -"use client"; - -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { forwardRef, useState } from "react"; -import { useDebouncedCallback } from "use-debounce"; -import { Input } from "~/components/ui/input"; -import { cn } from "~/components/utils"; -import { useTRPC } from "~/trpc/react"; - -type EditorTitleProps = { - parentId: string; - parentType: "page" | "journal_entry" | "block"; - title?: string; - placeholder?: string; - className?: string; - debounceTime?: number; - onTitleChange?: (title: string) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; -} & Omit, "value" | "onChange" | "onKeyDown">; - -export const EditorTitle = forwardRef( - ( - { - parentId, - parentType, - title = "", - placeholder = "Untitled", - className, - debounceTime = 400, - onTitleChange, - onKeyDown, - ...rest - }, - ref, - ) => { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - // Only pages can be edited - const isEditable = parentType === "page"; - - // Mutation for updating page titles - const { mutate: updatePageTitle } = useMutation( - trpc.pages.updateTitle.mutationOptions({}), - ); - - // Use local state only, initialized with the title prop - const [localTitle, setLocalTitle] = useState(title); - - // Debounced API call for page title updates - const debouncedUpdate = useDebouncedCallback((newTitle: string) => { - if (!isEditable) return; - - // Optimistically update the cache - queryClient.setQueryData( - trpc.pages.getById.queryOptions({ id: parentId }).queryKey, - (old: any) => { - if (!old) return old; - return { - ...old, - title: newTitle, - updatedAt: new Date().toISOString(), - }; - }, - ); - - // Update the pages.getAll query cache - queryClient.setQueryData( - trpc.pages.getAll.queryOptions().queryKey, - (old: any) => { - if (!old) return old; - return old.map((page: any) => - page.id === parentId - ? { - ...page, - title: newTitle, - updatedAt: new Date().toISOString(), - } - : page, - ); - }, - ); - - // Execute the mutation - updatePageTitle( - { id: parentId, title: newTitle || "" }, - { - onError: () => { - // If the mutation fails, invalidate queries to refetch correct data - queryClient.invalidateQueries({ - queryKey: trpc.pages.getById.queryOptions({ id: parentId }) - .queryKey, - }); - queryClient.invalidateQueries({ - queryKey: trpc.pages.getAll.queryOptions().queryKey, - }); - }, - }, - ); - }, debounceTime); - - const handleChange = (e: React.ChangeEvent) => { - const newTitle = e.target.value; - setLocalTitle(newTitle); - onTitleChange?.(newTitle); - - // Only trigger API update for pages - if (isEditable) { - debouncedUpdate(newTitle || ""); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Only call the parent onKeyDown if editing is allowed - if (isEditable) { - onKeyDown?.(e); - } - }; - - return ( - - ); - }, -); - -EditorTitle.displayName = "EditorTitle"; diff --git a/apps/web/src/components/editor/hooks/use-block-changes.ts b/apps/web/src/components/editor/hooks/use-block-changes.ts deleted file mode 100644 index 501e08c..0000000 --- a/apps/web/src/components/editor/hooks/use-block-changes.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { BlockWithChildren } from "@acme/db/schema"; -import { useCallback, useRef } from "react"; -import type { - BlockChange, - BlockChangeType, - ProcessedBlockChange, -} from "../types"; -import { flattenBlocks } from "../utils/block-transforms"; -import type { EditorBlock } from "./use-block-editor"; - -export function useBlockChanges(blocks: BlockWithChildren[]) { - // Track all changes per block ID and current parent children order - const blockChangesRef = useRef>(new Map()); - const currentParentChildrenRef = useRef([]); - const hasUnsavedChangesRef = useRef(false); - const existingBlockIdsRef = useRef>(new Set()); - - // Initialize existing block IDs from the loaded blocks - const initializeExistingBlocks = useCallback(() => { - const currentFlattened = flattenBlocks(blocks); - existingBlockIdsRef.current = new Set(currentFlattened.map((b) => b.id)); - }, [blocks]); - - // Add a block change to the tracking - const addBlockChange = useCallback((change: BlockChange) => { - const existingChanges = blockChangesRef.current.get(change.blockId) || []; - blockChangesRef.current.set(change.blockId, [...existingChanges, change]); - hasUnsavedChangesRef.current = true; - - // Track newly inserted blocks - if (change.type === "insert") { - existingBlockIdsRef.current.add(change.blockId); - } - }, []); - - // Handle individual block changes from editor.onChange - const handleBlockChanges = useCallback( - (changes: Array<{ type: BlockChangeType; block: EditorBlock }>) => { - for (const change of changes) { - const { block, type } = change; - const blockId = block.id; - - // Convert insert to update if block already exists - const actualType = - type === "insert" && existingBlockIdsRef.current.has(blockId) - ? "update" - : type; - - const newChange: BlockChange = { - blockId, - data: { ...block }, - timestamp: Date.now(), - type: actualType, - }; - - addBlockChange(newChange); - } - }, - [addBlockChange], - ); - - // Update parent children order - const updateParentChildren = useCallback((childrenIds: string[]) => { - currentParentChildrenRef.current = childrenIds; - }, []); - - // Process all batched changes and convert to API format - const processAllChanges = useCallback((): { - blockChanges: ProcessedBlockChange[]; - parentChildren: string[]; - } => { - const changes = blockChangesRef.current; - const parentChildren = currentParentChildrenRef.current; - const blockChanges: ProcessedBlockChange[] = []; - - for (const [blockId, blockChangeList] of changes.entries()) { - const hasInsert = blockChangeList.some((c) => c.type === "insert"); - const hasDelete = blockChangeList.some((c) => c.type === "delete"); - const lastChange = blockChangeList[blockChangeList.length - 1]; - - if (!lastChange) continue; - - // Skip blocks that were inserted then deleted in the same batch - if (hasInsert && hasDelete) { - continue; - } - - // Determine final operation type - let finalType: BlockChangeType; - if (hasDelete) { - finalType = "delete"; - } else if (hasInsert) { - finalType = "insert"; - } else { - finalType = "update"; - } - - blockChanges.push({ - blockId, - data: lastChange.data as unknown as EditorBlock, - newParentId: lastChange.newParentId, - newParentType: lastChange.newParentType, - type: finalType, - }); - } - - return { blockChanges, parentChildren }; - }, []); - - // Clear processed changes - const clearChanges = useCallback(() => { - blockChangesRef.current.clear(); - hasUnsavedChangesRef.current = false; - }, []); - - // Check if there are unsaved changes - const hasUnsavedChanges = () => hasUnsavedChangesRef.current; - - return { - addBlockChange, - clearChanges, - handleBlockChanges, - hasUnsavedChanges, - initializeExistingBlocks, - processAllChanges, - updateParentChildren, - }; -} diff --git a/apps/web/src/components/editor/hooks/use-block-editor.ts b/apps/web/src/components/editor/hooks/use-block-editor.ts deleted file mode 100644 index fadc581..0000000 --- a/apps/web/src/components/editor/hooks/use-block-editor.ts +++ /dev/null @@ -1,405 +0,0 @@ -import type { BlockWithChildren } from "@acme/db/schema"; -import { - type Block, - BlockNoteSchema, - defaultBlockSpecs, -} from "@blocknote/core"; -import { useCreateBlockNote } from "@blocknote/react"; -import { useMutation } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { toast } from "sonner"; -import { useDebouncedCallback } from "use-debounce"; -import { useTRPC } from "~/trpc/react"; -import type { BlockChange } from "../types"; -import { - convertToBlockNoteFormat, - extractAllBlockIds, -} from "../utils/block-transforms"; -import { - detectParentChanges, - flattenDocument, -} from "../utils/document-processor"; -import { useBlockChanges } from "./use-block-changes"; - -const schema = BlockNoteSchema.create({ - blockSpecs: { - ...defaultBlockSpecs, - // TODO: Add custom blocks here - }, -}); - -// Type for blocks that includes our custom title block -export type EditorBlock = Block< - typeof schema.blockSchema, - typeof schema.inlineContentSchema, - typeof schema.styleSchema ->; - -/** - * Hook to manage BlockNote editor with database synchronization. - * Handles block changes, deletions, and parent-child relationships. - */ -export function useBlockEditor( - blocks: BlockWithChildren[], - parentId: string, - parentType: "page" | "journal_entry" | "block", - isFullyLoaded: boolean, -) { - const trpc = useTRPC(); - const prevDocumentRef = useRef([]); - - // Block changes management - const { - addBlockChange, - clearChanges, - handleBlockChanges, - initializeExistingBlocks, - processAllChanges, - updateParentChildren, - } = useBlockChanges(blocks); - - // API mutation for processing editor changes - const { mutate: processEditorChanges } = useMutation( - trpc.blocks.processEditorChanges.mutationOptions({ - onError: (error) => - console.error("Process editor changes failed:", error), - onSuccess: (data) => - console.debug("Process editor changes successful:", data), - }), - ); - - // API mutation for updating embed timestamp (triggers embedding generation) - const { mutate: updateEmbedTimestamp } = useMutation( - trpc.pages.updateEmbedTimestamp.mutationOptions({ - onError: (error) => - console.error("Update embed timestamp failed:", error), - onSuccess: () => toast.success("Your page is now searchable."), - }), - ); - - // Convert blocks to BlockNote format - const initialBlocks = useMemo(() => { - if (blocks.length === 0) { - return undefined; - } - return convertToBlockNoteFormat(blocks); - }, [blocks]); - - // Create BlockNote editor - const editor = useCreateBlockNote({ - animations: false, - initialContent: initialBlocks, - schema, - }); - - // Send all changes to API - const sendChangesToAPI = useCallback(() => { - const { blockChanges, parentChildren } = processAllChanges(); - - if (blockChanges.length === 0 && parentChildren.length === 0) return; - - processEditorChanges({ - blockChanges, - parentChildren, - parentId, - parentType, - updateChildren: true, - }); - - clearChanges(); - }, [ - processAllChanges, - processEditorChanges, - parentId, - parentType, - clearChanges, - ]); - - // Debounced API calls - const debouncedSendChanges = useDebouncedCallback(sendChangesToAPI, 500, { - leading: false, - trailing: true, - }); - - // Track if we have pending embedding updates - const hasPendingEmbeddingUpdate = useRef(false); - // Track if embedding update has already been forced during cleanup - const hasEmbeddingBeenForced = useRef(false); - - // Force embedding update (used for cleanup) - const forceEmbeddingUpdate = useCallback(() => { - if ( - parentType === "page" && - hasPendingEmbeddingUpdate.current && - !hasEmbeddingBeenForced.current - ) { - updateEmbedTimestamp({ id: parentId }); - hasPendingEmbeddingUpdate.current = false; // Clear pending flag after forcing - hasEmbeddingBeenForced.current = true; // Mark as forced to prevent multiple calls - } - }, [parentType, parentId, updateEmbedTimestamp]); - - // Separate debounced call for embedding updates (longer delay) - const debouncedUpdateEmbedding = useDebouncedCallback( - () => { - // Only update embedding timestamp for pages (not individual blocks) - if (parentType === "page") { - updateEmbedTimestamp({ id: parentId }); - hasPendingEmbeddingUpdate.current = false; // Clear pending flag after update - hasEmbeddingBeenForced.current = false; // Reset forced flag for future cleanup calls - } - }, - 30000, // 30 seconds - { - leading: false, - trailing: true, - }, - ); - - // Handle editor changes - captures both block changes and page children order - const handleEditorChange = useCallback( - (e: { document: EditorBlock[] }) => { - // Skip all processing while loading - if (!isFullyLoaded) { - return; - } - - // Extract ALL block IDs from the document (flattened order) - const allBlockIds = extractAllBlockIds(e.document as any); - updateParentChildren(allBlockIds); - - // DETECT DELETIONS: Compare current document with previous to find missing blocks - const previousAllBlockIds = extractAllBlockIds( - prevDocumentRef.current as any, - ); - const currentAllBlockIds = new Set(allBlockIds); - - // Find blocks that were in previous document but not in current (these are deletions) - const deletedBlockIds = previousAllBlockIds.filter( - (blockId) => !currentAllBlockIds.has(blockId), - ); - - // Add delete operations for missing blocks - for (const deletedBlockId of deletedBlockIds) { - // Find the deleted block data from the previous document - const findBlockInDocument = ( - doc: EditorBlock[], - targetId: string, - ): EditorBlock | null => { - for (const block of doc) { - if (block.id === targetId) { - return block; - } - if (block.children && block.children.length > 0) { - const found = findBlockInDocument( - block.children as EditorBlock[], - targetId, - ); - if (found) return found; - } - } - return null; - }; - - const deletedBlock = findBlockInDocument( - prevDocumentRef.current, - deletedBlockId, - ); - if (deletedBlock) { - const deleteChange: BlockChange = { - blockId: deletedBlockId, - data: { - ...deletedBlock, - children: deletedBlock.children || [], - }, - timestamp: Date.now(), - type: "delete", - }; - - addBlockChange(deleteChange); - } - } - - // Flatten current and previous document structures to detect nesting changes - const currentFlattened = flattenDocument( - e.document, - parentId, - parentType, - ); - const prevFlattened = flattenDocument( - prevDocumentRef.current, - parentId, - parentType, - ); - - // Detect changes and add them to tracking - const changes = detectParentChanges(currentFlattened, prevFlattened); - - for (const change of changes) { - const newChange: BlockChange = { - blockId: change.blockId, - data: { - ...change.block, - children: change.block.children, - }, - newParentId: change.newParentId, - newParentType: change.newParentType as - | "page" - | "journal_entry" - | "block", - timestamp: Date.now(), - type: "update", - }; - - addBlockChange(newChange); - } - - // Update previous document reference - prevDocumentRef.current = e.document; - - debouncedSendChanges(); - - // Only trigger embedding update if user has actually made changes (detected via keydown) - if (parentType === "page" && hasPendingEmbeddingUpdate.current) { - debouncedUpdateEmbedding(); - } - }, - [ - isFullyLoaded, - parentId, - parentType, - updateParentChildren, - addBlockChange, - debouncedSendChanges, - debouncedUpdateEmbedding, - ], - ); - - // Update editor content when blocks change - useEffect(() => { - if (blocks.length > 0) { - // Convert all blocks to BlockNote format (preserving nested structure) - const allBlockNoteBlocks = convertToBlockNoteFormat(blocks); - - // Always replace the entire document to ensure correct nesting - editor.replaceBlocks(editor.document, allBlockNoteBlocks); - - // Update tracking references - initializeExistingBlocks(); - prevDocumentRef.current = allBlockNoteBlocks; - } - }, [blocks, editor, initializeExistingBlocks]); - - // Set up editor onChange listener for individual block changes - useEffect(() => { - const unsubscribe = editor.onChange((_, { getChanges }) => { - // Skip all changes while loading - if (!isFullyLoaded) { - return; - } - - const changes = getChanges(); - handleBlockChanges(changes as any); - }); - - return unsubscribe; - }, [editor, isFullyLoaded, handleBlockChanges]); - - // Set up event listeners to detect actual user input - useEffect(() => { - const markAsChanged = () => { - if (parentType === "page" && isFullyLoaded) { - hasPendingEmbeddingUpdate.current = true; - } - }; - - const handleKeyDown = (event: KeyboardEvent) => { - // Only set pending flag for actual content changes, not navigation keys - const isContentKey = - ![ - "Tab", - "Shift", - "Control", - "Alt", - "Meta", - "CapsLock", - "Escape", - "F1", - "F2", - "F3", - "F4", - "F5", - "F6", - "F7", - "F8", - "F9", - "F10", - "F11", - "F12", - ].includes(event.key) && - !event.key.startsWith("Arrow") && - !event.ctrlKey && - !event.metaKey; // Ignore shortcuts like Ctrl+C, Cmd+V, etc. - - if (isContentKey) { - markAsChanged(); - } - }; - - const handlePaste = () => markAsChanged(); - const handleCut = () => markAsChanged(); - - // Add event listeners to the editor's DOM element - const editorElement = editor.domElement; - if (editorElement) { - editorElement.addEventListener("keydown", handleKeyDown); - editorElement.addEventListener("paste", handlePaste); - editorElement.addEventListener("cut", handleCut); - - return () => { - editorElement.removeEventListener("keydown", handleKeyDown); - editorElement.removeEventListener("paste", handlePaste); - editorElement.removeEventListener("cut", handleCut); - }; - } - }, [editor, parentType, isFullyLoaded]); - - // Handle page unload events to force embedding updates - useEffect(() => { - const handleBeforeUnload = () => { - // Only force embedding updates if there are pending changes - if (hasPendingEmbeddingUpdate.current) { - forceEmbeddingUpdate(); - } - }; - - const handleVisibilityChange = () => { - // Only force embedding update when page becomes hidden if there are pending changes - if (document.hidden && hasPendingEmbeddingUpdate.current) { - forceEmbeddingUpdate(); - } - }; - - // Add event listeners - window.addEventListener("beforeunload", handleBeforeUnload); - document.addEventListener("visibilitychange", handleVisibilityChange); - - // Cleanup function to force embedding update and remove listeners - return () => { - if (hasPendingEmbeddingUpdate.current) { - forceEmbeddingUpdate(); - window.removeEventListener("beforeunload", handleBeforeUnload); - document.removeEventListener( - "visibilitychange", - handleVisibilityChange, - ); - } - }; - }, [forceEmbeddingUpdate]); - - return { - editor, - forceEmbeddingUpdate, - handleEditorChange, // Expose for manual triggering if needed - }; -} diff --git a/apps/web/src/components/editor/hooks/use-nested-blocks.ts b/apps/web/src/components/editor/hooks/use-nested-blocks.ts deleted file mode 100644 index 54a8f0d..0000000 --- a/apps/web/src/components/editor/hooks/use-nested-blocks.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { Block, BlockWithChildren } from "@acme/db/schema"; -import { useMemo, useRef } from "react"; - -/** - * Hook to convert flat blocks from the database into a nested structure with optimized incremental processing - * @param combinedBlocks - Flat array of blocks from the database - * @returns Nested array of blocks with proper parent-child relationships - */ -export function useNestedBlocks(combinedBlocks: Block[]): BlockWithChildren[] { - // Refs to store previous computation results for memoization - const previousBlocksRef = useRef([]); - const previousResultRef = useRef([]); - const blockMapRef = useRef>(new Map()); - const childBlockIdsRef = useRef>(new Set()); - - return useMemo(() => { - if (combinedBlocks.length === 0) { - // Reset refs when no blocks - previousBlocksRef.current = []; - previousResultRef.current = []; - blockMapRef.current.clear(); - childBlockIdsRef.current.clear(); - return []; - } - - const previousBlocks = previousBlocksRef.current; - const previousBlockMap = blockMapRef.current; - - // Check if this is just adding new blocks to existing ones (incremental loading) - const isIncrementalUpdate = - previousBlocks.length > 0 && - combinedBlocks.length > previousBlocks.length && - combinedBlocks - .slice(0, previousBlocks.length) - .every((block, index) => previousBlocks[index]?.id === block.id); - - if (isIncrementalUpdate) { - // Incremental processing: only add new blocks, then rebuild all relationships - const newBlocks = combinedBlocks.slice(previousBlocks.length); - - // Add new blocks to the existing block map (reset their children arrays) - for (const block of newBlocks) { - previousBlockMap.set(block.id, { - ...block, - children: [] as BlockWithChildren[], - }); - } - - // Now rebuild ALL parent-child relationships from scratch using all blocks - // This ensures children from new chunks connect to parents from previous chunks - - // First, reset all children arrays - for (const [_, blockWithChildren] of previousBlockMap.entries()) { - blockWithChildren.children = []; - } - - // Rebuild child block IDs set from all blocks - const allChildBlockIds = new Set(); - for (const block of combinedBlocks) { - if (Array.isArray(block.children)) { - for (const childId of block.children) { - if (typeof childId === "string") { - allChildBlockIds.add(childId); - } - } - } - } - - // Rebuild all parent-child relationships - for (const block of combinedBlocks) { - const blockWithChildren = previousBlockMap.get(block.id); - if (!blockWithChildren) continue; - - // If this block has children, find them and nest them - if (Array.isArray(block.children) && block.children.length > 0) { - const childrenIds = block.children.filter( - (id): id is string => typeof id === "string" && id.length > 0, - ); - - for (const childId of childrenIds) { - const childBlock = previousBlockMap.get(childId); - if (childBlock) { - blockWithChildren.children.push(childBlock); - } - } - } - } - - // Find root blocks (blocks that are not children of others) - const rootBlocks: BlockWithChildren[] = []; - for (const [blockId, blockWithChildren] of previousBlockMap.entries()) { - if (!allChildBlockIds.has(blockId)) { - rootBlocks.push(blockWithChildren); - } - } - - // Update refs for next iteration - previousBlocksRef.current = combinedBlocks; - previousResultRef.current = rootBlocks; - childBlockIdsRef.current = allChildBlockIds; - - return rootBlocks; - } else { - // Full recalculation: either first load or blocks changed significantly - const blockMap = new Map( - combinedBlocks.map((block) => [ - block.id, - { ...block, children: [] as BlockWithChildren[] }, - ]), - ); - - // Build the nested structure - const rootBlocks: BlockWithChildren[] = []; - - // First, identify which blocks are referenced as children by other blocks - const childBlockIds = new Set(); - for (const block of combinedBlocks) { - if (Array.isArray(block.children)) { - for (const childId of block.children) { - if (typeof childId === "string") { - childBlockIds.add(childId); - } - } - } - } - - // Process each block to build parent-child relationships - for (const block of combinedBlocks) { - const blockWithChildren = blockMap.get(block.id); - if (!blockWithChildren) continue; - - // If this block has children, find them and nest them (only if child blocks are available) - if (Array.isArray(block.children) && block.children.length > 0) { - const childrenIds = block.children.filter( - (id): id is string => typeof id === "string" && id.length > 0, - ); - - for (const childId of childrenIds) { - const childBlock = blockMap.get(childId); - if (childBlock) { - blockWithChildren.children.push(childBlock); - } - } - } - - // If this block is not a child of any other block, it's a root block - if (!childBlockIds.has(block.id)) { - rootBlocks.push(blockWithChildren); - } - } - - // Update refs for next iteration - previousBlocksRef.current = combinedBlocks; - previousResultRef.current = rootBlocks; - blockMapRef.current = blockMap; - childBlockIdsRef.current = childBlockIds; - - return rootBlocks; - } - }, [combinedBlocks]); -} diff --git a/apps/web/src/components/editor/lazy-block-editor.tsx b/apps/web/src/components/editor/lazy-block-editor.tsx deleted file mode 100644 index fd64f56..0000000 --- a/apps/web/src/components/editor/lazy-block-editor.tsx +++ /dev/null @@ -1,135 +0,0 @@ -"use client"; - -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useState } from "react"; -import { useTRPC } from "~/trpc/react"; -import { Skeleton } from "../ui/skeleton"; -import { BlockEditor } from "./block-editor"; -import { useNestedBlocks } from "./hooks/use-nested-blocks"; - -type BlockEditorProps = { - parentId: string; - parentType: "page" | "journal_entry" | "block"; -}; - -const LazyBlockEditorSkeleton = () => { - return ( -
- {/* Title skeleton */} - - {/* Content blocks skeleton */} -
- - - -
- - -
- -
- - - -
-
-
- ); -}; - -export function LazyBlockEditor({ parentId, parentType }: BlockEditorProps) { - const trpc = useTRPC(); - const [ready, setReady] = useState(false); - - // Fetch parent data (only for pages) - const { data: parentData } = useQuery({ - ...trpc.pages.getById.queryOptions({ id: parentId }), - enabled: parentType === "page", - refetchOnMount: true, - refetchOnWindowFocus: true, - staleTime: 1000, - }); - - // Progressive loading with infinite query - const { - data: infiniteData, - fetchNextPage, - isFetchingNextPage, - } = useInfiniteQuery({ - ...trpc.blocks.loadPageChunk.infiniteQueryOptions({ - limit: 100, - parentChildren: parentData?.children ?? [], - }), - enabled: !!parentData, - getNextPageParam: (lastPage) => { - return lastPage.hasMore ? lastPage.nextCursor : undefined; - }, - refetchOnMount: true, - staleTime: 1000, - }); - - // Combine all loaded blocks - const allBlocks = useMemo(() => { - if (!infiniteData?.pages) return []; - return infiniteData.pages - .flatMap((page) => page.blocks) - .filter( - (block): block is NonNullable => block !== undefined, - ); - }, [infiniteData?.pages]); - - // Check if there are more pages to load - const hasMorePages = useMemo(() => { - if (!infiniteData?.pages?.length) return false; - const lastPage = infiniteData.pages[infiniteData.pages.length - 1]; - return lastPage?.hasMore ?? false; - }, [infiniteData?.pages]); - - // Transform flat blocks into nested structure - const nestedBlocks = useNestedBlocks(allBlocks); - - // Auto-load next page in background - useEffect(() => { - if (hasMorePages && !isFetchingNextPage) { - const timer = setTimeout(() => { - fetchNextPage(); - }, 1000); - return () => clearTimeout(timer); - } - }, [hasMorePages, isFetchingNextPage, fetchNextPage]); - - // Manage ready state with fade-in delay - useEffect(() => { - // Set ready when we have actual data - if (infiniteData?.pages?.length && !ready) { - const timer = setTimeout(() => { - setReady(true); - }, 150); // for fade-in delay - return () => clearTimeout(timer); - } - }, [infiniteData?.pages?.length, ready]); - - // Show loading state until we have stable data - if (!ready) { - return ( -
- -
- ); - } - - return ( -
- -
- ); -} diff --git a/apps/web/src/components/editor/types.ts b/apps/web/src/components/editor/types.ts deleted file mode 100644 index 6431b07..0000000 --- a/apps/web/src/components/editor/types.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { BlockWithChildren } from "@acme/db/schema"; -import type { EditorBlock } from "./hooks/use-block-editor"; - -export type BlockNoteEditorProps = { - blocks: BlockWithChildren[]; - parentId: string; - parentType: "page" | "journal_entry" | "block"; - isFullyLoaded: boolean; - /** Title for the editor - used for pages and journal entries */ - title?: string; - /** Placeholder text for the title input */ - titlePlaceholder?: string; -}; - -export type BlockChangeType = "insert" | "update" | "delete"; - -export type BlockChange = { - blockId: string; - type: BlockChangeType; - timestamp: number; - data: { - id: string; - type: string; - content: any; - props: any; - children: any[]; - }; - newParentId?: string; - newParentType?: "page" | "journal_entry" | "block"; -}; - -export type FlattenedBlock = { - blockId: string; - block: any; - parentId: string; - parentType: string; -}; - -export type ProcessedBlockChange = { - blockId: string; - data: EditorBlock; - newParentId?: string; - newParentType?: "page" | "journal_entry" | "block"; - type: BlockChangeType; -}; diff --git a/apps/web/src/components/editor/utils/block-transforms.ts b/apps/web/src/components/editor/utils/block-transforms.ts deleted file mode 100644 index decb90b..0000000 --- a/apps/web/src/components/editor/utils/block-transforms.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { BlockWithChildren } from "@acme/db/schema"; -import type { Block as BlockNoteBlock } from "@blocknote/core"; - -/** - * Converts database blocks to BlockNote format - */ -export function convertToBlockNoteFormat( - blocks: BlockWithChildren[], -): BlockNoteBlock[] { - return blocks.map((block) => ({ - children: block.children, - content: block.content, - id: block.id, - props: block.props, - type: block.type, - })) as BlockNoteBlock[]; -} - -/** - * Flattens nested blocks for comparison and tracking - */ -export function flattenBlocks( - blocks: BlockWithChildren[], -): BlockWithChildren[] { - const flattened: BlockWithChildren[] = []; - - const addBlockAndChildren = (block: BlockWithChildren) => { - flattened.push(block); - // If block has nested children (objects), recursively add them - if (Array.isArray(block.children)) { - for (const child of block.children) { - addBlockAndChildren(child as BlockWithChildren); - } - } - }; - - for (const block of blocks) { - addBlockAndChildren(block); - } - - return flattened; -} - -/** - * Extracts all block IDs from a document in flattened order - */ -export function extractAllBlockIds(blocks: BlockNoteBlock[]): string[] { - const allIds: string[] = []; - - for (const block of blocks) { - allIds.push(block.id); - if ( - block.children && - Array.isArray(block.children) && - block.children.length > 0 - ) { - const childBlocks = block.children.filter( - (child: unknown): child is BlockNoteBlock => - typeof child === "object" && child !== null && "id" in child, - ); - allIds.push(...extractAllBlockIds(childBlocks)); - } - } - - return allIds; -} diff --git a/apps/web/src/components/editor/utils/document-processor.ts b/apps/web/src/components/editor/utils/document-processor.ts deleted file mode 100644 index 96620ce..0000000 --- a/apps/web/src/components/editor/utils/document-processor.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { Block as BlockNoteBlock } from "@blocknote/core"; -import type { EditorBlock } from "../hooks/use-block-editor"; -import type { FlattenedBlock } from "../types"; - -/** - * Flattens BlockNote document and tracks parent-child relationships - */ -export function flattenDocument( - blocks: EditorBlock[], - parentId: string, - parentType: string, - blockParentId?: string, - blockParentType?: string, -): FlattenedBlock[] { - const currentParentId = blockParentId || parentId; - const currentParentType = blockParentType || parentType; - const flattened: FlattenedBlock[] = []; - - for (const block of blocks) { - // Add current block - flattened.push({ - block, - blockId: block.id, - parentId: currentParentId, - parentType: currentParentType, - }); - - // Recursively add children - if ( - block.children && - Array.isArray(block.children) && - block.children.length > 0 - ) { - const childBlocks = block.children.filter( - (child) => child.id, - ) as BlockNoteBlock[]; - - if (childBlocks.length > 0) { - flattened.push( - ...flattenDocument( - childBlocks, - parentId, - parentType, - block.id, - "block", - ), - ); - } - } - } - - return flattened; -} - -/** - * Compares two flattened document structures to detect parent changes - */ -export function detectParentChanges( - currentFlattened: FlattenedBlock[], - prevFlattened: FlattenedBlock[], -) { - const changes: Array<{ - blockId: string; - block: EditorBlock; - newParentId: string; - newParentType: string; - changeType: "moved" | "children_changed"; - }> = []; - - // Create maps for easier comparison - const currentBlockParents = new Map( - currentFlattened.map(({ block, parentId, parentType }) => [ - block.id, - { block, parentId, parentType }, - ]), - ); - - const prevBlockParents = new Map( - prevFlattened.map(({ block, parentId, parentType }) => [ - block.id, - { block, parentId, parentType }, - ]), - ); - - // Detect blocks that have changed parents (nesting changes) - for (const [blockId, current] of currentBlockParents) { - const previous = prevBlockParents.get(blockId); - - // Check if block moved to a different parent - if ( - previous && - (previous.parentId !== current.parentId || - previous.parentType !== current.parentType) - ) { - changes.push({ - block: current.block, - blockId, - changeType: "moved", - newParentId: current.parentId, - newParentType: current.parentType, - }); - } - // Check for children changes only for blocks that didn't move - else if ( - previous && - previous.parentId === current.parentId && - previous.parentType === current.parentType - ) { - const prevChildrenIds = Array.isArray(previous.block.children) - ? previous.block.children.map((child: any) => child.id || child) - : []; - const currentChildrenIds = Array.isArray(current.block.children) - ? current.block.children.map((child: any) => child.id || child) - : []; - - // Compare children arrays by ID only - if ( - JSON.stringify(prevChildrenIds) !== JSON.stringify(currentChildrenIds) - ) { - changes.push({ - block: current.block, - blockId, - changeType: "children_changed", - newParentId: current.parentId, - newParentType: current.parentType, - }); - } - } - } - - return changes; -} diff --git a/apps/web/src/components/utils/default-map.ts b/apps/web/src/components/utils/default-map.ts new file mode 100644 index 0000000..3c5e9ab --- /dev/null +++ b/apps/web/src/components/utils/default-map.ts @@ -0,0 +1,21 @@ +/** + * A map that creates a default value if the key is not found. + */ +export class DefaultMap extends Map { + constructor( + private factory: () => V, + ...params: ConstructorParameters> + ) { + super(...params); + } + + get(key: K): V { + const value = super.get(key); + if (!value) { + const value = this.factory(); + this.set(key, value); + return value; + } + return value; + } +} diff --git a/cspell-words.txt b/cspell-words.txt index d99c554..ba00d17 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -11,6 +11,7 @@ NGROK nodenext overscan poppinss +reconnections rerank rmolinamir shadcn diff --git a/packages/api/package.json b/packages/api/package.json index d60c2f8..245112a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -20,6 +20,7 @@ "@acme/auth": "workspace:*", "@acme/db": "workspace:*", "@ai-sdk/openai": "^1.3.23", + "@blocknote/core": "^0.35.0", "@trpc/server": "^11.4.0", "ai": "^4.3.17", "superjson": "2.2.2", @@ -28,6 +29,7 @@ "devDependencies": { "@acme/tsconfig": "workspace:*", "@biomejs/biome": "^2.0.6", + "@blocknote/core": "^0.35.0", "typescript": "^5.8.3" } } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 3340f34..2dadd8a 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -22,3 +22,6 @@ type RouterOutputs = inferRouterOutputs; export { createTRPCContext, appRouter }; export type { AppRouter, RouterInputs, RouterOutputs }; + +// Export types from sub-routers +export type { BlockTransaction } from "./router/blocks.js"; diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 0a2fefd..ac17958 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,5 +1,6 @@ import { authRouter } from "./router/auth.js"; import { blocksRouter } from "./router/blocks.js"; +import { documentRouter } from "./router/document.js"; import { journalRouter } from "./router/journal.js"; import { notesRouter } from "./router/notes.js"; import { pagesRouter } from "./router/pages.js"; @@ -8,10 +9,10 @@ import { createTRPCRouter } from "./trpc.js"; export const appRouter = createTRPCRouter({ auth: authRouter, blocks: blocksRouter, + document: documentRouter, journal: journalRouter, notes: notesRouter, pages: pagesRouter, }); -// export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/api/src/router/blocks.ts b/packages/api/src/router/blocks.ts index 1b5f470..233730d 100644 --- a/packages/api/src/router/blocks.ts +++ b/packages/api/src/router/blocks.ts @@ -1,292 +1,100 @@ -import { eq, inArray } from "@acme/db"; -import type { DbTransaction } from "@acme/db/client"; +import { and, eq } from "@acme/db"; import { - Block, - type BlockType, - blockPropsSchemas, - blockTypeSchema, - Page, + BlockEdge, + BlockNode, + zInsertBlockEdge, + zInsertBlockNode, } from "@acme/db/schema"; import { z } from "zod/v4"; import { protectedProcedure } from "../trpc.js"; - -const blockDataSchema = z.object({ - children: z.unknown(), - content: z.unknown(), - id: z.string().uuid(), - props: z.record(z.string(), z.unknown()), - type: blockTypeSchema, -}); - -const parentTypeEnum = z.enum(["page", "journal_entry", "block"]); -const blockChangeTypeEnum = z.enum(["insert", "update", "delete"]); - -/** - * Updates the children array of a parent entity - */ -async function updateParentChildren( - tx: DbTransaction, - parentId: string, - parentType: "page" | "journal_entry" | "block", - children: string[], -): Promise { - switch (parentType) { - case "page": - await tx.update(Page).set({ children }).where(eq(Page.id, parentId)); - break; - case "block": - await tx.update(Block).set({ children }).where(eq(Block.id, parentId)); - break; - case "journal_entry": - // TODO: Implement when journal_entry table is available - break; - } -} - -/** - * Recursively collects all child block IDs for deletion - */ -async function collectChildBlockIds( - tx: DbTransaction, - blockId: string, - collected = new Set(), -): Promise { - if (collected.has(blockId)) { - return []; - } - collected.add(blockId); - - const [block] = await tx - .select({ children: Block.children }) - .from(Block) - .where(eq(Block.id, blockId)) - .limit(1); - - if (!block) return []; - - const childIds = (block.children as string[]) || []; - const allChildIds = [...childIds]; - - for (const childId of childIds) { - if (!collected.has(childId)) { - const grandChildIds = await collectChildBlockIds(tx, childId, collected); - allChildIds.push(...grandChildIds); - } - } - - return allChildIds; -} - -/** - * Processes and validates block data for database operations - */ -function processBlockData( - data: { - type: BlockType; - id: string; - props?: Record; - content?: unknown; - children?: unknown; - }, - userId: string, - defaultParentId: string, - defaultParentType: string, - newParentId?: string, - newParentType?: string, -) { - const propsSchema = blockPropsSchemas[data.type]; - const validatedProps = propsSchema.parse(data.props || {}); - - // Handle content: store whatever BlockNote sends us - const content = data.content; - - // Handle children: always an array - const children = Array.isArray(data.children) - ? data.children - .map((child) => (typeof child === "string" ? child : child.id)) - .filter(Boolean) - : []; - - // Use new parent info if provided, otherwise use defaults - const parentId = newParentId || defaultParentId; - const parentType = newParentType || defaultParentType; - - return { - children, - content, - created_by: userId, - id: data.id, - parent_id: parentId, - parent_type: parentType, - props: validatedProps, - type: data.type, - }; -} +export type BlockTransaction = + (typeof blocksRouter)["saveTransactions"]["_def"]["$types"]["input"]["transactions"][number]; export const blocksRouter = { - /** - * Loads blocks in chunks with pagination (Notion-style) - */ - loadPageChunk: protectedProcedure - .input( - z.object({ - cursor: z.string().uuid().optional(), - limit: z.number().min(1).max(100).default(50), - parentChildren: z.array(z.string().uuid()), - }), - ) - .query(async ({ ctx, input }) => { - if (input.parentChildren.length === 0) { - return { - blocks: [], - hasMore: false, - nextCursor: null, - }; - } - - // Calculate pagination - let startIndex = 0; - if (input.cursor) { - const cursorIndex = input.parentChildren.indexOf(input.cursor); - if (cursorIndex !== -1) { - startIndex = cursorIndex + 1; // Start after cursor - } - } - - const availableBlockIds = input.parentChildren.slice(startIndex); - const selectedBlockIds = availableBlockIds.slice(0, input.limit); - const hasMore = availableBlockIds.length > input.limit; - const nextCursor = - hasMore && selectedBlockIds.length > 0 - ? selectedBlockIds[selectedBlockIds.length - 1] - : null; - - // Fetch blocks and maintain order - const blocks = await ctx.db - .select() - .from(Block) - .where(inArray(Block.id, selectedBlockIds)); - - const blockMap = new Map(blocks.map((block) => [block.id, block])); - const orderedBlocks = selectedBlockIds - .map((id) => blockMap.get(id)) - .filter(Boolean); - - return { - blocks: orderedBlocks, - hasMore, - nextCursor, - }; - }), - /** - * Processes editor changes in a single atomic transaction. - * Handles block insertions, updates, deletions, and parent-child relationships. - */ - processEditorChanges: protectedProcedure + saveTransactions: protectedProcedure .input( z.object({ - blockChanges: z.array( - z.object({ - blockId: z.string().uuid(), - data: blockDataSchema, - newParentId: z.string().uuid().optional(), - newParentType: parentTypeEnum.optional(), - type: blockChangeTypeEnum, - }), + document_id: z.uuid(), + transactions: z.array( + z.discriminatedUnion("type", [ + z.object({ + args: zInsertBlockEdge.pick({ + from_id: true, + to_id: true, + }), + type: z.literal("edge_remove"), + }), + z.object({ + args: zInsertBlockEdge.omit({ + document_id: true, + user_id: true, + }), + type: z.literal("edge_insert"), + }), + z.object({ + args: zInsertBlockNode.omit({ + document_id: true, + user_id: true, + }), + type: z.literal("block_upsert"), + }), + z.object({ + args: zInsertBlockNode.pick({ id: true }), + type: z.literal("block_remove"), + }), + ]), ), - parentChildren: z.array(z.string().uuid()), - parentId: z.string().uuid(), - parentType: parentTypeEnum, - updateChildren: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { return await ctx.db.transaction(async (tx) => { - const results = { created: 0, deleted: 0, updated: 0 }; - - // Step 1: Collect all blocks to delete (including nested children) - const allBlockIdsToDelete = new Set(); - const deleteRequests = input.blockChanges.filter( - (change) => change.type === "delete", - ); - - for (const change of deleteRequests) { - allBlockIdsToDelete.add(change.blockId); - const childIds = await collectChildBlockIds(tx, change.blockId); - childIds.forEach((id) => allBlockIdsToDelete.add(id)); - } - - // Step 2: Update parent children arrays - if (input.updateChildren) { - const filteredParentChildren = input.parentChildren.filter( - (childId) => !allBlockIdsToDelete.has(childId), - ); - - await updateParentChildren( - tx, - input.parentId, - input.parentType, - filteredParentChildren, - ); - } - - // Update block parents that contain deleted children - const potentialParentBlocks = await tx - .select({ children: Block.children, id: Block.id }) - .from(Block) - .where(eq(Block.parent_id, input.parentId)); - - for (const parentBlock of potentialParentBlocks) { - const currentChildren = (parentBlock.children as string[]) || []; - const updatedChildren = currentChildren.filter( - (childId) => !allBlockIdsToDelete.has(childId), - ); - - if (updatedChildren.length !== currentChildren.length) { + for (const change of input.transactions) { + if (change.type === "edge_remove") { await tx - .update(Block) - .set({ children: updatedChildren }) - .where(eq(Block.id, parentBlock.id)); + .delete(BlockEdge) + .where( + and( + eq(BlockEdge.from_id, change.args.from_id), + eq(BlockEdge.to_id, change.args.to_id), + ), + ); } - } - // Step 3: Process insert/update operations - for (const change of input.blockChanges) { - if (change.type === "insert" || change.type === "update") { - const { data, type, newParentId, newParentType } = change; - - const blockData = processBlockData( - data, - ctx.session.user.id, - input.parentId, - input.parentType, - newParentId, - newParentType, - ); + if (change.type === "block_remove") { + await tx + .delete(BlockNode) + .where( + and( + eq(BlockNode.user_id, ctx.session.user.id), + eq(BlockNode.id, change.args.id), + ), + ); + } + if (change.type === "block_upsert") { await tx - .insert(Block) - .values(blockData) + .insert(BlockNode) + .values({ + ...change.args, + document_id: input.document_id, + user_id: ctx.session.user.id, + }) .onConflictDoUpdate({ - set: { - ...blockData, - updated_at: new Date().toISOString(), - }, - target: Block.id, + set: change.args, + target: BlockNode.id, }); - - results[type === "insert" ? "created" : "updated"]++; } - } - // Step 4: Delete all collected blocks - if (allBlockIdsToDelete.size > 0) { - const blockIdsArray = Array.from(allBlockIdsToDelete); - await tx.delete(Block).where(inArray(Block.id, blockIdsArray)); - results.deleted = blockIdsArray.length; + if (change.type === "edge_insert") { + await tx.insert(BlockEdge).values({ + document_id: input.document_id, + from_id: change.args.from_id, + to_id: change.args.to_id, + type: change.args.type, + user_id: ctx.session.user.id, + }); + } } - - return results; }); }), }; diff --git a/packages/api/src/router/document.ts b/packages/api/src/router/document.ts new file mode 100644 index 0000000..723a201 --- /dev/null +++ b/packages/api/src/router/document.ts @@ -0,0 +1,21 @@ +import { and, eq } from "@acme/db"; +import { Document, zDocument } from "@acme/db/schema"; +import type { TRPCRouterRecord } from "@trpc/server"; + +import { protectedProcedure } from "../trpc.js"; + +export const documentRouter = { + delete: protectedProcedure + .input(zDocument.pick({ id: true })) + .mutation(async ({ ctx, input }) => { + return await ctx.db + .delete(Document) + .where( + and( + eq(Document.id, input.id), + eq(Document.user_id, ctx.session.user.id), + ), + ) + .returning(); + }), +} satisfies TRPCRouterRecord; diff --git a/packages/api/src/router/journal.ts b/packages/api/src/router/journal.ts index 0b8117a..97ebb02 100644 --- a/packages/api/src/router/journal.ts +++ b/packages/api/src/router/journal.ts @@ -1,15 +1,5 @@ +import { and, between, cosineDistance, desc, eq, gt, sql } from "@acme/db"; import { - and, - between, - cosineDistance, - desc, - eq, - gt, - inArray, - sql, -} from "@acme/db"; -import { - Block, JournalEmbedding, JournalEntry, zJournalEntryDate, @@ -26,106 +16,6 @@ export type PlaceholderJournalEntry = { }; export const journalRouter = { - delete: protectedProcedure - .input(z.object({ id: z.string().uuid() })) - .mutation(async ({ ctx, input }) => { - try { - return await ctx.db.transaction(async (tx) => { - // 1. First, get the journal entry to ensure it exists and belongs to the user - const [journalToDelete] = await tx - .select() - .from(JournalEntry) - .where( - and( - eq(JournalEntry.id, input.id), - eq(JournalEntry.user_id, ctx.session.user.id), - ), - ) - .limit(1); - - if (!journalToDelete) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Journal entry not found", - }); - } - - // 2. Recursively collect all child block IDs - const collectChildIds = async ( - blockId: string, - ): Promise => { - const [block] = await tx - .select({ children: Block.children }) - .from(Block) - .where(eq(Block.id, blockId)) - .limit(1); - - if (!block) return []; - - const childIds = (block.children as string[]) || []; - const allChildIds = [...childIds]; - - // Recursively collect children of children - for (const childId of childIds) { - const grandChildIds = await collectChildIds(childId); - allChildIds.push(...grandChildIds); - } - - return allChildIds; - }; - - // 3. Get all blocks that belong to this journal entry - const journalBlocks = await tx - .select({ id: Block.id }) - .from(Block) - .where( - and( - eq(Block.parent_type, "journal_entry"), - eq(Block.parent_id, input.id), - ), - ); - - const allChildBlockIds: string[] = []; - - // Collect all nested child block IDs - for (const block of journalBlocks) { - allChildBlockIds.push(block.id); - const nestedChildIds = await collectChildIds(block.id); - allChildBlockIds.push(...nestedChildIds); - } - - // 4. Delete all child blocks first - if (allChildBlockIds.length > 0) { - await tx.delete(Block).where(inArray(Block.id, allChildBlockIds)); - } - - // 5. Finally, delete the journal entry - const result = await tx - .delete(JournalEntry) - .where( - and( - eq(JournalEntry.id, input.id), - eq(JournalEntry.user_id, ctx.session.user.id), - ), - ) - .returning(); - - return { - deletedBlocksCount: allChildBlockIds.length, - deletedJournalEntry: result[0], - }; - }); - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - console.error("Database error in journal.delete:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to delete journal entry", - }); - } - }), getBetween: protectedProcedure .input( z.object({ diff --git a/packages/api/src/router/notes.ts b/packages/api/src/router/notes.ts index 22e9168..2635b9d 100644 --- a/packages/api/src/router/notes.ts +++ b/packages/api/src/router/notes.ts @@ -7,7 +7,6 @@ import { embed } from "ai"; import { z } from "zod/v4"; import { protectedProcedure } from "../trpc.js"; -// TODO: Implement a Relevance Scorer/ReRanker once Vercel's `ai` package supports it. It's coming in ~v5.2.0. export const notesRouter = { getSimilarNotes: protectedProcedure .input( diff --git a/packages/api/src/router/pages.ts b/packages/api/src/router/pages.ts index da59e76..cf15592 100644 --- a/packages/api/src/router/pages.ts +++ b/packages/api/src/router/pages.ts @@ -1,42 +1,58 @@ -import { and, cosineDistance, desc, eq, gt, inArray, sql } from "@acme/db"; +import { and, cosineDistance, desc, eq, gt, sql } from "@acme/db"; import { - Block, + BlockEdge, + BlockNode, + Document, Page, PageEmbedding, zInsertPage, - zUpdatePage, } from "@acme/db/schema"; import { openai } from "@ai-sdk/openai"; import type { TRPCRouterRecord } from "@trpc/server"; import { TRPCError } from "@trpc/server"; import { embed } from "ai"; import { z } from "zod/v4"; - +import { blockNoteTree } from "../shared/block-note-tree.js"; import { protectedProcedure } from "../trpc.js"; export const pagesRouter = { create: protectedProcedure - .input(zInsertPage.omit({ user_id: true })) + .input(zInsertPage.omit({ document_id: true, user_id: true })) .mutation(async ({ ctx, input }) => { return await ctx.db.transaction(async (tx) => { try { - // Create the page - const pageData = { - ...input, - children: [], - user_id: ctx.session.user.id, - }; + const [document] = await tx + .insert(Document) + .values({ + user_id: ctx.session.user.id, + }) + .returning(); + + if (!document) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create document", + }); + } - const pageResult = await tx.insert(Page).values(pageData).returning(); + const [page] = await tx + .insert(Page) + .values({ + ...input, + children: [], + document_id: document.id, + user_id: ctx.session.user.id, + }) + .returning(); - if (!pageResult[0]) { + if (!page) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create page", }); } - return pageResult[0]; + return page; } catch (error) { console.error("Database error in pages.create:", error); throw new TRPCError({ @@ -46,99 +62,6 @@ export const pagesRouter = { } }); }), - // Delete a page and all its child blocks (cascade delete) - delete: protectedProcedure - .input(z.object({ id: z.uuid() })) - .mutation(async ({ ctx, input }) => { - try { - return await ctx.db.transaction(async (tx) => { - // 1. First, get the page to ensure it exists and belongs to the user - const [pageToDelete] = await tx - .select() - .from(Page) - .where( - and(eq(Page.id, input.id), eq(Page.user_id, ctx.session.user.id)), - ) - .limit(1); - - if (!pageToDelete) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Page not found", - }); - } - - // 2. Recursively collect all child block IDs - const collectChildIds = async ( - blockId: string, - ): Promise => { - const [block] = await tx - .select({ children: Block.children }) - .from(Block) - .where(eq(Block.id, blockId)) - .limit(1); - - if (!block) return []; - - const childIds = (block.children as string[]) || []; - const allChildIds = [...childIds]; - - // Recursively collect children of children - for (const childId of childIds) { - const grandChildIds = await collectChildIds(childId); - allChildIds.push(...grandChildIds); - } - - return allChildIds; - }; - - // 3. Get all direct child blocks of the page - const pageChildren = (pageToDelete.children as string[]) || []; - const allChildBlockIds: string[] = []; - - // Collect all nested child block IDs - for (const childId of pageChildren) { - allChildBlockIds.push(childId); - const nestedChildIds = await collectChildIds(childId); - allChildBlockIds.push(...nestedChildIds); - } - - // 4. Delete all child blocks first - if (allChildBlockIds.length > 0) { - await tx.delete(Block).where(inArray(Block.id, allChildBlockIds)); - } - - // 5. Finally, delete the page - const result = await tx - .delete(Page) - .where( - and(eq(Page.id, input.id), eq(Page.user_id, ctx.session.user.id)), - ) - .returning(); - - if (!result[0]) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Page not found or already deleted", - }); - } - - return { - deletedBlocksCount: allChildBlockIds.length, - deletedPage: result[0], - }; - }); - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - console.error("Database error in pages.delete:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to delete page", - }); - } - }), getAll: protectedProcedure.query(async ({ ctx }) => { try { return await ctx.db @@ -158,15 +81,40 @@ export const pagesRouter = { .input(z.object({ id: z.uuid() })) .query(async ({ ctx, input }) => { try { - const page = await ctx.db - .select() - .from(Page) - .where( - and(eq(Page.id, input.id), eq(Page.user_id, ctx.session.user.id)), + const { + rows: [page], + } = await ctx.db.execute< + Page & { + blocks: BlockNode[]; + edges: BlockEdge[]; + } + >(sql` + WITH page AS ( + SELECT * FROM ${Page} + WHERE ${Page.id} = ${input.id} AND ${Page.user_id} = ${ctx.session.user.id} + LIMIT 1 ) - .limit(1); + SELECT + page.*, + COALESCE( + (SELECT json_agg(${BlockNode}.*) FROM ${BlockNode} WHERE ${BlockNode.document_id} = page.document_id), + '[]'::json + ) as blocks, + COALESCE( + (SELECT json_agg(${BlockEdge}.*) FROM ${BlockEdge} WHERE ${BlockEdge.document_id} = page.document_id), + '[]'::json + ) as edges + FROM page + `); + + if (!page) { + return null; + } - return page[0] ?? null; + return { + ...page, + document: blockNoteTree(page.blocks, page.edges), + }; } catch (error) { if (error instanceof TRPCError) { throw error; @@ -216,88 +164,6 @@ export const pagesRouter = { return similarPages; }), - update: protectedProcedure - .input( - z.object({ - data: zUpdatePage, - id: z.uuid(), - title: z.string().min(1).max(255).optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - try { - const { id, ...updateData } = input; - - // Only update fields that are provided - const fieldsToUpdate = Object.fromEntries( - Object.entries(updateData).filter( - ([_, value]) => value !== undefined, - ), - ); - - if (Object.keys(fieldsToUpdate).length === 0) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No fields to update", - }); - } - - const result = await ctx.db - .update(Page) - .set(fieldsToUpdate) - .where(and(eq(Page.id, id), eq(Page.user_id, ctx.session.user.id))) - .returning(); - - if (result.length === 0) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Page not found", - }); - } - - return result[0]; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - console.error("Database error in pages.update:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to update page", - }); - } - }), - updateEmbedTimestamp: protectedProcedure - .input(z.object({ id: z.uuid() })) - .mutation(async ({ ctx, input }) => { - try { - const result = await ctx.db - .update(Page) - .set({ embed_updated_at: new Date().toISOString() }) - .where( - and(eq(Page.id, input.id), eq(Page.user_id, ctx.session.user.id)), - ) - .returning(); - - if (result.length === 0) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Page not found", - }); - } - - return result[0]; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - console.error("Database error in pages.updateEmbedTimestamp:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to update page embed timestamp", - }); - } - }), updateTitle: protectedProcedure .input( z.object({ diff --git a/packages/api/src/shared/block-note-schema.ts b/packages/api/src/shared/block-note-schema.ts new file mode 100644 index 0000000..31c9562 --- /dev/null +++ b/packages/api/src/shared/block-note-schema.ts @@ -0,0 +1,6 @@ +import { z } from "zod/v4"; + +// Simply using `any` types as we aren't really changing the BlockNote schema anywhere. +export const zBlockNoteBlockType = z.any(); +export const zBlockNoteBlockProps = z.any(); +export const zBlockNoteInlineContent = z.any(); diff --git a/packages/api/src/shared/block-note-tree.ts b/packages/api/src/shared/block-note-tree.ts new file mode 100644 index 0000000..a1d784d --- /dev/null +++ b/packages/api/src/shared/block-note-tree.ts @@ -0,0 +1,208 @@ +import type { BlockEdge, BlockNode } from "@acme/db/schema"; +import type { PartialBlock } from "@blocknote/core"; +import { z } from "zod/v4"; +import { zBlockNoteBlockProps, zBlockNoteBlockType } from "./block-note-schema"; + +/** + * Builds a hierarchical block structure from flat blocks and edges data + * Uses linked list approach with BFS traversal + */ +export function blockNoteTree( + blocks: BlockNode[], + edges: BlockEdge[], +): [PartialBlock, ...PartialBlock[]] | undefined { + if (!blocks || blocks.length === 0) { + return undefined; + } + + /** Maps block IDs to their corresponding block data. */ + const blockMap = new Map(blocks.map((block) => [block.id, block])); + + console.debug("🔍 Debug - Raw blocks:", blocks.length); + console.debug("🔍 Debug - Raw edges:", edges.length); + + /** Tracks sibling relationships between blocks (e.g., `from_id` -> `to_id`). */ + const siblingsMap = new Map( + edges.map((edge) => [edge.from_id, edge.to_id]), + ); + + /** Tracks parent-child relationships between blocks (e.g., `parent_id` -> `child_id`). */ + const childrenMap = new Map>(); + + console.debug("🔍 Debug - Children map size:", childrenMap.size); + console.debug("🔍 Debug - Sibling map size:", siblingsMap.size); + + // Build parent-child relationships from parent_id + for (const block of blocks) { + if (block.parent_id && blockMap.has(block.parent_id)) { + const children = childrenMap.get(block.parent_id) || new Set(); + if (!children.has(block.id)) { + children.add(block.id); + childrenMap.set(block.parent_id, children); + } + } + } + + // Find root blocks (blocks with null parent_id) + const rootBlocks = blocks.filter((block) => block.parent_id === null); + + console.debug("🔍 Debug - Root blocks count:", rootBlocks.length); + + if (rootBlocks.length === 0) { + return undefined; + } + + /** + * Orders sibling blocks using the linked list defined by sibling edges. + */ + function orderSiblings(siblings: Set): string[] { + const blockIds = Array.from(siblings); + + if (blockIds.length <= 1) { + return blockIds; + } + + // Check if any of these blocks are connected by sibling edges + const hasRelevantEdges = blockIds.some((id) => { + const sibling = siblingsMap.get(id); + return sibling && siblings.has(sibling); + }); + + if (!hasRelevantEdges) { + return blockIds; + } + + // Find all blocks that are targets (to_id) of sibling edges within this group + const targetsInGroup = new Set(); + for (const blockId of blockIds) { + const next = siblingsMap.get(blockId); + if (next && siblings.has(next)) { + targetsInGroup.add(next); + } + } + + // Find the head: block in this group that is NOT a target of any sibling edge in this group + const possibleHeads = blockIds.filter((id) => !targetsInGroup.has(id)); + + // If there are no possible heads, return the block IDs. + if (!possibleHeads[0]) { + return blockIds; + } + + console.debug( + `🔍 Found ${possibleHeads.length} possible heads out of ${blockIds.length} blocks`, + ); + + let head: string | null = null; + + // If we have exactly one non-target, that's our head + if (possibleHeads.length === 1) { + head = possibleHeads[0]; + } else if (possibleHeads.length > 1) { + // Multiple possible heads - pick the first one + head = possibleHeads[0]; + console.debug("🔍 Multiple possible heads, using first"); + } else if (blockIds[0]) { + // This shouldn't happen in a proper linked list, but fallback to first block + head = blockIds[0]; + console.error("🔍 No clear head found (circular?), using first block"); + } + + // If we still don't have a head, return the block IDs. + if (!head) { + return blockIds; + } + + // Traverse the linked list starting from head + const result: string[] = []; + const visited = new Set(); + + let current: string | undefined = head; + while (current && siblings.has(current) && !visited.has(current)) { + visited.add(current); + result.push(current); + current = siblingsMap.get(current); + } + + // Add any remaining blocks that weren't in the linked list + const remaining = blockIds.filter((id) => !result.includes(id)); + const finalResult = [...result, ...remaining]; + + if (remaining.length > 0) { + console.error( + `🔍 Found ${remaining.length} remaining blocks that weren't in the linked list`, + ); + } + console.debug( + `🔍 Ordered ${blockIds.length} siblings -> ${finalResult.length} result`, + ); + + return finalResult; + } + + /** + * Recursively builds the document tree using a BFS approach. + */ + function buildDocumentBranch(blockId: string): PartialBlock { + const block = blockMap.get(blockId); + + if (!block) { + console.error(`🔍 Block not found: ${blockId}`); + return {}; + } + + const zBlockNoteData = z.object({ + content: z.any(), + props: zBlockNoteBlockProps, + type: zBlockNoteBlockType, + }); + + const blockData = zBlockNoteData.parse(block.data); + + // Get children for this block + const childrenIds = childrenMap.get(blockId) || new Set(); + + // Order children using sibling linked lists + const orderedChildrenIds = orderSiblings(childrenIds); + + console.debug( + `🔍 Building block with ${childrenIds.size} children -> ${orderedChildrenIds.length} ordered`, + ); + + // Recursively build children (BFS) + const children = orderedChildrenIds.map((childId) => + buildDocumentBranch(childId), + ); + + // Convert database block to PartialBlock format + const partialBlock: PartialBlock = { + children: children, + content: blockData?.content || undefined, + id: block.id, + props: blockData?.props || {}, + type: blockData?.type || "paragraph", + }; + + partialBlock.content; + + return partialBlock; + } + + // Order root blocks using sibling linked lists + const rootBlockIds = new Set(rootBlocks.map((block) => block.id)); + const orderedRootIds = orderSiblings(rootBlockIds); + + console.debug("🔍 Debug - Final root order count:", orderedRootIds.length); + + // Build the tree starting from ordered root blocks + const [root, ...rest] = orderedRootIds.map((rootId) => + buildDocumentBranch(rootId), + ); + + // BlockNote requires at least one block + if (!root) { + return undefined; + } + + return [root, ...rest]; +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 6572cd9..3429389 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -4,8 +4,7 @@ "noEmit": false, "outDir": "./dist", "rootDir": "./src", - - /* Module Options: How TypeScript resolves and handles modules */ + "skipLibCheck": true, "module": "ESNext", // Use Node.js-style module resolution for compatibility "moduleDetection": "force", // Force detection of module format from file extension "moduleResolution": "Bundler" // Use Node.js module resolution algorithm diff --git a/packages/db/src/core/block-node.schema.ts b/packages/db/src/core/block-node.schema.ts new file mode 100644 index 0000000..a468714 --- /dev/null +++ b/packages/db/src/core/block-node.schema.ts @@ -0,0 +1,99 @@ +import { sql } from "drizzle-orm"; +import { + foreignKey, + pgEnum, + pgTable, + primaryKey, + text, +} from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { z } from "zod/v4"; +import { user } from "../auth/user.schema.js"; +import { Document } from "./document.schema.js"; + +export const BlockNode = pgTable( + "block_node", + (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + user_id: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + document_id: t + .uuid() + .notNull() + .references(() => Document.id, { onDelete: "cascade" }), + parent_id: t.uuid(), + data: t.jsonb().notNull().default({}), + created_at: t + .timestamp({ mode: "string", withTimezone: true }) + .defaultNow() + .notNull(), + updated_at: t + .timestamp({ mode: "string", withTimezone: true }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }), + (t) => [ + /** + * We have to write self-references in here, otherwise drizzle runs into TypeScript errors. + * @see {@link https://github.com/drizzle-team/drizzle-orm/issues/4308 | [BUG]: Self referencing Foreign Key causes any type} + */ + foreignKey({ + columns: [t.parent_id], + foreignColumns: [t.id], + }).onDelete("cascade"), + ], +); +export type BlockNode = typeof BlockNode.$inferSelect; + +export const zInsertBlockNode = createInsertSchema(BlockNode, { + id: z.uuid(), + data: z.record(z.string(), z.unknown()), +}).omit({ + created_at: true, + updated_at: true, +}); + +export const BlockEdgeType = pgEnum("block_edge_type", ["sibling"]); + +export const BlockEdge = pgTable( + "block_edge", + (t) => ({ + type: BlockEdgeType(), + user_id: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + document_id: t + .uuid() + .notNull() + .references(() => Document.id, { onDelete: "cascade" }), + from_id: t + .uuid() + .notNull() + .references(() => BlockNode.id, { onDelete: "cascade" }), + to_id: t + .uuid() + .notNull() + .references(() => BlockNode.id, { onDelete: "cascade" }), + created_at: t + .timestamp({ mode: "string", withTimezone: true }) + .defaultNow() + .notNull(), + updated_at: t + .timestamp({ mode: "string", withTimezone: true }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }), + (t) => [primaryKey({ columns: [t.from_id, t.to_id] })], +); +export type BlockEdge = typeof BlockEdge.$inferSelect; + +export const zInsertBlockEdge = createInsertSchema(BlockEdge, { + from_id: z.string(), + to_id: z.string(), +}).omit({ + created_at: true, + updated_at: true, +}); diff --git a/packages/db/src/core/block.schema.ts b/packages/db/src/core/block.schema.ts deleted file mode 100644 index c61f4c0..0000000 --- a/packages/db/src/core/block.schema.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { sql } from "drizzle-orm"; -import { index, pgTable } from "drizzle-orm/pg-core"; -import { createSelectSchema } from "drizzle-zod"; -import { z } from "zod/v4"; - -import { user } from "../auth/user.schema.js"; - -export const Block = pgTable( - "block", - (t) => ({ - id: t.uuid().notNull().primaryKey().defaultRandom(), - type: t.text().notNull(), // paragraph, heading, bulletListItem, numberedListItem, checkListItem, etc. - - // Props store the block-specific properties (BlockNote uses 'props' not 'properties') - props: t.jsonb().notNull().default({}), - - // Content stores the rich text content as InlineContent[] or undefined for void blocks - content: t.jsonb(), // Can be null for void blocks like divider - - // Children array stores ordered list of child block IDs - children: t.text().array().notNull().default([]), - - // Parent relationships - // Note: parent_id references different tables based on parent_type - // Cascade deletes must be handled at the application level - parent_type: t.text().notNull(), // 'page' | 'journal_entry' | 'block' - parent_id: t.uuid().notNull(), - - // Metadata - created_at: t - .timestamp({ mode: "string", withTimezone: true }) - .defaultNow() - .notNull(), - updated_at: t - .timestamp({ mode: "string", withTimezone: true }) - .defaultNow() - .notNull() - .$onUpdateFn(() => sql`now()`), - created_by: t - .text() - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - }), - (table) => [ - index("block_parent_idx").on(table.parent_type, table.parent_id), - index("block_created_by_idx").on(table.created_by), - ], -); - -// Block type validation - Updated to match BlockNote's default schema -export const blockTypeSchema = z.enum([ - "paragraph", - "heading", - "bulletListItem", - "numberedListItem", - "checkListItem", - "quote", - "divider", - "callout", - "toggleListItem", - "table", - "image", - "video", - "audio", - "file", - "codeBlock", -]); - -export const blockParentTypeSchema = z.enum(["page", "journal_entry", "block"]); - -// Type definitions -export type Block = typeof Block.$inferSelect; -export type NewBlock = typeof Block.$inferInsert; -export type BlockType = z.infer; -export type BlockParentType = z.infer; -export type BlockWithChildren = Omit & { - children: BlockWithChildren[]; -}; - -// Rich text content schema - supports BlockNote's InlineContent format -// Content is stored as InlineContent[] or null for void blocks - -// Define InlineContent type based on BlockNote structure -export type InlineContent = - | { type: "text"; text: string; styles?: Record } - | { type: "link"; href: string; content: InlineContent[] } - | unknown; // Allow other BlockNote inline content types - -// BlockNote-compatible block props schemas -export const blockPropsSchemas = { - paragraph: z.object({ - textColor: z.string().optional(), - backgroundColor: z.string().optional(), - textAlignment: z.enum(["left", "center", "right", "justify"]).optional(), - }), - heading: z.object({ - level: z.number().min(1).max(6).default(1), - textColor: z.string().optional(), - backgroundColor: z.string().optional(), - textAlignment: z.enum(["left", "center", "right", "justify"]).optional(), - }), - bulletListItem: z.object({ - textColor: z.string().optional(), - backgroundColor: z.string().optional(), - textAlignment: z.enum(["left", "center", "right", "justify"]).optional(), - }), - numberedListItem: z.object({ - textColor: z.string().optional(), - backgroundColor: z.string().optional(), - textAlignment: z.enum(["left", "center", "right", "justify"]).optional(), - }), - checkListItem: z.object({ - checked: z.boolean().default(false), - textColor: z.string().optional(), - backgroundColor: z.string().optional(), - textAlignment: z.enum(["left", "center", "right", "justify"]).optional(), - }), - quote: z.object({ - textColor: z.string().optional(), - backgroundColor: z.string().optional(), - textAlignment: z.enum(["left", "center", "right", "justify"]).optional(), - }), - divider: z.object({}), // Divider blocks have no props - callout: z.object({ - textColor: z.string().optional(), - backgroundColor: z.string().optional(), - textAlignment: z.enum(["left", "center", "right", "justify"]).optional(), - icon: z.string().optional(), // Emoji or icon identifier - }), - toggleListItem: z.object({ - textColor: z.string().optional(), - backgroundColor: z.string().optional(), - textAlignment: z.enum(["left", "center", "right", "justify"]).optional(), - }), - table: z.object({ - textColor: z.string().optional(), - backgroundColor: z.string().optional(), - }), - image: z.object({ - url: z.string(), - caption: z.string().optional(), - width: z.number().optional(), - textAlignment: z.enum(["left", "center", "right"]).optional(), - }), - video: z.object({ - url: z.string(), - caption: z.string().optional(), - width: z.number().optional(), - textAlignment: z.enum(["left", "center", "right"]).optional(), - }), - audio: z.object({ - url: z.string(), - caption: z.string().optional(), - }), - file: z.object({ - url: z.string(), - name: z.string(), - caption: z.string().optional(), - }), - codeBlock: z.object({ - language: z.string().optional(), - textColor: z.string().optional(), - backgroundColor: z.string().optional(), - }), -} as const; - -// Type helpers for inferring props based on block type -export type BlockPropsForType = z.infer< - (typeof blockPropsSchemas)[T] ->; - -// Union type for all possible block props -export type BlockProps = { - [K in BlockType]: BlockPropsForType; -}; - -// Helper to get the props schema for a specific block type -export const getBlockPropsSchema = (type: T) => - blockPropsSchemas[type]; - -// Type-safe helper functions for creating block inputs -export const createBlockInput = ( - type: T, - input: { - parentId: string; - parentType: "page" | "journal_entry" | "block"; - props: BlockPropsForType; - content?: InlineContent[]; - insertIndex?: number; - }, -) => ({ - type, - parentId: input.parentId, - parentType: input.parentType, - props: input.props, - content: input.content, - insertIndex: input.insertIndex, -}); - -export const updateBlockInput = ( - blockId: string, - input: { - props?: Partial>; - content?: InlineContent[]; - type?: T; - }, -) => ({ - blockId, - props: input.props, - content: input.content, - type: input.type, -}); - -// Specific helper functions for common block types -export const createParagraphBlock = (input: { - parentId: string; - parentType: "page" | "journal_entry" | "block"; - content?: InlineContent[]; - props?: BlockPropsForType<"paragraph">; - insertIndex?: number; -}) => - createBlockInput("paragraph", { - ...input, - props: input.props || {}, - }); - -export const createHeadingBlock = (input: { - parentId: string; - parentType: "page" | "journal_entry" | "block"; - content?: InlineContent[]; - level?: 1 | 2 | 3 | 4 | 5 | 6; - props?: Partial>; - insertIndex?: number; -}) => - createBlockInput("heading", { - ...input, - props: { level: input.level || 1, ...input.props }, - }); - -export const createCheckListItemBlock = (input: { - parentId: string; - parentType: "page" | "journal_entry" | "block"; - content?: InlineContent[]; - checked?: boolean; - props?: Partial>; - insertIndex?: number; -}) => - createBlockInput("checkListItem", { - ...input, - props: { checked: input.checked ?? false, ...input.props }, - }); - -export const createQuoteBlock = (input: { - parentId: string; - parentType: "page" | "journal_entry" | "block"; - content?: InlineContent[]; - props?: BlockPropsForType<"quote">; - insertIndex?: number; -}) => - createBlockInput("quote", { - ...input, - props: input.props || {}, - }); - -export const createDividerBlock = (input: { - parentId: string; - parentType: "page" | "journal_entry" | "block"; - insertIndex?: number; -}) => - createBlockInput("divider", { - ...input, - props: {}, - content: undefined, // Divider blocks have no content - }); - -export const createCalloutBlock = (input: { - parentId: string; - parentType: "page" | "journal_entry" | "block"; - content?: InlineContent[]; - props?: BlockPropsForType<"callout">; - insertIndex?: number; -}) => - createBlockInput("callout", { - ...input, - props: input.props || {}, - }); - -export const createToggleListItemBlock = (input: { - parentId: string; - parentType: "page" | "journal_entry" | "block"; - content?: InlineContent[]; - props?: BlockPropsForType<"toggleListItem">; - insertIndex?: number; -}) => - createBlockInput("toggleListItem", { - ...input, - props: input.props || {}, - }); - -export const updateParagraphBlock = ( - blockId: string, - input: { - content?: InlineContent[]; - props?: Partial>; - }, -) => updateBlockInput(blockId, input); - -export const updateCheckListItemBlock = ( - blockId: string, - input: { - content?: InlineContent[]; - props?: Partial>; - }, -) => updateBlockInput(blockId, input); - -export const updateQuoteBlock = ( - blockId: string, - input: { - content?: InlineContent[]; - props?: Partial>; - }, -) => updateBlockInput(blockId, input); - -export const updateDividerBlock = ( - blockId: string, - input: { - props?: Partial>; - }, -) => updateBlockInput(blockId, input); - -export const updateCalloutBlock = ( - blockId: string, - input: { - content?: InlineContent[]; - props?: Partial>; - }, -) => updateBlockInput(blockId, input); - -export const updateToggleListItemBlock = ( - blockId: string, - input: { - content?: InlineContent[]; - props?: Partial>; - }, -) => updateBlockInput(blockId, input); - -export const zBlock = createSelectSchema(Block); diff --git a/packages/db/src/core/document.schema.ts b/packages/db/src/core/document.schema.ts new file mode 100644 index 0000000..93e20ea --- /dev/null +++ b/packages/db/src/core/document.schema.ts @@ -0,0 +1,28 @@ +import { sql } from "drizzle-orm"; +import { pgTable, text } from "drizzle-orm/pg-core"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { user } from "../auth/user.schema.js"; + +export const Document = pgTable("document", (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + user_id: text() + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + created_at: t + .timestamp({ mode: "string", withTimezone: true }) + .defaultNow() + .notNull(), + updated_at: t + .timestamp({ mode: "string", withTimezone: true }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), +})); + +export type Document = typeof Document.$inferSelect; + +export const zInsertDocument = createInsertSchema(Document).pick({ + user_id: true, +}); + +export const zDocument = createSelectSchema(Document); diff --git a/packages/db/src/core/page.schema.ts b/packages/db/src/core/page.schema.ts index d2878f0..ebc8bcb 100644 --- a/packages/db/src/core/page.schema.ts +++ b/packages/db/src/core/page.schema.ts @@ -3,14 +3,18 @@ import { pgTable, text } from "drizzle-orm/pg-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { z } from "zod/v4"; import { user } from "../auth/user.schema.js"; +import { Document } from "./document.schema.js"; export const Page = pgTable("page", (t) => ({ id: t.uuid().notNull().primaryKey().defaultRandom(), user_id: text() .notNull() .references(() => user.id, { onDelete: "cascade" }), + document_id: t + .uuid() + .notNull() + .references(() => Document.id, { onDelete: "cascade" }), title: t.text().notNull(), - // Children array stores ordered list of child block IDs children: t.text().array().notNull().default([]), created_at: t .timestamp({ mode: "string", withTimezone: true }) @@ -29,8 +33,9 @@ export const Page = pgTable("page", (t) => ({ })); export type Page = typeof Page.$inferSelect; + export const zInsertPage = createInsertSchema(Page, { - title: z.string().min(1).max(255), + title: z.string().max(255), children: z.array(z.uuid()).default([]), }).omit({ created_at: true, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 86e6a94..5a63930 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -3,7 +3,8 @@ export * from "./auth/organization.schema.js"; export * from "./auth/session.schema.js"; export * from "./auth/user.schema.js"; export * from "./auth/verification.schema.js"; -export * from "./core/block.schema.js"; +export * from "./core/block-node.schema.js"; +export * from "./core/document.schema.js"; export * from "./core/journal-embedding.schema.js"; export * from "./core/journal-entry.schema.js"; export * from "./core/page.schema.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8bd423..45ea2cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,6 @@ importers: specifier: ^2.5.4 version: 2.5.4 - apps/ai: {} - apps/localtunnel: devDependencies: '@acme/db': @@ -83,14 +81,14 @@ importers: specifier: ^0.1.14 version: 0.1.14 '@blocknote/core': - specifier: ^0.33.0 - version: 0.33.0(@types/hast@3.0.4) + specifier: ^0.35.0 + version: 0.35.0(@types/hast@3.0.4) '@blocknote/mantine': - specifier: ^0.33.0 - version: 0.33.0(@types/hast@3.0.4)(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^0.35.0 + version: 0.35.0(@types/hast@3.0.4)(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@blocknote/react': - specifier: ^0.33.0 - version: 0.33.0(@types/hast@3.0.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: ^0.35.0 + version: 0.35.0(@types/hast@3.0.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@daveyplate/better-auth-ui': specifier: ^2.1.0 version: 2.1.0(7503a6438a2a526a6ce1f7001bf6c5f8) @@ -99,10 +97,7 @@ importers: version: 5.1.1(react-hook-form@7.60.0(react@19.1.0)) '@mantine/core': specifier: ^8.1.3 - version: 8.1.3(@mantine/hooks@8.1.3(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@mantine/hooks': - specifier: ^8.1.3 - version: 8.1.3(react@19.1.0) + version: 8.1.3(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mastra/core': specifier: ^0.12.1 version: 0.12.1(arktype@2.1.20)(openapi-types@12.1.3)(react@19.1.0)(valibot@1.0.0-beta.15(typescript@5.8.3))(zod@3.25.71) @@ -266,6 +261,9 @@ importers: '@ai-sdk/openai': specifier: ^1.3.23 version: 1.3.23(zod@3.25.71) + '@blocknote/core': + specifier: ^0.35.0 + version: 0.35.0(@types/hast@3.0.4) '@trpc/server': specifier: ^11.4.0 version: 11.4.0(typescript@5.8.3) @@ -582,22 +580,22 @@ packages: cpu: [x64] os: [win32] - '@blocknote/core@0.33.0': - resolution: {integrity: sha512-BngrC1zx6aQjjOtRII2XpgtWwDfAP9D/dsKEsWGzCYXcfbE3smssy8MzkQQqFBHf4klTxOgOYZosmb0GW10SZg==} + '@blocknote/core@0.35.0': + resolution: {integrity: sha512-Pn0DZHIU0fgKw8Cw4s7dspRXML6jZn3a0CUKnXOTsDEuJBoPrvFmQEPGb/OpFDfvCh5YftKoQYUAsiy1HJYN7A==} peerDependencies: '@hocuspocus/provider': ^2.15.2 peerDependenciesMeta: '@hocuspocus/provider': optional: true - '@blocknote/mantine@0.33.0': - resolution: {integrity: sha512-RN3B6kfG//53OIaOVT7DGgM1BduX3oU3co1qKgmIPy7NI8HoTMszHq73qRFwK/UqubLMnktCXxQaeeeZb12cxg==} + '@blocknote/mantine@0.35.0': + resolution: {integrity: sha512-Ao8Z2d84MQIuVRVobymYYyayFkYxtRW3L3XGNMqBPtezX0qsRivUGCmmRoBmbjbcJ2EqtGOVpTEU8jecYUZe5g==} peerDependencies: react: ^18.0 || ^19.0 || >= 19.0.0-rc react-dom: ^18.0 || ^19.0 || >= 19.0.0-rc - '@blocknote/react@0.33.0': - resolution: {integrity: sha512-1MHtJ1D0Rorrb25anWYReraSqTZ1ZVc9jK4xlR1G5A0AimR+P9lCSw1tH9TncrNfsihOQAy4/nY4QWRn6tHzRg==} + '@blocknote/react@0.35.0': + resolution: {integrity: sha512-SZqbVUmPGIBoqv/oCDN8LKGdDY04A+BlXptHAojeH5UY6gH7N6/kRTNvzZ9OIrCKlCHbRGK+8eiHjSTBFRAXUQ==} peerDependencies: react: ^18.0 || ^19.0 || >= 19.0.0-rc react-dom: ^18.0 || ^19.0 || >= 19.0.0-rc @@ -1407,11 +1405,6 @@ packages: peerDependencies: react: ^18.x || ^19.x - '@mantine/hooks@8.1.3': - resolution: {integrity: sha512-yL4SbyYjrkmtIhscswajNz9RL0iO2+V8CMtOi0KISch2rPNvTAJNumFuZaXgj4UHeDc0JQYSmcZ+EW8NGm7xcQ==} - peerDependencies: - react: ^18.x || ^19.x - '@mantine/utils@6.0.22': resolution: {integrity: sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ==} peerDependencies: @@ -7352,7 +7345,7 @@ snapshots: '@biomejs/cli-win32-x64@2.0.6': optional: true - '@blocknote/core@0.33.0(@types/hast@3.0.4)': + '@blocknote/core@0.35.0(@types/hast@3.0.4)': dependencies: '@emoji-mart/data': 1.2.1 '@shikijs/types': 3.2.1 @@ -7401,10 +7394,10 @@ snapshots: - sugar-high - supports-color - '@blocknote/mantine@0.33.0(@types/hast@3.0.4)(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@blocknote/mantine@0.35.0(@types/hast@3.0.4)(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@blocknote/core': 0.33.0(@types/hast@3.0.4) - '@blocknote/react': 0.33.0(@types/hast@3.0.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@blocknote/core': 0.35.0(@types/hast@3.0.4) + '@blocknote/react': 0.35.0(@types/hast@3.0.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mantine/hooks': 7.17.8(react@19.1.0) '@mantine/utils': 6.0.22(react@19.1.0) @@ -7421,9 +7414,9 @@ snapshots: - sugar-high - supports-color - '@blocknote/react@0.33.0(@types/hast@3.0.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@blocknote/react@0.35.0(@types/hast@3.0.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@blocknote/core': 0.33.0(@types/hast@3.0.4) + '@blocknote/core': 0.35.0(@types/hast@3.0.4) '@emoji-mart/data': 1.2.1 '@floating-ui/react': 0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) @@ -8011,10 +8004,10 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@mantine/core@8.1.3(@mantine/hooks@7.17.8(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/react': 0.26.28(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@mantine/hooks': 8.1.3(react@19.1.0) + '@mantine/hooks': 7.17.8(react@19.1.0) clsx: 2.1.1 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -8029,10 +8022,6 @@ snapshots: dependencies: react: 19.1.0 - '@mantine/hooks@8.1.3(react@19.1.0)': - dependencies: - react: 19.1.0 - '@mantine/utils@6.0.22(react@19.1.0)': dependencies: react: 19.1.0 From 00411df5051a42ff1b8ad77480f629ecc5de50b5 Mon Sep 17 00:00:00 2001 From: Robert Molina Date: Thu, 21 Aug 2025 02:19:09 -0400 Subject: [PATCH 2/5] cleanup --- .../(app)/pages/_components/page-editor.tsx | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/apps/web/src/app/(app)/pages/_components/page-editor.tsx b/apps/web/src/app/(app)/pages/_components/page-editor.tsx index b0e894d..6fe9ee2 100644 --- a/apps/web/src/app/(app)/pages/_components/page-editor.tsx +++ b/apps/web/src/app/(app)/pages/_components/page-editor.tsx @@ -41,28 +41,6 @@ export function PageEditor({ function handleEditorChange(transactions: BlockTransaction[]) { pendingChangesRef.current.push(...transactions); - // Leaving this here for debugging purposes because this logic is a fucking mess. - if (env.NODE_ENV === "development") { - console.debug("saveTransactions 👀", { - transactions: pendingChangesRef.current.map((t) => - t.type === "block_remove" || t.type === "block_upsert" - ? { - ...t, - element: document.querySelector(`[data-id="${t.args.id}"]`), - } - : { - ...t, - from_element: document.querySelector( - `[data-id="${t.args.from_id}"]`, - ), - to_element: document.querySelector( - `[data-id="${t.args.to_id}"]`, - ), - }, - ), - }); - } - debouncedMutate(); } From eac09849d364a76826e34316e193bfc057e39250 Mon Sep 17 00:00:00 2001 From: Robert Molina Date: Thu, 21 Aug 2025 02:24:41 -0400 Subject: [PATCH 3/5] cleanup --- apps/web/src/app/(app)/pages/_components/page-editor.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/app/(app)/pages/_components/page-editor.tsx b/apps/web/src/app/(app)/pages/_components/page-editor.tsx index 6fe9ee2..6157257 100644 --- a/apps/web/src/app/(app)/pages/_components/page-editor.tsx +++ b/apps/web/src/app/(app)/pages/_components/page-editor.tsx @@ -7,7 +7,6 @@ import { useMutation } from "@tanstack/react-query"; import { useRef } from "react"; import { useDebouncedCallback } from "use-debounce"; import { BlockEditor } from "~/components/editor/block-editor"; -import { env } from "~/env"; import { useTRPC } from "~/trpc/react"; type PageEditorProps = { From 19b0e8c40fae153b03b54964e9f85021215b94da Mon Sep 17 00:00:00 2001 From: Jorge Baralt Date: Thu, 21 Aug 2025 19:22:07 -0400 Subject: [PATCH 4/5] small fix --- apps/web/src/components/ui/custom-blocks/alert-block.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/custom-blocks/alert-block.tsx b/apps/web/src/components/ui/custom-blocks/alert-block.tsx index 331be5c..510c009 100644 --- a/apps/web/src/components/ui/custom-blocks/alert-block.tsx +++ b/apps/web/src/components/ui/custom-blocks/alert-block.tsx @@ -65,7 +65,8 @@ export const AlertBlock = createReactBlockSpec( render: (props) => { const alertType = alertTypes.find( (a) => a.value === props.block.props.type, - )!; + ); + if (!alertType) return null; const Icon = alertType.icon; return (
From 92ddfe549b9d09981a394fad57a672e83a1da8e9 Mon Sep 17 00:00:00 2001 From: Jorge Baralt Date: Thu, 21 Aug 2025 20:03:30 -0400 Subject: [PATCH 5/5] small change --- apps/web/src/app/(app)/pages/_components/page-editor.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(app)/pages/_components/page-editor.tsx b/apps/web/src/app/(app)/pages/_components/page-editor.tsx index 6157257..1ad82d1 100644 --- a/apps/web/src/app/(app)/pages/_components/page-editor.tsx +++ b/apps/web/src/app/(app)/pages/_components/page-editor.tsx @@ -21,6 +21,8 @@ export function PageEditor({ debounceTime = 200, }: PageEditorProps) { const trpc = useTRPC(); + const pendingChangesRef = useRef([]); + const { mutate, isPending } = useMutation({ ...trpc.blocks.saveTransactions.mutationOptions({}), onSuccess: () => { @@ -29,7 +31,7 @@ export function PageEditor({ } }, }); - const pendingChangesRef = useRef([]); + const debouncedMutate = useDebouncedCallback(() => { if (isPending) return; const transactions = pendingChangesRef.current;