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
169 changes: 114 additions & 55 deletions frontend/app/[locale]/blog/[slug]/PostDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ function plainTextFromPortableText(value: any): string {
.trim();
}

function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
return parts.map((part, index) => {
if (!part) return null;
if (urlRegex.test(part)) {
return (
<a
key={`link-${index}`}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent-primary)] underline underline-offset-4"
>
{part}
</a>
);
}
return <span key={`text-${index}`}>{part}</span>;
});
}

function seededShuffle<T>(items: T[], seed: number) {
const result = [...items];
let value = seed;
Expand Down Expand Up @@ -103,6 +125,13 @@ const recommendedQuery = groq`
"author": author->{
"name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name),
"image": image.asset->url
},
"body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{
...,
_type == "image" => {
...,
"url": asset->url
}
}
}
`;
Expand All @@ -115,6 +144,7 @@ export default async function PostDetails({
locale: string;
}) {
const t = await getTranslations({ locale, namespace: 'blog' });
const tNav = await getTranslations({ locale, namespace: 'navigation' });
const slugParam = String(slug || '').trim();
if (!slugParam) return notFound();

Expand Down Expand Up @@ -145,24 +175,32 @@ export default async function PostDetails({
post.author?.city,
].filter(Boolean) as string[];
const authorMeta = authorMetaParts.join(' · ');
const categoryLabel = post.categories?.[0];

return (
<main className="max-w-3xl mx-auto px-6 py-12">
<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>
<div className="mb-6 relative left-1/2 right-1/2 w-screen -translate-x-1/2 px-6">
<div className="mx-auto flex max-w-6xl justify-start">
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Link
href="/blog"
className="transition hover:text-[var(--accent-primary)] hover:underline underline-offset-4"
>
{tNav('blog')}
</Link>
<span>&gt;</span>
<span className="text-[var(--accent-primary)]">{post.title}</span>
</div>
</div>
</div>

{post.categories?.[0] && (
{categoryLabel && (
<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"
href={`/blog?category=${encodeURIComponent(categoryLabel)}`}
className="inline-flex items-center gap-1 text-[var(--accent-primary)] transition"
>
{post.categories[0] === 'Growth' ? 'Career' : post.categories[0]}
{categoryLabel === 'Growth' ? 'Career' : categoryLabel}
</Link>
</div>
)}
Expand All @@ -172,7 +210,14 @@ export default async function PostDetails({

{(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 && (
<Link
href={`/blog?author=${encodeURIComponent(authorName)}`}
className="transition hover:text-[var(--accent-primary)]"
>
{authorName}
</Link>
)}
{authorName && post.publishedAt && <span>·</span>}
{post.publishedAt && (
<span>{new Date(post.publishedAt).toLocaleDateString()}</span>
Expand All @@ -199,7 +244,14 @@ export default async function PostDetails({
const text = (block.children || [])
.map((c: any) => c.text || '')
.join('');
return <p key={block._key || `block-${index}`}>{text}</p>;
return (
<p
key={block._key || `block-${index}`}
className="whitespace-pre-line"
>
{linkifyText(text)}
</p>
);
}

if (block?._type === 'image' && block?.url) {
Expand All @@ -219,57 +271,64 @@ export default async function PostDetails({

{recommendedPosts.length > 0 && (
<>
<div className="mt-16 flex justify-center">
<div className="h-10 w-px bg-gray-200 dark:bg-gray-800" />
<div className="mt-16 relative left-1/2 right-1/2 w-screen -translate-x-1/2 px-6">
<div className="mx-auto h-px w-full max-w-6xl bg-gray-200 dark:bg-gray-800" />
</div>

<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">
<section className="mt-10 relative left-1/2 right-1/2 w-screen -translate-x-1/2 px-6">
<div className="mx-auto max-w-6xl">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{t('recommendedPosts')}
</h2>
<div className="mt-6 grid gap-6 auto-rows-fr sm:grid-cols-2 lg:grid-cols-3">
{recommendedPosts.map(item => (
<Link
key={item._id}
href={`/blog/${item.slug?.current}`}
className="group flex h-full flex-col"
>
{item.mainImage && (
<div className="relative h-48 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:underline underline-offset-4 dark:text-gray-100">
{item.title}
</h3>
{item.body && (
<p className="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-2">
{plainTextFromPortableText(item.body)}
</p>
)}
{(item.author?.name || item.publishedAt) && (
<div className="mt-2 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<div className="mt-auto pt-3 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>
))}
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>
</>
Expand Down
70 changes: 65 additions & 5 deletions frontend/app/[locale]/blog/category/[category]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import groq from 'groq';
import { notFound } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import Image from 'next/image';
import { client } from '@/client';
import { Link } from '@/i18n/routing';
import { BlogCategoryGrid } from '@/components/blog/BlogCategoryGrid';

export const revalidate = 0;
Expand Down Expand Up @@ -51,13 +53,11 @@ export default async function BlogCategoryPage({

if (!matchedCategory) return notFound();
const categoryTitle = matchedCategory.title;
const displayTitle =
categoryTitle === 'Growth' ? 'Career' : categoryTitle;

const posts: Post[] = await client.withConfig({ useCdn: false }).fetch(
groq`
*[_type == "post" && defined(slug.current) && $category in categories[]->title]
| order(publishedAt desc) {
| order(coalesce(publishedAt, _createdAt) desc) {
_id,
"title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title),
slug,
Expand All @@ -77,13 +77,73 @@ export default async function BlogCategoryPage({
{ locale, category: categoryTitle }
);

const featuredPost = posts[0];
const restPosts = posts.slice(1);
const featuredDate = featuredPost?.publishedAt
? new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(featuredPost.publishedAt))
: '';

return (
<main className="max-w-6xl mx-auto px-6 py-12">
<h1 className="text-4xl font-bold mb-4 text-center">
{displayTitle}
{categoryTitle}
</h1>
{featuredPost?.mainImage && (
<section className="mt-10">
<article className="group relative overflow-hidden rounded-3xl bg-white dark:bg-black">
<div className="h-[320px] w-full overflow-hidden sm:h-[380px] md:h-[450px] lg:h-[618px] max-h-[65vh]">
<Image
src={featuredPost.mainImage}
alt={featuredPost.title}
width={1400}
height={800}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
priority={false}
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-56 bg-gradient-to-t from-white/95 via-white/70 to-transparent dark:from-black/90 dark:via-black/60 sm:h-64" />
<div className="absolute inset-x-0 bottom-0 p-6 sm:p-8">
{featuredPost.categories?.[0] && (
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{featuredPost.categories[0]}
</div>
)}
<h2 className="mt-2 text-3xl font-semibold text-gray-900 transition group-hover:text-[var(--accent-primary)] dark:text-white dark:group-hover:text-[var(--accent-primary)] sm:text-4xl">
{featuredPost.title}
</h2>
<div className="mt-3 flex items-center gap-3 text-sm text-gray-800 dark:text-gray-200">
{featuredPost.author?.image && (
<Image
src={featuredPost.author.image}
alt={featuredPost.author.name || 'Author'}
width={28}
height={28}
className="h-7 w-7 rounded-full object-cover"
/>
)}
{featuredPost.author?.name && (
<span>{featuredPost.author.name}</span>
)}
{featuredPost.author?.name && featuredDate && <span>·</span>}
{featuredDate && <span>{featuredDate}</span>}
</div>
<Link
href={`/blog/${featuredPost.slug.current}`}
className="absolute bottom-6 right-6 inline-flex h-11 w-11 items-center justify-center rounded-full bg-[var(--accent-primary)] text-white opacity-0 transition group-hover:opacity-100 hover:brightness-110"
aria-label={featuredPost.title}
>
<span aria-hidden="true">↗</span>
</Link>
</div>
</article>
</section>
)}
<div className="mt-12">
<BlogCategoryGrid posts={posts} />
<BlogCategoryGrid posts={restPosts} />
Comment on lines +80 to +146
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

Avoid dropping the first post when it lacks an image.

featuredPost is always posts[0], but the featured section renders only when mainImage exists. If the first post lacks an image, it’s excluded from both the featured section and the grid.

✅ Suggested fix (select first post with an image, otherwise keep full list)
-const featuredPost = posts[0];
-const restPosts = posts.slice(1);
-const featuredDate = featuredPost?.publishedAt
+const featuredPost = posts.find(post => post.mainImage);
+const restPosts = featuredPost
+  ? posts.filter(post => post._id !== featuredPost._id)
+  : posts;
+const featuredDate = featuredPost?.publishedAt
   ? new Intl.DateTimeFormat(locale, {
       day: '2-digit',
       month: '2-digit',
       year: 'numeric',
     }).format(new Date(featuredPost.publishedAt))
   : '';

 ...
-{featuredPost?.mainImage && (
+{featuredPost && (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const featuredPost = posts[0];
const restPosts = posts.slice(1);
const featuredDate = featuredPost?.publishedAt
? new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(featuredPost.publishedAt))
: '';
return (
<main className="max-w-6xl mx-auto px-6 py-12">
<h1 className="text-4xl font-bold mb-4 text-center">
{displayTitle}
{categoryTitle}
</h1>
{featuredPost?.mainImage && (
<section className="mt-10">
<article className="group relative overflow-hidden rounded-3xl bg-white dark:bg-black">
<div className="h-[320px] w-full overflow-hidden sm:h-[380px] md:h-[450px] lg:h-[618px] max-h-[65vh]">
<Image
src={featuredPost.mainImage}
alt={featuredPost.title}
width={1400}
height={800}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
priority={false}
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-56 bg-gradient-to-t from-white/95 via-white/70 to-transparent dark:from-black/90 dark:via-black/60 sm:h-64" />
<div className="absolute inset-x-0 bottom-0 p-6 sm:p-8">
{featuredPost.categories?.[0] && (
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{featuredPost.categories[0]}
</div>
)}
<h2 className="mt-2 text-3xl font-semibold text-gray-900 transition group-hover:text-[var(--accent-primary)] dark:text-white dark:group-hover:text-[var(--accent-primary)] sm:text-4xl">
{featuredPost.title}
</h2>
<div className="mt-3 flex items-center gap-3 text-sm text-gray-800 dark:text-gray-200">
{featuredPost.author?.image && (
<Image
src={featuredPost.author.image}
alt={featuredPost.author.name || 'Author'}
width={28}
height={28}
className="h-7 w-7 rounded-full object-cover"
/>
)}
{featuredPost.author?.name && (
<span>{featuredPost.author.name}</span>
)}
{featuredPost.author?.name && featuredDate && <span>·</span>}
{featuredDate && <span>{featuredDate}</span>}
</div>
<Link
href={`/blog/${featuredPost.slug.current}`}
className="absolute bottom-6 right-6 inline-flex h-11 w-11 items-center justify-center rounded-full bg-[var(--accent-primary)] text-white opacity-0 transition group-hover:opacity-100 hover:brightness-110"
aria-label={featuredPost.title}
>
<span aria-hidden="true"></span>
</Link>
</div>
</article>
</section>
)}
<div className="mt-12">
<BlogCategoryGrid posts={posts} />
<BlogCategoryGrid posts={restPosts} />
const featuredPost = posts.find(post => post.mainImage);
const restPosts = featuredPost
? posts.filter(post => post._id !== featuredPost._id)
: posts;
const featuredDate = featuredPost?.publishedAt
? new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(featuredPost.publishedAt))
: '';
return (
<main className="max-w-6xl mx-auto px-6 py-12">
<h1 className="text-4xl font-bold mb-4 text-center">
{categoryTitle}
</h1>
{featuredPost && (
<section className="mt-10">
<article className="group relative overflow-hidden rounded-3xl bg-white dark:bg-black">
<div className="h-[320px] w-full overflow-hidden sm:h-[380px] md:h-[450px] lg:h-[618px] max-h-[65vh]">
<Image
src={featuredPost.mainImage}
alt={featuredPost.title}
width={1400}
height={800}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
priority={false}
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-56 bg-gradient-to-t from-white/95 via-white/70 to-transparent dark:from-black/90 dark:via-black/60 sm:h-64" />
<div className="absolute inset-x-0 bottom-0 p-6 sm:p-8">
{featuredPost.categories?.[0] && (
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{featuredPost.categories[0]}
</div>
)}
<h2 className="mt-2 text-3xl font-semibold text-gray-900 transition group-hover:text-[var(--accent-primary)] dark:text-white dark:group-hover:text-[var(--accent-primary)] sm:text-4xl">
{featuredPost.title}
</h2>
<div className="mt-3 flex items-center gap-3 text-sm text-gray-800 dark:text-gray-200">
{featuredPost.author?.image && (
<Image
src={featuredPost.author.image}
alt={featuredPost.author.name || 'Author'}
width={28}
height={28}
className="h-7 w-7 rounded-full object-cover"
/>
)}
{featuredPost.author?.name && (
<span>{featuredPost.author.name}</span>
)}
{featuredPost.author?.name && featuredDate && <span>·</span>}
{featuredDate && <span>{featuredDate}</span>}
</div>
<Link
href={`/blog/${featuredPost.slug.current}`}
className="absolute bottom-6 right-6 inline-flex h-11 w-11 items-center justify-center rounded-full bg-[var(--accent-primary)] text-white opacity-0 transition group-hover:opacity-100 hover:brightness-110"
aria-label={featuredPost.title}
>
<span aria-hidden="true"></span>
</Link>
</div>
</article>
</section>
)}
<div className="mt-12">
<BlogCategoryGrid posts={restPosts} />
🤖 Prompt for AI Agents
In `@frontend/app/`[locale]/blog/category/[category]/page.tsx around lines 80 -
146, The current logic always treats posts[0] as featuredPost then only renders
it if featuredPost.mainImage exists, which drops the first post from the grid
when it has no image; update the selection so featuredPost is the first post in
posts with a mainImage (e.g., find index via posts.findIndex(p => p.mainImage)),
set restPosts to posts with that featured item removed (if found) or to the full
posts array when no featured post exists, and keep the featuredDate logic tied
to the chosen featuredPost; ensure BlogCategoryGrid receives restPosts (or posts
when no featured found) and adjust any rendering guards that assume featuredPost
is posts[0].

</div>
{!posts.length && (
<p className="text-center text-gray-500 mt-10">{t('noPosts')}</p>
Expand Down
4 changes: 3 additions & 1 deletion frontend/app/[locale]/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import groq from 'groq';
import { unstable_noStore as noStore } from 'next/cache';
import { getTranslations } from 'next-intl/server';
import { client } from '@/client';
import BlogFilters from '@/components/blog/BlogFilters';
Expand All @@ -24,13 +25,14 @@ export default async function BlogPage({
}: {
params: Promise<{ locale: string }>;
}) {
noStore();
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'blog' });

const posts = await client.withConfig({ useCdn: false }).fetch(
groq`
*[_type == "post" && defined(slug.current)]
| order(publishedAt desc) {
| order(coalesce(publishedAt, _createdAt) desc) {
_id,
"title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title),
slug,
Expand Down
2 changes: 1 addition & 1 deletion frontend/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { createClient } from "@sanity/client";
export const client = createClient({
projectId: "6y9ive6v",
dataset: "production",
useCdn: true,
useCdn: false,
apiVersion: "2025-11-29",
});
Loading