Skip to content

Commit 3b05d25

Browse files
Rubayet-hasan-yasingeomachine
authored andcommitted
[archive-client] (feat/blog-details-optimization): Enhance blog details page with initial post prop and view count increment
1 parent 011ca32 commit 3b05d25

7 files changed

Lines changed: 138 additions & 82 deletions

File tree

archive-client/app/(home)/blogs/BlogsClientOptimized.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export default function BlogsClient() {
3939
const { data: postsData, isLoading: isLoadingPosts } = useQuery({
4040
queryKey: ["posts", postFilters],
4141
queryFn: () => api.getPosts(postFilters),
42+
staleTime: 30 * 1000,
43+
refetchOnMount: "always",
4244
});
4345

4446
const posts = postsData?.data || [];

archive-client/app/(home)/blogs/[slug]/BlogDetailsClient.tsx

Lines changed: 13 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,29 @@
11
"use client";
22

33
import { useRouter } from "next/navigation";
4-
import { useQuery } from "@tanstack/react-query";
54
import { Button } from "@/components/ui/button";
6-
import { api } from "@/lib/api";
75
import { BlogDetailsHeader } from "@/components/blogs/details/BlogDetailsHeader";
86
import { BlogDetailsContent } from "@/components/blogs/details/BlogDetailsContent";
97
import { BlogDetailsSidebar } from "@/components/blogs/details/BlogDetailsSidebar";
10-
import { useMemo } from "react";
8+
import { useBlogDetail } from "@/hooks/useBlogDetail";
9+
import type { ApiPost } from "@/types/blog.type";
1110

1211
interface BlogDetailsClientProps {
1312
slug: string;
13+
initialPost?: ApiPost;
1414
}
1515

16-
type PostWithOptionalTags = {
17-
tags?: string[] | string | null;
18-
};
19-
20-
export default function BlogDetailsClient({ slug }: BlogDetailsClientProps) {
16+
export default function BlogDetailsClient({ initialPost, slug }: BlogDetailsClientProps) {
2117
const router = useRouter();
22-
23-
const { data: post, isLoading, error } = useQuery({
24-
queryKey: ["post", slug],
25-
queryFn: () => api.getPostBySlug(slug),
26-
staleTime: 0,
27-
refetchOnMount: "always",
28-
refetchOnWindowFocus: true,
29-
});
30-
31-
// Calculate derived data
32-
const tags = useMemo(() => {
33-
if (!post || typeof post !== "object" || !("tags" in post)) return [];
34-
35-
const rawTags = (post as PostWithOptionalTags).tags;
36-
if (!rawTags) return [];
37-
38-
try {
39-
const parsed = typeof rawTags === "string" ? JSON.parse(rawTags) : rawTags;
40-
return Array.isArray(parsed) ? parsed.filter((tag): tag is string => typeof tag === "string") : [];
41-
} catch {
42-
return [];
43-
}
44-
}, [post]);
45-
46-
const readTime = useMemo(() => {
47-
if (!post?.content) return 1;
48-
const wordsPerMinute = 200;
49-
const wordCount = post.content.split(/\s+/).length;
50-
return Math.max(1, Math.ceil(wordCount / wordsPerMinute));
51-
}, [post?.content]);
52-
53-
const getAuthorInitials = (userId: number) => {
54-
return `U${userId}`.slice(0, 2).toUpperCase();
55-
};
56-
57-
const getAuthorColor = (userId: number) => {
58-
const colors = [
59-
'bg-blue-500',
60-
'bg-green-500',
61-
'bg-purple-500',
62-
'bg-pink-500',
63-
'bg-yellow-500',
64-
'bg-indigo-500'
65-
];
66-
return colors[Math.abs(userId) % colors.length];
67-
};
18+
const {
19+
post,
20+
isLoading,
21+
error,
22+
tags,
23+
readTime,
24+
getAuthorInitials,
25+
getAuthorColor,
26+
} = useBlogDetail(initialPost, slug);
6827

6928
if (error || (!post && !isLoading)) {
7029
return (

archive-client/app/(home)/blogs/[slug]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export default async function BlogDetailsPage({ params }: PageProps) {
4444

4545
return (
4646
<HydrationBoundary state={dehydrate(queryClient)}>
47-
<BlogDetailsClient slug={slug} />
47+
<BlogDetailsClient slug={slug} initialPost={post} />
4848
</HydrationBoundary>
4949
);
5050
}

archive-client/app/(home)/page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,10 @@ export default async function HomePage() {
3131

3232
// Prefetch popular posts using TanStack Query
3333
await queryClient.prefetchQuery({
34-
queryKey: ["posts", { is_featured: true, limit: 3, sort_by: "created_at", sort_order: "DESC" }],
34+
queryKey: ["posts", { limit: 3, sort_by: "view_count", sort_order: "DESC" }],
3535
queryFn: () => api.getPosts({
36-
is_featured: true,
3736
limit: 3,
38-
sort_by: "created_at",
37+
sort_by: "view_count",
3938
sort_order: "DESC"
4039
}),
4140
});

archive-client/components/home/CommunityTalksSectionOptimized.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@ import { SkeletonCardGrid } from "@/components/shared/SkeletonCard";
88
import { api } from "@/lib/api";
99

1010
export function CommunityTalksSection() {
11-
const { data: postsData, isLoading, error } = useQuery({
12-
queryKey: ["posts", { is_featured: true, limit: 3, sort_by: "created_at", sort_order: "DESC" }],
11+
const { data: posts = [], isLoading, error } = useQuery({
12+
queryKey: ["posts", { limit: 3, sort_by: "view_count", sort_order: "DESC" }],
1313
queryFn: () => api.getPosts({
14-
is_featured: true,
1514
limit: 3,
16-
sort_by: "created_at",
15+
sort_by: "view_count",
1716
sort_order: "DESC"
1817
}),
19-
staleTime: 60 * 1000,
18+
staleTime: 30 * 1000,
19+
refetchOnMount: "always",
20+
select: (data) => data.data
2021
});
2122

22-
const posts = postsData?.data || [];
2323

2424
return (
2525
<section className="py-10 lg:py-12 relative overflow-hidden">
Lines changed: 110 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import { useEffect, useMemo, useRef } from "react";
22
import { useRouter } from "next/navigation";
3-
import { useMutation, useQuery } from "@tanstack/react-query";
3+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
44
import type { ApiPost } from "@/types/blog.type";
55
import { incrementViewCountAction } from "@/lib/actions";
66
import { api } from "@/lib/api";
77

8+
type PostsQueryData = {
9+
data: Array<{ id: number; view_count: number }>;
10+
total: number;
11+
};
12+
813
export function useBlogDetail(initialPost: ApiPost | undefined, slug: string) {
914
const router = useRouter();
15+
const queryClient = useQueryClient();
1016
const incrementedPostId = useRef<number | null>(null);
11-
const incrementViewMutation = useMutation({
12-
mutationFn: incrementViewCountAction,
13-
});
1417

18+
// Fetch Post
1519
const { data, isLoading, error } = useQuery({
1620
queryKey: ["post", slug],
1721
queryFn: () => api.getPostBySlug(slug),
@@ -20,19 +24,86 @@ export function useBlogDetail(initialPost: ApiPost | undefined, slug: string) {
2024
staleTime: 60 * 1000,
2125
});
2226

27+
// Mutation for view count
28+
const incrementViewMutation = useMutation({
29+
mutationFn: async (postId: number) => {
30+
const success = await incrementViewCountAction(postId);
31+
if (!success) {
32+
throw new Error("Failed to increment post view count");
33+
}
34+
35+
return postId;
36+
},
37+
onSuccess: (postId) => {
38+
// Keep detail query in sync immediately after the increment succeeds.
39+
queryClient.setQueryData<ApiPost | null>(["post", slug], (currentPost) => {
40+
if (!currentPost || currentPost.id !== postId) {
41+
return currentPost;
42+
}
43+
44+
return {
45+
...currentPost,
46+
view_count: (currentPost.view_count ?? 0) + 1,
47+
};
48+
});
49+
50+
// Update all cached post-list variants (pagination/filter/sort) containing this post.
51+
queryClient.setQueriesData<PostsQueryData>(
52+
{ queryKey: ["posts"] },
53+
(currentPostsData) => {
54+
if (!currentPostsData?.data?.length) {
55+
return currentPostsData;
56+
}
57+
58+
let hasMatch = false;
59+
const updatedPosts = currentPostsData.data.map((post) => {
60+
if (post.id !== postId) {
61+
return post;
62+
}
63+
64+
hasMatch = true;
65+
return {
66+
...post,
67+
view_count: (post.view_count ?? 0) + 1,
68+
};
69+
});
70+
71+
if (!hasMatch) {
72+
return currentPostsData;
73+
}
74+
75+
return {
76+
...currentPostsData,
77+
data: updatedPosts,
78+
};
79+
}
80+
);
81+
82+
// Ensure visible lists eventually reconcile with backend truth.
83+
queryClient.invalidateQueries({ queryKey: ["posts"], refetchType: "all" });
84+
},
85+
});
86+
87+
// Validate post
2388
const post = useMemo(() => {
2489
if (!data) return null;
25-
if (data.status !== "published" || !data.is_public) return null;
90+
91+
if (data.status !== "published" || !data.is_public) {
92+
return null;
93+
}
94+
2695
return data;
2796
}, [data]);
2897

98+
// Increment view count only once
2999
useEffect(() => {
30100
if (post && incrementedPostId.current !== post.id) {
31101
incrementViewMutation.mutate(post.id);
32102
incrementedPostId.current = post.id;
33103
}
34-
}, [post, incrementViewMutation]);
104+
}, [post]);
35105

106+
// Redirect if post invalid
36107
useEffect(() => {
37108
if (!isLoading && data && !post) {
38109
router.push("/404");
@@ -43,17 +114,44 @@ export function useBlogDetail(initialPost: ApiPost | undefined, slug: string) {
43114
}
44115
}, [isLoading, data, post, error, router]);
45116

46-
const tags = useMemo(() => post?.keywords ? post.keywords.split(",").map(k => k.trim()).filter(Boolean) : [], [post?.keywords]);
117+
// Tags
118+
const tags = useMemo(() => {
119+
if (!post?.keywords) return [];
120+
121+
return post.keywords
122+
.split(",")
123+
.map(tag => tag.trim())
124+
.filter(Boolean);
125+
}, [post?.keywords]);
126+
127+
// Read time
47128
const readTime = useMemo(() => {
48129
if (post?.read_time && post.read_time > 0) {
49-
return `${post.read_time} min`;
130+
return `${post.read_time} min read`;
50131
}
51-
return "1 min"; // Fallback for UI components that expect a string
52-
}, [post?.read_time]);
53132

133+
if (!post?.content) return "1 min read";
134+
135+
const wordsPerMinute = 200;
136+
const wordCount = post.content.split(/\s+/).length;
137+
const minutes = Math.max(1, Math.ceil(wordCount / wordsPerMinute));
138+
139+
return `${minutes} min read`;
140+
}, [post?.read_time, post?.content]);
141+
142+
// Author avatar helpers
54143
const getAuthorInitials = (userId: number) => `U${userId}`;
144+
55145
const getAuthorColor = (userId: number) => {
56-
const colors = ["bg-blue-500", "bg-purple-500", "bg-green-500", "bg-red-500", "bg-yellow-500", "bg-pink-500"];
146+
const colors = [
147+
"bg-blue-500",
148+
"bg-purple-500",
149+
"bg-green-500",
150+
"bg-red-500",
151+
"bg-yellow-500",
152+
"bg-pink-500"
153+
];
154+
57155
return colors[userId % colors.length];
58156
};
59157

@@ -66,4 +164,4 @@ export function useBlogDetail(initialPost: ApiPost | undefined, slug: string) {
66164
getAuthorInitials,
67165
getAuthorColor
68166
};
69-
}
167+
}

archive-client/lib/actions.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
"use server";
2-
31
import type {
42
ApiCategory,
53
ApiSubcategory,
@@ -78,7 +76,7 @@ export async function loginAction(
7876
export async function getCategoriesAction(): Promise<ApiCategory[]> {
7977
try {
8078
const url = `${API_URL}/categories?status=approved`;
81-
const result = await serverFetch(url, { cache: "no-store" });
79+
const result = await serverFetch(url);
8280

8381
if (result.status && result.data) {
8482
return result.data.filter(
@@ -100,7 +98,7 @@ export async function getSubcategoriesAction(
10098
): Promise<ApiSubcategory[]> {
10199
try {
102100
const url = `${API_URL}/sub-categories?parent_uuid=${parentUuid}&status=approved`;
103-
const result = await serverFetch(url, { cache: "no-store" });
101+
const result = await serverFetch(url);
104102

105103
if (result.status && result.data) {
106104
return result.data;
@@ -129,7 +127,7 @@ export async function getPostsAction(
129127
});
130128

131129
const url = `${POSTAL_API_URL}/posts?${params.toString()}`;
132-
const result = await serverFetch(url, { cache: "no-store" });
130+
const result = await serverFetch(url);
133131

134132
if (result.status && result.data) {
135133
return {
@@ -152,7 +150,7 @@ export async function getPostBySlugAction(
152150
): Promise<ApiPost | null> {
153151
try {
154152
const url = `${POSTAL_API_URL}/posts/slug/${slug}`;
155-
const result = await serverFetch(url, { cache: "no-store" });
153+
const result = await serverFetch(url);
156154

157155
if (result.status && result.data) {
158156
return result.data;

0 commit comments

Comments
 (0)