Skip to content

Commit 4aa9f30

Browse files
committed
feat: refactor reaction and bookmark components to use new ResourceReaction and ResourceBookmarkable components
1 parent 5a6e50f commit 4aa9f30

File tree

7 files changed

+224
-63
lines changed

7 files changed

+224
-63
lines changed

src/app/[username]/[articleHandle]/_components/ArticleReaction.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import ReactionStatus from "@/components/render-props/ReactionStatus";
3+
import { ResourceReactionable } from "@/components/render-props/ResourceReactionable";
44
import clsx from "clsx";
55
import React from "react";
66

@@ -10,7 +10,7 @@ interface Props {
1010

1111
const ArticleReaction: React.FC<Props> = ({ article_id }) => {
1212
return (
13-
<ReactionStatus
13+
<ResourceReactionable
1414
resource_type="ARTICLE"
1515
resource_id={article_id}
1616
render={({ reactions, toggle }) => {

src/app/[username]/[articleHandle]/page.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import HomeLeftSidebar from "@/app/(home)/_components/HomeLeftSidebar";
22
import { persistenceRepository } from "@/backend/persistence/persistence-repositories";
33
import * as articleActions from "@/backend/services/article.actions";
44
import AppImage from "@/components/AppImage";
5+
import { CommentSection } from "@/components/comment-section";
56
import HomepageLayout from "@/components/layout/HomepageLayout";
67
import { readingTime, removeMarkdownSyntax } from "@/lib/utils";
78
import getFileUrl from "@/utils/getFileUrl";
@@ -12,11 +13,9 @@ import Link from "next/link";
1213
import { notFound } from "next/navigation";
1314
import type { Article, WithContext } from "schema-dts";
1415
import { eq } from "sqlkit";
15-
import ArticleSidebar from "./_components/ArticleSidebar";
16-
import ReactionStatus from "@/components/render-props/ReactionStatus";
17-
import clsx from "clsx";
1816
import ArticleReaction from "./_components/ArticleReaction";
19-
import { CommentSection } from "@/components/comment-section";
17+
import ArticleSidebar from "./_components/ArticleSidebar";
18+
import ResourceReaction from "@/components/ResourceReaction";
2019

2120
interface ArticlePageProps {
2221
params: Promise<{
@@ -154,7 +153,7 @@ const Page: NextPage<ArticlePageProps> = async ({ params }) => {
154153
<h1 className="text-2xl font-bold">{article?.title ?? ""}</h1>
155154
</div>
156155

157-
<ArticleReaction article_id={article.id} />
156+
<ResourceReaction resource_type="ARTICLE" resource_id={article.id} />
158157

159158
<div className="mx-auto content-typography">{parsedHTML}</div>
160159
</div>

src/components/ArticleCard.tsx

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import clsx from "clsx";
66
import Image from "next/image";
77
import Link from "next/link";
88
import { useMemo } from "react";
9-
import BookmarkStatus from "./render-props/BookmarkStatus";
10-
import ReactionStatus from "./render-props/ReactionStatus";
9+
import { ResourceBookmarkable } from "./render-props/ResourceBookmarkable";
10+
import { ResourceReactionable } from "./render-props/ResourceReactionable";
1111
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
1212
import UserInformationCard from "./UserInformationCard";
1313
import { useLoginPopup } from "./app-login-popup";
1414
import { useSession } from "@/store/session.atom";
15+
import ResourceReaction from "./ResourceReaction";
1516

1617
interface ArticleCardProps {
1718
id: string;
@@ -117,41 +118,9 @@ const ArticleCard = ({
117118
</div>
118119

119120
<div className="mt-4 flex items-center justify-between">
120-
<ReactionStatus
121-
resource_type="ARTICLE"
122-
resource_id={id}
123-
render={({ reactions, toggle }) => {
124-
return (
125-
<div className="flex gap-1">
126-
{reactions.map((r) => (
127-
<button
128-
key={r.reaction_type}
129-
className={clsx(
130-
"px-2 py-1 flex gap-1 cursor-pointer rounded-sm hover:bg-primary/20",
131-
{ "bg-primary/20": r.is_reacted }
132-
)}
133-
onClick={() => {
134-
if (!session?.user) {
135-
loginPopup.show();
136-
return;
137-
}
138-
toggle(r.reaction_type!);
139-
}}
140-
>
141-
<img
142-
src={`/reactions/${r.reaction_type}.svg`}
143-
alt={`reaction-${id}-${r.reaction_type}`}
144-
className="size-5 flex-none"
145-
/>
146-
<span>{r.count}</span>
147-
</button>
148-
))}
149-
</div>
150-
);
151-
}}
152-
/>
121+
<ResourceReaction resource_type="ARTICLE" resource_id={id} />
153122

154-
<BookmarkStatus
123+
<ResourceBookmarkable
155124
resource_type="ARTICLE"
156125
resource_id={id}
157126
render={({ bookmarked, toggle }) => (
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"use client";
2+
3+
import React from "react";
4+
import { ResourceReactionable } from "./render-props/ResourceReactionable";
5+
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
6+
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
7+
import { FaceIcon } from "@radix-ui/react-icons";
8+
9+
interface ResourceReactionProps {
10+
resource_type: "ARTICLE" | "COMMENT";
11+
resource_id: string;
12+
}
13+
const ResourceReaction = ({
14+
resource_type,
15+
resource_id,
16+
}: ResourceReactionProps) => {
17+
return (
18+
<ResourceReactionable
19+
resource_type={resource_type}
20+
resource_id={resource_id}
21+
render={({ reactions, toggle }) => {
22+
return (
23+
<div className="flex gap-2">
24+
{reactions
25+
.filter((r) => r.count)
26+
.map((reaction) => (
27+
<button
28+
key={reaction.reaction_type}
29+
onClick={() => toggle(reaction.reaction_type!)}
30+
className={`p-1 flex items-center gap-1 cursor-pointer rounded-sm hover:bg-primary/20 ${
31+
reaction.is_reacted ? "bg-primary/20" : ""
32+
}`}
33+
>
34+
<img
35+
src={`/reactions/${reaction.reaction_type}.svg`}
36+
alt={`reaction-${resource_id}-${reaction.reaction_type}`}
37+
className="flex-none size-4"
38+
/>
39+
<span>{reaction.count}</span>
40+
</button>
41+
))}
42+
<HoverCard openDelay={0}>
43+
<HoverCardTrigger asChild>
44+
<button className="p-1 border flex-none flex items-center gap-1 cursor-pointer rounded-sm hover:bg-primary/20">
45+
<FaceIcon />
46+
</button>
47+
</HoverCardTrigger>
48+
<HoverCardContent>
49+
<div className="flex items-center gap-2 flex-wrap">
50+
{reactions.map((reaction) => (
51+
<button
52+
onClick={() => toggle(reaction.reaction_type!)}
53+
key={reaction.reaction_type}
54+
className={`p-1 flex items-center gap-1 cursor-pointer rounded-sm hover:bg-primary/20 ${
55+
reaction.is_reacted ? "bg-primary/20" : ""
56+
}`}
57+
>
58+
<img
59+
src={`/reactions/${reaction.reaction_type}.svg`}
60+
alt={`reaction-${resource_id}-${reaction.reaction_type}`}
61+
className="size-5"
62+
/>
63+
</button>
64+
))}
65+
</div>
66+
</HoverCardContent>
67+
</HoverCard>
68+
</div>
69+
);
70+
}}
71+
/>
72+
);
73+
};
74+
75+
export default ResourceReaction;

src/components/comment-section.tsx

Lines changed: 136 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
"use client";
22

3-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4-
import * as commentActions from "@/backend/services/comment.action";
5-
import { Button } from "./ui/button";
63
import { CommentPresentation } from "@/backend/models/domain-models";
4+
import * as commentActions from "@/backend/services/comment.action";
5+
import { useTranslation } from "@/i18n/use-translation";
6+
import { formattedTime } from "@/lib/utils";
77
import { useSession } from "@/store/session.atom";
8+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9+
import clsx from "clsx";
10+
import { ChevronDown, ChevronUp, MessageSquare } from "lucide-react";
11+
import React, { useMemo, useState } from "react";
12+
import { useImmer } from "use-immer";
813
import { useLoginPopup } from "./app-login-popup";
14+
import { ResourceReactionable } from "./render-props/ResourceReactionable";
915
import { Textarea } from "./ui/textarea";
10-
import _t from "@/i18n/_t";
11-
import { useTranslation } from "@/i18n/use-translation";
12-
import { Loader } from "lucide-react";
13-
import React from "react";
14-
import _ from "lodash";
16+
import ResourceReaction from "./ResourceReaction";
1517

1618
export const CommentSection = (props: {
1719
resource_id: string;
@@ -96,10 +98,8 @@ export const CommentSection = (props: {
9698
/>
9799
</div>
98100

99-
{/* <pre>{JSON.stringify(query.data, null, 2)}</pre> */}
100-
101101
{/* Comments List */}
102-
<div className="space-y-4">
102+
<div className="space-y-10">
103103
{query?.data?.map((comment) => (
104104
<CommentItem key={comment.id} comment={comment} />
105105
))}
@@ -163,11 +163,133 @@ const CommentEditor = (props: {
163163
};
164164

165165
const CommentItem = (props: { comment: CommentPresentation }) => {
166+
const { _t } = useTranslation();
167+
const session = useSession();
168+
const appLoginPopup = useLoginPopup();
169+
const [isCollapsed, setIsCollapsed] = useState(false);
170+
const [showReplyBox, setShowReplyBox] = useState(false);
171+
const [replies, setReplies] = useImmer<CommentPresentation[]>(
172+
props.comment.replies ?? []
173+
);
174+
175+
const level = useMemo(() => props.comment.level ?? 0, [props.comment]);
176+
177+
const mutation = useMutation({
178+
mutationFn: (body: string) =>
179+
commentActions.createMyComment({
180+
body,
181+
resource_id: props.comment.id,
182+
resource_type: "COMMENT",
183+
}),
184+
onMutate: (body) => {
185+
if (!session?.user) {
186+
appLoginPopup.show();
187+
return;
188+
}
189+
190+
setReplies((draft) => {
191+
draft.unshift({
192+
id: crypto.randomUUID(),
193+
body,
194+
level: (props.comment.level ?? 0) + 1,
195+
author: {
196+
id: session?.user?.id || "temp-user-id",
197+
name: session?.user?.name || "Temp User",
198+
username: session?.user?.username || "tempuser",
199+
email: session?.user?.email || "[email protected]",
200+
},
201+
replies: [],
202+
created_at: new Date(),
203+
} satisfies CommentPresentation);
204+
});
205+
},
206+
});
207+
208+
const levelMargin = useMemo(
209+
() => Math.min(level, 8) * 12,
210+
[props.comment.level]
211+
);
166212
return (
167-
<div>
168-
<pre className="font-normal">{props.comment.body}</pre>
213+
<div
214+
data-comment-id={props.comment.id}
215+
className="group"
216+
style={{ marginLeft: `${levelMargin}px` }}
217+
>
218+
{/* <div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
219+
<button className="flex items-center gap-1 hover:text-foreground">
220+
<span className="font-medium">@{props.comment.author?.username}</span>
221+
</button>
222+
<span>•</span>
223+
<span>{formattedTime(new Date(props.comment.created_at!))}</span>
224+
</div> */}
225+
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
226+
<button
227+
onClick={() => setIsCollapsed(!isCollapsed)}
228+
className="flex items-center gap-1 hover:text-foreground"
229+
>
230+
{isCollapsed ? (
231+
<ChevronUp className="h-3 w-3" />
232+
) : (
233+
<ChevronDown className="h-3 w-3" />
234+
)}
235+
<span className="font-medium">@{props.comment.author?.username}</span>
236+
</button>
237+
<span></span>
238+
<span>{formattedTime(new Date(props.comment.created_at!))}</span>
239+
</div>
240+
241+
{!isCollapsed && (
242+
<>
243+
{/* Comment Content */}
244+
<div className="mb-2">
245+
<div className="prose prose-sm max-w-none text-foreground">
246+
{props.comment.body}
247+
</div>
248+
</div>
249+
250+
{/* Comment Attachments */}
251+
{/* {comment.attachments && comment.attachments.length > 0 && (
252+
<AttachmentDisplay attachments={comment.attachments} />
253+
)} */}
254+
255+
{/* Comment Actions */}
256+
<div className="flex items-center gap-4 mb-3">
257+
{level < 2 && (
258+
<button
259+
className="text-sm flex items-center hover:underline cursor-pointer"
260+
onClick={() => setShowReplyBox(!showReplyBox)}
261+
>
262+
<MessageSquare className="size-3 mr-1" />
263+
<span>{_t("Reply")}</span>
264+
</button>
265+
)}
266+
267+
<ResourceReaction
268+
resource_type="COMMENT"
269+
resource_id={props.comment.id}
270+
/>
271+
</div>
272+
273+
{/* Reply Box */}
274+
{showReplyBox && (
275+
<div className="mb-4 ml-4">
276+
<CommentEditor
277+
onSubmit={(value) => {
278+
mutation.mutate(value);
279+
setShowReplyBox(false);
280+
}}
281+
isLoading={false}
282+
placeholder={`Reply to ${props.comment.author?.username}`}
283+
/>
284+
</div>
285+
)}
169286

170-
{props.comment.replies?.map((c) => <CommentItem comment={c} />)}
287+
{/* Nested Replies */}
288+
{replies?.map((reply) => (
289+
<CommentItem key={reply.id} comment={reply} />
290+
))}
291+
</>
292+
)}
171293
</div>
172294
);
173295
};

src/components/render-props/BookmarkStatus.tsx renamed to src/components/render-props/ResourceBookmarkable.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ interface Props {
1414
}) => React.ReactNode;
1515
}
1616

17-
const BookmarkStatus: React.FC<Props> = ({
17+
export const ResourceBookmarkable: React.FC<Props> = ({
1818
resource_id,
1919
resource_type,
2020
render,
@@ -51,5 +51,3 @@ const BookmarkStatus: React.FC<Props> = ({
5151

5252
return <>{render({ toggle, bookmarked })}</>;
5353
};
54-
55-
export default BookmarkStatus;

src/components/render-props/ReactionStatus.tsx renamed to src/components/render-props/ResourceReactionable.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ interface Props {
2020
}) => React.ReactNode;
2121
}
2222

23-
const ReactionStatus: React.FC<Props> = ({
23+
export const ResourceReactionable: React.FC<Props> = ({
2424
resource_id,
2525
resource_type,
2626
render,
@@ -100,5 +100,3 @@ const ReactionStatus: React.FC<Props> = ({
100100
toggle,
101101
});
102102
};
103-
104-
export default ReactionStatus;

0 commit comments

Comments
 (0)