aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/mobile/components/bookmarks/BookmarkList.tsx31
-rw-r--r--apps/web/app/dashboard/lists/[listId]/page.tsx2
-rw-r--r--apps/web/app/dashboard/search/page.tsx2
-rw-r--r--apps/web/app/dashboard/tags/[tagName]/page.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/Bookmarks.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx47
-rw-r--r--packages/trpc/routers/bookmarks.ts122
-rw-r--r--packages/trpc/types/bookmarks.ts5
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>;