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 */}
{/* 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 && (
+
+ )}
+
+ ))}
+
+ {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 && (
- {' '}
)
}
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.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 && (
-
- )}
-
- )
- })}
+
{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 */}
+
-
- {/* 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.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.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 && (
+
+ )}
+
+ {/* Row 3: Article */}
+
+
+
+ {blogMetaData.youtubeHero ? (
+
+ ) : null}
+ {mdxRendered}
+
+
+
+ {isLaunchWeek7 &&
}
+ {isLaunchWeekX &&
}
+ {isGAWeek &&
}
+ {isLaunchWeek12 &&
}
+ {isLaunchWeek13 &&
}
+ {isLaunchWeek14 &&
}
+ {isLaunchWeek15 &&
}
+
+
+
+
+
+ {prevPost && (
+
+ )}
+
+
+ {nextPost && (
+
)}
-
- {/* Content */}
-
-
-
- {blogMetaData.youtubeHero ? (
-
- ) : (
- imageUrl && (
-
-
-
- )
- )}
- {mdxRendered}
-
-
- {isLaunchWeek7 &&
}
- {isLaunchWeekX &&
}
- {isGAWeek &&
}
- {isLaunchWeek12 &&
}
- {isLaunchWeek13 &&
}
- {isLaunchWeek14 &&
}
- {isLaunchWeek15 &&
}
-
-
-
- {prevPost && (
-
- )}
-
-
- {nextPost && (
-
- )}
-
-
-
- {/* Sidebar */}
-
-
-
-
- {(blogMetaData.tags as Array)?.map((tag) => {
- const tagName = typeof tag === 'string' ? tag : tag.name
- const tagId = typeof tag === 'string' ? tag : tag.id.toString()
- return (
-
- {tagName}
-
- )
- })}
-
-
-
{toc}
-
-
+
+
+ {/* TOC: starts at row 2 alongside image, sticky through article */}
+
-
-
+
+
+
+
>
)
}
diff --git a/apps/www/components/Blog/BlogViewToggle.tsx b/apps/www/components/Blog/BlogViewToggle.tsx
new file mode 100644
index 00000000000..055d7bf4be9
--- /dev/null
+++ b/apps/www/components/Blog/BlogViewToggle.tsx
@@ -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 (
+
+ {(['list', 'grid'] as BlogView[]).map((v) => (
+
{
+ 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 && (
+
+ )}
+
+ {v === 'list' ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+ )
+}
diff --git a/apps/www/components/Blog/FeaturedThumb.tsx b/apps/www/components/Blog/FeaturedThumb.tsx
index 4e72a00f1db..5ee94920791 100644
--- a/apps/www/components/Blog/FeaturedThumb.tsx
+++ b/apps/www/components/Blog/FeaturedThumb.tsx
@@ -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 (
-
+
+ {/* Image */}
-
+
-
-
+
+
+ {/* Text */}
+
+
+
+
+ {blog.title}
+
+
+
{blog.description}
+
+
+
+
+
{blog.formattedDate}
·
{blog.readingTime}
-
-
-
{blog.title}
-
{blog.description}
-
-
-
- {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 (
-
- {imageUrl && (
-
-
-
- )}
-
- {author.author}
-
-
- )
- })}
-
-
+
)
}
diff --git a/apps/www/components/CTASection.tsx b/apps/www/components/CTASection.tsx
new file mode 100644
index 00000000000..b8be1712283
--- /dev/null
+++ b/apps/www/components/CTASection.tsx
@@ -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
([])
+
+ 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(null)
+ const inView = useInView(containerRef, { once: true, amount: 0.4 })
+ const [positions, setPositions] = useState([])
+
+ 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 (
+
+
+
+
+ {positions.map((pos, i) => (
+
+

+
+ ))}
+
+
+ )
+}
+
+export function CTASection() {
+ const sendTelemetryEvent = useSendTelemetryEvent()
+ const contributors = useContributors()
+
+ return (
+
+ {/*
*/}
+
+
+ Build in a weekend,
+ scale to millions
+
+
+
+
+ sendTelemetryEvent({
+ action: 'start_project_button_clicked',
+ properties: { buttonLocation: 'CTA Banner' },
+ })
+ }
+ >
+ Start your project
+
+
+
+
+ sendTelemetryEvent({
+ action: 'request_demo_button_clicked',
+ properties: { buttonLocation: 'CTA Banner' },
+ })
+ }
+ >
+ Request a demo
+
+
+
+
+
+ )
+}