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
116 changes: 93 additions & 23 deletions frontend/app/[locale]/blog/[slug]/PostDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,15 @@ export default async function PostDetails({
const post: Post | null = await client
.withConfig({ useCdn: false })
.fetch(query, {
slug: slugParam,
locale,
});
slug: slugParam,
locale,
});
const recommendedAll: Post[] = await client
.withConfig({ useCdn: false })
.fetch(recommendedQuery, {
slug: slugParam,
locale,
});
slug: slugParam,
locale,
});
const recommendedPosts = seededShuffle(
recommendedAll,
hashString(slugParam)
Expand All @@ -212,9 +212,71 @@ export default async function PostDetails({
].filter(Boolean) as string[];
const authorMeta = authorMetaParts.join(' · ');
const categoryLabel = post.categories?.[0];
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL;
const postUrl = baseUrl
? `${baseUrl}/${locale}/blog/${slugParam}`
: null;
const blogUrl = baseUrl ? `${baseUrl}/${locale}/blog` : null;
const description = plainTextFromPortableText(post.body).slice(0, 160);
const breadcrumbsJsonLd =
blogUrl && postUrl
? {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: tNav('blog'),
item: blogUrl,
},
{
'@type': 'ListItem',
position: 2,
name: post.title,
item: postUrl,
},
],
}
: null;
const articleJsonLd =
postUrl
? {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: description || undefined,
mainEntityOfPage: postUrl,
url: postUrl,
datePublished: post.publishedAt || undefined,
author: post.author?.name
? {
'@type': 'Person',
name: post.author.name,
}
: undefined,
image: post.mainImage ? [post.mainImage] : undefined,
}
: null;

return (
<main className="max-w-3xl mx-auto px-6 py-12">
{breadcrumbsJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbsJsonLd),
}}
/>
)}
{articleJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(articleJsonLd),
}}
/>
)}
<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">
Expand Down Expand Up @@ -255,7 +317,11 @@ export default async function PostDetails({
</Link>
)}
{authorName && post.publishedAt && <span>·</span>}
{post.publishedAt && <span>{formatBlogDate(post.publishedAt)}</span>}
{post.publishedAt && (
<time dateTime={post.publishedAt}>
{formatBlogDate(post.publishedAt)}
</time>
)}
</div>
)}

Expand Down Expand Up @@ -328,30 +394,34 @@ export default async function PostDetails({
/>
</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-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}
<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-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.author?.name && item.publishedAt && (
<span>·</span>
)}
{item.publishedAt && (
<span>{formatBlogDate(item.publishedAt)}</span>
<time dateTime={item.publishedAt}>
{formatBlogDate(item.publishedAt)}
</time>
)}
</div>
)}
Expand Down
6 changes: 5 additions & 1 deletion frontend/app/[locale]/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ export async function generateMetadata({
const { slug, locale } = await params;

const post = await client.fetch(
groq`*[_type == "post" && slug.current == $slug][0]{ "title": coalesce(title[$locale], title.en, title) }`,
groq`*[_type == "post" && slug.current == $slug][0]{
"title": coalesce(title[$locale], title.en, title),
"description": pt::text(coalesce(body[$locale], body.en, body))[0...160]
}`,
{ slug, locale }
);

return {
title: post?.title || 'Post',
description: post?.description || undefined,
};
}

Expand Down
6 changes: 5 additions & 1 deletion frontend/app/[locale]/blog/category/[category]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ export default async function BlogCategoryPage({
<span>{featuredPost.author.name}</span>
)}
{featuredPost.author?.name && featuredDate && <span>·</span>}
{featuredDate && <span>{featuredDate}</span>}
{featuredDate && featuredPost.publishedAt && (
<time dateTime={featuredPost.publishedAt}>
{featuredDate}
</time>
)}
</div>
<Link
href={`/blog/${featuredPost.slug.current}`}
Expand Down
4 changes: 3 additions & 1 deletion frontend/components/blog/BlogCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ export default function BlogCard({
{(post.author?.name || categoryLabel) && formattedDate && (
<span>·</span>
)}
{formattedDate && <span>{formattedDate}</span>}
{formattedDate && post.publishedAt && (
<time dateTime={post.publishedAt}>{formattedDate}</time>
)}
</div>
)}

Expand Down
1 change: 0 additions & 1 deletion frontend/components/blog/BlogCategoryLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export function BlogCategoryLinks({
const tNav = useTranslations('navigation');
const pathname = usePathname();

// Helper function to get translated category label
const getCategoryLabel = (categoryName: string): string => {
const key = categoryName.toLowerCase() as
| 'tech'
Expand Down
31 changes: 18 additions & 13 deletions frontend/components/blog/BlogFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ export default function BlogFilters({
norm: string;
data?: Author;
} | null>(null);
const [authorProfile, setAuthorProfile] = useState<Author | null>(null);
const [authorProfile, setAuthorProfile] = useState<{
name: string;
data: Author;
} | null>(null);
const [selectedCategory, setSelectedCategory] = useState<{
name: string;
norm: string;
Expand All @@ -148,7 +151,6 @@ export default function BlogFilters({
router.replace(nextPath);
};

// Helper function to get translated category label
const getCategoryLabel = (categoryName: string): string => {
const key = categoryName.toLowerCase() as 'tech' | 'career' | 'insights' | 'news' | 'growth';
const categoryTranslations: Record<string, string> = {
Expand Down Expand Up @@ -224,7 +226,6 @@ export default function BlogFilters({
didClearSearchRef.current = true;
}, [pathname, router, searchParams, searchQuery]);

// categoryParam is handled via resolvedCategory to avoid state updates in effects.

const resolvedAuthor = useMemo(() => {
const normParam = normalizeAuthor(authorParam);
Expand All @@ -242,13 +243,9 @@ export default function BlogFilters({

useEffect(() => {
const name = resolvedAuthor?.name?.trim();
if (!name) {
setAuthorProfile(null);
return;
}
if (!name) return;

let active = true;
if (resolvedAuthor?.data) setAuthorProfile(resolvedAuthor.data);

fetch(
`/api/blog-author?name=${encodeURIComponent(name)}&locale=${encodeURIComponent(
Expand All @@ -259,7 +256,7 @@ export default function BlogFilters({
.then(response => (response.ok ? response.json() : null))
.then((data: Author | null) => {
if (!active) return;
if (data) setAuthorProfile(data);
if (data) setAuthorProfile({ name, data });
})
.catch(() => {
if (!active) return;
Expand All @@ -268,7 +265,7 @@ export default function BlogFilters({
return () => {
active = false;
};
}, [locale, resolvedAuthor?.data, resolvedAuthor?.name]);
}, [locale, resolvedAuthor?.name]);

const filteredPosts = useMemo(() => {
return posts.filter(post => {
Expand Down Expand Up @@ -299,7 +296,12 @@ export default function BlogFilters({
});
}, [posts, resolvedAuthor, resolvedCategory, searchQueryLower]);

const selectedAuthorData = authorProfile || resolvedAuthor?.data || null;
const selectedAuthorData = useMemo(() => {
const resolvedName = resolvedAuthor?.name;
if (!resolvedName) return null;
if (authorProfile?.name === resolvedName) return authorProfile.data;
return resolvedAuthor?.data || null;
}, [authorProfile, resolvedAuthor?.data, resolvedAuthor?.name]);
const authorBioText = useMemo(() => {
return plainTextFromPortableText(selectedAuthorData?.bio);
}, [selectedAuthorData]);
Expand Down Expand Up @@ -340,9 +342,12 @@ export default function BlogFilters({
</p>
{featuredPost.publishedAt && (
<div className="mt-6 flex items-center justify-between text-xs tracking-[0.25em] text-gray-500 dark:text-gray-400">
<span className="uppercase">
<time
dateTime={featuredPost.publishedAt}
className="uppercase"
>
{formatBlogDate(featuredPost.publishedAt)}
</span>
</time>
<Link
href={`/blog/${featuredPost.slug.current}`}
className="text-sm font-medium tracking-normal text-[var(--accent-primary)] transition hover:underline underline-offset-4"
Expand Down
39 changes: 34 additions & 5 deletions frontend/components/blog/BlogHeaderSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function BlogHeaderSearch() {
const [items, setItems] = useState<PostSearchItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const debounceRef = useRef<number | null>(null);

Expand All @@ -52,9 +53,22 @@ export function BlogHeaderSearch() {
}, [open]);

useEffect(() => {
if (!open || items.length || isLoading) return;
if (!open) return;
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
};
}, [open]);

useEffect(() => {
if (!open || items.length || !isLoading) return;
let active = true;
setIsLoading(true);
fetch(SEARCH_ENDPOINT, { cache: 'no-store' })
.then(response => (response.ok ? response.json() : []))
.then((result: PostSearchItem[]) => {
Expand Down Expand Up @@ -118,11 +132,23 @@ export function BlogHeaderSearch() {
setOpen(false);
};

const startLoading = () => {
if (!items.length && !isLoading) {
setIsLoading(true);
}
};

return (
<div className="relative flex items-center">
<div ref={containerRef} className="relative flex items-center">
<button
type="button"
onClick={() => setOpen(prev => !prev)}
onClick={() =>
setOpen(prev => {
const next = !prev;
if (next) startLoading();
return next;
})
}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Search blog"
>
Expand All @@ -148,7 +174,10 @@ export function BlogHeaderSearch() {
value={value}
onChange={event => {
setValue(event.target.value);
if (!open) setOpen(true);
if (!open) {
setOpen(true);
startLoading();
}
}}
onKeyDown={event => {
if (event.key === 'Escape') setOpen(false);
Expand Down