Skip to content

Commit f857b1a

Browse files
committed
feat: implement blog engagement toolbar with voting and bookmarking functionality
- Added BlogEngagementToolbar component for handling upvotes, downvotes, bookmarks, and sharing. - Integrated BlogEngagementToolbar into NewBlogCard for each blog post. - Updated NewBlogGrid to pass author information and engagement handlers. - Enhanced NewBlogsPage to manage hidden posts and author follow/mute states. - Introduced formatCompactNumber utility for formatting large numbers in a user-friendly way. - Created database migration for post votes to track user reactions on blog posts. - Added unit tests for formatCompactNumber utility. - Refactored various components to improve code readability and maintainability.
2 parents 5268931 + d52b92b commit f857b1a

File tree

12 files changed

+1096
-125
lines changed

12 files changed

+1096
-125
lines changed
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { NextResponse } from 'next/server';
2+
import { z } from 'zod';
3+
import { createServerComponentClient, createServiceRoleClient } from '@/lib/supabase/server-client';
4+
5+
const voteSchema = z.object({
6+
voteType: z.enum(['upvote', 'downvote']),
7+
});
8+
9+
interface SessionProfile {
10+
id: string;
11+
displayName: string | null;
12+
}
13+
14+
const getSessionProfile = async (): Promise<SessionProfile | null> => {
15+
const supabase = createServerComponentClient();
16+
const {
17+
data: { user },
18+
} = await supabase.auth.getUser();
19+
20+
if (!user) {
21+
return null;
22+
}
23+
24+
const { data, error } = await supabase
25+
.from('profiles')
26+
.select('id, display_name')
27+
.eq('user_id', user.id)
28+
.maybeSingle();
29+
30+
if (error || !data) {
31+
return null;
32+
}
33+
34+
return {
35+
id: data.id as string,
36+
displayName: (data.display_name as string | null) ?? null,
37+
};
38+
};
39+
40+
interface PostRecord {
41+
id: string;
42+
views: number | null;
43+
}
44+
45+
const fetchPostBySlug = async (slug: string): Promise<PostRecord | null> => {
46+
const supabase = createServiceRoleClient();
47+
const { data, error } = await supabase
48+
.from('posts')
49+
.select('id, views')
50+
.eq('slug', slug)
51+
.eq('status', 'published')
52+
.maybeSingle();
53+
54+
if (error || !data) {
55+
return null;
56+
}
57+
58+
return data as PostRecord;
59+
};
60+
61+
interface EngagementStats {
62+
upvotes: number;
63+
downvotes: number;
64+
comments: number;
65+
bookmarks: number;
66+
views: number;
67+
}
68+
69+
const loadEngagementStats = async (postId: string): Promise<EngagementStats> => {
70+
const supabase = createServiceRoleClient();
71+
72+
const [{ count: upvotes }, { count: downvotes }, { count: comments }, { count: bookmarks }] = await Promise.all([
73+
supabase
74+
.from('post_votes')
75+
.select('id', { head: true, count: 'exact' })
76+
.eq('post_id', postId)
77+
.eq('vote_type', 'upvote'),
78+
supabase
79+
.from('post_votes')
80+
.select('id', { head: true, count: 'exact' })
81+
.eq('post_id', postId)
82+
.eq('vote_type', 'downvote'),
83+
supabase
84+
.from('comments')
85+
.select('id', { head: true, count: 'exact' })
86+
.eq('post_id', postId)
87+
.eq('status', 'approved'),
88+
supabase
89+
.from('bookmarks')
90+
.select('id', { head: true, count: 'exact' })
91+
.eq('post_id', postId),
92+
]);
93+
94+
return {
95+
upvotes: upvotes ?? 0,
96+
downvotes: downvotes ?? 0,
97+
comments: comments ?? 0,
98+
bookmarks: bookmarks ?? 0,
99+
views: 0,
100+
};
101+
};
102+
103+
const buildEngagementResponse = async (
104+
post: PostRecord,
105+
profile: SessionProfile | null,
106+
): Promise<{ stats: EngagementStats; viewer: { vote: 'upvote' | 'downvote' | null; bookmarkId: string | null } }> => {
107+
const supabase = createServiceRoleClient();
108+
const stats = await loadEngagementStats(post.id);
109+
stats.views = post.views ?? 0;
110+
111+
let vote: 'upvote' | 'downvote' | null = null;
112+
let bookmarkId: string | null = null;
113+
114+
if (profile) {
115+
const [{ data: voteRow }, { data: bookmarkRow }] = await Promise.all([
116+
supabase
117+
.from('post_votes')
118+
.select('id, vote_type')
119+
.eq('post_id', post.id)
120+
.eq('profile_id', profile.id)
121+
.maybeSingle(),
122+
supabase
123+
.from('bookmarks')
124+
.select('id')
125+
.eq('post_id', post.id)
126+
.eq('profile_id', profile.id)
127+
.maybeSingle(),
128+
]);
129+
130+
vote = (voteRow?.vote_type as 'upvote' | 'downvote' | null) ?? null;
131+
bookmarkId = (bookmarkRow?.id as string | null) ?? null;
132+
}
133+
134+
return {
135+
stats,
136+
viewer: {
137+
vote,
138+
bookmarkId,
139+
},
140+
};
141+
};
142+
143+
export async function GET(
144+
_request: Request,
145+
{ params }: { params: Promise<{ slug: string }> },
146+
) {
147+
const { slug } = await params;
148+
const decodedSlug = decodeURIComponent(slug);
149+
const post = await fetchPostBySlug(decodedSlug);
150+
151+
if (!post) {
152+
return NextResponse.json({ error: 'Post not found.' }, { status: 404 });
153+
}
154+
155+
const profile = await getSessionProfile();
156+
const payload = await buildEngagementResponse(post, profile);
157+
158+
return NextResponse.json({
159+
postId: post.id,
160+
stats: payload.stats,
161+
viewer: payload.viewer,
162+
});
163+
}
164+
165+
export async function POST(
166+
request: Request,
167+
{ params }: { params: Promise<{ slug: string }> },
168+
) {
169+
const { slug } = await params;
170+
const decodedSlug = decodeURIComponent(slug);
171+
const profile = await getSessionProfile();
172+
173+
if (!profile) {
174+
return NextResponse.json({ error: 'Authentication required.' }, { status: 401 });
175+
}
176+
177+
const post = await fetchPostBySlug(decodedSlug);
178+
179+
if (!post) {
180+
return NextResponse.json({ error: 'Post not found.' }, { status: 404 });
181+
}
182+
183+
const raw = await request.json().catch(() => null);
184+
const parsed = voteSchema.safeParse(raw);
185+
186+
if (!parsed.success) {
187+
return NextResponse.json({ error: 'Invalid payload.' }, { status: 400 });
188+
}
189+
190+
const supabase = createServiceRoleClient();
191+
192+
const { data: existing } = await supabase
193+
.from('post_votes')
194+
.select('id, vote_type')
195+
.eq('post_id', post.id)
196+
.eq('profile_id', profile.id)
197+
.maybeSingle();
198+
199+
if (existing && existing.vote_type === parsed.data.voteType) {
200+
await supabase
201+
.from('post_votes')
202+
.delete()
203+
.eq('id', existing.id);
204+
205+
const payload = await buildEngagementResponse(post, profile);
206+
return NextResponse.json({
207+
postId: post.id,
208+
stats: payload.stats,
209+
viewer: payload.viewer,
210+
});
211+
}
212+
213+
await supabase
214+
.from('post_votes')
215+
.upsert(
216+
{
217+
post_id: post.id,
218+
profile_id: profile.id,
219+
vote_type: parsed.data.voteType,
220+
},
221+
{ onConflict: 'post_id,profile_id' },
222+
);
223+
224+
const payload = await buildEngagementResponse(post, profile);
225+
return NextResponse.json({
226+
postId: post.id,
227+
stats: payload.stats,
228+
viewer: payload.viewer,
229+
});
230+
}
231+
232+
export async function DELETE(
233+
_request: Request,
234+
{ params }: { params: Promise<{ slug: string }> },
235+
) {
236+
const { slug } = await params;
237+
const decodedSlug = decodeURIComponent(slug);
238+
const profile = await getSessionProfile();
239+
240+
if (!profile) {
241+
return NextResponse.json({ error: 'Authentication required.' }, { status: 401 });
242+
}
243+
244+
const post = await fetchPostBySlug(decodedSlug);
245+
246+
if (!post) {
247+
return NextResponse.json({ error: 'Post not found.' }, { status: 404 });
248+
}
249+
250+
const supabase = createServiceRoleClient();
251+
252+
await supabase
253+
.from('post_votes')
254+
.delete()
255+
.eq('post_id', post.id)
256+
.eq('profile_id', profile.id);
257+
258+
const payload = await buildEngagementResponse(post, profile);
259+
return NextResponse.json({
260+
postId: post.id,
261+
stats: payload.stats,
262+
viewer: payload.viewer,
263+
});
264+
}

src/app/topics/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ const normalizeParam = (value: string | string[] | undefined) =>
229229
(Array.isArray(value) ? value[0] : value) ?? null;
230230

231231
export default async function TopicsPage({ searchParams }: TopicsPageProps) {
232-
const resolvedSearchParams: SearchParamsShape = (await searchParams) ?? {};
232+
const resolvedSearchParams: SearchParamsShape = searchParams ? await searchParams : {};
233233

234234
const rawTopic = normalizeParam(resolvedSearchParams.topic);
235235
const rawQuery = normalizeParam(resolvedSearchParams.q);

src/components/admin/CommentsModeration.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,8 @@ export const CommentsModeration = ({
187187
<AlertDialogHeader>
188188
<AlertDialogTitle>Delete comment?</AlertDialogTitle>
189189
<AlertDialogDescription>
190-
This will permanently delete this comment from
191-
{" "}
192-
{comment.authorDisplayName ?? 'this reader'}. This action cannot be undone.
190+
This will permanently delete this comment from {comment.authorDisplayName ?? 'this reader'}. This
191+
action cannot be undone.
193192
</AlertDialogDescription>
194193
</AlertDialogHeader>
195194
<AlertDialogFooter>

0 commit comments

Comments
 (0)