new blog layout

This commit is contained in:
Francesco Sansalvadore committed 2026-06-23 16:08:03 +02:00
1 parent 15adf8e391
commit fe9f8bcaf3
10 files changed
+626 -421

No files matched your search

@@ -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 */}
<div className="sticky top-[65px] z-10 bg-background/80 backdrop-blur-sm border-b border-border">
<div className="mx-auto max-w-(--container-max-w,75rem) px-6">
<div className="py-3 flex flex-row items-center justify-between gap-2">
<div className="flex-1 max-w-[280px]">
<InputGroup className="w-full">
<InputGroupInput
size="small"
autoComplete="off"
type="search"
placeholder="Search posts"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<InputGroupAddon>
<Search />
</InputGroupAddon>
</InputGroup>
</div>
<BlogViewToggle view={view} setView={setView} />
</div>
</div>
<SectionContainer className="py-3!">
<Suspense fallback={null}>
<BlogFilters
view={view}
setView={setView}
onFilterChange={(_, search) => setSearchTerm(search ?? '')}
/>
</Suspense>
</SectionContainer>
</div>
{/* Posts */}
@@ -35,12 +35,14 @@ export default function CategoryClient({
<div>
{/* Filters row — categories navigate; search filters within this category */}
<div className="sticky top-[65px] z-10 bg-background/80 backdrop-blur-sm border-b border-border">
<SectionContainer className="py-0!">
<div className="py-3">
<Suspense fallback={null}>
<BlogFilters view={view} setView={setView} onSearch={setSearchTerm} />
</Suspense>
</div>
<SectionContainer className="py-3!">
<Suspense fallback={null}>
<BlogFilters
view={view}
setView={setView}
onFilterChange={(_, search) => setSearchTerm(search ?? '')}
/>
</Suspense>
</SectionContainer>
</div>
@@ -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 (
<div className="flex items-center gap-2">
<div className="flex items-center -space-x-1.5">
{valid.map((author, i) => (
<div
key={i}
className={`relative ${px} rounded-full ring-2 ring-background border border-foreground/20 overflow-hidden shrink-0`}
>
{author.author_image_url && (
<Image
src={
typeof author.author_image_url === 'string'
? author.author_image_url
: (author.author_image_url as { url: string }).url
}
fill
className="object-cover"
alt={author.author}
/>
)}
</div>
))}
</div>
{showName && (
<p className="text-foreground-lighter text-xs truncate">
{valid.map((a) => a.author).join(', ')}
</p>
)}
</div>
)
}
+96 -130
View File
@@ -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/<category> 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<string>('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<string>('')
const [showSearchInput, setShowSearchInput] = useState<boolean>(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/<cat>.
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 (
<div className="flex flex-row items-center justify-between gap-2">
{/* Mobile: category dropdown (hidden on lg+) */}
{!showSearchInput && (
<div className="flex lg:hidden">
<DropdownMenu>
@@ -163,53 +137,54 @@ function BlogFilters({ onFilterChange, view, setView }: Props) {
iconRight={<ChevronDown />}
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)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
{allCategories.map((category: string, i: number) => (
{allCategories.map((category) => (
<DropdownMenuItem
key={`item-${category}-${
// biome-ignore lint/suspicious/noArrayIndexKey: to disambiguate emtpy values
i
}`}
onClick={() => 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)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<div className="hidden lg:flex flex-wrap items-center grow gap-2">
{allCategories.map((category: string) => (
<Button
key={category}
variant={
category === 'all' && !searchTerm && !activeCategory
? 'default'
: category === activeCategory
? 'default'
: 'outline'
}
onClick={() => handleSetCategory(category)}
size={is2XL ? 'tiny' : 'small'}
className="rounded-full"
>
{category === 'all' ? 'All' : startCase(category.replaceAll('-', ' '))}
</Button>
))}
{/* Desktop: category pills */}
<div className="hidden lg:flex flex-wrap items-center grow gap-1">
{allCategories.map((category) => {
const isActive = category === activeCategory
return (
<Link
key={category}
href={categoryHref(category)}
scroll={false}
className={cn(
'relative px-3 py-1.5 text-sm rounded-full transition-colors',
isActive ? 'text-foreground' : 'text-foreground-lighter hover:text-foreground-light'
)}
>
{isActive && (
<motion.span
layoutId="blog-cat-bg"
className="absolute inset-0 rounded-full bg-surface-300 border border-border"
transition={{ type: 'spring', duration: 0.35, bounce: 0.15 }}
/>
)}
<span className="relative z-10">{categoryLabel(category)}</span>
</Link>
)
})}
</div>
{!showSearchInput && (
{/* Search (index only) */}
{showSearch && !showSearchInput && (
<div className="flex-1 flex justify-end">
{' '}
<Button
className="px-2 h-full"
size="medium"
@@ -221,7 +196,7 @@ function BlogFilters({ onFilterChange, view, setView }: Props) {
</div>
)}
{showSearchInput && (
{showSearch && showSearchInput && (
<div className="w-full h-auto flex justify-end gap-2 items-stretch lg:max-w-[240px] xl:max-w-[280px]">
<InputGroup className="w-full">
<InputGroupInput
@@ -230,7 +205,7 @@ function BlogFilters({ onFilterChange, view, setView }: Props) {
type="search"
placeholder="Search blog"
value={searchTerm}
onChange={handleSearchChange}
onChange={(event) => handleSearchByText(event.target.value)}
/>
<InputGroupAddon>
<Search />
@@ -250,18 +225,9 @@ function BlogFilters({ onFilterChange, view, setView }: Props) {
</InputGroup>
</div>
)}
<Button
variant="default"
title={isList ? 'Grid View' : 'List View'}
onClick={handleViewSelection}
className="h-full p-2 text-foreground-light"
>
{isList ? (
<Grid className="w-4 h-4 stroke-1.5" />
) : (
<AlignJustify className="w-4 h-4 stroke-1.5" />
)}
</Button>
{/* View toggle */}
<BlogViewToggle view={view} setView={setView} />
</div>
)
}
+28 -34
View File
@@ -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 (
<Link
href={post.path}
prefetch={false}
className="group inline-block min-w-full p-2 sm:p-4 h-full border border-transparent transition-all hover:bg-surface-200 dark:hover:bg-surface-75 rounded-xl"
>
<div className="flex flex-col space-y-2">
<div className="flex flex-col space-y-1">
<div className="border-default relative mb-3 w-full aspect-[1.91/1] overflow-hidden rounded-lg border shadow-xs">
<Image
fill
sizes={BLOG_GRID_IMAGE_SIZES}
src={imageUrl}
className="scale-100 object-cover overflow-hidden"
alt={`${post.title} thumbnail`}
/>
<Link href={post.path} prefetch={false} className="group flex flex-col h-full p-6">
<div className="relative w-full aspect-[1.91/1] overflow-hidden rounded-md border border-foreground/10">
<Image
fill
sizes="100%"
quality={100}
src={imageUrl}
className="object-cover"
alt={`${post.title} thumbnail`}
/>
</div>
<div className="flex flex-col gap-1 pt-4">
<h3 className="text-foreground text-lg group-hover:underline">{post.title}</h3>
<p className="text-foreground-lighter text-sm mt-1 line-clamp-2">{post.description}</p>
{post.date && (
<div className="text-foreground-lighter flex items-center space-x-1.5 text-[11px] mt-3">
<p>{dayjs(post.date).format('D MMM YYYY')}</p>
{post.readingTime && (
<>
<p>•</p>
<p>{post.readingTime}</p>
</>
)}
</div>
{post.date && (
<div className="text-foreground-lighter flex items-center space-x-1.5 text-sm">
<p>{dayjs(post.date).format('D MMM YYYY')}</p>
{post.readingTime && (
<>
<p>·</p>
<p>{post.readingTime}</p>
</>
)}
</div>
)}
<h3 className="text-foreground max-w-sm text-xl">{post.title}</h3>
<p className="text-foreground-light max-w-sm text-base mb-0!">{post.description}</p>
)}
<div className="mt-1.5">
<AuthorAvatars authors={author} />
</div>
</div>
</Link>
+5 -19
View File
@@ -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) => {
<Link
href={post.path}
prefetch={false}
className="group flex flex-col lg:grid lg:grid-cols-10 xl:grid-cols-12 w-full py-2 sm:py-4 h-full border-b"
className="group flex flex-col lg:grid lg:grid-cols-10 xl:grid-cols-12 w-full py-2 sm:py-4 h-full hover:bg-surface-75/50 transition-colors px-6"
>
<div className="flex w-full lg:col-span-8 xl:col-span-8">
<h3 className="text-foreground text-lg group-hover:underline">{post.title}</h3>
<h3 className="text-foreground text-sm group-hover:underline">{post.title}</h3>
</div>
<div className="lg:col-span-2 xl:col-span-4 flex justify-start items-center lg:grid grid-cols-2 xl:grid-cols-3 gap-2 text-sm">
<div className="hidden lg:flex items-center -space-x-2">
{authors.map((author: any, i: number) => {
if (!author) return null
return (
<div className="relative ring-background w-6 h-6 rounded-full ring-2" key={i}>
{author.author_image_url && (
<Image
src={author.author_image_url}
className="rounded-full object-cover border border-default w-full h-full"
alt={`${author.author} avatar`}
fill
/>
)}
</div>
)
})}
<div className="hidden lg:block">
<AuthorAvatars authors={authors} showName={false} size="md" />
</div>
{post.categories && (
<div className="hidden xl:flex text-foreground-lighter group-hover:text-foreground-light">
+165 -166
View File
@@ -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 && (
<div>
<p className="text-foreground mb-4">On this page</p>
<div className="mb-4">
<p className="text-foreground-lighter text-sm font-normal mb-3">On this page</p>
<div className="prose-toc">
{blogMetaData.toc && (
<ReactMarkdown>
@@ -102,185 +101,185 @@ const BlogPostRenderer = async ({
return (
<>
{isDraftMode && <DraftModeBanner />}
<DefaultLayout className="overflow-x-hidden">
<div
className="
container mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16
xl:px-20
"
>
<div className="grid grid-cols-12 gap-4">
<div className="hidden col-span-12 xl:block lg:col-span-2">
{/* Back button */}
<div className="overflow-x-clip">
<div className="mx-auto max-w-(--container-max-w,75rem) px-6">
<div className="grid grid-cols-12 xl:gap-x-8 xl:gap-y-0">
{/* Row 1, Col 1: Back button */}
<div className="col-span-12 xl:row-start-1 xl:col-start-1 xl:col-span-1 pt-8 xl:pt-12 flex items-start justify-start">
<Link
href="/blog"
className="text-foreground-lighter hover:text-foreground flex cursor-pointer items-center text-sm transition"
className="text-foreground-lighter hover:text-foreground inline-flex cursor-pointer items-center text-sm transition"
>
<ChevronLeft style={{ padding: 0 }} />
Back
<ArrowLeft strokeWidth={1.5} className="size-4" />
Blog
</Link>
</div>
<div className="col-span-12 lg:col-span-12 xl:col-span-10">
{/* Title and description */}
<div className="mb-6 lg:mb-10 max-w-5xl space-y-8">
<div className="space-y-4">
<Link href="/blog" className="text-brand hidden lg:inline-flex items-center">
Blog
</Link>
<h1 className="text-2xl sm:text-4xl">{blogMetaData.title}</h1>
<div className="text-light flex space-x-3 text-sm">
{/* Row 1, Col 2: Title + meta */}
<div className="col-span-12 xl:row-start-1 xl:col-start-2 xl:col-span-7 pt-4 xl:pt-12 pb-6 space-y-4">
<div className="space-y-4">
{(blogMetaData.tags as Tag[])?.length > 0 && (
<div className="flex flex-wrap gap-2">
{(blogMetaData.tags as Tag[])?.map((tag) => {
const tagName = typeof tag === 'string' ? tag : tag.name
const tagId = typeof tag === 'string' ? tag : tag.id.toString()
return (
<Link
className="flex"
href={`/blog/tags/${tagName}`}
key={`category-badge-${tagId}`}
>
<Badge>{tagName}</Badge>
</Link>
)
})}
</div>
)}
<h1 className="text-2xl sm:text-4xl max-w-3xl">{blogMetaData.title}</h1>
<div className="flex items-center gap-2">
<div className="text-foreground-lighter flex space-x-3 text-sm">
<p>{dayjs(blogMetaData.date).format('DD MMM YYYY')}</p>
<p>·</p>
<p>{(blogMetaData as any).readingTime}</p>
</div>
{authors.length > 0 && (
<div className="flex justify-between">
<div className="flex-1 flex flex-wrap gap-3 pt-2 md:gap-0 lg:gap-3">
{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 (
<div className="mr-4 w-max" key={`author-${i}-${author.author}`}>
<Link href={`/blog/authors/${authorId}`} className="cursor-pointer">
<div className="flex items-center gap-3">
{authorImageUrl && (
<div className="w-10">
<Image
src={authorImageUrl}
className="border-default rounded-full border w-full max-h-10 aspect-square object-cover"
alt={`${author.author} avatar`}
width={40}
height={40}
/>
</div>
)}
<div className="flex flex-col">
<span className="text-foreground mb-0 text-sm">
{author.author}
</span>
<span className="text-foreground-lighter mb-0 text-xs">
{author.position}
</span>
</div>
</div>
</Link>
</div>
{authors.length > 0 && (
<div className="flex items-center gap-4 mt-6">
{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 (
<Link
href={`/blog/authors/${authorId}`}
className="cursor-pointer"
key={`author-${i}-${author.author}`}
>
<div className="flex items-center gap-2">
{authorImageUrl && (
<Image
src={authorImageUrl}
className="border-default rounded-full border aspect-square object-cover"
alt={`${author.author} avatar`}
width={32}
height={32}
/>
)}
<div className="flex flex-col">
<span className="text-foreground text-sm">{author.author}</span>
<span className="text-foreground-lighter text-xs">
{author.position}
</span>
</div>
)
})}
</div>
</div>
</div>
</Link>
)
})}
</div>
)}
</div>
</div>
{/* Row 2: Hero image — col-start-1 col-span-9 = equal overhang each side of content (col 2–8) */}
{!blogMetaData.youtubeHero && blogMetaData.imgThumb && imageUrl && (
<div className="col-span-12 xl:row-start-2 xl:col-start-1 xl:col-span-9 mb-6">
<div className="relative w-full aspect-[1.91/1] overflow-hidden rounded-lg border border-foreground/10">
<Image
src={imageUrl}
alt={blogMetaData.title}
fill
quality={100}
sizes="(max-width: 768px) 100vw, 75vw"
className="object-cover"
/>
</div>
</div>
)}
{/* Row 3: Article */}
<div className="col-span-12 xl:row-start-3 xl:col-start-2 xl:col-span-7 pb-16">
<article>
<div className="prose prose-docs">
{blogMetaData.youtubeHero ? (
<iframe
title="YouTube video player"
className="w-full"
width="700"
height="350"
src={blogMetaData.youtubeHero}
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
) : null}
{mdxRendered}
</div>
</article>
{isLaunchWeek7 && <BlogLinks />}
{isLaunchWeekX && <LWXSummary />}
{isGAWeek && <LW11Summary />}
{isLaunchWeek12 && <LW12Summary />}
{isLaunchWeek13 && <LW13Summary />}
{isLaunchWeek14 && <LW14Summary />}
{isLaunchWeek15 && <LW15Summary />}
<div className="block lg:hidden py-8">
<div className="text-foreground-lighter text-sm">Share this article</div>
<ShareArticleActions title={blogMetaData.title} slug={blogMetaData.slug} />
</div>
<div className="grid gap-8 py-8 lg:grid-cols-1">
<div>
{prevPost && (
<NextCard
post={
prevPost as unknown as {
path: string
title: string
formattedDate: string
}
}
label="Previous post"
/>
)}
</div>
<div>
{nextPost && (
<NextCard
post={
nextPost as unknown as {
path: string
title: string
formattedDate: string
}
}
label="Next post"
className="text-right"
/>
)}
</div>
</div>
<div className="grid grid-cols-12 lg:gap-16 xl:gap-8">
{/* Content */}
<div className="col-span-12 lg:col-span-7 xl:col-span-7">
<article>
<div className={['prose prose-docs'].join(' ')}>
{blogMetaData.youtubeHero ? (
<iframe
title="YouTube video player"
className="w-full"
width="700"
height="350"
src={blogMetaData.youtubeHero}
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
/>
) : (
imageUrl && (
<div className="hidden md:block relative mb-8 w-full aspect-[1.91/1] overflow-auto rounded-lg border">
<Image
src={imageUrl}
alt={blogMetaData.title}
fill
sizes={BLOG_POST_HERO_IMAGE_SIZES}
className="object-cover m-0"
/>
</div>
)
)}
{mdxRendered}
</div>
</article>
{isLaunchWeek7 && <BlogLinks />}
{isLaunchWeekX && <LWXSummary />}
{isGAWeek && <LW11Summary />}
{isLaunchWeek12 && <LW12Summary />}
{isLaunchWeek13 && <LW13Summary />}
{isLaunchWeek14 && <LW14Summary />}
{isLaunchWeek15 && <LW15Summary />}
<div className="block lg:hidden py-8">
<div className="text-foreground-lighter text-sm">Share this article</div>
<ShareArticleActions title={blogMetaData.title} slug={blogMetaData.slug} />
</div>
<div className="grid gap-8 py-8 lg:grid-cols-1">
<div>
{prevPost && (
<NextCard
post={
prevPost as unknown as {
path: string
title: string
formattedDate: string
}
}
label="Previous post"
/>
)}
</div>
<div>
{nextPost && (
<NextCard
post={
nextPost as unknown as {
path: string
title: string
formattedDate: string
}
}
label="Next post"
className="text-right"
/>
)}
</div>
</div>
</div>
{/* Sidebar */}
<div className="relative col-span-12 space-y-8 lg:col-span-5 xl:col-span-3 xl:col-start-9">
<div className="space-y-6">
<div className="hidden lg:block">
<div className="flex flex-wrap gap-2">
{(blogMetaData.tags as Array<Tag>)?.map((tag) => {
const tagName = typeof tag === 'string' ? tag : tag.name
const tagId = typeof tag === 'string' ? tag : tag.id.toString()
return (
<Link href={`/blog/tags/${tagName}`} key={`category-badge-${tagId}`}>
<Badge>{tagName}</Badge>
</Link>
)
})}
</div>
</div>
<div className="hidden lg:block">{toc}</div>
<div className="hidden lg:block">
<div className="text-foreground text-sm">Share this article</div>
<ShareArticleActions title={blogMetaData.title} slug={blogMetaData.slug} />
</div>
</div>
</div>
{/* TOC: starts at row 2 alongside image, sticky through article */}
<div className="hidden xl:block xl:row-start-2 xl:row-span-2 xl:col-start-10 xl:col-span-3 xl:pl-6">
<div className="sticky top-24 flex flex-col gap-6 max-h-[calc(100vh-7rem)] mb-4">
<div className="overflow-y-auto min-h-0 flex-1">{toc}</div>
<div className="shrink-0">
<div className="text-foreground text-sm mb-2">Share this article</div>
<ShareArticleActions title={blogMetaData.title} slug={blogMetaData.slug} />
</div>
</div>
</div>
</div>
</div>
<CTABanner />
</DefaultLayout>
<div className="border-t">
<CTASection />
</div>
</div>
</>
)
}
@@ -0,0 +1,48 @@
'use client'
import { setBlogViewCookie, type BlogView } from 'app/blog/blog-view'
import { motion } from 'framer-motion'
import { AlignJustify, Grid } from 'lucide-react'
import { cn } from 'ui'
export default function BlogViewToggle({
view,
setView,
}: {
view: BlogView
setView: (view: BlogView) => void
}) {
return (
<div className="flex items-center border border-border rounded-md p-0.5 gap-0.5 bg-surface-100">
{(['list', 'grid'] as BlogView[]).map((v) => (
<button
key={v}
onClick={() => {
setView(v)
setBlogViewCookie(v)
}}
aria-label={`${v} view`}
className={cn(
'relative flex items-center justify-center w-7 h-7 rounded-sm transition-colors',
view === v ? 'text-foreground' : 'text-foreground-light hover:text-foreground'
)}
>
{view === v && (
<motion.span
layoutId="blog-view-bg"
className="absolute inset-0 rounded-sm bg-surface-300 border border-border"
transition={{ type: 'spring', duration: 0.3, bounce: 0.15 }}
/>
)}
<span className="relative z-10">
{v === 'list' ? (
<AlignJustify className="w-3.5 h-3.5" />
) : (
<Grid className="w-3.5 h-3.5" />
)}
</span>
</button>
))}
</div>
)
}
+24 -44
View File
@@ -1,6 +1,7 @@
import Image from 'next/image'
import Link from 'next/link'
import AuthorAvatars from './AuthorAvatars'
import authors from '@/lib/authors.json'
import {
BLOG_FEATURED_IMAGE_SIZES,
@@ -10,7 +11,6 @@ import {
import type PostTypes from '@/types/post'
function FeaturedThumb(blog: PostTypes) {
// For static posts, look up author info from authors.json
const authorArray = blog.author?.split(',').map((a) => a.trim()) || []
const author = []
@@ -30,65 +30,45 @@ function renderFeaturedThumb(blog: PostTypes, author: any[]) {
const imageUrl = getBlogThumbnailImage(blog) ?? BLOG_PLACEHOLDER_IMAGE
return (
<div key={blog.slug} className="w-full">
<div key={blog.slug} className="w-full grid lg:grid-cols-12 gap-8">
{/* Image */}
<Link
href={`${blog.path}`}
className="grid gap-4 lg:grid-cols-7 lg:gap-8 xl:gap-12 hover:bg-surface-200 dark:hover:bg-surface-75 p-2 sm:p-4 rounded-xl"
prefetch={false}
className="relative w-full aspect-[1.91/1] lg:col-span-6 overflow-hidden block group"
>
<div className="relative w-full aspect-2/1 lg:col-span-3 lg:aspect-3/2 overflow-auto rounded-lg border">
<div className="relative w-full h-full shadow-lg border border-foreground/10 rounded-lg overflow-hidden">
<Image
src={imageUrl}
fill
sizes={BLOG_FEATURED_IMAGE_SIZES}
priority
className="object-cover bg-alternative"
className="object-cover bg-alternative group-hover:scale-[1.02] transition-transform duration-300"
alt="blog thumbnail"
/>
</div>
<div className="flex flex-col space-y-2 lg:col-span-4 xl:justify-center max-w-xl">
<div className="text-lighter flex space-x-2 text-sm">
</Link>
{/* Text */}
<div className="flex flex-col lg:col-span-6 px-6 py-4 xl:justify-center">
<div>
<Link href={`${blog.path}`} prefetch={false} className="group">
<h2 className="h2 lg:text-2xl! xl:text-3xl! mb-2! group-hover:underline">
{blog.title}
</h2>
</Link>
<p className="p">{blog.description}</p>
</div>
<div className="flex items-center justify-between mt-4">
<AuthorAvatars authors={author} size="md" />
<div className="text-foreground-lighter flex space-x-2 text-sm">
<span>{blog.formattedDate}</span>
<span>·</span>
<span>{blog.readingTime}</span>
</div>
<div>
<h2 className="h2 lg:text-2xl! xl:text-3xl! mb-2!">{blog.title}</h2>
<p className="p xl:text-lg">{blog.description}</p>
</div>
<div className="flex flex-col w-max gap-2">
{author.filter(Boolean).map((author: any, i: number) => {
const authorImageUrl =
typeof author.author_image_url === 'string'
? author.author_image_url
: (author.author_image_url as { url: string })?.url || ''
return (
<div
className="flex items-center space-x-2"
key={`author-feat-${i}-${author.author}`}
>
{imageUrl && (
<div className="relative h-6 w-6 overflow-auto">
<Image
src={authorImageUrl}
alt={`${author.author} avatar`}
className="rounded-full object-cover"
fill
sizes="30px"
/>
</div>
)}
<div className="flex flex-col">
<span className="text-foreground m-0 text-sm">{author.author}</span>
</div>
</div>
)
})}
</div>
</div>
</Link>
</div>
</div>
)
}
+193
View File
@@ -0,0 +1,193 @@
'use client'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import { useInView } from 'framer-motion'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { Button } from 'ui'
interface GitHubContributor {
id: number
login: string
avatar_url: string
contributions: number
}
interface AvatarPosition {
id: number
login: string
avatar_url: string
x: number
y: number
size: number
opacity: number
delay: number
}
function useContributors() {
const [contributors, setContributors] = useState<GitHubContributor[]>([])
useEffect(() => {
let cancelled = false
async function fetchAll() {
try {
const pages = await Promise.all(
[1, 2].map((page) =>
fetch(
`https://api.github.com/repos/supabase/supabase/contributors?per_page=100&page=${page}`,
{ headers: { Accept: 'application/vnd.github.v3+json' } }
).then((r) => (r.ok ? r.json() : []))
)
)
if (!cancelled) setContributors(pages.flat())
} catch {
// silent fail
}
}
fetchAll()
return () => {
cancelled = true
}
}, [])
return contributors
}
function AvatarCloud({ contributors }: { contributors: GitHubContributor[] }) {
const containerRef = useRef<HTMLDivElement>(null)
const inView = useInView(containerRef, { once: true, amount: 0.4 })
const [positions, setPositions] = useState<AvatarPosition[]>([])
useEffect(() => {
if (contributors.length === 0 || !containerRef.current) return
const generate = () => {
const width = containerRef.current!.clientWidth
const avatarSize = 28
const gap = 6
const cols = Math.ceil(width / (avatarSize + gap))
const rows = 10
const centerX = cols / 2
const centerY = -3
const radiusX = cols * 0.35
const radiusY = rows * 0.8
const result: AvatarPosition[] = []
let ci = 0
for (let row = 0; row < rows; row++) {
const rowOffset = row % 2 === 0 ? 0 : (avatarSize + gap) / 2
for (let col = 0; col < cols; col++) {
const dx = (col - centerX) / radiusX
const dy = (row - centerY) / radiusY
if (dx * dx + dy * dy < 1) continue
if (Math.random() > 0.82) continue
const c = contributors[ci % contributors.length]
ci++
const edgeDistX = Math.min(col, cols - col - 1) / (cols * 0.12)
const edgeDistY = row / (rows * 0.3)
const edgeFade = Math.min(1, edgeDistX, edgeDistY)
result.push({
id: c.id,
login: c.login,
avatar_url: c.avatar_url,
x: col * (avatarSize + gap) + rowOffset + (Math.random() - 0.5) * 6,
y: row * (avatarSize + gap) + (Math.random() - 0.5) * 6,
size: 24 + Math.random() * 8,
opacity: Math.min(0.85, edgeFade * (0.35 + Math.random() * 0.5)),
delay: Math.random() * 2,
})
}
}
setPositions(result)
}
generate()
const onResize = () => generate()
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [contributors])
return (
<div ref={containerRef} className="absolute inset-x-0 bottom-0 h-[340px] overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-background via-transparent to-transparent z-10" />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-background via-transparent to-background z-10" />
<div className="relative w-full h-full">
{positions.map((pos, i) => (
<div
key={`${pos.id}-${i}`}
className="absolute transition-opacity duration-1000 ease-out"
style={{
left: pos.x,
top: pos.y,
width: pos.size,
height: pos.size,
opacity: inView ? pos.opacity : 0,
transitionDelay: `${pos.delay}s`,
}}
>
<img
src={pos.avatar_url}
alt={pos.login}
width={pos.size}
height={pos.size}
className="rounded-md border border-foreground/10 grayscale pointer-events-none"
loading="lazy"
/>
</div>
))}
</div>
</div>
)
}
export function CTASection() {
const sendTelemetryEvent = useSendTelemetryEvent()
const contributors = useContributors()
return (
<div className="relative overflow-hidden py-32">
{/*<AvatarCloud contributors={contributors} />*/}
<div className="relative z-20 flex flex-col items-center text-center gap-6">
<h2 className="h2">
<span className="text-foreground-lighter">Build in a weekend,</span>
<span className="text-foreground block sm:inline"> scale to millions</span>
</h2>
<div className="flex items-center gap-2">
<Button asChild size="medium">
<Link
href="https://supabase.com/dashboard"
onClick={() =>
sendTelemetryEvent({
action: 'start_project_button_clicked',
properties: { buttonLocation: 'CTA Banner' },
})
}
>
Start your project
</Link>
</Button>
<Button asChild size="medium" variant="default">
<Link
href="/contact/sales"
onClick={() =>
sendTelemetryEvent({
action: 'request_demo_button_clicked',
properties: { buttonLocation: 'CTA Banner' },
})
}
>
Request a demo
</Link>
</Button>
</div>
</div>
</div>
)
}