@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
33import groq from 'groq' ;
44import { getTranslations } from 'next-intl/server' ;
55import { client } from '@/client' ;
6+ import { Link } from '@/i18n/routing' ;
67
78type SocialLink = {
89 _key ?: string ;
@@ -21,6 +22,7 @@ type Author = {
2122} ;
2223
2324type Post = {
25+ _id ?: string ;
2426 title ?: string ;
2527 publishedAt ?: string ;
2628 mainImage ?: string ;
@@ -29,6 +31,7 @@ type Post = {
2931 resourceLink ?: string ;
3032 author ?: Author ;
3133 body ?: any [ ] ;
34+ slug ?: { current ?: string } ;
3235} ;
3336
3437function plainTextFromPortableText ( value : any ) : string {
@@ -40,26 +43,46 @@ function plainTextFromPortableText(value: any): string {
4043 . trim ( ) ;
4144}
4245
46+ function seededShuffle < T > ( items : T [ ] , seed : number ) {
47+ const result = [ ...items ] ;
48+ let value = seed ;
49+ for ( let i = result . length - 1 ; i > 0 ; i -= 1 ) {
50+ value = ( value * 1664525 + 1013904223 ) % 4294967296 ;
51+ const j = value % ( i + 1 ) ;
52+ [ result [ i ] , result [ j ] ] = [ result [ j ] , result [ i ] ] ;
53+ }
54+ return result ;
55+ }
56+
57+ function hashString ( input : string ) {
58+ let hash = 0 ;
59+ for ( let i = 0 ; i < input . length ; i += 1 ) {
60+ hash = ( hash * 31 + input . charCodeAt ( i ) ) >>> 0 ;
61+ }
62+ return hash ;
63+ }
64+
4365const query = groq `
4466 *[_type=="post" && slug.current==$slug][0]{
45- "title": coalesce(title[$locale], title.en, title),
67+ _id,
68+ "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title),
4669 publishedAt,
4770 "mainImage": mainImage.asset->url,
4871 "categories": categories[]->title,
4972 tags,
5073 resourceLink,
5174
5275 "author": author->{
53- "name": coalesce(name[$locale], name.en , name),
54- "company": coalesce(company[$locale], company.en , company),
55- "jobTitle": coalesce(jobTitle[$locale], jobTitle.en , jobTitle),
56- "city": coalesce(city[$locale], city.en , city),
57- "bio": coalesce(bio[$locale], bio.en , bio),
76+ "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl , name),
77+ "company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl , company),
78+ "jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl , jobTitle),
79+ "city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl , city),
80+ "bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl , bio),
5881 "image": image.asset->url,
5982 socialMedia[]{ _key, platform, url }
6083 },
6184
62- "body": coalesce(body[$locale], body.en , body)[]{
85+ "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl , body)[]{
6386 ...,
6487 _type == "image" => {
6588 ...,
@@ -68,6 +91,19 @@ const query = groq`
6891 }
6992 }
7093` ;
94+ const recommendedQuery = groq `
95+ *[_type=="post" && defined(slug.current) && slug.current != $slug]{
96+ _id,
97+ "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title),
98+ publishedAt,
99+ "mainImage": mainImage.asset->url,
100+ slug,
101+ "author": author->{
102+ "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name),
103+ "image": image.asset->url
104+ }
105+ }
106+ ` ;
71107
72108export default async function PostDetails ( {
73109 slug,
@@ -84,6 +120,14 @@ export default async function PostDetails({
84120 slug : slugParam ,
85121 locale,
86122 } ) ;
123+ const recommendedAll : Post [ ] = await client . fetch ( recommendedQuery , {
124+ slug : slugParam ,
125+ locale,
126+ } ) ;
127+ const recommendedPosts = seededShuffle (
128+ recommendedAll ,
129+ hashString ( slugParam )
130+ ) . slice ( 0 , 3 ) ;
87131
88132 if ( ! post ?. title ) return notFound ( ) ;
89133
@@ -98,38 +142,40 @@ export default async function PostDetails({
98142
99143 return (
100144 < main className = "max-w-3xl mx-auto px-6 py-12" >
101- < h1 className = "text-4xl font-bold text-gray-900" > { post . title } </ h1 >
102-
103- < div className = "mt-4 text-sm text-gray-500" >
104- { post . publishedAt && new Date ( post . publishedAt ) . toLocaleDateString ( ) }
105- </ div >
106-
107- { ( post . categories ?. length || 0 ) > 0 && (
108- < div className = "mt-4 flex flex-wrap gap-2" >
109- { post . categories ! . map ( ( cat , i ) => (
110- < span
111- key = { ` ${ cat } - ${ i } ` }
112- className = "text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded-md"
113- >
114- { cat }
115- </ span >
116- ) ) }
145+ < Link
146+ href = "/blog"
147+ className = "inline-flex items-center gap-2 text-sm text-gray-600 border-b border-current transition hover:text-[#ff00ff] hover:bg-sky-50 hover:shadow-[0_6px_18px_rgba(56,189,248,0.18)] dark:text-gray-300 dark:hover:bg-sky-900/20"
148+ >
149+ < span > ← </ span >
150+ < span > { t ( 'goBack' ) } </ span >
151+ </ Link >
152+
153+ { post . categories ?. [ 0 ] && (
154+ < div className = "text-sm font-medium text-gray-500 dark:text-gray-400 text-center" >
155+ < Link
156+ href = { `/blog?category= ${ encodeURIComponent ( post . categories [ 0 ] ) } ` }
157+ className = "inline-flex items-center gap-1 hover:text-[#ff00ff] transition"
158+ >
159+ { post . categories [ 0 ] }
160+ </ Link >
117161 </ div >
118162 ) }
163+ < h1 className = "mt-3 text-4xl font-bold text-gray-900 dark:text-gray-100 text-center" >
164+ { post . title }
165+ </ h1 >
119166
120- { ( post . tags ?. length || 0 ) > 0 && (
121- < div className = "mt-3 flex flex-wrap gap-2" >
122- { post . tags ! . map ( ( tag , i ) => (
123- < span
124- key = { `${ tag } -${ i } ` }
125- className = "text-xs bg-purple-50 text-purple-700 px-2 py-1 rounded-md"
126- >
127- #{ tag }
128- </ span >
129- ) ) }
167+ { ( authorName || post . publishedAt ) && (
168+ < div className = "mt-4 flex justify-center gap-2 text-sm text-gray-500 dark:text-gray-400" >
169+ { authorName && < span > { authorName } </ span > }
170+ { authorName && post . publishedAt && < span > ·</ span > }
171+ { post . publishedAt && (
172+ < span > { new Date ( post . publishedAt ) . toLocaleDateString ( ) } </ span >
173+ ) }
130174 </ div >
131175 ) }
132176
177+ { ( post . tags ?. length || 0 ) > 0 && null }
178+
133179 { post . mainImage && (
134180 < div className = "relative w-full h-[420px] rounded-2xl overflow-hidden border border-gray-200 my-8" >
135181 < Image
@@ -142,18 +188,18 @@ export default async function PostDetails({
142188 ) }
143189
144190 < article className = "prose prose-gray max-w-none" >
145- { post . body ?. map ( ( block : any ) => {
191+ { post . body ?. map ( ( block : any , index : number ) => {
146192 if ( block ?. _type === 'block' ) {
147193 const text = ( block . children || [ ] )
148194 . map ( ( c : any ) => c . text || '' )
149195 . join ( '' ) ;
150- return < p key = { block . _key || Math . random ( ) } > { text } </ p > ;
196+ return < p key = { block . _key || `block- ${ index } ` } > { text } </ p > ;
151197 }
152198
153199 if ( block ?. _type === 'image' && block ?. url ) {
154200 return (
155201 < img
156- key = { block . _key || block . url }
202+ key = { block . _key || `image- ${ index } ` }
157203 src = { block . url }
158204 alt = { post . title || 'Post image' }
159205 className = "rounded-xl border border-gray-200 my-6"
@@ -165,52 +211,67 @@ export default async function PostDetails({
165211 } ) }
166212 </ article >
167213
168- { post . resourceLink && (
169- < div className = "mt-10" >
170- < a
171- href = { post . resourceLink }
172- target = "_blank"
173- rel = "noopener noreferrer"
174- className = "inline-flex bg-green-600 text-white px-5 py-3 rounded-lg hover:bg-green-700 transition"
175- >
176- Visit Resource →
177- </ a >
178- </ div >
179- ) }
214+ { recommendedPosts . length > 0 && (
215+ < >
216+ < div className = "mt-16 flex justify-center" >
217+ < div className = "h-10 w-px bg-gray-200 dark:bg-gray-800" />
218+ </ div >
180219
181- { ( authorBio || authorName || authorMeta ) && (
182- < section className = "mt-12 p-6 rounded-2xl border border-gray-200 bg-white" >
183- < h2 className = "text-lg font-semibold" > { t ( 'aboutAuthor' ) } </ h2 >
184- < div className = "mt-4 flex items-start gap-4" >
185- { post . author ?. image && (
186- < div className = "relative w-14 h-14 shrink-0" >
187- < Image
188- src = { post . author . image }
189- alt = { authorName || 'Author' }
190- fill
191- className = "rounded-full object-cover border border-gray-200"
192- />
193- </ div >
194- ) }
195-
196- < div className = "min-w-0" >
197- { authorName && (
198- < p className = "text-sm font-semibold text-gray-900" >
199- { authorName }
200- </ p >
201- ) }
202- { authorMeta && (
203- < p className = "mt-1 text-sm text-gray-600" > { authorMeta } </ p >
204- ) }
205- { authorBio && (
206- < p className = "mt-3 text-sm text-gray-700 whitespace-pre-line leading-relaxed" >
207- { authorBio }
208- </ p >
209- ) }
220+ < section className = "mt-10" >
221+ < h2 className = "text-2xl font-semibold text-gray-900 dark:text-gray-100" >
222+ { t ( 'recommendedPosts' ) }
223+ </ h2 >
224+ < div className = "mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3" >
225+ { recommendedPosts . map ( item => (
226+ < Link
227+ key = { item . _id }
228+ href = { `/blog/${ item . slug ?. current } ` }
229+ className = "group block"
230+ >
231+ { item . mainImage && (
232+ < div className = "relative h-44 w-full overflow-hidden rounded-2xl" >
233+ < Image
234+ src = { item . mainImage }
235+ alt = { item . title || 'Post image' }
236+ fill
237+ className = "object-cover transition-transform duration-300 group-hover:scale-[1.03]"
238+ />
239+ </ div >
240+ ) }
241+ < h3 className = "mt-4 text-lg font-semibold text-gray-900 transition group-hover:text-[#ff00ff] dark:text-gray-100" >
242+ { item . title }
243+ </ h3 >
244+ { ( item . author ?. name || item . publishedAt ) && (
245+ < div className = "mt-2 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400" >
246+ { item . author ?. image && (
247+ < span className = "relative h-5 w-5 overflow-hidden rounded-full" >
248+ < Image
249+ src = { item . author . image }
250+ alt = { item . author . name || 'Author' }
251+ fill
252+ className = "object-cover"
253+ />
254+ </ span >
255+ ) }
256+ { item . author ?. name && < span > { item . author . name } </ span > }
257+ { item . author ?. name && item . publishedAt && < span > ·</ span > }
258+ { item . publishedAt && (
259+ < span >
260+ { new Date ( item . publishedAt ) . toLocaleDateString ( ) }
261+ </ span >
262+ ) }
263+ </ div >
264+ ) }
265+ </ Link >
266+ ) ) }
210267 </ div >
211- </ div >
212- </ section >
268+ </ section >
269+ </ >
213270 ) }
271+
272+ { post . resourceLink && null }
273+
274+ { ( authorBio || authorName || authorMeta ) && null }
214275 </ main >
215276 ) ;
216277}
0 commit comments