mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
new blog layout
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user