Skip to content

Commit 130c549

Browse files
kingRayhanclaude
andcommitted
feat: implement bookmarks listing with pagination and add bookmarks sidebar menu
- Add myBookmarks action with pagination support and article data joining - Add BookmarkArticlePresentation interface for bookmark display - Enable bookmarks menu item in dashboard sidebar - Update bookmark input validation for pagination parameters - Update API play route to test bookmark functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 8b9eb6f commit 130c549

File tree

6 files changed

+229
-44
lines changed

6 files changed

+229
-44
lines changed

src/app/api/play/route.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { getComments } from "@/backend/services/comment.action";
1+
import { myBookmarks } from "@/backend/services/bookmark.action";
22
import { NextResponse } from "next/server";
33

44
export async function GET() {
5-
const comments = await getComments({
6-
resource_id: "16196e73-275a-4af5-9186-39a5fec4244e",
5+
const bookmarks = await myBookmarks({
76
resource_type: "ARTICLE",
87
});
98

10-
return NextResponse.json(comments, {
9+
return NextResponse.json(bookmarks, {
1110
status: 200,
1211
headers: { "Content-Type": "application/json" },
1312
});

src/app/dashboard/_components/DashboardSidebar.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ const DashboardSidebar = () => {
4141
// url: "/notifications",
4242
// icon: BellIcon,
4343
// },
44-
// {
45-
// title: _t("Bookmarks"),
46-
// url: "/bookmarks",
47-
// icon: Bookmark,
48-
// },
44+
{
45+
title: _t("Bookmarks"),
46+
url: "/bookmarks",
47+
icon: Bookmark,
48+
},
4949
{
5050
title: _t("Settings"),
5151
url: "/settings",
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"use client";
2+
3+
import { useInfiniteQuery } from "@tanstack/react-query";
4+
import { myBookmarks } from "@/backend/services/bookmark.action";
5+
import ArticleCard from "@/components/ArticleCard";
6+
import VisibilitySensor from "@/components/VisibilitySensor";
7+
8+
interface BookmarkMeta {
9+
totalCount: number;
10+
currentPage: number;
11+
hasNextPage: boolean;
12+
totalPages: number;
13+
}
14+
15+
interface BookmarkData {
16+
nodes: any[];
17+
meta: BookmarkMeta;
18+
}
19+
20+
const BookmarksPage = () => {
21+
const {
22+
data,
23+
fetchNextPage,
24+
hasNextPage,
25+
isFetchingNextPage,
26+
isLoading,
27+
error,
28+
} = useInfiniteQuery<BookmarkData>({
29+
queryKey: ["bookmarks"],
30+
queryFn: async ({ pageParam = 1 }) => {
31+
const result = await myBookmarks({
32+
page: pageParam as number,
33+
limit: 10,
34+
offset: 0,
35+
});
36+
return result as BookmarkData;
37+
},
38+
getNextPageParam: (lastPage) => {
39+
return lastPage?.meta?.hasNextPage
40+
? lastPage.meta.currentPage + 1
41+
: undefined;
42+
},
43+
initialPageParam: 1,
44+
});
45+
46+
const bookmarks = data?.pages.flatMap((page) => page?.nodes || []) || [];
47+
const totalCount = data?.pages[0]?.meta?.totalCount || 0;
48+
49+
if (isLoading) {
50+
return (
51+
<div className="space-y-6">
52+
<div className="border-b pb-4">
53+
<h1 className="text-2xl font-semibold">My Bookmarks</h1>
54+
<p className="text-muted-foreground mt-1">
55+
Articles you've saved for later
56+
</p>
57+
</div>
58+
<div className="space-y-4">
59+
{Array.from({ length: 3 }).map((_, i) => (
60+
<div key={i} className="animate-pulse">
61+
<div className="bg-muted rounded-lg h-48" />
62+
</div>
63+
))}
64+
</div>
65+
</div>
66+
);
67+
}
68+
69+
if (error) {
70+
return (
71+
<div className="text-center py-12">
72+
<div className="text-destructive mb-2">⚠️ Error</div>
73+
<p className="text-muted-foreground">Failed to load bookmarks</p>
74+
</div>
75+
);
76+
}
77+
78+
if (bookmarks.length === 0) {
79+
return (
80+
<div className="space-y-6">
81+
<div className="border-b pb-4">
82+
<h1 className="text-2xl font-semibold">My Bookmarks</h1>
83+
<p className="text-muted-foreground mt-1">
84+
Articles you've saved for later
85+
</p>
86+
</div>
87+
<div className="text-center py-12">
88+
<div className="text-6xl mb-4">📚</div>
89+
<h2 className="text-xl font-medium mb-2">No bookmarks yet</h2>
90+
<p className="text-muted-foreground mb-6">
91+
Start bookmarking articles to see them here
92+
</p>
93+
<a
94+
href="/"
95+
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
96+
>
97+
Explore Articles
98+
</a>
99+
</div>
100+
</div>
101+
);
102+
}
103+
104+
return (
105+
<div className="space-y-6">
106+
<div className="border-b pb-4">
107+
<h1 className="text-2xl font-semibold">My Bookmarks</h1>
108+
<p className="text-muted-foreground mt-1">
109+
{totalCount} article{totalCount !== 1 ? "s" : ""} saved
110+
</p>
111+
</div>
112+
113+
<div className="space-y-6">
114+
{bookmarks.map((bookmark) => (
115+
<ArticleCard
116+
key={bookmark.id}
117+
id={bookmark.article.id}
118+
title={bookmark.article.title}
119+
excerpt=""
120+
coverImage={bookmark.article.cover_image}
121+
publishedAt={bookmark.created_at}
122+
readingTime={5}
123+
author={bookmark.article.author}
124+
handle={""}
125+
likes={0}
126+
comments={0}
127+
/>
128+
))}
129+
</div>
130+
131+
{/* {hasNextPage && <VisibilitySensor loading={isFetchingNextPage} />} */}
132+
</div>
133+
);
134+
};
135+
136+
export default BookmarksPage;

src/backend/models/domain-models.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,27 @@ export interface Bookmark {
130130
created_at: Date;
131131
}
132132

133+
export interface BookmarkArticlePresentation {
134+
id: string;
135+
resource_id: string;
136+
resource_type: "ARTICLE" | "COMMENT";
137+
user_id: string;
138+
created_at: Date;
139+
user: {
140+
id: string;
141+
name: string;
142+
username: string;
143+
email: string;
144+
profile_photo: string;
145+
};
146+
article: {
147+
id: string;
148+
title: string;
149+
path: string;
150+
cover_image?: IServerFile | null;
151+
};
152+
}
153+
133154
export interface Comment {
134155
id: string;
135156
resource_id: string;

src/backend/services/bookmark.action.ts

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"use server";
22

3+
import { and, eq } from "sqlkit";
34
import z from "zod";
5+
import { persistenceRepository } from "../persistence/persistence-repositories";
6+
import { pgClient } from "../persistence/clients";
47
import { BookmarkActionInput } from "./inputs/bookmark.input";
5-
import { authID } from "./session.actions";
68
import { ActionException, handleActionException } from "./RepositoryException";
7-
import { persistenceRepository } from "../persistence/persistence-repositories";
8-
import { and, eq } from "sqlkit";
9+
import { authID } from "./session.actions";
10+
11+
const sql = String.raw;
912

1013
export async function toggleResourceBookmark(
1114
_input: z.infer<typeof BookmarkActionInput.toggleBookmarkInput>
@@ -56,47 +59,71 @@ export async function toggleResourceBookmark(
5659
}
5760

5861
export async function myBookmarks(
59-
_input: z.infer<typeof BookmarkActionInput.toggleBookmarkInput>
62+
_input: z.infer<typeof BookmarkActionInput.myBookmarks>
6063
) {
6164
try {
6265
const sessionUserId = await authID();
6366
if (!sessionUserId) {
6467
throw new ActionException("Unauthorized");
6568
}
66-
const input =
67-
await BookmarkActionInput.toggleBookmarkInput.parseAsync(_input);
6869

69-
// -----------
70-
const [existingBookmark] = await persistenceRepository.bookmark.find({
71-
limit: 1,
72-
where: and(
73-
eq("resource_id", input.resource_id),
74-
eq("resource_type", input.resource_type),
75-
eq("user_id", sessionUserId)
76-
),
77-
});
70+
const input = await BookmarkActionInput.myBookmarks.parseAsync(_input);
71+
const resourceType = "ARTICLE";
72+
const offset = input.page > 1 ? (input.page - 1) * input.limit : 0;
7873

79-
if (existingBookmark) {
80-
// If bookmark exists, delete it
81-
await persistenceRepository.bookmark.delete({
82-
where: and(
83-
eq("resource_id", input.resource_id),
84-
eq("resource_type", input.resource_type),
85-
eq("user_id", sessionUserId)
86-
),
87-
});
88-
return { bookmarked: false };
89-
}
74+
const countQuery = sql`
75+
SELECT COUNT(*) AS totalCount
76+
FROM bookmarks
77+
WHERE user_id = $1 AND resource_type = $2
78+
`;
9079

91-
// If bookmark does not exist, create it
92-
await persistenceRepository.bookmark.insert([
93-
{
94-
resource_id: input.resource_id,
95-
resource_type: input.resource_type,
96-
user_id: sessionUserId,
97-
},
80+
const countResult: any = await pgClient?.executeSQL(countQuery, [
81+
sessionUserId,
82+
resourceType,
9883
]);
99-
return { bookmarked: true };
84+
const totalCount = countResult?.rows[0]?.totalCount || 0;
85+
const totalPages = Math.ceil(totalCount / input.limit);
86+
87+
const bookmarksQuery = sql`
88+
SELECT
89+
bookmarks.*,
90+
json_build_object(
91+
'id', articles.id,
92+
'title', articles.title,
93+
'cover_image', articles.cover_image,
94+
'path', concat(users.username, '/', articles.handle),
95+
'author', json_build_object(
96+
'id', users.id,
97+
'name', users.name,
98+
'username', users.username,
99+
'email', users.email,
100+
'profile_photo', users.profile_photo
101+
)
102+
) AS article
103+
FROM bookmarks
104+
LEFT JOIN articles ON articles.id = bookmarks.resource_id
105+
LEFT JOIN users ON users.id = articles.author_id
106+
WHERE bookmarks.user_id = $1 AND bookmarks.resource_type = $2
107+
ORDER BY bookmarks.created_at DESC
108+
LIMIT $3 OFFSET $4
109+
`;
110+
111+
const bookmarks = await pgClient?.executeSQL(bookmarksQuery, [
112+
sessionUserId,
113+
resourceType,
114+
input.limit,
115+
offset,
116+
]);
117+
118+
return {
119+
nodes: bookmarks?.rows,
120+
meta: {
121+
totalCount,
122+
currentPage: input.page,
123+
hasNextPage: input.page < totalPages,
124+
totalPages,
125+
},
126+
};
100127
} catch (error) {
101128
handleActionException(error);
102129
}

src/backend/services/inputs/bookmark.input.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ export const BookmarkActionInput = {
66
resource_type: z.enum(["ARTICLE", "COMMENT"]),
77
}),
88
myBookmarks: z.object({
9-
resource_type: z.enum(["ARTICLE", "COMMENT"]).optional(),
9+
limit: z.number().min(1).max(100).default(2),
10+
offset: z.number().min(0).default(0),
11+
page: z.number().min(1).default(1),
1012
}),
1113
bookmarkStatusInput: z.object({
1214
resource_id: z.string(),

0 commit comments

Comments
 (0)