Skip to content

Commit 665637d

Browse files
committed
feat: Implement bookmark status component with toggle functionality and integrate with backend services
1 parent fd8557a commit 665637d

File tree

5 files changed

+195
-19
lines changed

5 files changed

+195
-19
lines changed

src/backend/models/domain-models.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,5 @@ export interface Bookmark {
119119
resource_id: string;
120120
resource_type: "ARTICLE" | "COMMENT";
121121
user_id: string;
122+
created_at: Date;
122123
}

src/backend/services/bookmark.action.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"use server";
2+
13
import z from "zod";
24
import { BookmarkActionInput } from "./inputs/bookmark.input";
35
import { authID } from "./session.actions";
@@ -44,10 +46,91 @@ export async function toggleResourceBookmark(
4446
resource_id: input.resource_id,
4547
resource_type: input.resource_type,
4648
user_id: sessionUserId,
49+
created_at: new Date(),
4750
},
4851
]);
4952
return { bookmarked: true };
5053
} catch (error) {
5154
handleActionException(error);
5255
}
5356
}
57+
58+
export async function myBookmarks(
59+
_input: z.infer<typeof BookmarkActionInput.toggleBookmarkInput>
60+
) {
61+
try {
62+
const sessionUserId = await authID();
63+
if (!sessionUserId) {
64+
throw new ActionException("Unauthorized");
65+
}
66+
const input =
67+
await BookmarkActionInput.toggleBookmarkInput.parseAsync(_input);
68+
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+
});
78+
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+
}
90+
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+
},
98+
]);
99+
return { bookmarked: true };
100+
} catch (error) {
101+
handleActionException(error);
102+
}
103+
}
104+
105+
// <BookmarkStatus resource_type="ARTICLE" resource_id="12345">
106+
// {data => {}}
107+
// </BookmarkStatus>
108+
109+
export async function bookmarkStatus(
110+
_input: z.infer<typeof BookmarkActionInput.bookmarkStatusInput>
111+
) {
112+
try {
113+
const sessionUserId = await authID();
114+
if (!sessionUserId) {
115+
throw new ActionException("Unauthorized");
116+
}
117+
const input =
118+
await BookmarkActionInput.bookmarkStatusInput.parseAsync(_input);
119+
120+
// -----------
121+
const [existingBookmark] = await persistenceRepository.bookmark.find({
122+
limit: 1,
123+
where: and(
124+
eq("resource_id", input.resource_id),
125+
eq("resource_type", input.resource_type),
126+
eq("user_id", sessionUserId)
127+
),
128+
columns: ["id"],
129+
});
130+
131+
// If bookmark exists, return true
132+
return { bookmarked: Boolean(existingBookmark) };
133+
} catch (error) {
134+
handleActionException(error);
135+
}
136+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,11 @@ export const BookmarkActionInput = {
55
resource_id: z.string(),
66
resource_type: z.enum(["ARTICLE", "COMMENT"]),
77
}),
8+
myBookmarks: z.object({
9+
resource_type: z.enum(["ARTICLE", "COMMENT"]).optional(),
10+
}),
11+
bookmarkStatusInput: z.object({
12+
resource_id: z.string(),
13+
resource_type: z.enum(["ARTICLE", "COMMENT"]),
14+
}),
815
};

src/components/ArticleCard.tsx

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import Link from "next/link";
77
import { useMemo } from "react";
88
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
99
import UserInformationCard from "./UserInformationCard";
10+
import BookmarkStatus from "./BookmarkStatus";
11+
import clsx from "clsx";
1012

1113
interface ArticleCardProps {
1214
id: string;
@@ -38,7 +40,7 @@ const ArticleCard = ({
3840
likes,
3941
comments,
4042
}: ArticleCardProps) => {
41-
const { lang } = useTranslation();
43+
const { lang, _t } = useTranslation();
4244

4345
const articleUrl = useMemo(() => {
4446
return `/@${author.username}/${handle}`;
@@ -108,7 +110,8 @@ const ArticleCard = ({
108110
</Link>
109111
)}
110112
</div>
111-
{/* <div className="mt-4 flex items-center justify-between">
113+
114+
<div className="mt-4 flex items-center justify-between">
112115
<div className="flex items-center gap-4">
113116
<button className="flex items-center gap-1.5 text-sm text-neutral-500 transition-all duration-300 hover:text-neutral-800 focus:outline-none">
114117
<svg
@@ -145,23 +148,34 @@ const ArticleCard = ({
145148
<span className="text-xs font-medium">{comments}</span>
146149
</button>
147150
</div>
148-
<button className="text-neutral-400 hover:text-neutral-800 transition-colors duration-300">
149-
<svg
150-
xmlns="http://www.w3.org/2000/svg"
151-
width={24}
152-
height={24}
153-
viewBox="0 0 24 24"
154-
fill="none"
155-
stroke="currentColor"
156-
strokeWidth={2}
157-
strokeLinecap="round"
158-
strokeLinejoin="round"
159-
className="lucide lucide-bookmark h-4 w-4"
160-
>
161-
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
162-
</svg>
163-
</button>
164-
</div> */}
151+
152+
<BookmarkStatus
153+
resource_type="ARTICLE"
154+
resource_id={id}
155+
render={({ bookmarked, toggle }) => (
156+
<button
157+
onClick={toggle}
158+
className={clsx(
159+
"transition-colors duration-300 flex cursor-pointer px-2 py-1 rounded-sm hover:bg-primary/20",
160+
{ "bg-primary/20": bookmarked }
161+
)}
162+
>
163+
<svg
164+
xmlns="http://www.w3.org/2000/svg"
165+
viewBox="0 0 24 24"
166+
strokeLinecap="round"
167+
strokeLinejoin="round"
168+
className={clsx("size-6 stroke-2 fill-transparent", {
169+
"!stroke-current": !bookmarked,
170+
"!fill-current": bookmarked,
171+
})}
172+
>
173+
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" />
174+
</svg>
175+
</button>
176+
)}
177+
/>
178+
</div>
165179
</div>
166180
);
167181
};

src/components/BookmarkStatus.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as bookmarkAction from "@/backend/services/bookmark.action";
2+
import { useMutation, useQuery } from "@tanstack/react-query";
3+
import React, { useEffect, useState } from "react";
4+
5+
interface Props {
6+
resource_type: "ARTICLE" | "COMMENT";
7+
resource_id: string;
8+
render: ({
9+
toggle,
10+
bookmarked,
11+
}: {
12+
toggle: () => void;
13+
bookmarked: boolean;
14+
}) => React.ReactNode;
15+
}
16+
17+
const BookmarkStatus: React.FC<Props> = ({
18+
resource_id,
19+
resource_type,
20+
render,
21+
}) => {
22+
const [bookmarked, setBookmarked] = useState(false);
23+
24+
const status = useQuery({
25+
queryKey: ["bookmark-status", resource_id],
26+
queryFn: () =>
27+
bookmarkAction.bookmarkStatus({ resource_id, resource_type }),
28+
enabled: Boolean(resource_id) && Boolean(resource_type),
29+
});
30+
31+
const mutation = useMutation({
32+
mutationFn: () =>
33+
bookmarkAction.toggleResourceBookmark({ resource_id, resource_type }),
34+
35+
// Optimistic update before mutation
36+
onMutate: async () => {
37+
// Cancel any ongoing fetch to prevent race conditions
38+
await status.refetch(); // optional for consistency
39+
const previousBookmarked = bookmarked;
40+
41+
// Optimistically toggle state
42+
setBookmarked((prev) => !prev);
43+
44+
return { previousBookmarked };
45+
},
46+
47+
// Revert if there's an error
48+
onError: (_err, _vars, context) => {
49+
setBookmarked(context?.previousBookmarked ?? false);
50+
},
51+
52+
// Ensure state is accurate after success
53+
onSuccess: (data) => {
54+
setBookmarked(data?.bookmarked ?? false);
55+
},
56+
});
57+
58+
const toggle = () => {
59+
mutation.mutate();
60+
};
61+
62+
useEffect(() => {
63+
if (status.data) {
64+
setBookmarked(status.data.bookmarked ?? false);
65+
}
66+
}, [status.data]);
67+
68+
return <>{render({ toggle, bookmarked })}</>;
69+
};
70+
71+
export default BookmarkStatus;

0 commit comments

Comments
 (0)