diff --git a/apps/www/app/blog/authors/[author]/AuthorClient.tsx b/apps/www/app/blog/authors/[author]/AuthorClient.tsx index 59bb0a5f000..cdfdbecc157 100644 --- a/apps/www/app/blog/authors/[author]/AuthorClient.tsx +++ b/apps/www/app/blog/authors/[author]/AuthorClient.tsx @@ -3,14 +3,16 @@ import { type BlogView } from 'app/blog/blog-view' import BlogGridItem from 'components/Blog/BlogGridItem' import BlogListItem from 'components/Blog/BlogListItem' -import BlogViewToggle from 'components/Blog/BlogViewToggle' import { Search } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' -import { useMemo, useState } from 'react' +import { Suspense, useMemo, useState } from 'react' import type PostTypes from 'types/post' import { InputGroup, InputGroupAddon, InputGroupInput } from 'ui' +import BlogFilters from '../../../../components/Blog/BlogFilters' +import SectionContainer from '../../../../components/Layouts/SectionContainer' + interface Author { author_id: string author: string @@ -90,26 +92,15 @@ export default function AuthorClient({ author, authorId, blogs, initialView }: A {/* Filters row — divider above the header, search aligned with the view toggle */}
-
-
-
- - setSearchTerm(e.target.value)} - /> - - - - -
- -
-
+ + + setSearchTerm(search ?? '')} + /> + +
{/* Posts */} diff --git a/apps/www/app/blog/categories/[category]/CategoryClient.tsx b/apps/www/app/blog/categories/[category]/CategoryClient.tsx index 968f8b7768e..6034bf046f2 100644 --- a/apps/www/app/blog/categories/[category]/CategoryClient.tsx +++ b/apps/www/app/blog/categories/[category]/CategoryClient.tsx @@ -35,12 +35,14 @@ export default function CategoryClient({
{/* Filters row — categories navigate; search filters within this category */}
- -
- - - -
+ + + setSearchTerm(search ?? '')} + /> +
diff --git a/apps/www/components/Blog/AuthorAvatars.tsx b/apps/www/components/Blog/AuthorAvatars.tsx new file mode 100644 index 00000000000..e48369026d1 --- /dev/null +++ b/apps/www/components/Blog/AuthorAvatars.tsx @@ -0,0 +1,46 @@ +import Image from 'next/image' +import type Author from '@/types/author' + +interface Props { + authors: (Author | undefined)[] + showName?: boolean + size?: 'sm' | 'md' +} + +export default function AuthorAvatars({ authors, showName = true, size = 'sm' }: Props) { + const valid = authors.filter(Boolean) as Author[] + if (!valid.length) return null + + const px = size === 'md' ? 'w-6 h-6' : 'w-5 h-5' + + return ( +
+
+ {valid.map((author, i) => ( +
+ {author.author_image_url && ( + {author.author} + )} +
+ ))} +
+ {showName && ( +

+ {valid.map((a) => a.author).join(', ')} +

+ )} +
+ ) +} diff --git a/apps/www/components/Blog/BlogFilters.tsx b/apps/www/components/Blog/BlogFilters.tsx index 7015b7cbac4..a58e33750be 100644 --- a/apps/www/components/Blog/BlogFilters.tsx +++ b/apps/www/components/Blog/BlogFilters.tsx @@ -1,9 +1,13 @@ 'use client' -import { LOCAL_STORAGE_KEYS, useBreakpoint } from 'common' +import { type BlogView } from 'app/blog/blog-view' +import { useBreakpoint } from 'common' +import BlogViewToggle from 'components/Blog/BlogViewToggle' +import { motion } from 'framer-motion' import { startCase } from 'lib/helpers' -import { AlignJustify, ChevronDown, X as CloseIcon, Grid, Search } from 'lucide-react' -import { useRouter, useSearchParams } from 'next/navigation' +import { ChevronDown, X as CloseIcon, Search } from 'lucide-react' +import Link from 'next/link' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useState } from 'react' import { Button, @@ -18,71 +22,74 @@ import { InputGroupInput, } from 'ui' -import type { BlogView } from '@/app/blog/blog-view' - interface Props { - onFilterChange: (category?: string, search?: string) => void view: BlogView setView: (view: any) => void + /** + * Blog index search: keeps the query in the URL (`?q=`) and filters the full + * post set client-side via the index's fetch logic. + */ + onFilterChange?: (category?: string, search?: string) => void + /** + * Category-page search: filters the in-memory posts for the current category. + * No URL sync / fetch. Mutually exclusive with `onFilterChange`. + */ + onSearch?: (term: string) => void } -/** - * ✅ search via text input - * ✅ update searchTerm when deleting text input - * ✅ search via q param - * ✅ search via category if no q param - * ✅ search via category and reset q param if present - */ +// Hard-coded so they can be curated/reordered. Each (except "all") maps to a +// statically generated /blog/categories/ page, so selecting a +// category is a navigation (prefetched, no client fetch / skeleton flash). +const allCategories = [ + 'all', + 'product', + 'company', + 'postgres', + 'developers', + 'engineering', + 'launch-week', +] -function BlogFilters({ onFilterChange, view, setView }: Props) { - const { BLOG_VIEW } = LOCAL_STORAGE_KEYS - const isList = view === 'list' - const [category, setCategory] = useState('all') +const categoryHref = (category: string) => + category === 'all' ? '/blog' : `/blog/categories/${category}` + +const categoryLabel = (category: string) => + category === 'all' ? 'All' : startCase(category.replaceAll('-', ' ')) + +function BlogFilters({ view, setView, onFilterChange, onSearch }: Props) { + const showSearch = Boolean(onFilterChange) || Boolean(onSearch) const [searchTerm, setSearchTerm] = useState('') const [showSearchInput, setShowSearchInput] = useState(false) const router = useRouter() + const pathname = usePathname() const searchParams = useSearchParams() const q = searchParams?.get('q') - const activeCategory = searchParams?.get('category') const isMobile = useBreakpoint(1023) - const is2XL = useBreakpoint(1535) - // Use hard-coded categories here as they: - // - serve as a reference - // - are easier to reorder - const allCategories = [ - 'all', - 'product', - 'company', - 'postgres', - 'developers', - 'engineering', - 'launch-week', - ] + // Active category is derived from the URL: /blog or /blog/categories/. + const categoryMatch = pathname?.match(/^\/blog\/categories\/([^/]+)/) + const activeCategory = categoryMatch?.[1] ?? 'all' - // Debounced filter change to avoid too many API calls + // Debounced search to avoid too many API calls const debouncedFilterChange = useCallback( (() => { let timeoutId: NodeJS.Timeout - return (cat: string, search: string) => { + return (search: string) => { clearTimeout(timeoutId) timeoutId = setTimeout(() => { - onFilterChange(cat, search) + onFilterChange?.(undefined, search) }, 300) } })(), [onFilterChange] ) - // Handle URL params on mount + // Reflect ?q= deep links into the search state on mount (index only) useEffect(() => { - if (q) { + if (onFilterChange && q) { setSearchTerm(q) - onFilterChange(category, q) - } else if (activeCategory && activeCategory !== 'all') { - setCategory(activeCategory) - onFilterChange(activeCategory, '') + onFilterChange(undefined, q) } }, []) // Only run on mount @@ -90,17 +97,18 @@ function BlogFilters({ onFilterChange, view, setView }: Props) { (text: string) => { setSearchTerm(text) - // Update URL - if (text.length > 0) { - router?.replace(`/blog?q=${text}`, { scroll: false }) + if (onFilterChange) { + // Index: keep the query in the URL and filter the full list (debounced). + router?.replace(text.length > 0 ? `/blog?q=${encodeURIComponent(text)}` : '/blog', { + scroll: false, + }) + debouncedFilterChange(text) } else { - router?.replace('/blog', { scroll: false }) + // Category: filter the in-memory posts; no URL change. + onSearch?.(text) } - - // Trigger filter change (debounced) - debouncedFilterChange(category, text) }, - [category, router, debouncedFilterChange] + [router, debouncedFilterChange, onFilterChange, onSearch] ) useEffect(() => { @@ -117,43 +125,9 @@ function BlogFilters({ onFilterChange, view, setView }: Props) { setShowSearchInput(!isMobile) }, [isMobile]) - const handleSetCategory = useCallback( - (newCategory: string) => { - setSearchTerm('') - setCategory(newCategory) - - // Update URL - if (newCategory === 'all') { - router?.replace('/blog', { scroll: false }) - } else { - router?.replace(`/blog?category=${newCategory}`, { scroll: false }) - } - - // Trigger filter change immediately for category changes - onFilterChange(newCategory, '') - }, - [router, onFilterChange] - ) - - const handleSearchChange = useCallback( - (event: any) => { - setCategory('all') - handleSearchByText(event.target.value) - }, - [handleSearchByText] - ) - - const handleViewSelection = () => { - setView((prevView: 'list' | 'grid') => { - const newValue = prevView === 'list' ? 'grid' : 'list' - localStorage.setItem(BLOG_VIEW, newValue) - - return newValue - }) - } - return (
+ {/* Mobile: category dropdown (hidden on lg+) */} {!showSearchInput && (
@@ -163,53 +137,54 @@ function BlogFilters({ onFilterChange, view, setView }: Props) { iconRight={} className="w-full min-w-[200px] flex justify-between items-center py-2" > - {!activeCategory ? 'All Posts' : startCase(activeCategory?.replaceAll('-', ' '))} + {activeCategory === 'all' ? 'All Posts' : categoryLabel(activeCategory)} - {allCategories.map((category: string, i: number) => ( + {allCategories.map((category) => ( handleSetCategory(category)} - className={cn( - (category === 'all' && !activeCategory) || category === activeCategory - ? 'text-brand-600' - : '' - )} + key={category} + onClick={() => router.push(categoryHref(category))} + className={cn(category === activeCategory ? 'text-brand-600' : '')} > - {category === 'all' ? 'All Posts' : startCase(category.replaceAll('-', ' '))} + {category === 'all' ? 'All Posts' : categoryLabel(category)} ))}
)} -
- {allCategories.map((category: string) => ( - - ))} + + {/* Desktop: category pills */} +
+ {allCategories.map((category) => { + const isActive = category === activeCategory + return ( + + {isActive && ( + + )} + {categoryLabel(category)} + + ) + })}
- {!showSearchInput && ( + {/* Search (index only) */} + {showSearch && !showSearchInput && (
- {' '} + + {/* View toggle */} +
) } diff --git a/apps/www/components/Blog/BlogGridItem.tsx b/apps/www/components/Blog/BlogGridItem.tsx index 63357185e94..449136012e7 100644 --- a/apps/www/components/Blog/BlogGridItem.tsx +++ b/apps/www/components/Blog/BlogGridItem.tsx @@ -2,12 +2,9 @@ import dayjs from 'dayjs' import Image from 'next/image' import Link from 'next/link' +import AuthorAvatars from './AuthorAvatars' import authors from '@/lib/authors.json' -import { - BLOG_GRID_IMAGE_SIZES, - BLOG_PLACEHOLDER_IMAGE, - getBlogThumbnailImage, -} from '@/lib/blog-images' +import { BLOG_PLACEHOLDER_IMAGE, getBlogThumbnailImage } from '@/lib/blog-images' import type Author from '@/types/author' import type PostTypes from '@/types/post' @@ -32,36 +29,33 @@ const BlogGridItem = ({ post }: Props) => { const imageUrl = getBlogThumbnailImage(post) ?? BLOG_PLACEHOLDER_IMAGE return ( - -
-
-
- {`${post.title} + +
+ {`${post.title} +
+
+

{post.title}

+

{post.description}

+ {post.date && ( +
+

{dayjs(post.date).format('D MMM YYYY')}

+ {post.readingTime && ( + <> +

•

+

{post.readingTime}

+ + )}
- - {post.date && ( -
-

{dayjs(post.date).format('D MMM YYYY')}

- {post.readingTime && ( - <> -

·

-

{post.readingTime}

- - )} -
- )} -

{post.title}

-

{post.description}

+ )} +
+
diff --git a/apps/www/components/Blog/BlogListItem.tsx b/apps/www/components/Blog/BlogListItem.tsx index d5b19ae748d..aa9c45aadba 100644 --- a/apps/www/components/Blog/BlogListItem.tsx +++ b/apps/www/components/Blog/BlogListItem.tsx @@ -1,11 +1,11 @@ import dayjs from 'dayjs' -import Image from 'next/image' import Link from 'next/link' import React from 'react' import { Badge } from 'ui' import blogAuthors from '@/lib/authors.json' import type PostTypes from '@/types/post' +import AuthorAvatars from './AuthorAvatars' interface Props { post: PostTypes @@ -35,28 +35,14 @@ const BlogListItem = ({ post }: Props) => {
-

{post.title}

+

{post.title}

-
- {authors.map((author: any, i: number) => { - if (!author) return null - return ( -
- {author.author_image_url && ( - {`${author.author} - )} -
- ) - })} +
+
{post.categories && (
diff --git a/apps/www/components/Blog/BlogPostRenderer.tsx b/apps/www/components/Blog/BlogPostRenderer.tsx index a1f07e6e222..c396a1bc020 100644 --- a/apps/www/components/Blog/BlogPostRenderer.tsx +++ b/apps/www/components/Blog/BlogPostRenderer.tsx @@ -1,14 +1,14 @@ import dayjs from 'dayjs' -import { ChevronLeft } from 'lucide-react' +import { ArrowLeft } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import ReactMarkdown from 'react-markdown' import type { PostReturnType, ProcessedBlogData, StaticAuthor, Tag } from 'types/post' import { Badge } from 'ui' +import { CTASection } from '../CTASection' import DraftModeBanner from '@/components/Blog/DraftModeBanner' import ShareArticleActions from '@/components/Blog/ShareArticleActions' -import CTABanner from '@/components/CTABanner' import BlogLinks from '@/components/LaunchWeek/7/BlogLinks' import LW11Summary from '@/components/LaunchWeek/11/LW11Summary' import LW12Summary from '@/components/LaunchWeek/12/LWSummary' @@ -16,8 +16,7 @@ import LW13Summary from '@/components/LaunchWeek/13/Releases/LWSummary' import LW14Summary from '@/components/LaunchWeek/14/Releases/LWSummary' import LW15Summary from '@/components/LaunchWeek/15/LWSummary' import LWXSummary from '@/components/LaunchWeek/X/LWXSummary' -import DefaultLayout from '@/components/Layouts/Default' -import { BLOG_POST_HERO_IMAGE_SIZES, getBlogThumbnailImage } from '@/lib/blog-images' +import { getBlogThumbnailImage } from '@/lib/blog-images' import { compileBlogMdx } from '@/lib/mdx/compileBlogMdx' import mdxComponents from '@/lib/mdx/mdxComponents' @@ -81,8 +80,8 @@ const BlogPostRenderer = async ({ const isLaunchWeek15 = blogMetaData.launchweek?.toString().toLocaleLowerCase() === '15' const toc = blogMetaData.toc && ( -
-

On this page

+
+

On this page

{blogMetaData.toc && ( @@ -102,185 +101,185 @@ const BlogPostRenderer = async ({ return ( <> {isDraftMode && } - -
-
-
- {/* Back button */} +
+
+
+ {/* Row 1, Col 1: Back button */} +
- - Back + + Blog
-
- {/* Title and description */} -
-
- - Blog - -

{blogMetaData.title}

-
+ + {/* Row 1, Col 2: Title + meta */} +
+
+ {(blogMetaData.tags as Tag[])?.length > 0 && ( +
+ {(blogMetaData.tags as Tag[])?.map((tag) => { + const tagName = typeof tag === 'string' ? tag : tag.name + const tagId = typeof tag === 'string' ? tag : tag.id.toString() + return ( + + {tagName} + + ) + })} +
+ )} +

{blogMetaData.title}

+
+

{dayjs(blogMetaData.date).format('DD MMM YYYY')}

·

{(blogMetaData as any).readingTime}

- {authors.length > 0 && ( -
-
- {authors.map((author, i: number) => { - const authorImageUrl = author.author_image_url - - const authorId = - (author as any).author_id || - (author as any).username || - author.author.toLowerCase().replace(/\s+/g, '_') - - return ( -
- -
- {authorImageUrl && ( -
- {`${author.author} -
- )} -
- - {author.author} - - - {author.position} - -
-
- +
+ {authors.length > 0 && ( +
+ {authors.map((author, i: number) => { + const authorImageUrl = author.author_image_url + const authorId = + (author as any).author_id || + (author as any).username || + author.author.toLowerCase().replace(/\s+/g, '_') + return ( + +
+ {authorImageUrl && ( + {`${author.author} + )} +
+ {author.author} + + {author.position} +
- ) - })} -
-
+
+ + ) + })} +
+ )} +
+
+ + {/* Row 2: Hero image — col-start-1 col-span-9 = equal overhang each side of content (col 2–8) */} + {!blogMetaData.youtubeHero && blogMetaData.imgThumb && imageUrl && ( +
+
+ {blogMetaData.title} +
+
+ )} + + {/* Row 3: Article */} +
+
+
+ {blogMetaData.youtubeHero ? ( +