Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 140 additions & 79 deletions frontend/app/[locale]/blog/[slug]/PostDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
import groq from 'groq';
import { getTranslations } from 'next-intl/server';
import { client } from '@/client';
import { Link } from '@/i18n/routing';

type SocialLink = {
_key?: string;
Expand All @@ -21,6 +22,7 @@ type Author = {
};

type Post = {
_id?: string;
title?: string;
publishedAt?: string;
mainImage?: string;
Expand All @@ -29,6 +31,7 @@ type Post = {
resourceLink?: string;
author?: Author;
body?: any[];
slug?: { current?: string };
};

function plainTextFromPortableText(value: any): string {
Expand All @@ -40,26 +43,46 @@ function plainTextFromPortableText(value: any): string {
.trim();
}

function seededShuffle<T>(items: T[], seed: number) {
const result = [...items];
let value = seed;
for (let i = result.length - 1; i > 0; i -= 1) {
value = (value * 1664525 + 1013904223) % 4294967296;
const j = value % (i + 1);
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}

function hashString(input: string) {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
}
return hash;
}

const query = groq`
*[_type=="post" && slug.current==$slug][0]{
"title": coalesce(title[$locale], title.en, title),
_id,
"title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title),
publishedAt,
"mainImage": mainImage.asset->url,
"categories": categories[]->title,
tags,
resourceLink,

"author": author->{
"name": coalesce(name[$locale], name.en, name),
"company": coalesce(company[$locale], company.en, company),
"jobTitle": coalesce(jobTitle[$locale], jobTitle.en, jobTitle),
"city": coalesce(city[$locale], city.en, city),
"bio": coalesce(bio[$locale], bio.en, bio),
"name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name),
"company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl, company),
"jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl, jobTitle),
"city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl, city),
"bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl, bio),
"image": image.asset->url,
socialMedia[]{ _key, platform, url }
},

"body": coalesce(body[$locale], body.en, body)[]{
"body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{
...,
_type == "image" => {
...,
Expand All @@ -68,6 +91,19 @@ const query = groq`
}
}
`;
const recommendedQuery = groq`
*[_type=="post" && defined(slug.current) && slug.current != $slug]{
_id,
"title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title),
publishedAt,
"mainImage": mainImage.asset->url,
slug,
"author": author->{
"name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name),
"image": image.asset->url
}
}
`;

export default async function PostDetails({
slug,
Expand All @@ -84,6 +120,14 @@ export default async function PostDetails({
slug: slugParam,
locale,
});
const recommendedAll: Post[] = await client.fetch(recommendedQuery, {
slug: slugParam,
locale,
});
const recommendedPosts = seededShuffle(
recommendedAll,
hashString(slugParam)
).slice(0, 3);

if (!post?.title) return notFound();

Expand All @@ -98,38 +142,40 @@ export default async function PostDetails({

return (
<main className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-4xl font-bold text-gray-900">{post.title}</h1>

<div className="mt-4 text-sm text-gray-500">
{post.publishedAt && new Date(post.publishedAt).toLocaleDateString()}
</div>

{(post.categories?.length || 0) > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{post.categories!.map((cat, i) => (
<span
key={`${cat}-${i}`}
className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded-md"
>
{cat}
</span>
))}
<Link
href="/blog"
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"
>
<span>&larr;</span>
<span>{t('goBack')}</span>
</Link>

{post.categories?.[0] && (
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 text-center">
<Link
href={`/blog?category=${encodeURIComponent(post.categories[0])}`}
className="inline-flex items-center gap-1 hover:text-[#ff00ff] transition"
>
{post.categories[0]}
</Link>
</div>
)}
<h1 className="mt-3 text-4xl font-bold text-gray-900 dark:text-gray-100 text-center">
{post.title}
</h1>

{(post.tags?.length || 0) > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{post.tags!.map((tag, i) => (
<span
key={`${tag}-${i}`}
className="text-xs bg-purple-50 text-purple-700 px-2 py-1 rounded-md"
>
#{tag}
</span>
))}
{(authorName || post.publishedAt) && (
<div className="mt-4 flex justify-center gap-2 text-sm text-gray-500 dark:text-gray-400">
{authorName && <span>{authorName}</span>}
{authorName && post.publishedAt && <span>·</span>}
{post.publishedAt && (
<span>{new Date(post.publishedAt).toLocaleDateString()}</span>
)}
Comment on lines +167 to +173
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the page locale when formatting dates.
toLocaleDateString() without a locale uses the server default and can mismatch the blog locale. Pass locale in both places.

💡 Suggested fix
-          {post.publishedAt && (
-            <span>{new Date(post.publishedAt).toLocaleDateString()}</span>
-          )}
+          {post.publishedAt && (
+            <span>
+              {new Date(post.publishedAt).toLocaleDateString(locale)}
+            </span>
+          )}
-                      {item.publishedAt && (
-                        <span>
-                          {new Date(item.publishedAt).toLocaleDateString()}
-                        </span>
-                      )}
+                      {item.publishedAt && (
+                        <span>
+                          {new Date(item.publishedAt).toLocaleDateString(locale)}
+                        </span>
+                      )}

Also applies to: 257-260

🤖 Prompt for AI Agents
In `@frontend/app/`[locale]/blog/[slug]/PostDetails.tsx around lines 166 - 172,
The date formatting uses new Date(post.publishedAt).toLocaleDateString() which
defaults to the server locale; update PostDetails.tsx to pass the page locale
into toLocaleDateString (e.g., new
Date(post.publishedAt).toLocaleDateString(locale)) wherever dates are formatted
(the instance in the author/published block and the other occurrence around the
257–260 area) so the blog dates render using the page locale; locate the date
calls in the PostDetails component and supply the locale variable used by the
page.

</div>
)}

{(post.tags?.length || 0) > 0 && null}

{post.mainImage && (
<div className="relative w-full h-[420px] rounded-2xl overflow-hidden border border-gray-200 my-8">
<Image
Expand All @@ -142,18 +188,18 @@ export default async function PostDetails({
)}

<article className="prose prose-gray max-w-none">
{post.body?.map((block: any) => {
{post.body?.map((block: any, index: number) => {
if (block?._type === 'block') {
const text = (block.children || [])
.map((c: any) => c.text || '')
.join('');
return <p key={block._key || Math.random()}>{text}</p>;
return <p key={block._key || `block-${index}`}>{text}</p>;
}

if (block?._type === 'image' && block?.url) {
return (
<img
key={block._key || block.url}
key={block._key || `image-${index}`}
src={block.url}
alt={post.title || 'Post image'}
className="rounded-xl border border-gray-200 my-6"
Expand All @@ -165,52 +211,67 @@ export default async function PostDetails({
})}
</article>

{post.resourceLink && (
<div className="mt-10">
<a
href={post.resourceLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex bg-green-600 text-white px-5 py-3 rounded-lg hover:bg-green-700 transition"
>
Visit Resource →
</a>
</div>
)}
{recommendedPosts.length > 0 && (
<>
<div className="mt-16 flex justify-center">
<div className="h-10 w-px bg-gray-200 dark:bg-gray-800" />
</div>

{(authorBio || authorName || authorMeta) && (
<section className="mt-12 p-6 rounded-2xl border border-gray-200 bg-white">
<h2 className="text-lg font-semibold">{t('aboutAuthor')}</h2>
<div className="mt-4 flex items-start gap-4">
{post.author?.image && (
<div className="relative w-14 h-14 shrink-0">
<Image
src={post.author.image}
alt={authorName || 'Author'}
fill
className="rounded-full object-cover border border-gray-200"
/>
</div>
)}

<div className="min-w-0">
{authorName && (
<p className="text-sm font-semibold text-gray-900">
{authorName}
</p>
)}
{authorMeta && (
<p className="mt-1 text-sm text-gray-600">{authorMeta}</p>
)}
{authorBio && (
<p className="mt-3 text-sm text-gray-700 whitespace-pre-line leading-relaxed">
{authorBio}
</p>
)}
<section className="mt-10">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{t('recommendedPosts')}
</h2>
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{recommendedPosts.map(item => (
<Link
key={item._id}
href={`/blog/${item.slug?.current}`}
className="group block"
>
{item.mainImage && (
<div className="relative h-44 w-full overflow-hidden rounded-2xl">
<Image
src={item.mainImage}
alt={item.title || 'Post image'}
fill
className="object-cover transition-transform duration-300 group-hover:scale-[1.03]"
/>
</div>
)}
<h3 className="mt-4 text-lg font-semibold text-gray-900 transition group-hover:text-[#ff00ff] dark:text-gray-100">
{item.title}
</h3>
{(item.author?.name || item.publishedAt) && (
<div className="mt-2 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
{item.author?.image && (
<span className="relative h-5 w-5 overflow-hidden rounded-full">
<Image
src={item.author.image}
alt={item.author.name || 'Author'}
fill
className="object-cover"
/>
</span>
)}
{item.author?.name && <span>{item.author.name}</span>}
{item.author?.name && item.publishedAt && <span>·</span>}
{item.publishedAt && (
<span>
{new Date(item.publishedAt).toLocaleDateString()}
</span>
)}
</div>
)}
</Link>
))}
</div>
</div>
</section>
</section>
</>
)}

{post.resourceLink && null}

{(authorBio || authorName || authorMeta) && null}
</main>
);
}
34 changes: 25 additions & 9 deletions frontend/app/[locale]/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,27 @@ export default async function BlogPage({
*[_type == "post" && defined(slug.current)]
| order(publishedAt desc) {
_id,
"title": coalesce(title[$locale], title.en, title),
"title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title),
slug,
publishedAt,
tags,
resourceLink,

"categories": categories[]->title,

"body": coalesce(body[$locale], body.en, body)[]{
"body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{
...,
children[]{
text
}
},
"mainImage": mainImage.asset->url,
"author": author->{
"name": coalesce(name[$locale], name.en, name),
"company": coalesce(company[$locale], company.en, company),
"jobTitle": coalesce(jobTitle[$locale], jobTitle.en, jobTitle),
"city": coalesce(city[$locale], city.en, city),
"bio": coalesce(bio[$locale], bio.en, bio),
"name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name),
"company": coalesce(company[$locale], company[lower($locale)], company.uk, company.en, company.pl, company),
"jobTitle": coalesce(jobTitle[$locale], jobTitle[lower($locale)], jobTitle.uk, jobTitle.en, jobTitle.pl, jobTitle),
"city": coalesce(city[$locale], city[lower($locale)], city.uk, city.en, city.pl, city),
"bio": coalesce(bio[$locale], bio[lower($locale)], bio.uk, bio.en, bio.pl, bio),
"image": image.asset->url,
socialMedia[]{
_key,
Expand All @@ -62,11 +62,27 @@ export default async function BlogPage({
`,
{ locale }
);
const categories = await client.fetch(
groq`
*[_type == "category"] | order(orderRank asc) {
_id,
title
}
`
);
Comment on lines +65 to +72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Categories query lacks locale-aware title projection.

The categories query fetches raw title without locale coalesce, unlike the post fields. If title is stored as an object with locale keys (e.g., {en: "...", uk: "..."}), this will return the object instead of a resolved string, breaking category display.

🛠️ Suggested fix
  const categories = await client.fetch(
    groq`
      *[_type == "category"] | order(orderRank asc) {
        _id,
-       title
+       "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title)
      }
-   `
+   `,
+   { locale }
  );
🤖 Prompt for AI Agents
In `@frontend/app/`[locale]/blog/page.tsx around lines 65 - 72, The categories
query returns the raw title object instead of a locale-resolved string; update
the groq passed to client.fetch (the categories query that assigns to
categories) to project a locale-aware title using coalesce with the current
locale (e.g., title: coalesce(title[locale], title.en, /* fallback */)) so the
returned categories have a string title for display; ensure you reference the
same locale variable used elsewhere in page.tsx.

const featuredPost = posts?.[0];

return (
<main className="max-w-6xl mx-auto px-6 py-12">
<h1 className="text-4xl font-bold mb-10 text-center">{t('title')}</h1>
<BlogFilters posts={posts} />
<h1 className="text-4xl font-bold mb-4 text-center">{t('title')}</h1>
<p className="mx-auto max-w-2xl text-center text-base text-gray-500 dark:text-gray-400">
{t('subtitle')}
</p>
<BlogFilters
posts={posts}
categories={categories}
featuredPost={featuredPost}
/>
</main>
);
}
Loading