Skip to content

Commit 2ff3a56

Browse files
authored
Merge pull request #132 from atomly/jorge/feat/sidebar-pages-pagination
Jorge/feat/sidebar pages pagination
2 parents e749013 + 19a4370 commit 2ff3a56

File tree

6 files changed

+161
-32
lines changed

6 files changed

+161
-32
lines changed

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"react-dom": "~19.1.1",
7070
"react-hook-form": "~7.57.0",
7171
"react-virtuoso": "~4.13.0",
72+
"react-window": "^2.1.1",
7273
"remark-gfm": "~4.0.1",
7374
"remove-markdown": "~0.6.2",
7475
"sonner": "~2.0.7",

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

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"use client";
22

33
import type { Page } from "@acme/db/schema";
4-
import { useQuery } from "@tanstack/react-query";
5-
import { BookOpen, ChevronRight } from "lucide-react";
4+
import { useInfiniteQuery } from "@tanstack/react-query";
5+
import { BookOpen, ChevronRight, Loader2 } from "lucide-react";
66
import { useState } from "react";
7+
import { List } from "react-window";
78
import {
89
Collapsible,
910
CollapsibleContent,
@@ -19,21 +20,31 @@ import { AppSidebarPageItem } from "./app-sidebar-page-item";
1920
import { CreatePageButton } from "./create-page-button";
2021

2122
type AppSidebarPagesProps = {
22-
initialPages: Page[];
23+
infinitePagesQueryOptions: {
24+
direction: "forward" | "backward";
25+
limit: number;
26+
};
2327
defaultOpen?: boolean;
2428
};
2529

2630
export const AppSidebarPages = ({
27-
initialPages,
31+
infinitePagesQueryOptions,
2832
defaultOpen = true,
2933
}: AppSidebarPagesProps) => {
3034
const trpc = useTRPC();
3135
const { state, setOpen } = useSidebar();
36+
const queryOptions = trpc.pages.getInfinite.infiniteQueryOptions(
37+
infinitePagesQueryOptions,
38+
);
39+
const { data, fetchNextPage, isFetchingNextPage, hasNextPage } =
40+
useInfiniteQuery({
41+
...queryOptions,
42+
getNextPageParam: ({ nextCursor }) => {
43+
return nextCursor;
44+
},
45+
});
3246

33-
const { data: pages } = useQuery({
34-
...trpc.pages.getByUser.queryOptions(),
35-
initialData: initialPages,
36-
});
47+
const pages = data?.pages?.flatMap((page) => page.items) ?? [];
3748

3849
const [isOpen, setIsOpen] = useState(defaultOpen);
3950

@@ -53,23 +64,65 @@ export const AppSidebarPages = ({
5364
<Collapsible
5465
open={isOpen}
5566
onOpenChange={setIsOpen}
56-
className="group/collapsible"
67+
className="group/collapsible flex min-h-0 flex-1 flex-col"
5768
>
5869
<CollapsibleTrigger asChild>
59-
<SidebarMenuButton tooltip="Pages" onClick={handlePagesClick}>
60-
<BookOpen />
70+
<SidebarMenuButton
71+
className="min-h-8"
72+
tooltip="Pages"
73+
onClick={handlePagesClick}
74+
>
75+
{isFetchingNextPage ? (
76+
<Loader2 className="size-3 animate-spin" />
77+
) : (
78+
<BookOpen />
79+
)}
6180
<span>Pages</span>
6281
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
6382
</SidebarMenuButton>
6483
</CollapsibleTrigger>
65-
<CollapsibleContent>
66-
<SidebarMenuSub className="mr-0 pr-0">
84+
85+
<CollapsibleContent className="flex min-h-0 flex-col">
86+
<SidebarMenuSub className="mr-0 flex-1 overflow-scroll pr-0">
6787
<CreatePageButton />
68-
{pages?.map((page) => (
69-
<AppSidebarPageItem key={page.id} page={page} />
70-
))}
88+
<List
89+
rowComponent={PageRow}
90+
rowCount={pages.length}
91+
rowHeight={28}
92+
// @ts-expect-error - react-window types are incorrectly expecting index/style in rowProps
93+
rowProps={{
94+
pages,
95+
}}
96+
onRowsRendered={({ stopIndex }) => {
97+
// Fetch next page when user scrolls near the end
98+
if (
99+
stopIndex >= pages.length - 5 &&
100+
!isFetchingNextPage &&
101+
hasNextPage
102+
) {
103+
fetchNextPage();
104+
}
105+
}}
106+
/>
71107
</SidebarMenuSub>
72108
</CollapsibleContent>
73109
</Collapsible>
74110
);
75111
};
112+
113+
const PageRow = ({
114+
index,
115+
style,
116+
pages,
117+
}: {
118+
index: number;
119+
style: React.CSSProperties;
120+
} & { pages: Page[] }) => {
121+
const page = pages?.[index];
122+
if (!page) return null;
123+
return (
124+
<div style={style}>
125+
<AppSidebarPageItem page={page} />
126+
</div>
127+
);
128+
};

apps/web/src/app/(app)/@appSidebar/default.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,26 @@ import {
1111
} from "~/components/ui/sidebar";
1212
import { Skeleton } from "~/components/ui/skeleton";
1313
import { env } from "~/env";
14-
import { api } from "~/trpc/server";
14+
import { prefetch, trpc } from "~/trpc/server";
1515
import { DynamicAppSidebarDevtools } from "./_components/app-sidebar-devtools.dynamic";
1616
import { AppSidebarNavigation } from "./_components/app-sidebar-main";
1717
import { AppSidebarPages } from "./_components/app-sidebar-pages";
1818
import { AppSidebarPagesSkeleton } from "./_components/app-sidebar-pages-skeleton";
1919
import { AppSidebarUser } from "./_components/app-sidebar-user";
2020
import { AppSidebarUserSkeleton } from "./_components/app-sidebar-user-skeleton";
2121

22+
export const infinitePagesQueryOptions = {
23+
direction: "forward" as const,
24+
limit: 25,
25+
} as const;
26+
2227
async function SuspendedAppSidebarPages() {
23-
const pages = await api.pages.getByUser();
24-
return <AppSidebarPages initialPages={pages} />;
28+
prefetch(
29+
trpc.pages.getInfinite.infiniteQueryOptions(infinitePagesQueryOptions),
30+
);
31+
return (
32+
<AppSidebarPages infinitePagesQueryOptions={infinitePagesQueryOptions} />
33+
);
2534
}
2635

2736
export default function AppSidebar() {
@@ -42,8 +51,8 @@ export default function AppSidebar() {
4251
</Suspense>
4352
</SidebarHeader>
4453
<Separator />
45-
<SidebarContent>
46-
<SidebarGroup className="gap-y-1">
54+
<SidebarContent className="flex flex-1 flex-col">
55+
<SidebarGroup className="flex min-h-0 flex-1 flex-col gap-y-1">
4756
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
4857
<AppSidebarNavigation items={navigationItems} />
4958
<Suspense fallback={<AppSidebarPagesSkeleton />}>

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

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useDebouncedCallback } from "use-debounce";
77
import { FullHeightTextarea } from "~/components/ui/full-height-textarea";
88
import { cn } from "~/components/utils";
99
import { useTRPC } from "~/trpc/react";
10+
import { infinitePagesQueryOptions } from "../../@appSidebar/default";
1011

1112
const DEFAULT_PLACEHOLDER = "New page";
1213
const DEFAULT_DEBOUNCE_TIME = 150;
@@ -52,19 +53,22 @@ export function PageTitleTextarea({
5253
},
5354
);
5455

55-
// Update the pages.getAll query cache
56+
// Update the pages.getInfinite query cache from sidebar
5657
queryClient.setQueryData(
57-
trpc.pages.getByUser.queryOptions().queryKey,
58+
trpc.pages.getInfinite.infiniteQueryOptions(infinitePagesQueryOptions)
59+
.queryKey,
5860
(old) => {
5961
if (!old) return old;
60-
return old.map((p) =>
61-
p.id === page.id
62-
? {
63-
...p,
64-
title: newTitle,
65-
}
66-
: p,
67-
);
62+
const pages = old.pages.map((p) => ({
63+
...p,
64+
items: p.items.map((item) =>
65+
item.id === page.id ? { ...item, title: newTitle } : item,
66+
),
67+
}));
68+
return {
69+
...old,
70+
pages,
71+
};
6872
},
6973
);
7074

packages/api/src/api-router/pages.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { blocknoteBlocks } from "@acme/blocknote/server";
2-
import { and, cosineDistance, desc, eq, gt, sql } from "@acme/db";
2+
import { and, cosineDistance, desc, eq, gt, lt, sql } from "@acme/db";
33
import {
44
Document,
55
DocumentEmbedding,
@@ -114,6 +114,54 @@ export const pagesRouter = {
114114
});
115115
}
116116
}),
117+
getInfinite: protectedProcedure
118+
.input(
119+
z.object({
120+
cursor: z.iso.datetime().optional(),
121+
direction: z.enum(["forward", "backward"]).default("forward"),
122+
limit: z.number().min(1).max(50).default(10),
123+
}),
124+
)
125+
.query(async ({ ctx, input }) => {
126+
try {
127+
const { limit, cursor, direction } = input;
128+
129+
const pages = await ctx.db
130+
.select()
131+
.from(Page)
132+
.where(
133+
and(
134+
eq(Page.user_id, ctx.session.user.id),
135+
cursor
136+
? direction === "forward"
137+
? lt(Page.updated_at, cursor)
138+
: gt(Page.updated_at, cursor)
139+
: undefined,
140+
),
141+
)
142+
.orderBy(desc(Page.updated_at))
143+
.limit(limit + 1);
144+
145+
let nextCursor: string | undefined;
146+
if (pages.length > limit) {
147+
const nextItem = pages.pop();
148+
nextCursor = nextItem?.updated_at
149+
? new Date(nextItem.updated_at).toISOString()
150+
: undefined;
151+
}
152+
153+
return {
154+
items: pages,
155+
nextCursor,
156+
};
157+
} catch (error) {
158+
console.error("Database error in pages.getInfinite:", error);
159+
throw new TRPCError({
160+
code: "INTERNAL_SERVER_ERROR",
161+
message: "Failed to fetch pages",
162+
});
163+
}
164+
}),
117165
getRelevantPages: protectedProcedure
118166
.input(
119167
z.object({

pnpm-lock.yaml

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)