From d18554be8b3439445e4a4e6abe8898e54cd1aad6 Mon Sep 17 00:00:00 2001 From: Anna Date: Wed, 21 Jan 2026 11:05:16 +0000 Subject: [PATCH] feat(Blog): Adding last published post to the blog and category page, recommended posts, Changing styles to one unified format, Bug fixes --- .../app/[locale]/blog/[slug]/PostDetails.tsx | 169 ++++++++++++------ .../blog/category/[category]/page.tsx | 70 +++++++- frontend/app/[locale]/blog/page.tsx | 4 +- frontend/client.ts | 2 +- frontend/components/blog/BlogCard.tsx | 50 +++--- frontend/components/blog/BlogCategoryGrid.tsx | 8 +- .../components/blog/BlogCategoryLinks.tsx | 22 +-- frontend/components/blog/BlogFilters.tsx | 105 +++++++---- frontend/components/blog/BlogGrid.tsx | 3 + frontend/messages/en.json | 1 + frontend/messages/pl.json | 1 + frontend/messages/uk.json | 1 + 12 files changed, 304 insertions(+), 132 deletions(-) diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index 34169551..d1bcf0ce 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -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 ( + + {part} + + ); + } + return {part}; + }); +} + function seededShuffle(items: T[], seed: number) { const result = [...items]; let value = seed; @@ -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 + } } } `; @@ -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(); @@ -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 (
- - - {t('goBack')} - +
+
+
+ + {tNav('blog')} + + > + {post.title} +
+
+
- {post.categories?.[0] && ( + {categoryLabel && (
- {post.categories[0] === 'Growth' ? 'Career' : post.categories[0]} + {categoryLabel === 'Growth' ? 'Career' : categoryLabel}
)} @@ -172,7 +210,14 @@ export default async function PostDetails({ {(authorName || post.publishedAt) && (
- {authorName && {authorName}} + {authorName && ( + + {authorName} + + )} {authorName && post.publishedAt && ·} {post.publishedAt && ( {new Date(post.publishedAt).toLocaleDateString()} @@ -199,7 +244,14 @@ export default async function PostDetails({ const text = (block.children || []) .map((c: any) => c.text || '') .join(''); - return

{text}

; + return ( +

+ {linkifyText(text)} +

+ ); } if (block?._type === 'image' && block?.url) { @@ -219,57 +271,64 @@ export default async function PostDetails({ {recommendedPosts.length > 0 && ( <> -
-
+
+
-
-

- {t('recommendedPosts')} -

-
- {recommendedPosts.map(item => ( - - {item.mainImage && ( -
- {item.title -
- )} -

+
+
+

+ {t('recommendedPosts')} +

+
+ {recommendedPosts.map(item => ( + + {item.mainImage && ( +
+ {item.title +
+ )} +

{item.title}

+ {item.body && ( +

+ {plainTextFromPortableText(item.body)} +

+ )} {(item.author?.name || item.publishedAt) && ( -
+
{item.author?.image && ( {item.author.name - - )} - {item.author?.name && {item.author.name}} - {item.author?.name && item.publishedAt && ·} - {item.publishedAt && ( - - {new Date(item.publishedAt).toLocaleDateString()} - - )} -
- )} - - ))} + alt={item.author.name || 'Author'} + fill + className="object-cover" + /> + + )} + {item.author?.name && {item.author.name}} + {item.author?.name && item.publishedAt && ·} + {item.publishedAt && ( + + {new Date(item.publishedAt).toLocaleDateString()} + + )} +
+ )} + + ))} +
diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx index 5de76c23..af501d25 100644 --- a/frontend/app/[locale]/blog/category/[category]/page.tsx +++ b/frontend/app/[locale]/blog/category/[category]/page.tsx @@ -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; @@ -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, @@ -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 (

- {displayTitle} + {categoryTitle}

+ {featuredPost?.mainImage && ( +
+
+
+ {featuredPost.title} +
+
+
+ {featuredPost.categories?.[0] && ( +
+ {featuredPost.categories[0]} +
+ )} +

+ {featuredPost.title} +

+
+ {featuredPost.author?.image && ( + {featuredPost.author.name + )} + {featuredPost.author?.name && ( + {featuredPost.author.name} + )} + {featuredPost.author?.name && featuredDate && ·} + {featuredDate && {featuredDate}} +
+ + + +
+
+
+ )}
- +
{!posts.length && (

{t('noPosts')}

diff --git a/frontend/app/[locale]/blog/page.tsx b/frontend/app/[locale]/blog/page.tsx index bd751d04..7ebacae2 100644 --- a/frontend/app/[locale]/blog/page.tsx +++ b/frontend/app/[locale]/blog/page.tsx @@ -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'; @@ -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, diff --git a/frontend/client.ts b/frontend/client.ts index 7b8b0588..45ad6da9 100644 --- a/frontend/client.ts +++ b/frontend/client.ts @@ -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", }); diff --git a/frontend/components/blog/BlogCard.tsx b/frontend/components/blog/BlogCard.tsx index 7599939d..8b59043a 100644 --- a/frontend/components/blog/BlogCard.tsx +++ b/frontend/components/blog/BlogCard.tsx @@ -14,9 +14,11 @@ import type { export default function BlogCard({ post, onAuthorSelect, + disableHoverColor = false, }: { post: Post; onAuthorSelect: (author: Author) => void; + disableHoverColor?: boolean; }) { const t = useTranslations('blog'); const locale = useLocale(); @@ -26,7 +28,7 @@ export default function BlogCard({ .map(b => (b.children ?? []).map((c: PortableTextSpan) => c.text ?? '').join(' ') ) - .join(' ') + .join('\n') .slice(0, 160) || ''; const formattedDate = useMemo(() => { if (!post.publishedAt) return ''; @@ -64,8 +66,7 @@ export default function BlogCard({ rounded-lg bg-gray-100 shadow-[0_8px_24px_rgba(0,0,0,0.08)] - dark:border dark:border-[rgba(56,189,248,0.25)] - dark:shadow-[0_0_0_1px_rgba(56,189,248,0.25),0_12px_28px_rgba(56,189,248,0.18)] + dark:border dark:border-[rgba(56,189,248,0.4)] dark:border-[0.5px] transition-transform duration-300 " > @@ -82,27 +83,26 @@ export default function BlogCard({
{post.title} {excerpt && ( -

+

{excerpt}

)} @@ -117,7 +117,7 @@ export default function BlogCard({ className="flex items-center gap-2 hover:text-[#ff00ff] hover:underline underline-offset-4 transition" > {post.author?.image && ( - + {post.author.name )} - {post.author?.name && formattedDate && ·} - {formattedDate && {formattedDate}} - {(formattedDate || post.author?.name) && categoryLabel && ( + {post.author?.name && categoryLabel && ·} + {categoryLabel && ( + + {categoryLabel} + + )} + {(post.author?.name || categoryLabel) && formattedDate && ( · )} - {categoryLabel && {categoryLabel}} + {formattedDate && {formattedDate}}
)} diff --git a/frontend/components/blog/BlogCategoryGrid.tsx b/frontend/components/blog/BlogCategoryGrid.tsx index 349ef1ed..5b7f2e14 100644 --- a/frontend/components/blog/BlogCategoryGrid.tsx +++ b/frontend/components/blog/BlogCategoryGrid.tsx @@ -6,5 +6,11 @@ import type { Post } from '@/components/blog/BlogFilters'; export function BlogCategoryGrid({ posts }: { posts: Post[] }) { if (!posts.length) return null; - return {}} />; + return ( + {}} + disableHoverColor + /> + ); } diff --git a/frontend/components/blog/BlogCategoryLinks.tsx b/frontend/components/blog/BlogCategoryLinks.tsx index 01b3f458..b6646cff 100644 --- a/frontend/components/blog/BlogCategoryLinks.tsx +++ b/frontend/components/blog/BlogCategoryLinks.tsx @@ -39,6 +39,17 @@ export function BlogCategoryLinks({ return ( ); } diff --git a/frontend/components/blog/BlogFilters.tsx b/frontend/components/blog/BlogFilters.tsx index 7dfeeb42..ef32b7d0 100644 --- a/frontend/components/blog/BlogFilters.tsx +++ b/frontend/components/blog/BlogFilters.tsx @@ -79,7 +79,7 @@ function plainTextFromPortableText(value?: PortableText): string { .map(block => (block.children || []).map(child => child.text || '').join(' ') ) - .join(' ') + .join('\n') .trim(); } @@ -132,6 +132,11 @@ export default function BlogFilters({ const clearAll = () => { setSelectedAuthor(null); setSelectedCategory(null); + const params = new URLSearchParams(searchParams?.toString() || ''); + params.delete('author'); + params.delete('category'); + const nextPath = params.toString() ? `${pathname}?${params}` : pathname; + router.replace(nextPath); }; const allCategories = useMemo(() => { if (categories.length) { @@ -173,6 +178,9 @@ export default function BlogFilters({ const searchQuery = useMemo(() => { return (searchParams?.get('search') || '').trim(); }, [searchParams]); + const authorParam = useMemo(() => { + return (searchParams?.get('author') || '').trim(); + }, [searchParams]); const searchQueryLower = searchQuery.toLowerCase(); const didClearSearchRef = useRef(false); @@ -195,11 +203,25 @@ export default function BlogFilters({ // categoryParam is handled via resolvedCategory to avoid state updates in effects. + const resolvedAuthor = useMemo(() => { + const normParam = normalizeAuthor(authorParam); + if (!normParam) return selectedAuthor; + if (selectedAuthor?.norm === normParam) return selectedAuthor; + const match = posts.find( + post => normalizeAuthor(post.author?.name || '') === normParam + ); + return { + name: match?.author?.name || authorParam, + norm: normParam, + data: match?.author, + }; + }, [authorParam, posts, selectedAuthor]); + const filteredPosts = useMemo(() => { return posts.filter(post => { - if (selectedAuthor) { + if (resolvedAuthor) { const authorName = normalizeAuthor(post.author?.name || ''); - if (authorName !== selectedAuthor.norm) return false; + if (authorName !== resolvedAuthor.norm) return false; } if (resolvedCategory) { @@ -222,71 +244,80 @@ export default function BlogFilters({ return true; }); - }, [posts, resolvedCategory, selectedAuthor, searchQueryLower]); + }, [posts, resolvedAuthor, resolvedCategory, searchQueryLower]); - const selectedAuthorData = selectedAuthor?.data || null; + const selectedAuthorData = resolvedAuthor?.data || null; const authorBioText = useMemo(() => { return plainTextFromPortableText(selectedAuthorData?.bio); }, [selectedAuthorData]); return (
- {!selectedAuthor && featuredPost && ( + {!resolvedAuthor && featuredPost && (
-
+
{featuredPost.mainImage && ( - {featuredPost.title} +
+ {featuredPost.title} +
)}
{featuredPost.categories?.[0] && ( -
+
{featuredPost.categories[0]}
)} {featuredPost.title} -

+

{plainTextExcerpt(featuredPost.body)}

{featuredPost.publishedAt && ( -

- {new Date(featuredPost.publishedAt).toLocaleDateString( - locale - )} -

+
+ + {new Date(featuredPost.publishedAt).toLocaleDateString( + locale + )} + + + {t('readMore')} + +
)}
)} - {selectedAuthor && ( + {resolvedAuthor && (
> - - {selectedAuthor.name} + + {resolvedAuthor.name}
@@ -294,7 +325,7 @@ export default function BlogFilters({ (selectedAuthorData.image || authorBioText) && (
{selectedAuthorData.image && ( -
+
{selectedAuthorData.name )} -
- {!selectedAuthor && allCategories.length > 0 && ( -
+
+ {!resolvedAuthor && allCategories.length > 0 && ( +
- +
{!filteredPosts.length && ( diff --git a/frontend/components/blog/BlogGrid.tsx b/frontend/components/blog/BlogGrid.tsx index cd4cf2c9..9c0fc7b7 100644 --- a/frontend/components/blog/BlogGrid.tsx +++ b/frontend/components/blog/BlogGrid.tsx @@ -7,9 +7,11 @@ import type { Author, Post } from '@/components/blog/BlogFilters'; export default function BlogGrid({ posts, onAuthorSelect, + disableHoverColor = false, }: { posts: Post[]; onAuthorSelect: (author: Author) => void; + disableHoverColor?: boolean; }) { const t = useTranslations('blog'); @@ -24,6 +26,7 @@ export default function BlogGrid({ key={post._id} post={post} onAuthorSelect={onAuthorSelect} + disableHoverColor={disableHoverColor} /> ))}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8cb0ecc3..e00aede2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -177,6 +177,7 @@ "noPostsForTags": "No posts found for selected tags", "visitResource": "Visit Resource", "readArticle": "Read article", + "readMore": "Read more", "aboutAuthor": "About the author", "author": "Author", "removeAuthor": "Remove author", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 7f26bab3..f5bced5b 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -54,6 +54,7 @@ "noPostsForTags": "Nie znaleziono wpisów dla wybranych tagów", "visitResource": "Odwiedź zasób", "readArticle": "Czytaj artykuł", + "readMore": "Czytaj dalej", "aboutAuthor": "O autorze", "author": "Autor", "removeAuthor": "Usuń autora", diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index e2f8ed7a..7133465e 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -177,6 +177,7 @@ "noPostsForTags": "Немає статей за обраними тегами", "visitResource": "Відвідати ресурс", "readArticle": "Читати статтю", + "readMore": "Читати далі", "aboutAuthor": "Про автора", "author": "Автор", "removeAuthor": "Видалити автора",