diff --git a/apps/web/package.json b/apps/web/package.json index 7f55925..9bd16e2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -69,6 +69,7 @@ "react-dom": "~19.1.1", "react-hook-form": "~7.57.0", "react-virtuoso": "~4.13.0", + "react-window": "^2.1.1", "remark-gfm": "~4.0.1", "remove-markdown": "~0.6.2", "sonner": "~2.0.7", diff --git a/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-pages.tsx b/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-pages.tsx index b94795d..68a01de 100644 --- a/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-pages.tsx +++ b/apps/web/src/app/(app)/@appSidebar/_components/app-sidebar-pages.tsx @@ -1,9 +1,10 @@ "use client"; import type { Page } from "@acme/db/schema"; -import { useQuery } from "@tanstack/react-query"; -import { BookOpen, ChevronRight } from "lucide-react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { BookOpen, ChevronRight, Loader2 } from "lucide-react"; import { useState } from "react"; +import { List } from "react-window"; import { Collapsible, CollapsibleContent, @@ -19,21 +20,31 @@ import { AppSidebarPageItem } from "./app-sidebar-page-item"; import { CreatePageButton } from "./create-page-button"; type AppSidebarPagesProps = { - initialPages: Page[]; + infinitePagesQueryOptions: { + direction: "forward" | "backward"; + limit: number; + }; defaultOpen?: boolean; }; export const AppSidebarPages = ({ - initialPages, + infinitePagesQueryOptions, defaultOpen = true, }: AppSidebarPagesProps) => { const trpc = useTRPC(); const { state, setOpen } = useSidebar(); + const queryOptions = trpc.pages.getInfinite.infiniteQueryOptions( + infinitePagesQueryOptions, + ); + const { data, fetchNextPage, isFetchingNextPage, hasNextPage } = + useInfiniteQuery({ + ...queryOptions, + getNextPageParam: ({ nextCursor }) => { + return nextCursor; + }, + }); - const { data: pages } = useQuery({ - ...trpc.pages.getByUser.queryOptions(), - initialData: initialPages, - }); + const pages = data?.pages?.flatMap((page) => page.items) ?? []; const [isOpen, setIsOpen] = useState(defaultOpen); @@ -53,23 +64,65 @@ export const AppSidebarPages = ({ - - + + {isFetchingNextPage ? ( + + ) : ( + + )} Pages - - + + + - {pages?.map((page) => ( - - ))} + { + // Fetch next page when user scrolls near the end + if ( + stopIndex >= pages.length - 5 && + !isFetchingNextPage && + hasNextPage + ) { + fetchNextPage(); + } + }} + /> ); }; + +const PageRow = ({ + index, + style, + pages, +}: { + index: number; + style: React.CSSProperties; +} & { pages: Page[] }) => { + const page = pages?.[index]; + if (!page) return null; + return ( +
+ +
+ ); +}; diff --git a/apps/web/src/app/(app)/@appSidebar/default.tsx b/apps/web/src/app/(app)/@appSidebar/default.tsx index 3a560cb..d18f150 100644 --- a/apps/web/src/app/(app)/@appSidebar/default.tsx +++ b/apps/web/src/app/(app)/@appSidebar/default.tsx @@ -11,7 +11,7 @@ import { } from "~/components/ui/sidebar"; import { Skeleton } from "~/components/ui/skeleton"; import { env } from "~/env"; -import { api } from "~/trpc/server"; +import { prefetch, trpc } from "~/trpc/server"; import { DynamicAppSidebarDevtools } from "./_components/app-sidebar-devtools.dynamic"; import { AppSidebarNavigation } from "./_components/app-sidebar-main"; import { AppSidebarPages } from "./_components/app-sidebar-pages"; @@ -19,9 +19,18 @@ import { AppSidebarPagesSkeleton } from "./_components/app-sidebar-pages-skeleto import { AppSidebarUser } from "./_components/app-sidebar-user"; import { AppSidebarUserSkeleton } from "./_components/app-sidebar-user-skeleton"; +export const infinitePagesQueryOptions = { + direction: "forward" as const, + limit: 25, +} as const; + async function SuspendedAppSidebarPages() { - const pages = await api.pages.getByUser(); - return ; + prefetch( + trpc.pages.getInfinite.infiniteQueryOptions(infinitePagesQueryOptions), + ); + return ( + + ); } export default function AppSidebar() { @@ -42,8 +51,8 @@ export default function AppSidebar() { - - + + Navigation }> diff --git a/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx b/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx index a8bbe41..04021ff 100644 --- a/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx +++ b/apps/web/src/app/(app)/pages/_components/page-title-textarea.tsx @@ -7,6 +7,7 @@ import { useDebouncedCallback } from "use-debounce"; import { FullHeightTextarea } from "~/components/ui/full-height-textarea"; import { cn } from "~/components/utils"; import { useTRPC } from "~/trpc/react"; +import { infinitePagesQueryOptions } from "../../@appSidebar/default"; const DEFAULT_PLACEHOLDER = "New page"; const DEFAULT_DEBOUNCE_TIME = 150; @@ -52,19 +53,22 @@ export function PageTitleTextarea({ }, ); - // Update the pages.getAll query cache + // Update the pages.getInfinite query cache from sidebar queryClient.setQueryData( - trpc.pages.getByUser.queryOptions().queryKey, + trpc.pages.getInfinite.infiniteQueryOptions(infinitePagesQueryOptions) + .queryKey, (old) => { if (!old) return old; - return old.map((p) => - p.id === page.id - ? { - ...p, - title: newTitle, - } - : p, - ); + const pages = old.pages.map((p) => ({ + ...p, + items: p.items.map((item) => + item.id === page.id ? { ...item, title: newTitle } : item, + ), + })); + return { + ...old, + pages, + }; }, ); diff --git a/packages/api/src/api-router/pages.ts b/packages/api/src/api-router/pages.ts index a22c8d4..56b11d4 100644 --- a/packages/api/src/api-router/pages.ts +++ b/packages/api/src/api-router/pages.ts @@ -1,5 +1,5 @@ import { blocknoteBlocks } from "@acme/blocknote/server"; -import { and, cosineDistance, desc, eq, gt, sql } from "@acme/db"; +import { and, cosineDistance, desc, eq, gt, lt, sql } from "@acme/db"; import { Document, DocumentEmbedding, @@ -114,6 +114,54 @@ export const pagesRouter = { }); } }), + getInfinite: protectedProcedure + .input( + z.object({ + cursor: z.iso.datetime().optional(), + direction: z.enum(["forward", "backward"]).default("forward"), + limit: z.number().min(1).max(50).default(10), + }), + ) + .query(async ({ ctx, input }) => { + try { + const { limit, cursor, direction } = input; + + const pages = await ctx.db + .select() + .from(Page) + .where( + and( + eq(Page.user_id, ctx.session.user.id), + cursor + ? direction === "forward" + ? lt(Page.updated_at, cursor) + : gt(Page.updated_at, cursor) + : undefined, + ), + ) + .orderBy(desc(Page.updated_at)) + .limit(limit + 1); + + let nextCursor: string | undefined; + if (pages.length > limit) { + const nextItem = pages.pop(); + nextCursor = nextItem?.updated_at + ? new Date(nextItem.updated_at).toISOString() + : undefined; + } + + return { + items: pages, + nextCursor, + }; + } catch (error) { + console.error("Database error in pages.getInfinite:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to fetch pages", + }); + } + }), getRelevantPages: protectedProcedure .input( z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0179d10..ac89afb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,6 +245,9 @@ importers: react-virtuoso: specifier: ~4.13.0 version: 4.13.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react-window: + specifier: ^2.1.1 + version: 2.1.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) remark-gfm: specifier: ~4.0.1 version: 4.0.1 @@ -5800,6 +5803,12 @@ packages: react: '>=16 || >=17 || >= 18 || >= 19' react-dom: '>=16 || >=17 || >= 18 || >=19' + react-window@2.1.1: + resolution: {integrity: sha512-Wx5yHri8G1nFxImnJRkEEKtRTnG3cWaqknUJyYvgisQtl1mw/d8LQmLXfuKxpn2dY8IwDn5mCIuxm2NVyIvgVQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@19.1.1: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} @@ -12823,6 +12832,11 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) + react-window@2.1.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react@19.1.1: {} readable-stream@3.6.2: