diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index d1bcf0ce..611cc811 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -4,6 +4,7 @@ import groq from 'groq'; import { getTranslations } from 'next-intl/server'; import { client } from '@/client'; import { Link } from '@/i18n/routing'; +import { formatBlogDate } from '@/lib/blog/date'; export const revalidate = 0; @@ -67,6 +68,41 @@ function linkifyText(text: string) { }); } +function renderPortableTextSpans( + children: Array<{ _type?: string; text?: string; marks?: string[] }> = [], + markDefs: Array<{ _key?: string; _type?: string; href?: string }> = [] +) { + const linkMap = new Map( + markDefs + .filter(def => def?._type === 'link' && def?._key && def?.href) + .map(def => [def._key as string, def.href as string]) + ); + + return children.map((child, index) => { + const text = child?.text || ''; + if (!text) return null; + const marks = child?.marks || []; + const linkKey = marks.find(mark => linkMap.has(mark)); + + if (linkKey) { + const href = linkMap.get(linkKey)!; + return ( + + {text} + + ); + } + + return {linkifyText(text)}; + }); +} + function seededShuffle(items: T[], seed: number) { const result = [...items]; let value = seed; @@ -219,9 +255,7 @@ export default async function PostDetails({ )} {authorName && post.publishedAt && ·} - {post.publishedAt && ( - {new Date(post.publishedAt).toLocaleDateString()} - )} + {post.publishedAt && {formatBlogDate(post.publishedAt)}} )} @@ -241,15 +275,12 @@ export default async function PostDetails({
{post.body?.map((block: any, index: number) => { if (block?._type === 'block') { - const text = (block.children || []) - .map((c: any) => c.text || '') - .join(''); return (

- {linkifyText(text)} + {renderPortableTextSpans(block.children, block.markDefs)}

); } @@ -320,9 +351,7 @@ export default async function PostDetails({ {item.author?.name && {item.author.name}} {item.author?.name && item.publishedAt && ·} {item.publishedAt && ( - - {new Date(item.publishedAt).toLocaleDateString()} - + {formatBlogDate(item.publishedAt)} )} )} diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx index af501d25..05296614 100644 --- a/frontend/app/[locale]/blog/category/[category]/page.tsx +++ b/frontend/app/[locale]/blog/category/[category]/page.tsx @@ -5,6 +5,7 @@ import Image from 'next/image'; import { client } from '@/client'; import { Link } from '@/i18n/routing'; import { BlogCategoryGrid } from '@/components/blog/BlogCategoryGrid'; +import { formatBlogDate } from '@/lib/blog/date'; export const revalidate = 0; @@ -79,13 +80,7 @@ export default async function BlogCategoryPage({ 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)) - : ''; + const featuredDate = formatBlogDate(featuredPost?.publishedAt); return (
diff --git a/frontend/app/api/blog-author/route.ts b/frontend/app/api/blog-author/route.ts new file mode 100644 index 00000000..4df84c69 --- /dev/null +++ b/frontend/app/api/blog-author/route.ts @@ -0,0 +1,41 @@ +import groq from 'groq'; +import { NextResponse } from 'next/server'; +import { client } from '@/client'; + +export const revalidate = 0; + +const authorQuery = groq` + *[_type == "author" && ( + name[$locale] == $name || + name[lower($locale)] == $name || + name.en == $name || + name.pl == $name || + name.uk == $name + )][0]{ + "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 } + } +`; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const name = (searchParams.get('name') || '').trim(); + const locale = (searchParams.get('locale') || 'en').trim(); + + if (!name) { + return NextResponse.json(null, { status: 400 }); + } + + const author = await client + .withConfig({ useCdn: false }) + .fetch(authorQuery, { name, locale }); + + return NextResponse.json(author || null, { + headers: { 'Cache-Control': 'no-store' }, + }); +} diff --git a/frontend/components/blog/AuthorModal.tsx b/frontend/components/blog/AuthorModal.tsx deleted file mode 100644 index 3ee5b901..00000000 --- a/frontend/components/blog/AuthorModal.tsx +++ /dev/null @@ -1,262 +0,0 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import Image from 'next/image'; -import { createPortal } from 'react-dom'; - -type SocialLink = { - platform?: string; - url?: string; - _key?: string; -}; - -type Author = { - name?: string; - image?: string; - company?: string; - jobTitle?: string; - city?: string; - bio?: any; - socialMedia?: SocialLink[]; -}; - -function plainTextFromPortableText(value: any): string { - if (!Array.isArray(value)) return ''; - return value - .filter(b => b?._type === 'block') - .map(b => (b.children || []).map((c: any) => c.text || '').join('')) - .join('\n') - .trim(); -} - -function formatDateGB(dateString?: string) { - if (!dateString) return ''; - const d = new Date(dateString); - if (Number.isNaN(d.getTime())) return ''; - return new Intl.DateTimeFormat('en-GB', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }).format(d); -} - -function SocialIcon({ platform }: { platform?: string }) { - const p = (platform || '').toLowerCase(); - - if (p === 'github') { - return ( - - ); - } - - if (p === 'linkedin') { - return ( - - ); - } - - if (p === 'twitter' || p === 'x') { - return ( - - ); - } - - if (p === 'website') { - return ( - - ); - } - - return ( - - {p ? p.slice(0, 1).toUpperCase() : '?'} - - ); -} - -export default function AuthorModal({ - author, - publishedAt, -}: { - author: Author; - publishedAt?: string; -}) { - const [open, setOpen] = useState(false); - const [mounted, setMounted] = useState(false); - - const bioText = useMemo( - () => plainTextFromPortableText(author?.bio), - [author] - ); - - const formattedDate = useMemo(() => formatDateGB(publishedAt), [publishedAt]); - const authorName = author?.name || 'Unknown author'; - const metaText = formattedDate ? `${authorName} · ${formattedDate}` : authorName; - - useEffect(() => setMounted(true), []); - - useEffect(() => { - if (!open) return; - - const prevOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false); - }; - - window.addEventListener('keydown', onKeyDown); - - return () => { - document.body.style.overflow = prevOverflow; - window.removeEventListener('keydown', onKeyDown); - }; - }, [open]); - - const hasMeta = author?.jobTitle || author?.company || author?.city; - const hasSocial = - Array.isArray(author?.socialMedia) && author.socialMedia.length > 0; - - const trigger = ( - - ); - - if (!mounted) return trigger; - - return ( - <> - {trigger} - - {open && - createPortal( -
setOpen(false)} - > -
e.stopPropagation()} - > - - -
- {author?.image && ( -
- {author?.name -
- )} - -
- {author?.name && ( -

- {author.name} -

- )} - - {hasMeta && ( -

- {author?.jobTitle && {author.jobTitle}} - {author?.jobTitle && - (author?.company || author?.city) && · } - {author?.company && {author.company}} - {author?.company && author?.city && · } - {author?.city && {author.city}} -

- )} -
-
- - {bioText && ( -
-

- Bio -

-

- {bioText} -

-
- )} - - {hasSocial && ( -
-

- Social links -

- -
- {author.socialMedia!.map((item, idx) => { - if (!item?.url) return null; - const label = item.platform || 'link'; - - return ( - - - - {label.toLowerCase() === 'twitter' ? 'X' : label} - - - ); - })} -
-
- )} -
-
, - document.body - )} - - ); -} diff --git a/frontend/components/blog/BlogCard.tsx b/frontend/components/blog/BlogCard.tsx index cb4b6785..9b22b2e6 100644 --- a/frontend/components/blog/BlogCard.tsx +++ b/frontend/components/blog/BlogCard.tsx @@ -3,7 +3,8 @@ import { useMemo } from 'react'; import Image from 'next/image'; import Link from 'next/link'; -import { useLocale, useTranslations } from 'next-intl'; +import { useTranslations } from 'next-intl'; +import { formatBlogDate } from '@/lib/blog/date'; import type { Author, Post, @@ -21,7 +22,6 @@ export default function BlogCard({ disableHoverColor?: boolean; }) { const t = useTranslations('blog'); - const locale = useLocale(); const excerpt = (post.body ?? []) .filter((b): b is PortableTextBlock => b._type === 'block') @@ -30,16 +30,10 @@ export default function BlogCard({ ) .join('\n') .slice(0, 160) || ''; - const formattedDate = useMemo(() => { - if (!post.publishedAt) return ''; - const date = new Date(post.publishedAt); - if (Number.isNaN(date.getTime())) return ''; - return new Intl.DateTimeFormat(locale, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }).format(date); - }, [post.publishedAt, locale]); + const formattedDate = useMemo( + () => formatBlogDate(post.publishedAt), + [post.publishedAt] + ); const categoryLabel = post.categories?.[0] === 'Growth' ? 'Career' : post.categories?.[0]; diff --git a/frontend/components/blog/BlogFilters.tsx b/frontend/components/blog/BlogFilters.tsx index 0ce1d7e1..53ab044c 100644 --- a/frontend/components/blog/BlogFilters.tsx +++ b/frontend/components/blog/BlogFilters.tsx @@ -7,6 +7,7 @@ import { useSearchParams } from 'next/navigation'; import { usePathname, useRouter } from '@/i18n/routing'; import BlogGrid from '@/components/blog/BlogGrid'; import { Link } from '@/i18n/routing'; +import { formatBlogDate } from '@/lib/blog/date'; export type PortableTextSpan = { _type: 'span'; @@ -27,6 +28,12 @@ export type PortableTextImage = { export type PortableText = Array; +export type SocialLink = { + _key?: string; + platform?: string; + url?: string; +}; + export type Author = { name?: string; image?: string; @@ -34,6 +41,7 @@ export type Author = { jobTitle?: string; city?: string; bio?: PortableText; + socialMedia?: SocialLink[]; }; export type Post = { @@ -115,6 +123,7 @@ export default function BlogFilters({ norm: string; data?: Author; } | null>(null); + const [authorProfile, setAuthorProfile] = useState(null); const [selectedCategory, setSelectedCategory] = useState<{ name: string; norm: string; @@ -231,6 +240,36 @@ export default function BlogFilters({ }; }, [authorParam, posts, selectedAuthor]); + useEffect(() => { + const name = resolvedAuthor?.name?.trim(); + if (!name) { + setAuthorProfile(null); + return; + } + + let active = true; + if (resolvedAuthor?.data) setAuthorProfile(resolvedAuthor.data); + + fetch( + `/api/blog-author?name=${encodeURIComponent(name)}&locale=${encodeURIComponent( + locale + )}`, + { cache: 'no-store' } + ) + .then(response => (response.ok ? response.json() : null)) + .then((data: Author | null) => { + if (!active) return; + if (data) setAuthorProfile(data); + }) + .catch(() => { + if (!active) return; + }); + + return () => { + active = false; + }; + }, [locale, resolvedAuthor?.data, resolvedAuthor?.name]); + const filteredPosts = useMemo(() => { return posts.filter(post => { if (resolvedAuthor) { @@ -260,7 +299,7 @@ export default function BlogFilters({ }); }, [posts, resolvedAuthor, resolvedCategory, searchQueryLower]); - const selectedAuthorData = resolvedAuthor?.data || null; + const selectedAuthorData = authorProfile || resolvedAuthor?.data || null; const authorBioText = useMemo(() => { return plainTextFromPortableText(selectedAuthorData?.bio); }, [selectedAuthorData]); @@ -302,9 +341,7 @@ export default function BlogFilters({ {featuredPost.publishedAt && (
- {new Date(featuredPost.publishedAt).toLocaleDateString( - locale - )} + {formatBlogDate(featuredPost.publishedAt)}
- {selectedAuthorData && - (selectedAuthorData.image || authorBioText) && ( -
+ {selectedAuthorData && ( +
{selectedAuthorData.image && (
)} + {(selectedAuthorData.jobTitle || + selectedAuthorData.company || + selectedAuthorData.city) && ( +

+ {[ + selectedAuthorData.jobTitle, + selectedAuthorData.company, + selectedAuthorData.city, + ] + .filter(Boolean) + .join(' · ')} +

+ )} {authorBioText && (

{authorBioText}

)} + {selectedAuthorData.socialMedia?.length ? ( +
+ {selectedAuthorData.socialMedia + .filter(item => item?.url) + .map((item, index) => ( + + {item.platform || 'link'} + + ))} +
+ ) : null}
)} diff --git a/frontend/components/blog/BlogHeaderSearch.tsx b/frontend/components/blog/BlogHeaderSearch.tsx index f6e26406..a8e34f8c 100644 --- a/frontend/components/blog/BlogHeaderSearch.tsx +++ b/frontend/components/blog/BlogHeaderSearch.tsx @@ -118,11 +118,6 @@ export function BlogHeaderSearch() { setOpen(false); }; - const clear = () => { - setValue(''); - setOpen(false); - }; - return (