11import { useEffect , useMemo , useRef } from "react" ;
22import { useRouter } from "next/navigation" ;
3- import { useMutation , useQuery } from "@tanstack/react-query" ;
3+ import { useMutation , useQuery , useQueryClient } from "@tanstack/react-query" ;
44import type { ApiPost } from "@/types/blog.type" ;
55import { incrementViewCountAction } from "@/lib/actions" ;
66import { api } from "@/lib/api" ;
77
8+ type PostsQueryData = {
9+ data : Array < { id : number ; view_count : number } > ;
10+ total : number ;
11+ } ;
12+
813export 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+ }
0 commit comments