diff options
| author | MohamedBassem <me@mbassem.com> | 2024-03-17 13:12:11 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-03-17 13:12:11 +0000 |
| commit | fc8eb79b98bbea558bd614dc71dd66b72ab9b0c0 (patch) | |
| tree | e868b500b8b5d00cf82011013b68dd3e669bbec3 | |
| parent | e86f6a9cf0eb271abfc7cf53ec10ef372d52f0bd (diff) | |
| download | karakeep-fc8eb79b98bbea558bd614dc71dd66b72ab9b0c0.tar.zst | |
feature: Implemente pagination support
| -rw-r--r-- | apps/mobile/components/bookmarks/BookmarkList.tsx | 31 | ||||
| -rw-r--r-- | apps/web/app/dashboard/lists/[listId]/page.tsx | 2 | ||||
| -rw-r--r-- | apps/web/app/dashboard/search/page.tsx | 2 | ||||
| -rw-r--r-- | apps/web/app/dashboard/tags/[tagName]/page.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/Bookmarks.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx | 47 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 122 | ||||
| -rw-r--r-- | packages/trpc/types/bookmarks.ts | 5 |
8 files changed, 143 insertions, 70 deletions
diff --git a/apps/mobile/components/bookmarks/BookmarkList.tsx b/apps/mobile/components/bookmarks/BookmarkList.tsx index e7b5e5f2..8a19c045 100644 --- a/apps/mobile/components/bookmarks/BookmarkList.tsx +++ b/apps/mobile/components/bookmarks/BookmarkList.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Keyboard, Text, View } from "react-native"; +import { ActivityIndicator, Keyboard, Text, View } from "react-native"; import Animated, { LinearTransition } from "react-native-reanimated"; import { api } from "@/lib/trpc"; import { useScrollToTop } from "@react-navigation/native"; @@ -20,13 +20,26 @@ export default function BookmarkList({ const [refreshing, setRefreshing] = useState(false); const flatListRef = useRef(null); useScrollToTop(flatListRef); - const { data, isPending, isPlaceholderData } = - api.bookmarks.getBookmarks.useQuery(query); + const { + data, + isPending, + isPlaceholderData, + error, + fetchNextPage, + isFetchingNextPage, + } = api.bookmarks.getBookmarks.useInfiniteQuery(query, { + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }); useEffect(() => { setRefreshing(isPending || isPlaceholderData); }, [isPending, isPlaceholderData]); + if (error) { + return <Text>{JSON.stringify(error)}</Text>; + } + if (isPending || !data) { return <FullPageSpinner />; } @@ -51,11 +64,21 @@ export default function BookmarkList({ <Text className="text-xl">No Bookmarks</Text> </View> } - data={data.bookmarks} + data={data.pages.flatMap((p) => p.bookmarks)} refreshing={refreshing} onRefresh={onRefresh} onScrollBeginDrag={Keyboard.dismiss} keyExtractor={(b) => b.id} + onEndReached={() => fetchNextPage()} + ListFooterComponent={ + isFetchingNextPage ? ( + <View className="items-center"> + <ActivityIndicator /> + </View> + ) : ( + <View /> + ) + } /> ); } diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx index b9a26053..a8ba4feb 100644 --- a/apps/web/app/dashboard/lists/[listId]/page.tsx +++ b/apps/web/app/dashboard/lists/[listId]/page.tsx @@ -43,7 +43,7 @@ export default async function ListPage({ <hr /> <BookmarksGrid query={{ listId: list.id, archived: false }} - bookmarks={bookmarks.bookmarks} + bookmarks={bookmarks} /> </div> ); diff --git a/apps/web/app/dashboard/search/page.tsx b/apps/web/app/dashboard/search/page.tsx index 38099c18..62d42a43 100644 --- a/apps/web/app/dashboard/search/page.tsx +++ b/apps/web/app/dashboard/search/page.tsx @@ -24,7 +24,7 @@ function SearchComp() { {data ? ( <BookmarksGrid query={{ ids: data.bookmarks.map((b) => b.id) }} - bookmarks={data.bookmarks} + bookmarks={data} /> ) : ( <Loading /> diff --git a/apps/web/app/dashboard/tags/[tagName]/page.tsx b/apps/web/app/dashboard/tags/[tagName]/page.tsx index dee29c5e..f06062c3 100644 --- a/apps/web/app/dashboard/tags/[tagName]/page.tsx +++ b/apps/web/app/dashboard/tags/[tagName]/page.tsx @@ -38,7 +38,7 @@ export default async function TagPage({ <div className="container flex flex-col gap-3"> <span className="pt-4 text-2xl">{tagName}</span> <hr /> - <BookmarksGrid query={query} bookmarks={bookmarks.bookmarks} /> + <BookmarksGrid query={query} bookmarks={bookmarks} /> </div> ); } diff --git a/apps/web/components/dashboard/bookmarks/Bookmarks.tsx b/apps/web/components/dashboard/bookmarks/Bookmarks.tsx index 601b1627..96e5c067 100644 --- a/apps/web/components/dashboard/bookmarks/Bookmarks.tsx +++ b/apps/web/components/dashboard/bookmarks/Bookmarks.tsx @@ -35,7 +35,7 @@ export default async function Bookmarks({ {showDivider && <hr />} <BookmarksGrid query={query} - bookmarks={bookmarks.bookmarks} + bookmarks={bookmarks} showEditorCard={showEditorCard} /> </div> diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx index 644991bb..b689a192 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo } from "react"; +import { ActionButton } from "@/components/ui/action-button"; import { api } from "@/lib/trpc"; import tailwindConfig from "@/tailwind.config"; import { Slot } from "@radix-ui/react-slot"; @@ -10,6 +11,7 @@ import resolveConfig from "tailwindcss/resolveConfig"; import type { ZBookmark, ZGetBookmarksRequest, + ZGetBookmarksResponse, } from "@hoarder/trpc/types/bookmarks"; import EditorCard from "./EditorCard"; @@ -55,24 +57,45 @@ export default function BookmarksGrid({ showEditorCard = false, }: { query: ZGetBookmarksRequest; - bookmarks: ZBookmark[]; + bookmarks: ZGetBookmarksResponse; showEditorCard?: boolean; + itemsPerPage?: number; }) { - const { data } = api.bookmarks.getBookmarks.useQuery(query, { - initialData: { bookmarks: initialBookmarks }, - }); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + api.bookmarks.getBookmarks.useInfiniteQuery(query, { + initialData: () => ({ + pages: [initialBookmarks], + pageParams: [query.cursor], + }), + initialCursor: null, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }); + const breakpointConfig = useMemo(() => getBreakpointConfig(), []); - if (data.bookmarks.length == 0) { + const bookmarks = data!.pages.flatMap((b) => b.bookmarks); + if (bookmarks.length == 0) { return <p>No bookmarks</p>; } return ( - <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> - {showEditorCard && ( - <BookmarkCard> - <EditorCard /> - </BookmarkCard> + <> + <Masonry className="flex gap-4" breakpointCols={breakpointConfig}> + {showEditorCard && ( + <BookmarkCard> + <EditorCard /> + </BookmarkCard> + )} + {bookmarks.map((b) => renderBookmark(b))} + </Masonry> + {hasNextPage && ( + <ActionButton + loading={isFetchingNextPage} + onClick={() => fetchNextPage()} + className="mx-auto w-min" + variant="ghost" + > + Load More + </ActionButton> )} - {data.bookmarks.map((b) => renderBookmark(b))} - </Masonry> + </> ); } diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index f91c8c6a..c2d78c7f 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -1,5 +1,5 @@ import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; -import { and, desc, eq, exists, inArray } from "drizzle-orm"; +import { and, desc, eq, exists, inArray, lte } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -21,6 +21,7 @@ import { getSearchIdxClient } from "@hoarder/shared/search"; import { authedProcedure, Context, router } from "../index"; import { + DEFAULT_NUM_BOOKMARKS_PER_PAGE, zBareBookmarkSchema, ZBookmark, ZBookmarkContent, @@ -322,7 +323,7 @@ export const bookmarksAppRouter = router({ }); if (resp.hits.length == 0) { - return { bookmarks: [] }; + return { bookmarks: [], nextCursor: null }; } const results = await ctx.db.query.bookmarks.findMany({ where: and( @@ -343,66 +344,79 @@ export const bookmarksAppRouter = router({ }, }); - return { bookmarks: results.map(toZodSchema) }; + return { bookmarks: results.map(toZodSchema), nextCursor: null }; }), getBookmarks: authedProcedure .input(zGetBookmarksRequestSchema) .output(zGetBookmarksResponseSchema) .query(async ({ input, ctx }) => { if (input.ids && input.ids.length == 0) { - return { bookmarks: [] }; + return { bookmarks: [], nextCursor: null }; } + if (!input.limit) { + input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE; + } + + const sq = ctx.db.$with("bookmarksSq").as( + ctx.db + .select() + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, ctx.user.id), + input.archived !== undefined + ? eq(bookmarks.archived, input.archived) + : undefined, + input.favourited !== undefined + ? eq(bookmarks.favourited, input.favourited) + : undefined, + input.ids ? inArray(bookmarks.id, input.ids) : undefined, + input.tagId !== undefined + ? exists( + ctx.db + .select() + .from(tagsOnBookmarks) + .where( + and( + eq(tagsOnBookmarks.bookmarkId, bookmarks.id), + eq(tagsOnBookmarks.tagId, input.tagId), + ), + ), + ) + : undefined, + input.listId !== undefined + ? exists( + ctx.db + .select() + .from(bookmarksInLists) + .where( + and( + eq(bookmarksInLists.bookmarkId, bookmarks.id), + eq(bookmarksInLists.listId, input.listId), + ), + ), + ) + : undefined, + input.cursor ? lte(bookmarks.createdAt, input.cursor) : undefined, + ), + ) + .limit(input.limit + 1) + .orderBy(desc(bookmarks.createdAt)), + ); // TODO: Consider not inlining the tags in the response of getBookmarks as this query is getting kinda expensive const results = await ctx.db + .with(sq) .select() - .from(bookmarks) - .where( - and( - eq(bookmarks.userId, ctx.user.id), - input.archived !== undefined - ? eq(bookmarks.archived, input.archived) - : undefined, - input.favourited !== undefined - ? eq(bookmarks.favourited, input.favourited) - : undefined, - input.ids ? inArray(bookmarks.id, input.ids) : undefined, - input.tagId !== undefined - ? exists( - ctx.db - .select() - .from(tagsOnBookmarks) - .where( - and( - eq(tagsOnBookmarks.bookmarkId, bookmarks.id), - eq(tagsOnBookmarks.tagId, input.tagId), - ), - ), - ) - : undefined, - input.listId !== undefined - ? exists( - ctx.db - .select() - .from(bookmarksInLists) - .where( - and( - eq(bookmarksInLists.bookmarkId, bookmarks.id), - eq(bookmarksInLists.listId, input.listId), - ), - ), - ) - : undefined, - ), - ) - .leftJoin(tagsOnBookmarks, eq(bookmarks.id, tagsOnBookmarks.bookmarkId)) + .from(sq) + .leftJoin(tagsOnBookmarks, eq(sq.id, tagsOnBookmarks.bookmarkId)) .leftJoin(bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id)) - .leftJoin(bookmarkLinks, eq(bookmarkLinks.id, bookmarks.id)) - .leftJoin(bookmarkTexts, eq(bookmarkTexts.id, bookmarks.id)) - .orderBy(desc(bookmarks.createdAt)); + .leftJoin(bookmarkLinks, eq(bookmarkLinks.id, sq.id)) + .leftJoin(bookmarkTexts, eq(bookmarkTexts.id, sq.id)) + .orderBy(desc(sq.createdAt)); const bookmarksRes = results.reduce<Record<string, ZBookmark>>( (acc, row) => { - const bookmarkId = row.bookmarks.id; + const bookmarkId = row.bookmarksSq.id; if (!acc[bookmarkId]) { let content: ZBookmarkContent; if (row.bookmarkLinks) { @@ -413,7 +427,7 @@ export const bookmarksAppRouter = router({ throw new Error("Unknown content type"); } acc[bookmarkId] = { - ...row.bookmarks, + ...row.bookmarksSq, content, tags: [], }; @@ -435,7 +449,15 @@ export const bookmarksAppRouter = router({ {}, ); - return { bookmarks: Object.values(bookmarksRes) }; + const bookmarksArr = Object.values(bookmarksRes); + + let nextCursor = null; + if (bookmarksArr.length > input.limit) { + const nextItem = bookmarksArr.pop(); + nextCursor = nextItem?.createdAt ?? null; + } + + return { bookmarks: bookmarksArr, nextCursor }; }), updateTags: authedProcedure diff --git a/packages/trpc/types/bookmarks.ts b/packages/trpc/types/bookmarks.ts index e23d6b4b..e366859e 100644 --- a/packages/trpc/types/bookmarks.ts +++ b/packages/trpc/types/bookmarks.ts @@ -47,17 +47,22 @@ export type ZNewBookmarkRequest = z.infer<typeof zNewBookmarkRequestSchema>; // GET /v1/bookmarks +export const DEFAULT_NUM_BOOKMARKS_PER_PAGE = 20; + export const zGetBookmarksRequestSchema = z.object({ ids: z.array(z.string()).optional(), archived: z.boolean().optional(), favourited: z.boolean().optional(), tagId: z.string().optional(), listId: z.string().optional(), + limit: z.number().max(100).optional(), + cursor: z.date().nullish(), }); export type ZGetBookmarksRequest = z.infer<typeof zGetBookmarksRequestSchema>; export const zGetBookmarksResponseSchema = z.object({ bookmarks: z.array(zBookmarkSchema), + nextCursor: z.date().nullable(), }); export type ZGetBookmarksResponse = z.infer<typeof zGetBookmarksResponseSchema>; |
