Skip to content

Commit 7a7415b

Browse files
committed
feat(block-editor): new block editor and page editor
1 parent 494dc76 commit 7a7415b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1151
-2338
lines changed

apps/web/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,12 @@
2222
"@assistant-ui/react-ai-sdk": "^0.10.16",
2323
"@assistant-ui/react-markdown": "^0.10.6",
2424
"@assistant-ui/styles": "^0.1.14",
25-
"@blocknote/core": "^0.33.0",
26-
"@blocknote/mantine": "^0.33.0",
27-
"@blocknote/react": "^0.33.0",
25+
"@blocknote/core": "^0.35.0",
26+
"@blocknote/mantine": "^0.35.0",
27+
"@blocknote/react": "^0.35.0",
2828
"@daveyplate/better-auth-ui": "^2.1.0",
2929
"@hookform/resolvers": "^5.0.1",
3030
"@mantine/core": "^8.1.3",
31-
"@mantine/hooks": "^8.1.3",
3231
"@mastra/core": "^0.12.1",
3332
"@mastra/libsql": "^0.12.0",
3433
"@mastra/loggers": "^0.10.5",

apps/web/src/ai/mastra/agents/journl-agent.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const AGENT_INSTRUCTIONS = () => {
1616
return `
1717
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.
1818
19-
**Today's date is ${today}.**
19+
Current date: ${today}
2020
2121
## Your Personality
2222
@@ -36,8 +36,6 @@ When users mention their thoughts, experiences, or ask about patterns, you intui
3636
3737
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.
3838
39-
**The links to the journal entries and pages are always relative to the current page**.
40-
4139
## Your Approach for Different Needs
4240
4341
**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
5351
You naturally structure your responses with:
5452
1. Acknowledging what they're asking about
5553
2. Sharing what you found (with links to sources)
54+
- NOTE: **The links to the journal entries and pages are always relative to the current page.**
5655
3. Pointing out patterns or insights that stand out
5756
4. Ending with a thoughtful question or suggestion
5857

apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-page-item.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function AppSidebarPageItem(props: AppSidebarPageItemProps) {
3434
href={`/pages/${page?.id}`}
3535
className="line-clamp-1 min-w-0 flex-1 truncate hover:underline"
3636
>
37-
{page?.title || "New Page"}
37+
{page?.title || "New page"}
3838
</Link>
3939
{!!page && (
4040
<DeletePageButton

apps/web/src/app/(app)/@appSidebar/_components/create-page-button.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function CreatePageButton() {
2727
createPage(
2828
{
2929
children: [],
30-
title: "New Page",
30+
title: "",
3131
},
3232
{
3333
onError: (error) => {
@@ -64,7 +64,7 @@ export function CreatePageButton() {
6464
disabled={showLoading}
6565
>
6666
<Plus />
67-
{showLoading ? "Creating..." : "New Page"}
67+
{showLoading ? "Creating..." : "New page"}
6868
</Button>
6969
</div>
7070
</SidebarMenuSubButton>

apps/web/src/app/(app)/@appSidebar/_components/delete-page-button.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function DeletePageButton({ page, className }: DeletePageButtonProps) {
3232
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
3333

3434
const { mutate: deletePage, isPending: isDeleting } = useMutation(
35-
trpc.pages.delete.mutationOptions({}),
35+
trpc.document.delete.mutationOptions({}),
3636
);
3737

3838
const handleDelete = () => {
@@ -42,16 +42,14 @@ export function DeletePageButton({ page, className }: DeletePageButtonProps) {
4242
const confirmDelete = () => {
4343
startTransition(() => {
4444
deletePage(
45-
{ id: page.id },
45+
{ id: page.document_id },
4646
{
4747
onError: (error) => {
4848
console.error("Failed to delete page:", error);
4949
},
50-
onSuccess: (result) => {
51-
const deletedPageId = result.deletedPage.id;
52-
50+
onSuccess: () => {
5351
// Only navigate away if we're currently on the deleted page
54-
if (pathname === `/pages/${deletedPageId}`) {
52+
if (pathname === `/pages/${page.id}`) {
5553
router.push("/journal");
5654
}
5755

@@ -60,18 +58,18 @@ export function DeletePageButton({ page, className }: DeletePageButtonProps) {
6058
trpc.pages.getAll.queryOptions().queryKey,
6159
(oldPages: Page[] | undefined) => {
6260
if (!oldPages) return [];
63-
return oldPages.filter((p) => p.id !== deletedPageId);
61+
return oldPages.filter((p) => p.id !== page.id);
6462
},
6563
);
6664

6765
// Remove the specific page from cache
6866
queryClient.removeQueries({
69-
queryKey: trpc.pages.getById.queryKey({ id: deletedPageId }),
67+
queryKey: trpc.pages.getById.queryKey({ id: page.id }),
7068
});
7169

7270
// Cancel any in-flight queries for this page
7371
queryClient.cancelQueries({
74-
queryKey: trpc.pages.getById.queryKey({ id: deletedPageId }),
72+
queryKey: trpc.pages.getById.queryKey({ id: page.id }),
7573
});
7674

7775
// Close the dialog after successful deletion
Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { notFound } from "next/navigation";
2-
import { api, prefetch, trpc } from "~/trpc/server";
3-
import { PageEditor } from "../_components/page-editor";
2+
import { Suspense } from "react";
3+
import { api } from "~/trpc/server";
4+
import { DynamicPageEditor } from "../_components/page-editor.dynamic";
5+
import { PageTitleInput } from "../_components/page-title-input";
46

57
export default async function Page({
68
params,
@@ -15,14 +17,27 @@ export default async function Page({
1517
notFound();
1618
}
1719

18-
if (page?.children && page.children.length > 0) {
19-
prefetch(
20-
trpc.blocks.loadPageChunk.queryOptions({
21-
limit: 100,
22-
parentChildren: page.children,
23-
}),
24-
);
25-
}
26-
27-
return <PageEditor id={id} />;
20+
return (
21+
<div className="flex h-full flex-col gap-4 p-4">
22+
<div className="min-h-0 flex-1">
23+
<PageTitleInput
24+
page={{
25+
id: page.id,
26+
title: page.title,
27+
}}
28+
className="mb-4 pl-13"
29+
/>
30+
<Suspense>
31+
<DynamicPageEditor
32+
page={{
33+
document_id: page.document_id,
34+
id: page.id,
35+
title: page.title,
36+
}}
37+
initialBlocks={page.document}
38+
/>
39+
</Suspense>
40+
</div>
41+
</div>
42+
);
2843
}

apps/web/src/app/(app)/pages/_components/page-blocks.tsx

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"use client";
2+
3+
import dynamic from "next/dynamic";
4+
5+
export const DynamicPageEditor = dynamic(
6+
() => import("./page-editor").then((mod) => mod.PageEditor),
7+
{
8+
ssr: false,
9+
},
10+
);
Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,72 @@
11
"use client";
22

3-
import { LazyBlockEditor } from "~/components/editor/lazy-block-editor";
3+
import type { BlockTransaction } from "@acme/api";
4+
import type { Page } from "@acme/db/schema";
5+
import type { PartialBlock } from "@blocknote/core";
6+
import { useMutation } from "@tanstack/react-query";
7+
import { useRef } from "react";
8+
import { useDebouncedCallback } from "use-debounce";
9+
import { BlockEditor } from "~/components/editor/block-editor";
10+
import { env } from "~/env";
11+
import { useTRPC } from "~/trpc/react";
412

513
type PageEditorProps = {
6-
id: string;
14+
page: Pick<Page, "id" | "title" | "document_id">;
15+
initialBlocks: [PartialBlock, ...PartialBlock[]] | undefined;
16+
debounceTime?: number;
717
};
818

9-
export function PageEditor({ id }: PageEditorProps) {
19+
export function PageEditor({
20+
page,
21+
initialBlocks,
22+
debounceTime = 200,
23+
}: PageEditorProps) {
24+
const trpc = useTRPC();
25+
const { mutate, isPending } = useMutation({
26+
...trpc.blocks.saveTransactions.mutationOptions({}),
27+
onSuccess: () => {
28+
if (pendingChangesRef.current.length > 0) {
29+
debouncedMutate();
30+
}
31+
},
32+
});
33+
const pendingChangesRef = useRef<BlockTransaction[]>([]);
34+
const debouncedMutate = useDebouncedCallback(() => {
35+
if (isPending) return;
36+
const transactions = pendingChangesRef.current;
37+
pendingChangesRef.current = [];
38+
mutate({ document_id: page.document_id, transactions });
39+
}, debounceTime);
40+
41+
function handleEditorChange(transactions: BlockTransaction[]) {
42+
pendingChangesRef.current.push(...transactions);
43+
44+
// Leaving this here for debugging purposes because this logic is a fucking mess.
45+
if (env.NODE_ENV === "development") {
46+
console.debug("saveTransactions 👀", {
47+
transactions: pendingChangesRef.current.map((t) =>
48+
t.type === "block_remove" || t.type === "block_upsert"
49+
? {
50+
...t,
51+
element: document.querySelector(`[data-id="${t.args.id}"]`),
52+
}
53+
: {
54+
...t,
55+
from_element: document.querySelector(
56+
`[data-id="${t.args.from_id}"]`,
57+
),
58+
to_element: document.querySelector(
59+
`[data-id="${t.args.to_id}"]`,
60+
),
61+
},
62+
),
63+
});
64+
}
65+
66+
debouncedMutate();
67+
}
68+
1069
return (
11-
<div className="flex h-full flex-col gap-4 p-4">
12-
<div className="min-h-0 flex-1">
13-
<LazyBlockEditor parentId={id} parentType="page" />
14-
</div>
15-
</div>
70+
<BlockEditor initialBlocks={initialBlocks} onChange={handleEditorChange} />
1671
);
1772
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"use client";
2+
3+
import type { Page } from "@acme/db/schema";
4+
import { useMutation, useQueryClient } from "@tanstack/react-query";
5+
import { useState } from "react";
6+
import { useDebouncedCallback } from "use-debounce";
7+
import { Input } from "~/components/ui/input";
8+
import { cn } from "~/components/utils";
9+
import { useTRPC } from "~/trpc/react";
10+
11+
type PageEditorTitleProps = {
12+
page: Pick<Page, "id" | "title">;
13+
placeholder?: string;
14+
className?: string;
15+
debounceTime?: number;
16+
onTitleChange?: (title: string) => void;
17+
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
18+
} & Omit<React.ComponentProps<"input">, "value" | "onChange" | "onKeyDown">;
19+
20+
export function PageTitleInput({
21+
page,
22+
placeholder = "New page",
23+
className,
24+
debounceTime = 150,
25+
onTitleChange,
26+
onKeyDown,
27+
ref,
28+
...rest
29+
}: PageEditorTitleProps) {
30+
const trpc = useTRPC();
31+
const queryClient = useQueryClient();
32+
const { mutate: updatePageTitle } = useMutation(
33+
trpc.pages.updateTitle.mutationOptions({}),
34+
);
35+
const [title, setTitle] = useState(page.title);
36+
37+
// Debounced API call for page title updates
38+
const debouncedUpdate = useDebouncedCallback((newTitle: string) => {
39+
// Optimistically update the cache
40+
queryClient.setQueryData(
41+
trpc.pages.getById.queryOptions({ id: page.id }).queryKey,
42+
(old) => {
43+
if (!old) return old;
44+
return {
45+
...old,
46+
title: newTitle,
47+
updatedAt: new Date().toISOString(),
48+
};
49+
},
50+
);
51+
52+
// Update the pages.getAll query cache
53+
queryClient.setQueryData(
54+
trpc.pages.getAll.queryOptions().queryKey,
55+
(old) => {
56+
if (!old) return old;
57+
return old.map((p) =>
58+
p.id === page.id
59+
? {
60+
...p,
61+
title: newTitle,
62+
}
63+
: p,
64+
);
65+
},
66+
);
67+
68+
// Execute the mutation
69+
updatePageTitle(
70+
{ id: page.id, title: newTitle },
71+
{
72+
onError: () => {
73+
// If the mutation fails, invalidate queries to refetch correct data
74+
queryClient.invalidateQueries({
75+
queryKey: trpc.pages.getById.queryOptions({ id: page.id }).queryKey,
76+
});
77+
queryClient.invalidateQueries({
78+
queryKey: trpc.pages.getAll.queryOptions().queryKey,
79+
});
80+
},
81+
},
82+
);
83+
}, debounceTime);
84+
85+
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
86+
const newTitle = e.target.value;
87+
setTitle(newTitle);
88+
onTitleChange?.(newTitle);
89+
debouncedUpdate(newTitle);
90+
}
91+
92+
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
93+
onKeyDown?.(e);
94+
}
95+
96+
return (
97+
<Input
98+
ref={ref}
99+
value={title}
100+
onChange={handleChange}
101+
onKeyDown={handleKeyDown}
102+
placeholder={placeholder}
103+
className={cn(
104+
"!bg-transparent !outline-none !ring-0 !text-3xl border-none px-0 font-bold placeholder:text-muted-foreground/60",
105+
className,
106+
)}
107+
{...rest}
108+
/>
109+
);
110+
}

0 commit comments

Comments
 (0)