mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
Merge branch 'www-refresh' into feature/www-composer
This commit is contained in:
commit
734184e045
169 files changed
+13256
-1054
No files matched your search
@@ -0,0 +1,200 @@
|
||||
import Examples from 'data/Examples'
|
||||
import Link from 'next/link'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const EXAMPLE_LOGO: Record<string, { src: string; invert?: boolean }> = {
|
||||
'Stripe Subscriptions Starter': { src: '/images/logos/frameworks/stripe.svg' },
|
||||
'Next.js Starter': { src: '/images/logos/frameworks/nextjs.svg', invert: true },
|
||||
'AI Chatbot': { src: '/images/logos/frameworks/openai.svg', invert: true },
|
||||
'LangChain + Next.js Starter': { src: '/images/logos/frameworks/langchain.svg' },
|
||||
'Flutter User Management': { src: '/images/logos/frameworks/flutter.svg' },
|
||||
'Expo React Native Starter': { src: '/images/logos/frameworks/expo.svg', invert: true },
|
||||
}
|
||||
|
||||
function SkeletonBar({ className }: { className?: string }) {
|
||||
return <div className={cn(`rounded bg-foreground-muted/10`, className)} />
|
||||
}
|
||||
|
||||
function StripeSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-label="Pricing page with subscription tiers"
|
||||
className="flex flex-col items-center justify-center gap-3 p-5 w-full"
|
||||
>
|
||||
<div className="flex -mb-8">
|
||||
<div className="w-24 aspect-[9/16] border flex flex-col items-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 p-2">
|
||||
<SkeletonBar className="w-8 h-2.5" />
|
||||
<SkeletonBar className="w-16 h-2.5" />
|
||||
</div>
|
||||
|
||||
<SkeletonBar className="w-20 aspect-square mt-4" />
|
||||
</div>
|
||||
|
||||
<div className="w-24 aspect-[9/16] border flex flex-col items-center border-l-0 bg-[#635BFF]/5">
|
||||
<div className="flex flex-col items-center justify-center gap-2 p-2">
|
||||
<SkeletonBar className="w-8 h-2.5 bg-purple-800" />
|
||||
<SkeletonBar className="w-16 h-2.5 bg-purple-900" />
|
||||
</div>
|
||||
|
||||
<SkeletonBar className="w-20 aspect-square mt-4 dark:bg-muted" />
|
||||
</div>
|
||||
|
||||
<div className="w-24 aspect-[9/16] border flex flex-col items-center border-l-0">
|
||||
<div className="flex flex-col items-center justify-center gap-2 p-2">
|
||||
<SkeletonBar className="w-8 h-2.5" />
|
||||
<SkeletonBar className="w-16 h-2.5" />
|
||||
</div>
|
||||
|
||||
<SkeletonBar className="w-20 aspect-square mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NextjsSkeleton() {
|
||||
return (
|
||||
<div
|
||||
aria-label="Dashboard with sidebar navigation and data grid"
|
||||
className="flex w-full h-full"
|
||||
>
|
||||
{/* Sidebar */}
|
||||
<div className="w-24 border-r border-foreground-muted/10 flex flex-col gap-2 p-3 pt-3">
|
||||
<SkeletonBar className="h-3 w-3 rounded-sm bg-[#0070F3]" />
|
||||
<div className="flex flex-col gap-1.5 mt-3">
|
||||
<SkeletonBar className="h-2 w-full bg-muted" />
|
||||
<SkeletonBar className="h-2 w-14" />
|
||||
<SkeletonBar className="h-2 w-full" />
|
||||
<SkeletonBar className="h-2 w-10" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Main */}
|
||||
<div className="flex-1 flex flex-col gap-2 p-3">
|
||||
<SkeletonBar className="h-2.5 w-16 bg-muted" />
|
||||
{/* 3x4 data grid */}
|
||||
<div className="grid grid-cols-4 grid-rows-3 gap-1.5 flex-1">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-sm bg-foreground-muted/10 min-h-[40px]',
|
||||
[1, 6, 9].includes(i) && 'bg-muted/70'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SKELETON_CONTENT: Record<string, () => React.JSX.Element> = {
|
||||
'Stripe Subscriptions Starter': StripeSkeleton,
|
||||
'Next.js Starter': NextjsSkeleton,
|
||||
}
|
||||
|
||||
export function BuiltWithSupabaseSection() {
|
||||
const featuredExamples = Examples.slice(0, 2)
|
||||
const gridExamples = Examples.slice(2, 6)
|
||||
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
{/* Header row */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] w-full px-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Kickstart your next project <br />
|
||||
<span className="text-foreground">with production ready templates</span>
|
||||
</h3>
|
||||
<Link
|
||||
href="/docs/guides/examples"
|
||||
className="text-sm text-foreground-light hover:text-foreground underline"
|
||||
>
|
||||
View all examples
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
{/* Featured row - 2 large cells */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{featuredExamples.map((example) => {
|
||||
const logo = EXAMPLE_LOGO[example.title]
|
||||
const Skeleton = SKELETON_CONTENT[example.title]
|
||||
return (
|
||||
<Link
|
||||
key={example.title}
|
||||
href={example.repo_url ?? '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden hover:bg-surface-100 transition-colors"
|
||||
>
|
||||
<div className="px-6 py-5 flex flex-col gap-2.5">
|
||||
{logo && (
|
||||
<img
|
||||
src={logo.src}
|
||||
alt=""
|
||||
className={`h-5 w-fit object-contain ${logo.invert ? ' dark:invert' : ''}`}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-foreground text-sm font-medium">{example.title}</h4>
|
||||
<p className="text-foreground-lighter text-sm mt-1 line-clamp-2">
|
||||
{example.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center px-6 pt-4 pb-0 overflow-hidden">
|
||||
{/* Browser frame */}
|
||||
<div className="w-full rounded-t-lg border border-b-0 border-border-stronger/50 shadow-lg -mb-[10%] overflow-hidden">
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border bg-background-200">
|
||||
<span className="size-2.5 rounded-full bg-foreground-muted/30" />
|
||||
<span className="size-2.5 rounded-full bg-foreground-muted/30" />
|
||||
<span className="size-2.5 rounded-full bg-foreground-muted/30" />
|
||||
</div>
|
||||
{/* Skeleton content */}
|
||||
<div className="h-[240px] flex items-center justify-center bg-background">
|
||||
{Skeleton ? <Skeleton /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grid row - 4 smaller cells */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mt-3">
|
||||
{gridExamples.map((example) => {
|
||||
const logo = EXAMPLE_LOGO[example.title]
|
||||
return (
|
||||
<Link
|
||||
key={example.title}
|
||||
href={example.repo_url ?? '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex flex-col bg-surface-75 border border-border rounded-lg px-6 py-10 hover:bg-surface-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{logo && (
|
||||
<img
|
||||
src={logo.src}
|
||||
alt=""
|
||||
className={`h-5 w-5 object-contain${logo.invert ? ' dark:invert' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-foreground text-sm font-medium">{example.title}</h4>
|
||||
<p className="text-foreground-lighter text-sm mt-1 line-clamp-2">
|
||||
{example.description}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</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" type="default">
|
||||
<Link
|
||||
href="/contact/sales"
|
||||
onClick={() =>
|
||||
sendTelemetryEvent({
|
||||
action: 'request_demo_button_clicked',
|
||||
properties: { buttonLocation: 'CTA Banner' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Request a demo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { topTweets } from 'shared-data/tweets'
|
||||
import { Button } from 'ui'
|
||||
import { TweetCard } from 'ui-patterns/TweetCard'
|
||||
|
||||
const STAIRCASE_COLUMNS = 5
|
||||
const TWEETS_PER_COL = 3
|
||||
const STAIRCASE_OFFSET = 48
|
||||
|
||||
export function CommunitySection() {
|
||||
const tweets = topTweets.slice(0, STAIRCASE_COLUMNS * TWEETS_PER_COL)
|
||||
const columns: (typeof tweets)[] = Array.from({ length: STAIRCASE_COLUMNS }, (_, i) =>
|
||||
tweets.slice(i * TWEETS_PER_COL, (i + 1) * TWEETS_PER_COL)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="pt-16 px-6">
|
||||
<div className="flex flex-col items-center gap-3 mb-12">
|
||||
<h3 className="text-2xl md:text-4xl text-center text-foreground-lighter">
|
||||
Join the community
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-center">
|
||||
Discover what our community has to say about their Supabase experience.
|
||||
</p>
|
||||
<Button asChild type="default" size="small" className="mt-2">
|
||||
<Link href="https://discord.supabase.com/" target="_blank">
|
||||
Join us on Discord
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative overflow-hidden max-h-[600px] pt-24"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, black 60%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 60%, transparent 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 max-w-[1480px] mx-auto">
|
||||
{columns.map((col, colIdx) => (
|
||||
<div
|
||||
key={colIdx}
|
||||
className="flex-1 flex flex-col gap-4 min-w-0"
|
||||
style={{
|
||||
transform: `translateY(-${Math.abs(colIdx - Math.floor(STAIRCASE_COLUMNS / 2)) * STAIRCASE_OFFSET}px)`,
|
||||
}}
|
||||
>
|
||||
{col.map((tweet) => (
|
||||
<Link
|
||||
key={tweet.url}
|
||||
href={tweet.url}
|
||||
target="_blank"
|
||||
className="block group/tweet-card break-inside-avoid"
|
||||
>
|
||||
<TweetCard
|
||||
handle={`@${tweet.handle}`}
|
||||
quote={tweet.text}
|
||||
img_url={tweet.img_url}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { topTweets } from 'shared-data/tweets'
|
||||
import { Button } from 'ui'
|
||||
import { TweetCard } from 'ui-patterns/TweetCard'
|
||||
|
||||
const COLS = 5
|
||||
const TWEETS_PER_PAGE = 15
|
||||
|
||||
// V-shape offsets: outer columns high, center lowest
|
||||
const COL_OFFSETS = [0, 60, 100, 60, 0]
|
||||
|
||||
function distributeToColumns(tweets: typeof topTweets, cols: number) {
|
||||
const columns: (typeof topTweets)[] = Array.from({ length: cols }, () => [])
|
||||
tweets.forEach((tweet, i) => columns[i % cols].push(tweet))
|
||||
return columns
|
||||
}
|
||||
|
||||
export function CommunitySectionV2() {
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (topTweets.length === 0) return
|
||||
const interval = setInterval(() => {
|
||||
setPage((p) => p + 1)
|
||||
}, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (topTweets.length === 0) return null
|
||||
|
||||
const currentTweets = Array.from({ length: TWEETS_PER_PAGE }, (_, i) => {
|
||||
const idx = (page * TWEETS_PER_PAGE + i) % topTweets.length
|
||||
return topTweets[idx]
|
||||
})
|
||||
|
||||
const columns = distributeToColumns(currentTweets, COLS)
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="mx-auto">
|
||||
<div className="flex flex-col items-center gap-3 px-6 py-20">
|
||||
<h3 className="text-2xl md:text-4xl text-center">Join the community</h3>
|
||||
<p className="text-foreground-lighter text-center text-sm">
|
||||
Discover what our community has to say about their Supabase experience.
|
||||
</p>
|
||||
<Button asChild type="default" size="small" className="mt-2">
|
||||
<Link href="https://discord.supabase.com/" target="_blank">
|
||||
Join us on Discord
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative overflow-hidden h-[620px]"
|
||||
style={{
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, black 50%, transparent 100%), linear-gradient(to right, transparent, black 10%, black 90%, transparent)',
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, black 50%, transparent 100%), linear-gradient(to right, transparent, black 10%, black 90%, transparent)',
|
||||
maskComposite: 'intersect',
|
||||
WebkitMaskComposite: 'destination-in',
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div key={page} className="grid grid-cols-2 md:grid-cols-5 gap-2 -mx-4">
|
||||
{columns.map((col, colIdx) => (
|
||||
<div
|
||||
key={colIdx}
|
||||
className="flex flex-col gap-2"
|
||||
style={{ transform: `translateY(${COL_OFFSETS[colIdx]}px)` }}
|
||||
>
|
||||
{col.map((tweet, i) => (
|
||||
<motion.div
|
||||
key={tweet.url}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{
|
||||
duration: 0.45,
|
||||
delay: (colIdx + i * COLS) * 0.03,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
}}
|
||||
>
|
||||
<Link href={tweet.url} target="_blank" className="block group/tweet-card">
|
||||
<TweetCard
|
||||
handle={`@${tweet.handle}`}
|
||||
quote={tweet.text}
|
||||
img_url={tweet.img_url}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
const customerStories = [
|
||||
{
|
||||
name: 'Firecrawl',
|
||||
logo: '/images/customers/logos/firecrawl.png',
|
||||
icon: '/images/customers/logos/firecrawl-icon.svg',
|
||||
tagline: 'Switched from Pinecone to Supabase Vector to boost efficiency and accuracy.',
|
||||
quote:
|
||||
"We looked at the alternatives and chose Supabase because it's open source, it's simpler, and for all the ways we need to use it, Supabase has been just as performant — if not more performant — than the other vector databases.",
|
||||
author: 'Caleb Peffer, CEO, Firecrawl',
|
||||
authorImg: '/images/blog/avatars/caleb-peffer.jpg',
|
||||
slug: 'firecrawl',
|
||||
rawIcon: true,
|
||||
iconFilter: 'brightness(0) invert(1)',
|
||||
bgColor: 'color(display-p3 0.980392 0.364706 0.098039)',
|
||||
bgGradient:
|
||||
'linear-gradient(to bottom left, color(display-p3 0.980392 0.364706 0.098039 / 1) 0%, color(display-p3 0.980392 0.364706 0.098039 / 0.9) 100%)',
|
||||
dimBgColor: 'color(display-p3 0.980392 0.364706 0.098039 / 0.9)',
|
||||
textColor: 'light' as 'light' | 'dark',
|
||||
},
|
||||
{
|
||||
name: 'Rally',
|
||||
logo: '/images/customers/logos/rally.png',
|
||||
icon: '/images/customers/logos/rally-icon.svg',
|
||||
rawIcon: true,
|
||||
tagline: 'From first line of code to fully licensed fintech in three months.',
|
||||
quote:
|
||||
"We could not have built this company without Supabase. If I had to go and build all these components myself, we wouldn't even have launched.",
|
||||
author: 'Thiago Peres, Founder & CTO, Rally',
|
||||
authorImg: '/images/blog/avatars/thiago-peres-rally.jpeg',
|
||||
slug: 'rally',
|
||||
bgColor: 'color(display-p3 0.275 0.306 0.8)',
|
||||
bgGradient:
|
||||
'linear-gradient(to bottom left, color(display-p3 0.275 0.306 0.8 / 1) 0%, color(display-p3 0.118 0.176 0.769 / 1) 100%)',
|
||||
dimBgColor: 'color(display-p3 0.118 0.176 0.769 / 1)',
|
||||
textColor: 'light' as 'light' | 'dark',
|
||||
},
|
||||
{
|
||||
name: 'Hyper',
|
||||
logo: '/images/customers/logos/hyper.svg',
|
||||
icon: '/images/customers/logos/hyper-icon.svg',
|
||||
tagline: 'An AI-native marketing platform with agents that operate across the entire workflow.',
|
||||
quote:
|
||||
'I will get on a podcast and talk about how much I love Supabase. With Supabase we can move fast and build things that delight our customers without having to worry about infrastructure.',
|
||||
author: 'Elliot Fleck, Co-founder, Hyper',
|
||||
authorImg: '/images/blog/avatars/elliot-fleck-hyper.jpeg',
|
||||
slug: 'hyper',
|
||||
bgColor: '#222222',
|
||||
bgGradient: 'linear-gradient(to bottom left, #2a2a2a 0%, #181818 100%)',
|
||||
dimBgColor: '#1e1e1e',
|
||||
textColor: 'light' as 'light' | 'dark',
|
||||
},
|
||||
{
|
||||
name: 'E2B',
|
||||
logo: '/images/customers/logos/e2b.png',
|
||||
icon: '/images/customers/logos/e2b-icon.svg',
|
||||
tagline: 'Secure, scalable execution of AI-generated code in the cloud.',
|
||||
quote:
|
||||
"Supabase empowers us to focus on innovation rather than infrastructure. It's the backbone of our platform, enabling scalability and seamless developer experiences.",
|
||||
author: 'Vasek Mlejnsky, CEO, E2B',
|
||||
authorImg: '/images/blog/avatars/vasek-mlejnsky.jpg',
|
||||
slug: 'e2b',
|
||||
rawIcon: true,
|
||||
iconFilter: 'brightness(0) invert(1)',
|
||||
bgColor: 'color(display-p3 1 0.533 0)',
|
||||
bgGradient:
|
||||
'linear-gradient(to bottom left, color(display-p3 1 0.533 0 / 1) 0%, color(display-p3 0.7 0.373 0 / 1) 100%)',
|
||||
dimBgColor: 'color(display-p3 0.7 0.373 0 / 1)',
|
||||
textColor: 'light' as 'light' | 'dark',
|
||||
},
|
||||
{
|
||||
name: 'Mobbin',
|
||||
logo: '/images/customers/logos/mobbin.png',
|
||||
icon: '/images/customers/logos/mobbin-icon.svg',
|
||||
tagline: 'Migrated 200,000 users from Firebase for a better authentication experience.',
|
||||
quote:
|
||||
'Migrating to Supabase meant that we could instantly fix our Auth problems and save money. Just being on Supabase alone gives us confidence we can deliver on whatever users need in the future.',
|
||||
author: 'Jian Jie Liau, Co-founder & CTO, Mobbin',
|
||||
authorImg: '/images/blog/avatars/jian-mobbin.jpg',
|
||||
slug: 'mobbin',
|
||||
bgColor: '#000000',
|
||||
bgGradient: 'linear-gradient(to bottom left, #0a0a0a 0%, #000000 100%)',
|
||||
dimBgColor: '#080808',
|
||||
textColor: 'light' as 'light' | 'dark',
|
||||
},
|
||||
]
|
||||
|
||||
const aiBuilderStories = [
|
||||
{
|
||||
name: 'Lovable',
|
||||
logo: '/images/logos/publicity/lovable.svg',
|
||||
description: 'We store embeddings in a PostgreSQL database.',
|
||||
slug: 'lovable',
|
||||
photo: '/images/logos/publicity/lovable-team.png',
|
||||
},
|
||||
{
|
||||
name: 'Bolt',
|
||||
logo: '/images/logos/publicity/bolt.svg',
|
||||
description: 'We store embeddings in a PostgreSQL database, hosted by Supabase.',
|
||||
slug: 'bolt',
|
||||
photo: '/images/logos/publicity/bolt-team.png',
|
||||
},
|
||||
]
|
||||
|
||||
function LovableLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 121 122" fill="none" className={className}>
|
||||
<path
|
||||
fill="white"
|
||||
fillRule="evenodd"
|
||||
d="M36.069 0c19.92 0 36.068 16.155 36.068 36.084v13.713h12.004c19.92 0 36.069 16.156 36.069 36.084 0 19.928-16.149 36.083-36.069 36.083H0v-85.88C0 16.155 16.148 0 36.069 0Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconChip({
|
||||
story,
|
||||
size = 'md',
|
||||
}: {
|
||||
story: (typeof customerStories)[0]
|
||||
size?: 'sm' | 'md'
|
||||
}) {
|
||||
const isLight = story.textColor === 'light'
|
||||
const s = story as any
|
||||
const filter =
|
||||
s.iconFilter ?? (s.rawIcon ? undefined : isLight ? 'brightness(0) invert(1)' : 'brightness(0)')
|
||||
return (
|
||||
<img
|
||||
src={story.icon}
|
||||
alt={story.name}
|
||||
className={cn('object-contain shrink-0', size === 'md' ? 'h-8 w-8' : 'h-6 w-6')}
|
||||
style={filter ? { filter } : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Change INACTIVE_PAD to adjust closed column width automatically
|
||||
const INACTIVE_PAD = 22 // px padding on each side of icon in closed columns
|
||||
const ACTIVE_PAD = 32 // px padding on each side when card is open
|
||||
const ICON_PX = 32 // w-8 = 32px
|
||||
const INACTIVE_COL_WIDTH = INACTIVE_PAD * 2 + ICON_PX // = 76px
|
||||
|
||||
export function CustomerStoriesSection() {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const active = customerStories[activeIdx]
|
||||
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
{/* Header row */}
|
||||
<div className="">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
How industry leaders <br />{' '}
|
||||
<span className="text-foreground">are building with Supabase</span>
|
||||
</h3>
|
||||
<Link
|
||||
href="/customers"
|
||||
className="text-sm text-foreground-light hover:text-foreground underline"
|
||||
>
|
||||
More customer stories
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards row */}
|
||||
<div className="px-6 mx-auto max-w-[var(--container-max-w,75rem)] w-full">
|
||||
{/* Mobile: stacked cards */}
|
||||
<div className="flex flex-col gap-2 md:hidden">
|
||||
{customerStories.map((story, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const isDark = story.textColor === 'dark'
|
||||
return (
|
||||
<div
|
||||
key={story.slug}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveIdx(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setActiveIdx(index)
|
||||
}
|
||||
}}
|
||||
className="text-left rounded-lg p-5 flex flex-col gap-4 overflow-hidden transition-opacity cursor-pointer"
|
||||
style={{ background: isActive ? story.bgGradient : story.dimBgColor }}
|
||||
>
|
||||
<IconChip story={story} size="sm" />
|
||||
{isActive && (
|
||||
<div className="flex flex-col gap-3 flex-1">
|
||||
<div>
|
||||
<p
|
||||
className="text-sm font-medium"
|
||||
style={{ color: isDark ? '#111' : 'white' }}
|
||||
>
|
||||
{story.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: isDark ? '#888' : 'rgba(255,255,255,0.6)' }}
|
||||
>
|
||||
{story.tagline}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className="text-xl font-normal leading-snug text-pretty"
|
||||
style={{
|
||||
color: isDark ? '#222' : 'white',
|
||||
fontFamily: 'var(--font-heading)',
|
||||
}}
|
||||
>
|
||||
{story.quote}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<img
|
||||
src={story.authorImg}
|
||||
alt={story.author}
|
||||
className="h-6 w-6 rounded-full object-cover shrink-0 ring-1 ring-white/30"
|
||||
/>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color: isDark ? '#888' : 'rgba(255,255,255,0.65)' }}
|
||||
>
|
||||
{story.author}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/customers/${story.slug}`}
|
||||
className="text-xs underline"
|
||||
style={{ color: isDark ? '#555' : 'rgba(255,255,255,0.7)' }}
|
||||
>
|
||||
Read the story →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Desktop: animated accordion grid */}
|
||||
<div
|
||||
className="hidden md:grid min-h-[480px] gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: customerStories
|
||||
.map((_, i) => (i === activeIdx ? '1fr' : `${INACTIVE_COL_WIDTH}px`))
|
||||
.join(' '),
|
||||
}}
|
||||
>
|
||||
{customerStories.map((story, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const isDark = story.textColor === 'dark'
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
key={story.slug}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveIdx(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setActiveIdx(index)
|
||||
}
|
||||
}}
|
||||
className="text-left flex flex-col items-start gap-8 overflow-hidden cursor-pointer"
|
||||
style={{
|
||||
background: story.bgGradient,
|
||||
borderRadius: 8,
|
||||
padding: isActive ? ACTIVE_PAD : `${ACTIVE_PAD}px ${INACTIVE_PAD}px`,
|
||||
boxShadow: isDark
|
||||
? 'inset 0 0 0 1px rgba(0,0,0,0.07)'
|
||||
: 'inset 0 0 0 1px rgba(255,255,255,0.12)',
|
||||
}}
|
||||
transition={{ type: 'spring', duration: 0.4, bounce: 0 }}
|
||||
>
|
||||
<motion.div layout>
|
||||
<IconChip story={story} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
layout
|
||||
className="flex flex-col gap-1.5 flex-1 w-[35rem]"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isActive ? 1 : 0,
|
||||
filter: isActive ? 'blur(0px)' : 'blur(2px)',
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.42,
|
||||
ease: [0.165, 0.84, 0.44, 1],
|
||||
delay: isActive ? 0.2 : 0,
|
||||
}}
|
||||
>
|
||||
{/* Top: company name + tagline */}
|
||||
<motion.div layout className="flex flex-col gap-1">
|
||||
<motion.p
|
||||
layout
|
||||
className="text-sm font-medium"
|
||||
style={{ color: isDark ? '#111' : 'white' }}
|
||||
>
|
||||
{story.name}
|
||||
</motion.p>
|
||||
<motion.p
|
||||
layout
|
||||
className="text-xs leading-relaxed text-pretty"
|
||||
style={{ color: isDark ? '#888' : 'rgba(255,255,255,0.6)' }}
|
||||
>
|
||||
{story.tagline}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom: quote + author */}
|
||||
<motion.div layout className="flex flex-col gap-4 mt-auto">
|
||||
<motion.p
|
||||
layout
|
||||
className="text-2xl font-normal leading-snug text-balance"
|
||||
style={{
|
||||
color: isDark ? '#222' : 'white',
|
||||
fontFamily: 'var(--font-heading)',
|
||||
}}
|
||||
>
|
||||
{story.quote}
|
||||
</motion.p>
|
||||
<motion.div layout className="flex items-center gap-2.5 mb-4">
|
||||
<img
|
||||
src={story.authorImg}
|
||||
alt={story.author}
|
||||
className="h-7 w-7 rounded-full object-cover shrink-0 ring-1 ring-white/30"
|
||||
/>
|
||||
<motion.p
|
||||
layout
|
||||
className="text-xs"
|
||||
style={{ color: isDark ? '#888' : 'rgba(255,255,255,0.65)' }}
|
||||
>
|
||||
{story.author}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
<Link
|
||||
href={`/customers/${story.slug}`}
|
||||
className="text-xs underline"
|
||||
style={{ color: isDark ? '#555' : 'rgba(255,255,255,0.7)' }}
|
||||
>
|
||||
Read the story →
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Builder stories */}
|
||||
<div className="mt-10">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6">
|
||||
<div className="grid grid-cols-1 items-end lg:grid-cols-[420px_1fr] gap-8">
|
||||
<div className="flex flex-col md:min-h-[320px] gap-4 justify-between">
|
||||
<h3 className="text-xl md:text-3xl text-foreground-lighter">
|
||||
Powering the next wave
|
||||
<br />
|
||||
<span className="text-foreground">of AI builders</span>
|
||||
</h3>
|
||||
<p className="text-sm text-foreground-lighter leading-relaxed text-pretty max-w-sm">
|
||||
Create full backend experiences on behalf of your users with Supabase for Platforms,
|
||||
the all-in-one solution for building AI-native platforms and marketplaces.
|
||||
</p>
|
||||
|
||||
<Button type="default" size="medium" asChild className="w-fit mt-auto">
|
||||
<Link href="/partners/integrations">Explore Supabase for Platforms</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{aiBuilderStories.map((story) => (
|
||||
<Link
|
||||
key={story.slug}
|
||||
href={`/customers/${story.slug}`}
|
||||
className="group relative overflow-hidden rounded-lg w-full aspect-[4/3] border border-white/10 flex flex-col justify-end p-5"
|
||||
>
|
||||
{/* Photo background */}
|
||||
<img
|
||||
src={story.photo}
|
||||
alt={story.name}
|
||||
className="absolute inset-0 w-full h-full object-cover select-none scale-[1.015]"
|
||||
/>
|
||||
{/* Dark gradient overlay — heavier at bottom */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent" />
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-[background-color] duration-300" />
|
||||
|
||||
{/* Logo + description at bottom */}
|
||||
<div className="relative z-10 flex flex-col gap-2">
|
||||
<img
|
||||
src={story.logo}
|
||||
alt={story.name}
|
||||
className="h-6 w-auto object-contain object-left brightness-0 invert select-none"
|
||||
/>
|
||||
<p className="text-white/75 text-sm leading-relaxed text-pretty">
|
||||
{story.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import BrowserFrame from '~/components/BrowserFrame'
|
||||
import { AnimatePresence, motion, useInView } from 'framer-motion'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const dashboardStories = [
|
||||
{
|
||||
logo: '/images/customers/logos/hyper-icon.svg',
|
||||
name: 'Hyper',
|
||||
quote:
|
||||
'We store embeddings in a PostgreSQL database, hosted by Supabase, to perform a similarity search to identify the most relevant sections within the MDN.',
|
||||
author: 'Hermina Condei, Director at MDN, Mozilla',
|
||||
slug: 'mozilla-mdn',
|
||||
},
|
||||
{
|
||||
logo: '/images/customers/logos/chatbase-icon.svg',
|
||||
name: 'Chatbase',
|
||||
quote:
|
||||
'The SQL Editor in Supabase is my favorite feature — it lets us run complex queries, debug issues, and iterate on our schema without ever leaving the browser.',
|
||||
author: 'Yasser Elsaid, Founder of Chatbase',
|
||||
slug: 'chatbase',
|
||||
},
|
||||
{
|
||||
logo: '/images/customers/logos/markprompt-icon.svg',
|
||||
name: 'Markprompt',
|
||||
quote:
|
||||
'Row Level Security in Supabase allows us to set granular access policies directly in Postgres, so our multi-tenant architecture stays secure by default.',
|
||||
author: 'Michael Fester, Co-Founder of Markprompt',
|
||||
slug: 'markprompt',
|
||||
},
|
||||
]
|
||||
|
||||
export function DashboardFeaturesSection({
|
||||
title,
|
||||
tabs,
|
||||
}: {
|
||||
title: React.ReactNode
|
||||
tabs: {
|
||||
label: string
|
||||
panel: React.FC<{ isDark: boolean }>
|
||||
highlights: { label: string; link?: string }[]
|
||||
}[]
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [activeTabIdx, setActiveTabIdx] = useState(0)
|
||||
const sectionRef = useRef(null)
|
||||
const isInView = useInView(sectionRef, { once: true })
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
const Panel = tabs[activeTabIdx].panel
|
||||
const story = dashboardStories[activeTabIdx] ?? dashboardStories[0]
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div
|
||||
ref={sectionRef}
|
||||
className="relative mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-10 pb-4 overflow-hidden"
|
||||
>
|
||||
<div className="pt-12 flex flex-col gap-8">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter">{title}</h3>
|
||||
<div className="flex gap-2" role="tablist">
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveTabIdx(index)}
|
||||
aria-selected={index === activeTabIdx}
|
||||
role="tab"
|
||||
className={cn(
|
||||
'py-1.5 px-3 lg:py-2 lg:px-8 border rounded-full bg-alternative hover:border-foreground text-sm opacity-80 transition-all',
|
||||
'hover:border-foreground-lighter hover:text-foreground',
|
||||
index === activeTabIdx ? 'opacity-100 !border-foreground' : ''
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrowserFrame
|
||||
className="overflow-hidden bg-default w-full mx-auto translate-y-16"
|
||||
contentClassName="aspect-video overflow-hidden rounded-lg"
|
||||
>
|
||||
{isInView && (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={tabs[activeTabIdx]?.label}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.1, delay: 0.2 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="relative w-full max-w-full h-full"
|
||||
>
|
||||
<Panel
|
||||
key={String(resolvedTheme?.includes('dark'))}
|
||||
isDark={resolvedTheme?.includes('dark') ?? false}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
|
||||
{/* Customer story banner */}
|
||||
<div className="border-t border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] py-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={story.slug}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col md:flex-row md:items-center gap-4 md:gap-6 px-6 py-5"
|
||||
>
|
||||
<img
|
||||
src={story.logo}
|
||||
alt={story.name}
|
||||
className="h-8 w-8 shrink-0 object-contain grayscale opacity-80 dark:invert"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
<span className="font-medium text-foreground">{story.name}</span>
|
||||
{' — '}
|
||||
{story.quote} <span className="text-foreground-muted">{story.author}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/customers/${story.slug}`}
|
||||
className="shrink-0 text-sm text-foreground-lighter hover:text-foreground transition-colors whitespace-nowrap"
|
||||
>
|
||||
View more about {story.name}
|
||||
</Link>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { createHighlighter, type ThemeRegistration } from 'shiki'
|
||||
|
||||
import { FrameworksSectionClient } from './FrameworksSectionClient'
|
||||
|
||||
// ── Shiki themes ────────────────────────────────────────────────────────────
|
||||
|
||||
const supabaseDark: ThemeRegistration = {
|
||||
name: 'supabase-dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#ffffff',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#bda4ff' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
|
||||
{ scope: ['markup.underline.link'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['markup.inserted'], settings: { foreground: '#3ecf8e' } },
|
||||
{ scope: ['markup.deleted'], settings: { foreground: '#F06A50' } },
|
||||
],
|
||||
}
|
||||
|
||||
const supabaseLight: ThemeRegistration = {
|
||||
name: 'supabase-light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#525252',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#6b35dc' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#525252' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
|
||||
{ scope: ['markup.underline.link'], settings: { foreground: '#525252' } },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Framework data ──────────────────────────────────────────────────────────
|
||||
|
||||
const frameworksList: {
|
||||
name: string
|
||||
icon: string
|
||||
code: string
|
||||
lang: 'javascript' | 'dart' | 'svelte' | 'vue'
|
||||
docsUrl: string
|
||||
}[] = [
|
||||
{
|
||||
name: 'React',
|
||||
icon: 'M45.74 23.6983C45.2739 23.5379 44.7909 23.3861 44.2937 23.2426C44.3754 22.909 44.4504 22.5798 44.5171 22.2561C45.6119 16.9418 44.8961 12.6605 42.4518 11.2509C40.1079 9.89927 36.2748 11.3085 32.4035 14.6776C32.0313 15.0016 31.6579 15.3446 31.2848 15.704C31.0362 15.4662 30.7879 15.2364 30.5403 15.0165C26.4831 11.4141 22.4164 9.89599 19.9744 11.3097C17.6329 12.6652 16.9394 16.69 17.9249 21.7265C18.0201 22.2129 18.1313 22.7097 18.2571 23.2148C17.6816 23.3782 17.1259 23.5524 16.5943 23.7377C11.8376 25.3961 8.7998 27.9952 8.7998 30.6911C8.7998 33.4755 12.0609 36.2683 17.0153 37.9617C17.4063 38.0953 17.812 38.2217 18.2301 38.3416C18.0944 38.8879 17.9763 39.4232 17.8773 39.9454C16.9376 44.8944 17.6714 48.8242 20.0068 50.1711C22.4189 51.5622 26.4673 50.1324 30.4093 46.6865C30.7209 46.4141 31.0336 46.1253 31.3469 45.8225C31.7529 46.2135 32.1582 46.5835 32.5615 46.9306C36.3798 50.2164 40.151 51.5432 42.4842 50.1925C44.894 48.7975 45.6772 44.576 44.6604 39.4399C44.5828 39.0476 44.4924 38.6469 44.3909 38.239C44.6752 38.155 44.9543 38.0682 45.2265 37.978C50.3771 36.2715 53.7282 33.5127 53.7282 30.6911C53.7282 27.9854 50.5924 25.3688 45.74 23.6983ZM44.6228 36.1561C44.3772 36.2375 44.1251 36.3161 43.8682 36.3923C43.2996 34.5922 42.5322 32.6781 41.5931 30.7005C42.4893 28.7699 43.227 26.8803 43.7797 25.0919C44.2393 25.2249 44.6854 25.3651 45.1152 25.5132C49.2728 26.9444 51.8089 29.0605 51.8089 30.6911C51.8089 32.4279 49.07 34.6826 44.6228 36.1561ZM42.7776 39.8126C43.2272 42.0837 43.2914 44.1371 42.9936 45.7423C42.726 47.1847 42.1878 48.1463 41.5225 48.5315C40.1066 49.351 37.0787 48.2857 33.8132 45.4757C33.4388 45.1535 33.0618 44.8096 32.6835 44.4455C33.9495 43.061 35.2147 41.4514 36.4495 39.6638C38.6215 39.4711 40.6735 39.156 42.5344 38.7258C42.626 39.0955 42.7074 39.4581 42.7776 39.8126ZM24.1169 48.3898C22.7336 48.8784 21.6318 48.8924 20.9658 48.5084C19.5486 47.691 18.9594 44.5358 19.7631 40.3033C19.8551 39.8186 19.9647 39.3207 20.091 38.8118C21.9314 39.2187 23.9684 39.5116 26.1456 39.6881C27.3887 41.4373 28.6905 43.0452 30.0024 44.453C29.7157 44.7297 29.4302 44.9931 29.1463 45.2413C27.4032 46.7651 25.6564 47.8461 24.1169 48.3898ZM17.6361 36.1455C15.4453 35.3967 13.6361 34.4235 12.396 33.3616C11.2817 32.4073 10.7191 31.4599 10.7191 30.6911C10.7191 29.0551 13.1581 26.9684 17.226 25.5501C17.7196 25.378 18.2363 25.2158 18.7725 25.0635C19.3347 26.8923 20.0722 28.8043 20.9623 30.7378C20.0607 32.7 19.3128 34.6425 18.745 36.4927C18.3628 36.3829 17.9924 36.2672 17.6361 36.1455ZM19.8085 21.3579C18.9642 17.0428 19.5249 13.7876 20.936 12.9708C22.4391 12.1006 25.7628 13.3413 29.2659 16.4518C29.4898 16.6506 29.7146 16.8587 29.9401 17.074C28.6347 18.4756 27.3448 20.0714 26.1127 21.8103C23.9997 22.0061 21.977 22.3208 20.1174 22.742C20.0004 22.2717 19.8969 21.8097 19.8085 21.3579ZM39.1886 26.1433C38.744 25.3754 38.2876 24.6257 37.8223 23.8964C39.2558 24.0777 40.6293 24.3182 41.9191 24.6126C41.5318 25.8536 41.0492 27.1511 40.4811 28.4813C40.0735 27.7076 39.6425 26.9275 39.1886 26.1433ZM31.2854 18.4456C32.1707 19.4047 33.0573 20.4756 33.9293 21.6374C33.0506 21.5959 32.161 21.5743 31.264 21.5743C30.3755 21.5743 29.4925 21.5954 28.6192 21.6362C29.4921 20.4852 30.3863 19.4158 31.2854 18.4456ZM23.3317 26.1566C22.8876 26.9267 22.4645 27.7025 22.0634 28.4799C21.5045 27.1543 21.0263 25.8509 20.6357 24.5923C21.9176 24.3054 23.2846 24.0709 24.7089 23.8931C24.2371 24.6291 23.7769 25.3843 23.3317 26.1564V26.1566ZM24.75 37.626C23.2783 37.4618 21.8908 37.2394 20.6093 36.9604C21.0061 35.6793 21.4948 34.3481 22.0655 32.994C22.4677 33.7707 22.8925 34.5469 23.3393 35.3187H23.3393C23.7945 36.1049 24.266 36.875 24.75 37.626ZM31.3385 43.0719C30.4289 42.0904 29.5215 41.0047 28.6353 39.8368C29.4956 39.8706 30.3726 39.8879 31.264 39.8879C32.1798 39.8879 33.085 39.8672 33.9761 39.8276C33.1012 41.0164 32.2178 42.1038 31.3385 43.0719ZM40.4994 32.9249C41.0999 34.2937 41.6061 35.618 42.0081 36.8772C40.7054 37.1744 39.2989 37.4138 37.8171 37.5916C38.2835 36.8525 38.7439 36.0899 39.1963 35.3055C39.6539 34.5118 40.0885 33.717 40.4994 32.9249ZM37.5337 34.3466C36.8314 35.5643 36.1104 36.7268 35.3784 37.8241C34.0452 37.9194 32.6678 37.9685 31.264 37.9685C29.8659 37.9685 28.5058 37.9251 27.1962 37.8401C26.4347 36.7284 25.698 35.5625 25.0002 34.3571H25.0004C24.3044 33.155 23.6638 31.9427 23.0834 30.7372C23.6636 29.5289 24.3025 28.3152 24.9945 27.1152L24.9944 27.1155C25.6882 25.9123 26.4184 24.7521 27.1729 23.6473C28.509 23.5463 29.8792 23.4936 31.2639 23.4936H31.264C32.655 23.4936 34.0269 23.5467 35.3626 23.6486C36.1056 24.7453 36.8308 25.9017 37.5274 27.1051C38.2319 28.3219 38.879 29.5275 39.4642 30.7099C38.8808 31.9126 38.2351 33.1303 37.5337 34.3466ZM41.4931 12.9137C42.9976 13.7813 43.5826 17.2804 42.6374 21.8688C42.5771 22.1615 42.5092 22.4597 42.4354 22.762C40.5715 22.3319 38.5474 22.0118 36.4282 21.813C35.1937 20.055 33.9143 18.4567 32.6302 17.0731C32.9755 16.741 33.3202 16.4243 33.6636 16.1254C36.9805 13.2388 40.0806 12.0991 41.4931 12.9137ZM31.264 26.6791C3Line truncated
|
||||
code: `import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.SUPABASE_URL,
|
||||
process.env.SUPABASE_ANON_KEY
|
||||
)
|
||||
|
||||
export default function App() {
|
||||
const [todos, setTodos] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
supabase.from('todos').select('*')
|
||||
.then(({ data }) => setTodos(data))
|
||||
}, [])
|
||||
|
||||
return <TodoList items={todos} />
|
||||
}`,
|
||||
lang: 'javascript' as const,
|
||||
docsUrl: '/docs/guides/getting-started/quickstarts/reactjs',
|
||||
},
|
||||
{
|
||||
name: 'Next.js',
|
||||
icon: 'M42.3148 48.6796C38.9009 50.9525 34.8014 52.2771 30.3924 52.2771C18.4957 52.2771 8.85156 42.6329 8.85156 30.7362C8.85156 18.8395 18.4957 9.19531 30.3924 9.19531C42.2891 9.19531 51.9333 18.8395 51.9333 30.7362C51.9333 37.1564 49.1245 42.9207 44.6688 46.8671L39.5552 40.2803V21.8278H36.584V36.4531L25.2299 21.8278H21.4808V39.6473H24.4801V25.6368L42.3148 48.6796Z',
|
||||
code: `import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
)
|
||||
|
||||
export default async function Page() {
|
||||
const { data: todos } = await supabase
|
||||
.from('todos')
|
||||
.select('*')
|
||||
|
||||
return <TodoList items={todos} />
|
||||
}`,
|
||||
lang: 'javascript' as const,
|
||||
docsUrl: '/docs/guides/getting-started/quickstarts/nextjs',
|
||||
},
|
||||
{
|
||||
name: 'Flutter',
|
||||
icon: 'M46.5067 10.3828L34.3509 10.3962L14.75 29.9971L20.7974 36.0519L26.1125 30.7666L46.5067 10.3828Z M34.6996 28.4653C34.5272 28.4573 34.3493 28.4491 34.2378 28.5965L23.7856 39.0471L29.7894 45.0142L29.7825 45.021L34.079 49.3212C34.1072 49.3462 34.1352 49.3741 34.1637 49.4026C34.2813 49.5201 34.4074 49.6462 34.5895 49.6055C36.5743 49.601 38.5591 49.6017 40.544 49.6025C42.529 49.6032 44.5142 49.604 46.4998 49.5995L35.9333 39.0234L46.4963 28.467L34.906 28.464C34.8415 28.4719 34.7711 28.4686 34.6996 28.4653Z',
|
||||
code: `import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
await Supabase.initialize(
|
||||
url: 'YOUR_SUPABASE_URL',
|
||||
anonKey: 'YOUR_SUPABASE_ANON_KEY',
|
||||
);
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
final supabase = Supabase.instance.client;
|
||||
|
||||
final data = await supabase
|
||||
.from('todos')
|
||||
.select('*');`,
|
||||
lang: 'dart' as const,
|
||||
docsUrl: '/docs/guides/getting-started/quickstarts/flutter',
|
||||
},
|
||||
{
|
||||
name: 'Svelte',
|
||||
icon: 'M29.9094 11.2292C35.956 7.37668 44.3187 9.17299 48.553 15.2334H48.5532C50.5831 18.0746 51.3826 21.614 50.771 25.0519C50.4778 26.677 49.8581 28.2259 48.9493 29.6047C50.2752 32.1335 50.7201 35.0322 50.2136 37.8422C49.6086 41.2154 47.6106 44.1777 44.7096 46.0024L34.0903 52.7707C28.0445 56.623 19.6818 54.8274 15.4466 48.7665C13.4171 45.9251 12.6176 42.3859 13.2288 38.948C13.5223 37.3227 14.1422 35.7738 15.0512 34.3949C13.7247 31.8665 13.2794 28.9677 13.786 26.1577C14.3913 22.7845 16.3893 19.8223 19.29 17.9974L29.9094 11.2292ZM19.8146 45.9861C21.8311 48.8931 25.4469 50.2333 28.8709 49.343H28.8708C29.6345 49.139 30.3624 48.8192 31.0293 48.3946L41.6512 41.6252C43.396 40.5274 44.5979 38.7455 44.9622 36.7164C45.33 34.6483 44.8489 32.5192 43.6278 30.8101C41.6113 27.9032 37.9955 26.5629 34.5715 27.4531C33.8084 27.6571 33.081 27.9768 32.4147 28.4012L28.3617 30.9842C28.1601 31.1125 27.9401 31.2092 27.7093 31.271C26.6776 31.5384 25.5887 31.1342 24.9815 30.2584C24.614 29.7429 24.4693 29.1012 24.5801 28.4779C24.6899 27.8669 25.0519 27.3302 25.5774 26.9996L36.2002 20.2298C36.4017 20.1015 36.6218 20.0048 36.8526 19.9431C37.8838 19.6754 38.9725 20.0795 39.5793 20.9551C39.9039 21.4146 40.0556 21.974 40.0078 22.5345L39.9714 22.9285L40.3662 23.0484C41.8596 23.4989 43.265 24.2014 44.5218 25.1254L45.0657 25.5245L45.2658 24.9145C45.3729 24.59 45.4577 24.2586 45.5196 23.9225C45.8873 21.8544 45.4063 19.7254 44.1852 18.0162C42.1687 15.1093 38.553 13.7691 35.129 14.6593C34.3653 14.8633 33.6374 15.1832 32.9705 15.6077L22.3487 22.3777C20.6036 23.475 19.4016 25.2568 19.0376 27.2858C18.6699 29.3539 19.1509 31.4829 20.372 33.192C22.3885 36.099 26.0043 37.4392 29.4283 36.549C30.1914 36.345 30.9188 36.0256 31.5853 35.6017L35.6389 33.0177C35.8402 32.8895 36.06 32.7929 36.2905 32.7311C37.3221 32.4637 38.4111 32.868 39.0183 33.7438C39.3857 34.2592 39.5306 34.9007 39.4205 35.524C39.3102 36.1352 38.948 36.6718 38.4224 37.0028L27.7996 43.7722C27.5981 43.9006 27.378 43.9973 27.1471 44.059C26.116 44.3266 25.0273 43.9225 24.4204 43.0469C24.0955 42.5876 23.9438 42.0281 23.992 41.4675L24.0284 41.0735L23.6336 40.9537C22.1404 40.5032 20.7351 39.8011 19.4783 38.8776L18.934 38.4778L18.734 39.0878C18.6266 39.4122 18.5418 39.7437 18.4801 40.0798C18.1125 42.1479 18.5935 44.277 19.8146 45.9861Z',
|
||||
code: `<script>
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient(
|
||||
PUBLIC_SUPABASE_URL,
|
||||
PUBLIC_SUPABASE_ANON_KEY
|
||||
)
|
||||
|
||||
let todos = []
|
||||
|
||||
async function loadTodos() {
|
||||
const { data } = await supabase
|
||||
.from('todos').select('*')
|
||||
todos = data
|
||||
}
|
||||
</script>`,
|
||||
lang: 'svelte' as const,
|
||||
docsUrl: '/docs/guides/getting-started/quickstarts/sveltekit',
|
||||
},
|
||||
{
|
||||
name: 'Vue',
|
||||
icon: 'M43.0532 13.4531H50.1147L30.2756 47.8158L10.4365 13.4531H17.4978L30.2755 35.5845L43.0532 13.4531ZM42.1764 13.4531L30.2755 34.0659L18.3746 13.4531L25.6939 13.4531L30.2756 21.3888L34.8572 13.4531L42.1764 13.4531Z',
|
||||
code: `<script setup>
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const supabase = createClient(
|
||||
import.meta.env.VITE_SUPABASE_URL,
|
||||
import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||
)
|
||||
|
||||
const todos = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await supabase
|
||||
.from('todos').select('*')
|
||||
todos.value = data
|
||||
})
|
||||
</script>`,
|
||||
lang: 'vue' as const,
|
||||
docsUrl: '/docs/guides/getting-started/quickstarts/vuejs',
|
||||
},
|
||||
{
|
||||
name: 'Nuxt',
|
||||
icon: 'M32.5784 45.4741H50.2199C50.7802 45.4741 51.3307 45.3325 51.8159 45.0634C52.3012 44.7943 52.7041 44.4072 52.9842 43.9409C53.2642 43.4748 53.4115 42.946 53.4113 42.4078C53.4111 41.8696 53.2633 41.3409 52.9828 40.875L41.1352 21.164C40.8552 20.6979 40.4524 20.3109 39.9672 20.0418C39.4821 19.7727 38.9317 19.631 38.3715 19.631C37.8113 19.631 37.261 19.7727 36.7758 20.0418C36.2906 20.3109 35.8878 20.6979 35.6078 21.164L32.5784 26.2073L26.6555 16.3452C26.3753 15.8792 25.9723 15.4922 25.487 15.2232C25.0017 14.9541 24.4513 14.8125 23.8909 14.8125C23.3306 14.8125 22.7802 14.9541 22.2949 15.2232C21.8096 14.9541 21.4066 15.8792 21.1263 16.3452L6.38358 40.875C6.10311 41.3409 5.95532 41.8696 5.95508 42.4078C5.95483 42.946 6.10214 43.4748 6.38219 43.9409C6.66224 44.4072 7.06515 44.7943 7.5504 45.0634C8.03564 45.3325 8.58612 45.4741 9.14645 45.4741H20.2203C24.6079 45.4741 27.8436 43.6229 30.07 40.0113L38.3706 26.2073L47.0599 40.6619H35.4754L32.5784 45.4741Z',
|
||||
code: `<script setup>
|
||||
const supabase = useSupabaseClient()
|
||||
|
||||
const { data: todos } = await useAsyncData(
|
||||
'todos',
|
||||
async () => {
|
||||
const { data } = await supabase
|
||||
.from('todos')
|
||||
.select('*')
|
||||
return data
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TodoList :items="todos" />
|
||||
</template>`,
|
||||
lang: 'vue' as const,
|
||||
docsUrl: '/docs/guides/getting-started/quickstarts/nuxtjs',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Example links per framework ──────────────────────────────────────────────
|
||||
|
||||
const frameworkExamples: Record<
|
||||
string,
|
||||
{ title: string; description: string; url: string; icon: string }[]
|
||||
> = {
|
||||
React: [
|
||||
{
|
||||
title: 'Todo List',
|
||||
description: 'CRUD app with real-time and RLS.',
|
||||
url: '/docs/guides/getting-started/tutorials/with-react',
|
||||
icon: 'ListChecks',
|
||||
},
|
||||
{
|
||||
title: 'Auth & User Management',
|
||||
description: 'Email, social login, and user profiles.',
|
||||
url: '/docs/guides/auth/quickstarts/react',
|
||||
icon: 'Users',
|
||||
},
|
||||
],
|
||||
'Next.js': [
|
||||
{
|
||||
title: 'App Router Quickstart',
|
||||
description: 'Server Components with SSR auth.',
|
||||
url: '/docs/guides/getting-started/quickstarts/nextjs',
|
||||
icon: 'Rocket',
|
||||
},
|
||||
{
|
||||
title: 'Auth with PKCE',
|
||||
description: 'Secure auth flow with middleware.',
|
||||
url: '/docs/guides/auth/server-side/nextjs',
|
||||
icon: 'KeyRound',
|
||||
},
|
||||
],
|
||||
Flutter: [
|
||||
{
|
||||
title: 'User Management',
|
||||
description: 'Auth and profile management.',
|
||||
url: '/docs/guides/getting-started/tutorials/with-flutter',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
title: 'Deep Linking',
|
||||
description: 'OAuth and magic link for mobile.',
|
||||
url: '/docs/guides/auth/native-mobile-deep-linking',
|
||||
icon: 'Link2',
|
||||
},
|
||||
],
|
||||
Svelte: [
|
||||
{
|
||||
title: 'Todo List',
|
||||
description: 'Server-side auth and real-time data.',
|
||||
url: '/docs/guides/getting-started/tutorials/with-svelte',
|
||||
icon: 'ListChecks',
|
||||
},
|
||||
{
|
||||
title: 'Auth Helpers',
|
||||
description: 'Sessions and protected routes.',
|
||||
url: '/docs/guides/auth/server-side/sveltekit',
|
||||
icon: 'ShieldCheck',
|
||||
},
|
||||
],
|
||||
Vue: [
|
||||
{
|
||||
title: 'Todo List',
|
||||
description: 'CRUD with Vue 3 Composition API.',
|
||||
url: '/docs/guides/getting-started/tutorials/with-vue-3',
|
||||
icon: 'ListChecks',
|
||||
},
|
||||
{
|
||||
title: 'Auth with Vue',
|
||||
description: 'Email and OAuth authentication.',
|
||||
url: '/docs/guides/getting-started/quickstarts/vuejs',
|
||||
icon: 'KeyRound',
|
||||
},
|
||||
],
|
||||
Nuxt: [
|
||||
{
|
||||
title: 'Nuxt Module',
|
||||
description: 'Auto-configured auth and client.',
|
||||
url: '/docs/guides/getting-started/quickstarts/nuxtjs',
|
||||
icon: 'Blocks',
|
||||
},
|
||||
{
|
||||
title: 'Server Routes',
|
||||
description: 'Service role access from server.',
|
||||
url: '/docs/guides/auth/server-side/nuxt',
|
||||
icon: 'Server',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ── Server component: pre-highlights all code ───────────────────────────────
|
||||
|
||||
export async function FrameworksSection() {
|
||||
const hl = await createHighlighter({
|
||||
themes: [supabaseDark, supabaseLight],
|
||||
langs: ['javascript', 'dart', 'svelte', 'vue'],
|
||||
})
|
||||
|
||||
const frameworks = frameworksList.map((fw) => ({
|
||||
name: fw.name,
|
||||
icon: fw.icon,
|
||||
docsUrl: fw.docsUrl,
|
||||
darkHtml: hl.codeToHtml(fw.code, { lang: fw.lang, theme: 'supabase-dark' }),
|
||||
lightHtml: hl.codeToHtml(fw.code, { lang: fw.lang, theme: 'supabase-light' }),
|
||||
examples: frameworkExamples[fw.name] ?? [],
|
||||
}))
|
||||
|
||||
hl.dispose()
|
||||
|
||||
return <FrameworksSectionClient frameworks={frameworks} />
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
type Framework = {
|
||||
name: string
|
||||
icon: string
|
||||
docsUrl: string
|
||||
darkHtml: string
|
||||
lightHtml: string
|
||||
examples: { title: string; description: string; url: string; icon: string }[]
|
||||
}
|
||||
|
||||
export function FrameworksSectionClient({ frameworks }: { frameworks: Framework[] }) {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const active = frameworks[activeIdx]
|
||||
|
||||
const handleTabChange = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border py-24">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 md:gap-0">
|
||||
{/* Left: title */}
|
||||
<div className="flex flex-col justify-between py-4 gap-6 md:gap-10">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter">
|
||||
Use Supabase with{' '}
|
||||
<span className="block">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={active.name}
|
||||
initial={{ filter: 'blur(2px)', opacity: 0 }}
|
||||
animate={{ filter: 'blur(0px)', opacity: 1, transition: { duration: 0.25 } }}
|
||||
exit={{ filter: 'blur(2px)', opacity: 0, transition: { duration: 0.1 } }}
|
||||
className="inline-block text-foreground"
|
||||
>
|
||||
{active.name}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Right: icon tabs + code */}
|
||||
<div className="border border-border flex flex-col rounded-md overflow-clip">
|
||||
{/* 6-col icon row */}
|
||||
<div className="grid grid-cols-6 border-b border-border">
|
||||
{frameworks.map((framework, index) => (
|
||||
<button
|
||||
key={framework.name}
|
||||
onClick={() => handleTabChange(index)}
|
||||
className={cn(
|
||||
'flex items-center justify-center py-4 border-r border-border last:border-r-0 transition-colors',
|
||||
index === activeIdx
|
||||
? 'bg-surface-75 text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light hover:bg-surface-75/50'
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width={28}
|
||||
height={28}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
viewBox="0 0 61 61"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d={framework.icon} fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Code area */}
|
||||
<div className="relative h-[440px] overflow-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.name}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
|
||||
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.lightHtml }}
|
||||
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Link
|
||||
href={active.docsUrl}
|
||||
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Read docs for <span>{active.name}</span>
|
||||
<svg
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
export function Hero() {
|
||||
const sendTelemetryEvent = useSendTelemetryEvent()
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-32 pb-24 md:pt-40 md:pb-32">
|
||||
<div className="flex flex-col gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16 items-end">
|
||||
<h1 className="text-foreground text-4xl sm:text-5xl sm:leading-none">
|
||||
<span className="block">Build in a weekend</span>
|
||||
<span className="text-brand block">Scale to millions</span>
|
||||
</h1>
|
||||
<p className="text-foreground-lighter text-sm text-balance">
|
||||
Start your project with a Postgres database. Add Authentication, Data APIs, Edge
|
||||
Functions, Realtime Data, Storage, and Vector embeddings.
|
||||
</p>
|
||||
</div>
|
||||
<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: 'Homepage Hero' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Start your project
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="medium" type="default">
|
||||
<Link
|
||||
href="/contact/sales"
|
||||
onClick={() =>
|
||||
sendTelemetryEvent({
|
||||
action: 'request_demo_button_clicked',
|
||||
properties: { buttonLocation: 'Homepage Hero' },
|
||||
})
|
||||
}
|
||||
>
|
||||
Request a demo
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import getContent from '~/data/home/content'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
import { BuiltWithSupabaseSection } from './BuiltWithSupabaseSection'
|
||||
import { CommunitySectionV2 as CommunitySection } from './CommunitySectionV2'
|
||||
import { CTASection } from './CTASection'
|
||||
import { CustomerStoriesSection } from './CustomerStoriesSection'
|
||||
import { DashboardFeaturesSection } from './DashboardFeaturesSection'
|
||||
import { Hero } from './Hero'
|
||||
import { LogosGrid } from './LogosGrid'
|
||||
import { OpenSourceSection } from './OpenSourceSection'
|
||||
|
||||
const Products = dynamic(() => import('~/components/Products/index'))
|
||||
|
||||
function SectionBlock({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <section className={`border-b border-border ${className ?? ''}`}>{children}</section>
|
||||
}
|
||||
|
||||
export function HomeContent({ frameworksSlot }: { frameworksSlot: React.ReactNode }) {
|
||||
const content = getContent()
|
||||
|
||||
return (
|
||||
<div className="[&_.container]:!max-w-[var(--container-max-w,75rem)] [&_.container]:!px-6">
|
||||
<section>
|
||||
<Hero />
|
||||
</section>
|
||||
<SectionBlock className="border-b-0 relative">
|
||||
<div className="-translate-y-16">
|
||||
<Products {...content.productsSection} />
|
||||
</div>
|
||||
</SectionBlock>
|
||||
<section>
|
||||
<LogosGrid />
|
||||
</section>
|
||||
<section>
|
||||
<DashboardFeaturesSection {...content.dashboardFeatures} />
|
||||
</section>
|
||||
<section>{frameworksSlot}</section>
|
||||
<section className="border-b border-border">
|
||||
<BuiltWithSupabaseSection />
|
||||
</section>
|
||||
<section>
|
||||
<CustomerStoriesSection />
|
||||
</section>
|
||||
<section>
|
||||
<CommunitySection />
|
||||
</section>
|
||||
<section>
|
||||
<OpenSourceSection />
|
||||
</section>
|
||||
<section>
|
||||
<CTASection />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ComponentProps, useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
BetasharesLogo,
|
||||
BoltLogo,
|
||||
ChatbaseLogo,
|
||||
FigmaLogo,
|
||||
GithubLogo,
|
||||
GoodtapeLogo,
|
||||
GopuffLogo,
|
||||
GumloopLogo,
|
||||
HappyteamsLogo,
|
||||
HumataLogo,
|
||||
LangchainLogo,
|
||||
LoopsLogo,
|
||||
LovableLogo,
|
||||
MarkpromptLogo,
|
||||
MdnLogo,
|
||||
MobbinLogo,
|
||||
MozillaLogo,
|
||||
PikaLogo,
|
||||
PwcLogo,
|
||||
ResendLogo,
|
||||
SoshiLogo,
|
||||
SubmagicLogo,
|
||||
TempoLogo,
|
||||
V0Logo,
|
||||
} from './logos/PublicityLogos'
|
||||
|
||||
const gridLogos: { name: string; Logo: (props: ComponentProps<'svg'>) => React.JSX.Element }[] = [
|
||||
{ name: 'betashares', Logo: BetasharesLogo },
|
||||
{ name: 'bolt', Logo: BoltLogo },
|
||||
{ name: 'chatbase', Logo: ChatbaseLogo },
|
||||
{ name: 'figma', Logo: FigmaLogo },
|
||||
{ name: 'github', Logo: GithubLogo },
|
||||
{ name: 'goodtape', Logo: GoodtapeLogo },
|
||||
{ name: 'gopuff', Logo: GopuffLogo },
|
||||
{ name: 'gumloop', Logo: GumloopLogo },
|
||||
{ name: 'happyteams', Logo: HappyteamsLogo },
|
||||
{ name: 'humata', Logo: HumataLogo },
|
||||
{ name: 'langchain', Logo: LangchainLogo },
|
||||
{ name: 'loops', Logo: LoopsLogo },
|
||||
{ name: 'lovable', Logo: LovableLogo },
|
||||
{ name: 'markprompt', Logo: MarkpromptLogo },
|
||||
{ name: 'mdn', Logo: MdnLogo },
|
||||
{ name: 'mobbin', Logo: MobbinLogo },
|
||||
{ name: 'mozilla', Logo: MozillaLogo },
|
||||
{ name: 'pika', Logo: PikaLogo },
|
||||
{ name: 'pwc', Logo: PwcLogo },
|
||||
{ name: 'resend', Logo: ResendLogo },
|
||||
{ name: 'soshi', Logo: SoshiLogo },
|
||||
{ name: 'submagic', Logo: SubmagicLogo },
|
||||
{ name: 'tempo', Logo: TempoLogo },
|
||||
{ name: 'v0', Logo: V0Logo },
|
||||
]
|
||||
|
||||
const LOGOS_PER_PAGE = 12
|
||||
|
||||
export function LogosGrid() {
|
||||
const [page, setPage] = useState(0)
|
||||
const totalPages = Math.ceil(gridLogos.length / LOGOS_PER_PAGE)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPage((p) => (p + 1) % totalPages)
|
||||
}, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [totalPages])
|
||||
|
||||
const start = page * LOGOS_PER_PAGE
|
||||
const currentLogos = gridLogos.slice(start, start + LOGOS_PER_PAGE)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 py-6">
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Trusted by fast-growing companies worldwide
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-y border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 border-x border-border py-10">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={page}
|
||||
className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-y-10 gap-x-6"
|
||||
>
|
||||
{currentLogos.map(({ name, Logo }, i) => (
|
||||
<motion.div
|
||||
key={name}
|
||||
initial={{ opacity: 0, scale: 0.95, filter: 'blur(2px)' }}
|
||||
animate={{ opacity: 0.7, scale: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, scale: 0.95, filter: 'blur(2px)' }}
|
||||
transition={{
|
||||
duration: 0.45,
|
||||
delay: i * 0.03,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
}}
|
||||
className="flex items-center justify-center h-10 text-foreground-lighter"
|
||||
>
|
||||
<Logo className="h-8 lg:h-12 w-auto" />
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
||||
import { useInView } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { useRef } from 'react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
// ── Pixel font (5×7 per glyph) ─────────────────────────────────────────────
|
||||
|
||||
const GLYPHS: Record<string, string[]> = {
|
||||
'0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
|
||||
'1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
|
||||
'2': ['01110', '10001', '00001', '00110', '01000', '10000', '11111'],
|
||||
'3': ['01110', '10001', '00001', '00110', '00001', '10001', '01110'],
|
||||
'4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
|
||||
'5': ['11111', '10000', '11110', '00001', '00001', '10001', '01110'],
|
||||
'6': ['01110', '10001', '10000', '11110', '10001', '10001', '01110'],
|
||||
'7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
|
||||
'8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
|
||||
'9': ['01110', '10001', '10001', '01111', '00001', '10001', '01110'],
|
||||
'.': ['00000', '00000', '00000', '00000', '00000', '00000', '00100'],
|
||||
K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
|
||||
' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
|
||||
}
|
||||
|
||||
function textToPixelMask(text: string): Set<string> {
|
||||
const mask = new Set<string>()
|
||||
let cursorX = 0
|
||||
for (const ch of text) {
|
||||
const glyph = GLYPHS[ch]
|
||||
if (!glyph) {
|
||||
cursorX += 3
|
||||
continue
|
||||
}
|
||||
for (let row = 0; row < glyph.length; row++) {
|
||||
for (let col = 0; col < glyph[row].length; col++) {
|
||||
if (glyph[row][col] === '1') mask.add(`${cursorX + col},${row}`)
|
||||
}
|
||||
}
|
||||
cursorX += 6 // 5 wide + 1 gap
|
||||
}
|
||||
return mask
|
||||
}
|
||||
|
||||
// ── Contribution graph ──────────────────────────────────────────────────────
|
||||
|
||||
const CELL = 3
|
||||
const GAP = 0.5
|
||||
const COLS = 72
|
||||
const ROWS = 40
|
||||
|
||||
const TEXT = '98.4K'
|
||||
const TEXT_MASK = textToPixelMask(TEXT)
|
||||
const TEXT_W = TEXT.length * 6 - 1
|
||||
const TEXT_H = 7
|
||||
// Position text toward the right
|
||||
const TEXT_OFFSET_X = Math.floor(COLS * 0.56 - TEXT_W / 2)
|
||||
const TEXT_OFFSET_Y = Math.floor((ROWS - TEXT_H) / 2)
|
||||
|
||||
const LEVELS_BG = [
|
||||
'hsl(var(--background-surface-300) / 0.2)',
|
||||
'hsl(var(--brand-400) / 0.08)',
|
||||
'hsl(var(--brand-500) / 0.1)',
|
||||
'hsl(var(--brand-600) / 0.12)',
|
||||
'hsl(var(--brand-default) / 0.12)',
|
||||
]
|
||||
|
||||
const LEVELS_TEXT = [
|
||||
'hsl(var(--brand-600))',
|
||||
'hsl(var(--brand-default))',
|
||||
'hsl(var(--brand-default))',
|
||||
'hsl(var(--brand-600))',
|
||||
'hsl(var(--brand-500))',
|
||||
]
|
||||
|
||||
function makeGrid(): { level: number; isText: boolean }[][] {
|
||||
let seed = 0x9e3779b9
|
||||
const rand = () => {
|
||||
seed ^= seed << 13
|
||||
seed ^= seed >> 17
|
||||
seed ^= seed << 5
|
||||
return (seed >>> 0) / 0xffffffff
|
||||
}
|
||||
return Array.from({ length: COLS }, (_, c) =>
|
||||
Array.from({ length: ROWS }, (_, r) => {
|
||||
const v = rand()
|
||||
const level = v < 0.45 ? 0 : v < 0.65 ? 1 : v < 0.8 ? 2 : v < 0.92 ? 3 : 4
|
||||
const localX = c - TEXT_OFFSET_X
|
||||
const localY = r - TEXT_OFFSET_Y
|
||||
const isText = TEXT_MASK.has(`${localX},${localY}`)
|
||||
return { level, isText }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const GRID = makeGrid()
|
||||
const SVG_W = COLS * (CELL + GAP) - GAP
|
||||
const SVG_H = ROWS * (CELL + GAP) - GAP
|
||||
|
||||
function ContribGraph() {
|
||||
const ref = useRef<SVGSVGElement>(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-50px' })
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||
preserveAspectRatio="xMaxYMid slice"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
aria-hidden
|
||||
>
|
||||
{GRID.map((col, c) =>
|
||||
col.map(({ level, isText }, r) => (
|
||||
<rect
|
||||
key={`${c}-${r}`}
|
||||
x={c * (CELL + GAP)}
|
||||
y={r * (CELL + GAP)}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
rx={1}
|
||||
fill={isText ? LEVELS_TEXT[level] : LEVELS_BG[level]}
|
||||
opacity={isInView ? 1 : 0}
|
||||
style={{
|
||||
transition: `opacity ${isText ? '0.6s' : '0.4s'} ease ${isText ? 0.3 + c * 0.015 : c * 0.02 + r * 0.01}s`,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function OpenSourceSection() {
|
||||
const sendTelemetryEvent = useSendTelemetryEvent()
|
||||
|
||||
const starsNum = 98400
|
||||
|
||||
return (
|
||||
<div className="border-b border-border overflow-hidden">
|
||||
<div className="relative py-24 flex flex-col justify-center">
|
||||
{/* Contrib graph background — right-aligned, radial fade */}
|
||||
<div
|
||||
className="hidden md:block absolute inset-y-0 left-1/4 right-[calc((100%-var(--container-max-w,75rem))/2*-1)]"
|
||||
style={{
|
||||
maskImage: 'radial-gradient(ellipse 55% 90% at 58% 50%, black 15%, transparent 60%)',
|
||||
WebkitMaskImage:
|
||||
'radial-gradient(ellipse 55% 90% at 58% 50%, black 15%, transparent 60%)',
|
||||
}}
|
||||
>
|
||||
<ContribGraph />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-[var(--container-max-w,75rem)] mx-auto px-6">
|
||||
<div className="flex flex-col items-center text-center md:items-start md:text-left justify-between gap-6 py-10 max-w-lg md:max-w-lg mx-auto md:mx-0">
|
||||
<div>
|
||||
<h2 className="text-4xl text-foreground text-balance">Open source from day one</h2>
|
||||
<p className="mt-4 text-sm leading-relaxed text-foreground-lighter">
|
||||
Supabase is built in the open because we believe great developer tools should be
|
||||
transparent, inspectable, and owned by the community. Read, contribute, self-host.
|
||||
You're never locked in, and always in control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
asChild
|
||||
size="small"
|
||||
type="default"
|
||||
icon={
|
||||
<svg viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.5 2.22168C5.23312 2.22168 2.58496 4.87398 2.58496 8.14677C2.58496 10.7642 4.27962 12.9853 6.63026 13.7684C6.92601 13.8228 7.03366 13.6401 7.03366 13.4827C7.03366 13.3425 7.02893 12.9693 7.02597 12.4754C5.38041 12.8333 5.0332 11.681 5.0332 11.681C4.76465 10.996 4.37663 10.8139 4.37663 10.8139C3.83954 10.4471 4.41744 10.4542 4.41744 10.4542C5.01072 10.4956 5.32303 11.0647 5.32303 11.0647C5.85065 11.9697 6.70774 11.7082 7.04431 11.5568C7.09873 11.1741 7.25134 10.9132 7.42051 10.7654C6.10737 10.6157 4.72621 10.107 4.72621 7.83683C4.72621 7.19031 4.95689 6.66092 5.33486 6.24686C5.27394 6.09721 5.07105 5.49447 5.39283 4.67938C5.39283 4.67938 5.88969 4.51967 7.01947 5.28626C7.502 5.15466 7.99985 5.08763 8.5 5.08692C9.00278 5.08929 9.50851 5.15495 9.98113 5.28626C11.1103 4.51967 11.606 4.67879 11.606 4.67879C11.9289 5.49447 11.7255 6.09721 11.6651 6.24686C12.0437 6.66092 12.2732 7.19031 12.2732 7.83683C12.2732 10.1129 10.8897 10.6139 9.5724 10.7606C9.78475 10.9434 9.97344 11.3048 9.97344 11.8579C9.97344 12.6493 9.96634 13.2887 9.96634 13.4827C9.96634 13.6413 10.0728 13.8258 10.3733 13.7678C11.5512 13.3728 12.5751 12.6175 13.3003 11.6089C14.0256 10.6002 14.4155 9.38912 14.415 8.14677C14.415 4.87398 11.7663 2.22168 8.5 2.22168Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href="https://github.com/supabase"
|
||||
target="_blank"
|
||||
onClick={() => sendTelemetryEvent({ action: 'homepage_github_button_clicked' })}
|
||||
>
|
||||
View on GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { ComponentProps } from 'react'
|
||||
|
||||
export function DartLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 62 62" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="m40.912 15.32-5.008-5.007c-.652-.655-2.013-1.438-3.163-1.438-.989 0-1.958.2-2.588.575l-11.54 5.77h21.23q.543 0 1.069.1m-25.269 2.507-6.191 12.9c-.248.523-.575 1.431-.575 2.012 0 1.256.552 2.54 1.438 3.452l5.36 5.32a6 6 0 0 1-.032-.597zm5.075 28.763 5.704 5.705.29.289h18.116V46.66H21.643q-.467 0-.925-.07m25.324-1.77h6.55V27.4l-9.617-9.617a4.43 4.43 0 0 0-3.132-1.298H17.53l.27.27 27.918 27.742zm-27.874-.865a4.92 4.92 0 0 0 3.475 1.44h23.183l-1.273-1.266-25.379-25.22-1.265-1.257v23.262a4.3 4.3 0 0 0 1.26 3.041"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExpoLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 61 61" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M45.5 25.727a3.35 3.35 0 0 0-1.31-1.08 3.42 3.42 0 0 0-3.303.233 3.3 3.3 0 0 0-1.137 1.254 2.915 2.915 0 0 0 .579 3.32 4.97 4.97 0 0 0 3.292-.92 4.8 4.8 0 0 0 1.878-2.807m-9.995-12.813-2.14-1.151-9.317 5.083.743.394 1.413.727 2.557-1.393 6.746-3.684zm.863-.351a.33.33 0 0 1 .236.22l3.121 9.026a.296.296 0 0 1-.148.393 5.96 5.96 0 0 0-2.654 2.882 5.8 5.8 0 0 0-.259 3.877 6.26 6.26 0 0 0 2.591 3.405 6.48 6.48 0 0 0 4.214.993.4.4 0 0 1 .387.234l3.224 9.265a.37.37 0 0 1-.149.406l-9.91 5.68a.4.4 0 0 1-.148.029.4.4 0 0 1-.268-.044l-3.477-2.14a.34.34 0 0 1-.147-.145l-6.792-15.307-10.342 5.768a.49.49 0 0 1-.402.014l-2.35-1.311a.333.333 0 0 1-.148-.437l10.047-18.69a.36.36 0 0 1 .163-.145l10.017-5.477a.39.39 0 0 1 .356 0l2.84 1.504zm-12.423 5.022-.487-.247-9.685 18.077 1.767.975 8.352-10.71a.4.4 0 0 1 .341-.146.42.42 0 0 1 .297.218l9.094 20.539 2.43 1.47-9.761-27.977-.415-1.165-1.946-1.049.015.015zm14.978 10.779a3.63 3.63 0 0 1 .177-2.428c.339-.77.931-1.403 1.68-1.797a4.17 4.17 0 0 1 4.815.585 3.635 3.635 0 0 1 .108 5.21l-.108.106a4.11 4.11 0 0 1-2.793 1.1 4.12 4.12 0 0 1-2.8-1.085 3.97 3.97 0 0 1-1.079-1.691"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FlutterLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 62 61" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m46.507 10.383-12.156.013L14.75 29.997l6.047 6.055 5.316-5.285zM34.7 28.465c-.173-.008-.35-.016-.462.131L23.786 39.048l6.003 5.967-.007.007 4.297 4.3q.042.038.085.082c.117.117.243.243.425.203q2.977-.006 5.955-.004 2.977.003 5.956-.002L35.933 39.023l10.563-10.556-11.59-.003c-.064.008-.135.005-.206.001"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FlutterflowLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 61 62" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M44.714 12.64a2.91 2.91 0 0 1 2.571 1.515 2.87 2.87 0 0 1-.053 2.836l-.018.03-4.517 7.503a2.88 2.88 0 0 1-2.42 1.39h-5.191l2.145 4.874.008.014.013.023a2.87 2.87 0 0 1-.03 2.877l-.018.03-4.518 7.504a2.88 2.88 0 0 1-2.419 1.389h-6.364l-4.552 4.767-.009.009a2.27 2.27 0 0 1-2.061.628 2.27 2.27 0 0 1-1.642-1.342l-.009-.02-2.395-5.46-.013-.02-.013-.023-.006-.012a2.87 2.87 0 0 1 .03-2.877l.018-.03 4.382-7.278-3.229-7.339.06-.03-.002-.004a2.87 2.87 0 0 1 .325-1.996l.019-.034.018-.03 4.518-7.504a2.88 2.88 0 0 1 2.419-1.39h22.953M20.992 42.569h-4.8l1.41 3.238.003.009q.035.086.118.101c.05.01.09 0 .13-.035l.006-.007zm13.723-11.133h-14.51l-.048.002h-.005l3.979 9.036h6.103c.251 0 .493-.133.63-.347l.011-.018 4.524-7.509a.76.76 0 0 0 .043-.717.79.79 0 0 0-.727-.447m-16.019 1.856-3.59 6.009a.77.77 0 0 0-.024.766l.014.025.007.012.015.024.028.042.032.042.031.034.005.006.021.02.018.018a.7.7 0 0 0 .407.18l.034.003h.02l.026.001h6.092zm-.944-7.363 1.577 3.592.031-.01c.272-.091.557-.14.845-.145h14.108l-1.51-3.437zm26.922-11.192H30.113l3.98 9.038h6.1a.76.76 0 0 0 .631-.348l.011-.018L45.36 15.9a.76.76 0 0 0 .043-.717.79.79 0 0 0-.709-.447zm-16.79 0h-6.068c-.25 0-.49.133-.627.347l-.01.017-4.514 7.509a.76.76 0 0 0-.042.718.79.79 0 0 0 .704.446h14.526z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function KotlinLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
strokeMiterlimit="10"
|
||||
d="M29.6 11.4 11.4 28.2V13.4c0-1.1 1-2 2.1-2zm15.5 3.3L25.4 32.9 11.6 45.5q-.3-.45-.3-.9V33.7l24.2-22.3h8c2 0 2.9 2.1 1.6 3.3m-.6 31.9h-28L33 31.4l13 11.9c1.3 1.2.4 3.3-1.5 3.3"
|
||||
transform="scale(10.66667)"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LangchainLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 100 100" {...props}>
|
||||
<rect width="100" height="100" rx="20" fill="currentColor" />
|
||||
<path
|
||||
d="M54.2612 54.2583L63.1942 45.3253C67.8979 40.6215 67.8979 32.9952 63.1942 28.2914C58.4904 23.5877 50.8641 23.5877 46.1603 28.2914L37.2273 37.2244"
|
||||
stroke="#7FC8FF"
|
||||
strokeWidth="12.0389"
|
||||
/>
|
||||
<path
|
||||
d="M45.7427 45.7412L36.8098 54.6742C32.106 59.3779 32.106 67.0042 36.8098 71.708C41.5135 76.4118 49.1398 76.4118 53.8436 71.708L62.7766 62.775"
|
||||
stroke="#7FC8FF"
|
||||
strokeWidth="12.0389"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NestjsLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 264.6 255.6"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M153.3 4.2c-1.8 0-3.5.4-5 1 3.3 2.1 5.1 5 6 8.3 0 .5.2.8.3 1.3l.1 1.1c.3 5.7-1.5 6.4-2.7 9.8-1.9 4.3-1.4 9 .9 12.7.2.5.4 1 .8 1.4-2.5-16.3 11.1-18.8 13.7-23.9.2-4.4-3.5-7.4-6.4-9.5a14.3 14.3 0 0 0-7.7-2.2zM174 8c-.3 1.5 0 1.1-.1 1.9l-.2 1.7-.4 1.5-.5 1.6-.8 1.5-.5.7-.4.6c-.3.5-.6 1-1 1.3-.3.4-.6.9-1 1.2l-1.3 1c-1.4 1.1-3 1.9-4.3 2.9-.5.3-1 .5-1.3 1-.5.2-.9.6-1.3 1l-1.1 1.2-1 1.3-.9 1.3-.7 1.5-.5 1.5a21 21 0 0 0-.5 1.6l-.1.9-.1.7-.1 1.7v1.1l.3 1.6c0 .6.1 1 .3 1.6l.5 1.5.4 1-14.8-5.8-7.5-2-4-1a120 120 0 0 0-11.8-1.7h-.4A115.5 115.5 0 0 0 87 34.9l-3 .6c-2 .3-3.9.8-5.7 1.2l-3 .8-2.7 1.2-2.2 1-.3.1-1.8 1-.5.1-2 1-1.2.7-.6.3-1.7 1-1.6 1-1.3.9-.1.1-1.3 1H58l-1 .8-.4.3-1 .8c0 .2-.1.2-.2.3l-1.2 1v.2c-.5.3-.9.7-1.2 1.1l-.2.1-1 1c0 .2-.3.3-.4.5l-1 1.1-.4.3-1.4 1.6-.2.2a38.1 38.1 0 0 1-7 6 48.9 48.9 0 0 1-12.1 6c-2.7.5-5.5 1.6-7.9 1.8l-1.6.2-1.6.4-1.6.6-1.5.7-1.4.9c-.5.3-1 .7-1.3 1.1-.5.3-1 .8-1.3 1.2l-1.1 1.3-1 1.4-.9 1.5-.7 1.7-.6 1.7-.3 1.5v.2L6 86.2v2.1a6.9 6.9 0 0 0 .7 2.4l.7 1.2.8 1.2a17.1 17.1 0 0 0 2.4 2c1.5 1.4 1.9 1.9 3.9 2.9l1 .5h.2v.4a13.3 13.3 0 0 0 1 3.1l.5 1.2.1.3a28.3 28.3 0 0 0 1.8 2.8l1 1.2 1.3 1.1h.1a14.2 14.2 0 0 0 5.4 3l.3.1.8.2c-.2 3.5-.3 6.8.3 8 .5 1.2 3.4-2.7 6.2-7.2-.4 4.4-.6 9.7 0 11.2.7 1.6 4.6-3.4 8-9a74.7 74.7 0 0 1 92 65.8c-.8-7-9.4-10.8-13.4-9.9-2 4.8-5.2 11-10.5 14.8.4-4.3.2-8.7-.7-13-1.4 6-4.2 11.5-8 16.3a18 18 0 0 1-15.5-7l-.5-.8-.5-1.4-.4-1.3V176c0-.5.1-1 .3-1.4 0-.4.2-.9.4-1.3l.8-1.4c1-3 1-5.6-1-7l-1.1-.7-.9-.3-.5-.2-1.4-.3a5 5 0 0 0-1.3-.2l-1.4-.1h-1l-1.4.2-1.4.3-1.3.4-1.3.6-1.3.7c-15 9.8-6 32.8 4.2 39.5-3.8.7-7.8 1.5-8.9 2.3l-.1.2a60.9 60.9 0 0 0 19.2 7.4 61.5 61.5 0 0 0 72.6-51.3l.4 1.7c.2 1.2.5 2.4.6 3.7l.2 1.7v.3l.2 1.6.1 2.2v5.4l-.1.8v1.5c-.2.2-.2.4-.2.5 0 .6 0 1-.2 1.5v.6c0 .7-.2 1.2-.3 1.9v.1l-.4 1.8v.2c0 .6-.2 1.2-.4 1.8v.2l-.5 1.8v.2l-.5 1.8v.1l-.6 2-.7 1.8-.8 1.9-.7 1.9c-.4.5-.6 1.2-1 1.8l-.1.4s0 .2-.2.2a61.2 61.2 0 0 1-18.1 21.7l-1.6 1.1c0 .2-.3.2-.4.4l-1.4 1 .2.3 2.7-.4h.1a137.7 137.7 0 0 0 6.5-1.2l.9-.2 1.3-.3 1.2-.3c6.4-1.5 12.7-3.7 18.7-6.2-10.2 14-24 25.3-40.1 32.8a103.2 103.2 0 0 0 83.1-52.6c-2.7 15-8.6 29.1-17.4 41.5a101.7 101.7 0 0 0 44.5-69.2c2.2 10.2 2.8 20.7 1.8 31.1 46.7-65 4-132.5-14-150.3l-.1-.3v.1l-.1-.1-.2 2.3a87 87 0 0 1-.6 4.3l-1.1 4.3a53.7 53.7 0 0 1-3.5 8 44 44 0 0 1-9.9 12l-1.5 1.4a36 36 0 0 1-7.4 4.7l-4 1.8a45.5 45.5 0 0 1-8.6 2.3l-4.4.6a49.7 49.7 0 0 1-11.9-.8l-4.3-1.1a48 48 0 0 0 20.7-6.8l3.6-2.6 3.3-2.9 3-3.2c1-1.1 1.9-2.3 2.7-3.5.2-.1.3-.4.4-.6l1.9-3.1a44.5 44.5 0 0 0 3.5-8c.4-1.4.8-2.9 1-4.3.3-1.5.6-2.9.7-4.3l.3-4.4-.1-3.1-.6-4.3c-.2-1.5-.5-3-1-4.4-.4-1.3-.8-2.7-1.4-4.1-.5-1.4-1.1-2.7-1.8-4l-2.2-3.8a71.3 71.3 0 0 0-5.5-6.9 40.4 40.4 0 0 0-12-8.6C178 9.3 176 8.6 174 8z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NextjsLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 180 180" {...props}>
|
||||
<mask
|
||||
height="180"
|
||||
id=":r8:mask0_408_134"
|
||||
maskUnits="userSpaceOnUse"
|
||||
width="180"
|
||||
x="0"
|
||||
y="0"
|
||||
style={{ maskType: 'alpha' as const }}
|
||||
>
|
||||
<circle cx="90" cy="90" fill="currentColor" r="90"></circle>
|
||||
</mask>
|
||||
<g mask="url(#:r8:mask0_408_134)">
|
||||
<circle cx="90" cy="90" data-circle="true" fill="currentColor" r="90"></circle>
|
||||
<path
|
||||
d="M149.508 157.52L69.142 54H54V125.97H66.1136V69.3836L139.999 164.845C143.333 162.614 146.509 160.165 149.508 157.52Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<rect fill="currentColor" height="72" width="12" x="115" y="54"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NuxtjsLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 61 61" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M32.578 45.474H50.22c.56 0 1.11-.141 1.596-.41a3.13 3.13 0 0 0 1.168-1.123c.28-.466.427-.995.427-1.533s-.148-1.067-.428-1.533L41.135 21.164a3.13 3.13 0 0 0-1.168-1.122 3.3 3.3 0 0 0-1.596-.411c-.56 0-1.11.142-1.595.41a3.13 3.13 0 0 0-1.168 1.123l-3.03 5.043-5.923-9.862a3.13 3.13 0 0 0-1.168-1.122 3.3 3.3 0 0 0-1.596-.41c-.56 0-1.11.141-1.596.41a3.13 3.13 0 0 0-1.169 1.122L6.384 40.875c-.28.466-.429.995-.429 1.533s.147 1.067.427 1.533.683.853 1.168 1.122c.486.27 1.036.411 1.596.411H20.22c4.388 0 7.624-1.851 9.85-5.463l8.3-13.804 8.69 14.455H35.475zM20.04 40.657l-7.728-.002 11.584-19.271 5.78 9.636-3.87 6.44c-1.478 2.343-3.158 3.197-5.766 3.197"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function OpenaiLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 260" {...props}>
|
||||
<path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReactLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 62 62" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M45.74 23.698a30 30 0 0 0-1.446-.455q.122-.501.223-.987c1.095-5.314.38-9.595-2.065-11.005-2.344-1.352-6.177.058-10.048 3.427a30 30 0 0 0-1.12 1.026 28 28 0 0 0-.744-.687c-4.057-3.603-8.124-5.121-10.566-3.707-2.341 1.355-3.035 5.38-2.05 10.417q.144.73.333 1.488-.864.244-1.663.523C11.838 25.396 8.8 27.995 8.8 30.69c0 2.784 3.26 5.577 8.215 7.27q.587.2 1.215.38a29 29 0 0 0-.353 1.604c-.94 4.95-.206 8.88 2.13 10.226 2.412 1.391 6.46-.039 10.402-3.484q.468-.41.938-.864.609.586 1.215 1.108c3.818 3.285 7.589 4.612 9.922 3.262 2.41-1.395 3.193-5.617 2.176-10.753a25 25 0 0 0-.27-1.201q.427-.125.837-.261c5.15-1.706 8.501-4.465 8.501-7.287 0-2.706-3.136-5.322-7.988-6.993m-1.117 12.458q-.37.122-.755.236c-.568-1.8-1.336-3.714-2.275-5.692.896-1.93 1.634-3.82 2.187-5.608q.69.2 1.335.421c4.158 1.431 6.694 3.547 6.694 5.178 0 1.737-2.739 3.992-7.186 5.465m-1.845 3.657c.45 2.27.513 4.324.216 5.93-.268 1.442-.806 2.403-1.471 2.788-1.416.82-4.444-.245-7.71-3.055a28 28 0 0 1-1.13-1.03c1.267-1.385 2.532-2.995 3.767-4.782 2.171-.193 4.223-.508 6.084-.938q.138.555.244 1.087M24.117 48.39c-1.383.488-2.485.502-3.151.118-1.417-.817-2.007-3.972-1.203-8.205q.139-.727.328-1.491c1.84.407 3.877.7 6.055.876a47 47 0 0 0 3.856 4.765q-.43.416-.856.788c-1.743 1.524-3.49 2.605-5.03 3.149m-6.48-12.245c-2.192-.748-4-1.722-5.241-2.783-1.114-.955-1.677-1.902-1.677-2.67 0-1.637 2.44-3.724 6.507-5.142q.741-.258 1.547-.486c.562 1.828 1.3 3.74 2.19 5.674a47 47 0 0 0-2.218 5.755q-.574-.165-1.109-.348m2.171-14.787c-.844-4.315-.283-7.57 1.128-8.387 1.503-.87 4.827.37 8.33 3.48q.336.3.674.623a46 46 0 0 0-3.827 4.736c-2.113.196-4.136.51-5.996.932q-.176-.706-.309-1.384m19.38 4.785a61 61 0 0 0-1.366-2.247 42 42 0 0 1 4.097.717 41 41 0 0 1-1.438 3.868 67 67 0 0 0-1.292-2.338m-7.903-7.697a41 41 0 0 1 2.644 3.191 57 57 0 0 0-5.31 0 42 42 0 0 1 2.666-3.191m-7.953 7.71q-.666 1.157-1.269 2.324a42 42 0 0 1-1.427-3.888 43 43 0 0 1 4.073-.699 56 56 0 0 0-1.377 2.263m1.418 11.47a41 41 0 0 1-4.14-.666 43 43 0 0 1 1.456-3.966 56.791 56.791 0 0 0 2.685 4.632m6.589 5.446a43 43 0 0 1-2.704-3.235q1.291.05 2.629.05 1.375 0 2.712-.06a41 41 0 0 1-2.637 3.245m9.16-10.147a41 41 0 0 1 1.51 3.952c-1.304.297-2.71.537-4.192.715q.7-1.11 1.38-2.286.686-1.192 1.302-2.381m-2.965 1.422a63 63 0 0 1-2.156 3.477c-1.333.095-2.71.145-4.114.145a63 63 0 0 1-4.068-.129A54 54 0 0 1 25 34.357a54 54 0 0 1-1.917-3.62 54 54 0 0 1 1.912-3.622 54 54 0 0 1 2.178-3.468 54 54 0 0 1 4.09-.153h.001c1.391 0 2.763.053 4.099.155a58 58 0 0 1 2.164 3.456 63 63 0 0 1 1.937 3.605 58 58 0 0 1-1.93 3.637m3.96-21.433c1.504.867 2.089 4.366 1.143 8.955q-.09.439-.202.893a46 46 0 0 0-6.007-.949c-1.234-1.758-2.514-3.356-3.798-4.74q.518-.499 1.034-.948c3.316-2.886 6.417-4.026 7.83-3.211m-10.23 13.765a4.012 4.012 0 1 1 0 8.024 4.012 4.012 0 0 1 0-8.024"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReactiveXLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 62 62" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="m29.323 12.449.005-.012s-.53-.514-.868-.74c-1.831-1.22-4.562-1.622-6.217.097-.287.297-.548.61-.806.92-.19.227-.379.453-.576.67-.53.58-1.172 1.061-1.911 1.334-.415.156-.83.19-1.248.226-.23.02-.462.04-.696.08-.675.112-1.366.353-1.912.787-.594.482-.835 1.124-.9 1.863-.04.361-.049.729-.057 1.095-.006.22-.01.44-.023.657-.08 1.703-.627 2.185-1.848 3.132-.514.386-.947.9-1.269 1.446-.609 1.077-.217 2.18.175 3.279.227.64.454 1.278.484 1.91.016.354-.016.707-.145 1.045-.09.258-.242.454-.397.653a5 5 0 0 0-.197.263c-.29.401-.482.883-.402 1.381s.338.964.579 1.398c.466.771 1.044 1.462 1.654 2.12.026-.012.052.016.078.044v.002c.572 1.792 1.428 3.518 2.525 5.16 4.177 6.233 10.956 8.386 10.956 8.386 10.04 3.406 16.9-1.606 16.9-1.606 6.314-4.338 7.583-9.35 7.583-9.35-5.092 3.968-7.293 3.582-7.293 3.582 3.695-1.607 5.27-4.386 5.27-4.386 3.453-4.707 2.28-9.671 2.28-9.671-2.2 4.787-4.209 6.105-4.209 6.105 5.414-8.242.032-13.223.032-13.223 2.201 4.692-.722 10.411-.722 10.411-2.458 5.173-5.945 7.02-5.945 7.02 3.888.724 6.748-1.895 6.748-1.895-5.575 6.025-11.615 5.736-11.615 5.736 2.538 2.843 6.345 2.602 6.345 2.602-4.98 1.173-9.655-.482-13.494-3.679a21 21 0 0 1-2.057-1.928s-.578-.61-.69-.771l-.017-.016c-.08 2.972 3.02 5.736 3.02 5.736-3.887-1.607-5.67-5.093-5.67-5.093s-2.619-4.467-.723-9.56c1.895-5.092 7.63-6.185 7.63-6.185 4.74 2.297 8.756 3.02 8.756 3.02 8.467 1.414 7.985-2.73 7.985-2.73.08-3.567-5.302-7.359-5.302-7.359-4.366-3.232-8.542-4.038-11.796-3.956m6.334 6.382c.482 0 .868.386.868.867a.864.864 0 0 1-.868.868.864.864 0 0 1-.867-.867c0-.482.385-.868.867-.868m-5.494-1.542-5.478-1.703q-.01 0-.032-.007a.8.8 0 0 0-.45.007s-3.23.819-2.667 2.586c0 0 .337 1.109 1.253 2.185l6.025-.289z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function RedwoodLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 62 61" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="m22.67 16.278 7.525 5.119c.17.112.368.174.572.177.203-.001.402-.063.571-.178l7.53-5.135a1.023 1.023 0 0 0-.143-1.75L31.201 10.8a1.04 1.04 0 0 0-.903 0l-7.502 3.712a1.021 1.021 0 0 0-.125 1.767m10.652 6.725c0 .34.168.657.446.85L39.8 27.96c.386.265.903.232 1.252-.08l5.06-4.51a1.023 1.023 0 0 0-.045-1.567l-4.832-3.85a1.03 1.03 0 0 0-1.212-.046l-6.255 4.263c-.273.189-.44.499-.446.832M18.32 28.592c.244.218.37.54.338.866-.032.328-.222.62-.51.78l-3.601 2.152a1.02 1.02 0 0 1-1.159-.079 1.03 1.03 0 0 1-.34-1.114l1.333-4.177a1.02 1.02 0 0 1 .703-.677.99.99 0 0 1 .95.223zm19.537.436-6.512-4.441a1.03 1.03 0 0 0-1.143 0l-6.513 4.44a1.04 1.04 0 0 0-.446.781c-.017.317.11.625.343.838l6.507 5.8a1.02 1.02 0 0 0 1.36 0l6.507-5.8c.235-.213.36-.521.337-.838a1.02 1.02 0 0 0-.44-.78m-17.37-1.148-5.054-4.51a1.05 1.05 0 0 1-.343-.809c.005-.302.146-.587.383-.774l4.831-3.873a1.04 1.04 0 0 1 1.218-.046l6.25 4.263a1.028 1.028 0 0 1 0 1.721l-6.027 4.108a1.026 1.026 0 0 1-1.258-.08m25.952 6.478-5.146-3.07a1.015 1.015 0 0 0-1.2.115l-6.29 5.595a1.03 1.03 0 0 0 .298 1.721l8.725 3.523q.182.075.377.075c.405 0 .773-.238.938-.609l2.704-6.036a1.03 1.03 0 0 0-.406-1.314m.715-7.338 1.332 4.177h-.011a1.03 1.03 0 0 1-.15.917 1.023 1.023 0 0 1-1.348.276l-3.608-2.152a1 1 0 0 1-.492-.78c-.033-.326.092-.65.338-.866L45.5 26.56c.258-.231.615-.315.949-.224.333.095.597.351.703.683M28.068 37.939a1.02 1.02 0 0 0-.326-.941l-6.29-5.595a1.015 1.015 0 0 0-1.2-.114l-5.146 3.07a1.04 1.04 0 0 0-.412 1.307l2.71 6.037c.225.503.806.738 1.316.533l8.719-3.523c.33-.131.567-.424.629-.774m3.087 1.721 6.999 2.823c.342.146.578.467.617.838.044.375-.123.745-.434.958l-7.004 4.848a1 1 0 0 1-.572.184 1.03 1.03 0 0 1-.572-.184l-6.998-4.848a1.02 1.02 0 0 1-.44-.958c.046-.375.291-.696.64-.838l6.998-2.823c.246-.098.52-.098.766 0"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function RemixLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 61 61" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M44.594 39.764c.33 4.179.33 6.137.33 8.275h-9.818c0-.466.008-.891.016-1.324.028-1.343.056-2.743-.166-5.57-.293-4.142-2.104-5.062-5.432-5.062H14.08V28.55h15.91c4.206 0 6.31-1.258 6.31-4.595 0-2.931-2.104-4.71-6.31-4.71H14.08v-7.37h17.664c9.52 0 14.252 4.428 14.252 11.5 0 5.291-3.33 8.741-7.827 9.316 3.797.748 6.017 2.875 6.425 7.072"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14.08 48.04v-5.616h10.383c1.733 0 2.11 1.266 2.11 2.022v3.593z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SolidjsLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 62 61" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M26.604 13.56c9.353-2.148 21.405 7.04 21.405 7.04l-4.094 6.904-.078-.016q.078.061.078.062a16 16 0 0 0-.722-.53c-2.732-1.93-12.692-8.369-20.683-6.534l-.118.03c-.24.059-.49.121-.705.2-1.222.4-2.186 1.039-2.86 1.828l-.022-.004 3.454-5.933q.064-.105.123-.217c.066-.122.134-.245.22-.36.663-.97 1.738-1.778 3.178-2.24.216-.079.466-.141.705-.2l.002-.001z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m21.455 30.312 8.945-2.924c2.699-.88 5.923-.355 8.357 1.145-.023-.016.723.478.802.567l3.385-1.332c-.244-.167-1.144-.82-1.456-1.023a48 48 0 0 0-5.086-2.886c-4.15-2.016-9.303-3.676-13.722-2.665l-.117.03c-.243.06-.45.112-.625.176l-.013.005-.012.004c-1.735.566-2.821 1.637-3.26 2.844-.438 1.2-.276 2.646.705 4.06.535.77 1.255 1.447 2.097 1.999M37.165 44.533l.004.008-.009-.001z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m41.109 35.964-3.25 5.838c-.058-.946-.402-1.928-1.056-2.87-2.195-3.163-7.066-4.733-10.885-3.487L12.72 39.78l.062-.191 3.846-6.934 13.998-4.574c1.742-.569 3.756-.502 5.603.09 1.848.593 3.468 1.693 4.458 3.118 1.14 1.645 1.183 3.33.422 4.675"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M14.073 40.923a45 45 0 0 1-.827-.55l12.898-4.235c1.742-.569 3.756-.502 5.603.09 1.848.593 3.468 1.694 4.458 3.119.98 1.413 1.143 2.858.706 4.06-.44 1.206-1.526 2.277-3.261 2.843l-.013.004-.012.005c-.176.064-.382.116-.625.176l-.118.03c-4.419 1.011-9.571-.65-13.721-2.663a48 48 0 0 1-5.088-2.879"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function StripeLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 512 214" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M512 110.08c0-36.409-17.636-65.138-51.342-65.138c-33.85 0-54.33 28.73-54.33 64.854c0 42.808 24.179 64.426 58.88 64.426c16.925 0 29.725-3.84 39.396-9.244v-28.445c-9.67 4.836-20.764 7.823-34.844 7.823c-13.796 0-26.027-4.836-27.591-21.618h69.547c0-1.85.284-9.245.284-12.658m-70.258-13.511c0-16.071 9.814-22.756 18.774-22.756c8.675 0 17.92 6.685 17.92 22.756zm-90.31-51.627c-13.939 0-22.899 6.542-27.876 11.094l-1.85-8.818h-31.288v165.83l35.555-7.537l.143-40.249c5.12 3.698 12.657 8.96 25.173 8.96c25.458 0 48.64-20.48 48.64-65.564c-.142-41.245-23.609-63.716-48.498-63.716m-8.534 97.991c-8.391 0-13.37-2.986-16.782-6.684l-.143-52.765c3.698-4.124 8.818-6.968 16.925-6.968c12.942 0 21.902 14.506 21.902 33.137c0 19.058-8.818 33.28-21.902 33.28M241.493 36.551l35.698-7.68V0l-35.698 7.538zm0 10.809h35.698v124.444h-35.698zm-38.257 10.524L200.96 47.36h-30.72v124.444h35.556V87.467c8.39-10.951 22.613-8.96 27.022-7.396V47.36c-4.551-1.707-21.191-4.836-29.582 10.524m-71.112-41.386l-34.702 7.395l-.142 113.92c0 21.05 15.787 36.551 36.836 36.551c11.662 0 20.195-2.133 24.888-4.693V140.8c-4.55 1.849-27.022 8.391-27.022-12.658V77.653h27.022V47.36h-27.022zM35.982 83.484c0-5.546 4.551-7.68 12.09-7.68c10.808 0 24.461 3.272 35.27 9.103V51.484c-11.804-4.693-23.466-6.542-35.27-6.542C19.2 44.942 0 60.018 0 85.192c0 39.252 54.044 32.995 54.044 49.92c0 6.541-5.688 8.675-13.653 8.675c-11.804 0-26.88-4.836-38.827-11.378v33.849c13.227 5.689 26.596 8.106 38.827 8.106c29.582 0 49.92-14.648 49.92-40.106c-.142-42.382-54.329-34.845-54.329-50.774"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SvelteLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 62 61" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M28.842 13.574c4.783-3.047 11.397-1.627 14.746 3.167a10.27 10.27 0 0 1 1.754 7.766 9.7 9.7 0 0 1-1.44 3.6c1.048 2 1.4 4.293 1 6.516a9.63 9.63 0 0 1-4.354 6.454l-8.4 5.353c-4.78 3.047-11.395 1.627-14.745-3.167a10.27 10.27 0 0 1-1.754-7.765 9.7 9.7 0 0 1 1.441-3.602c-1.049-2-1.401-4.292-1-6.515a9.64 9.64 0 0 1 4.353-6.454zm-7.984 27.49a6.67 6.67 0 0 0 7.163 2.655 6.1 6.1 0 0 0 1.707-.75l8.401-5.354a5.8 5.8 0 0 0 2.619-3.883 6.18 6.18 0 0 0-1.055-4.671 6.67 6.67 0 0 0-7.163-2.655 6.1 6.1 0 0 0-1.706.75l-3.206 2.043q-.24.153-.516.226a2.01 2.01 0 0 1-2.158-.8 1.86 1.86 0 0 1-.317-1.409c.087-.483.373-.907.789-1.169l8.402-5.354a1.9 1.9 0 0 1 .516-.227 2.01 2.01 0 0 1 2.156.8c.257.364.377.806.34 1.25l-.03.311.313.095c1.181.356 2.293.912 3.287 1.643l.43.315.158-.482a6 6 0 0 0 .2-.785 6.18 6.18 0 0 0-1.055-4.671 6.67 6.67 0 0 0-7.163-2.655 6.1 6.1 0 0 0-1.707.75l-8.401 5.355a5.8 5.8 0 0 0-2.619 3.881 6.18 6.18 0 0 0 1.056 4.672 6.67 6.67 0 0 0 7.163 2.655 6.1 6.1 0 0 0 1.706-.75l3.206-2.043q.24-.152.515-.227a2.01 2.01 0 0 1 2.158.801c.29.408.405.915.318 1.408a1.75 1.75 0 0 1-.79 1.17l-8.402 5.354a1.9 1.9 0 0 1-.516.227 2.01 2.01 0 0 1-2.156-.8 1.88 1.88 0 0 1-.34-1.25l.03-.311-.313-.095a10.8 10.8 0 0 1-3.286-1.642l-.43-.316-.159.482a6 6 0 0 0-.2.785 6.18 6.18 0 0 0 1.055 4.671"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TurbopackLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 62 62" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M18.275 15.256 31 7.984l19.903 11.372v5.732L31 13.714 18.3 20.97zM16.073 39.216l12.688 7.25-4.963 2.893-12.7-7.257V19.356l4.943-2.824.027 5.722.005.003zM45.927 24.824v14.392L31 47.745v.007l-4.958 2.89L31 53.477l19.903-11.374V27.668z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m31 16.294-11.56 6.605 12.69 7.252 11.558-6.608zM18.312 24.834v13.081L31 45.167V32.084z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function VercelLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 222" {...props}>
|
||||
<path fill="currentColor" d="m128 0 128 221.705H0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function VueLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 61 61" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M43.053 13.453h7.062l-19.84 34.363-19.838-34.363h7.06l12.779 22.132zm-.877 0-11.9 20.613-11.901-20.613h7.319l4.582 7.936 4.581-7.936z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import { ComponentProps } from 'react'
|
||||
|
||||
export function OnePasswordLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 143 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M22.48 41.59c6.4 0 11.59-5.19 11.59-11.59s-5.19-11.59-11.59-11.59S10.89 23.6 10.89 30s5.19 11.59 11.59 11.59m2.218-17.766v6.656c0 .178-.07.35-.197.475l-.8.8a.135.135 0 0 0 0 .19l.8.8a.67.67 0 0 1 .197.475v3.135a.67.67 0 0 1-.672.672h-3.093a.67.67 0 0 1-.672-.672v-6.621c0-.179.071-.35.197-.476l.782-.782a.134.134 0 0 0 0-.19l-.782-.782a.67.67 0 0 1-.197-.476v-3.204c0-.372.301-.673.672-.673h3.093c.371 0 .672.302.672.673"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M86.833 26.552c.954 0 1.836-.018 2.718.018.09 0 .234.234.27.378l1.404 6.75c.054.252.108.486.198.864.09-.234.144-.324.162-.432a830 830 0 0 0 1.71-7.146c.072-.342.198-.468.558-.45.738.018 1.458.018 2.196 0 .324-.018.432.108.504.414.612 2.412 1.242 4.824 1.854 7.236.036.108.072.216.162.45.27-1.278.504-2.412.756-3.564.288-1.368.576-2.754.846-4.122.072-.324.216-.432.54-.414.792.018 1.584 0 2.43 0-.054.288-.09.522-.144.738-.774 3.168-1.566 6.336-2.34 9.504-.09.36-.234.468-.594.468-1.116-.018-2.25-.018-3.384-.018-.558-2.376-1.116-4.734-1.656-7.074-.036 0-.054-.018-.09-.018-.558 2.358-1.116 4.716-1.692 7.074h-3.744c-.918-3.528-1.782-7.056-2.664-10.656M130.049 22.575v5.093c-.27-.216-.522-.45-.774-.648-1.728-1.332-4.608-.864-5.958 1.026s-1.494 3.96-.81 6.12c.504 1.62 1.566 2.79 3.258 3.222 1.602.414 3.402-.036 4.176-1.53.018-.036.072-.054.198-.162v1.53h2.844V22.575zm-.108 9.251c.018.972-.162 1.782-.774 2.484-.756.864-2.646.9-3.348-.324-.774-1.368-.792-2.79-.018-4.14.792-1.386 2.808-1.368 3.654 0 .396.63.522 1.332.486 1.98M55.281 24.176a4.8 4.8 0 0 0-2.898-.936c-1.872-.018-3.744-.018-5.616-.018-.108 0-.198.018-.324.036v13.968h3.078v-.504c0-1.206.018-2.43 0-3.636 0-.306.09-.414.414-.414.9 0 1.8 0 2.7-.072 2.718-.216 4.338-1.944 4.41-4.644.018-1.548-.504-2.844-1.764-3.78m-3.582 5.778c-.594 0-1.188-.018-1.8 0-.288 0-.396-.09-.396-.396.018-.558 0-1.098 0-1.656 0-.54.018-1.08 0-1.62 0-.27.072-.414.378-.396.756.036 1.53.018 2.286.054.9.054 1.53.648 1.638 1.548.216 1.548-.558 2.448-2.106 2.466M67.22 37.226c-.054-2.538-.054-5.04-.198-7.524-.072-1.17-.756-2.124-1.8-2.754-1.638-.99-4.608-.972-6.246.63-.576.576-1.026 1.224-1.224 2.052.756.18 1.476.324 2.16.54.396.108.666.108.828-.36.072-.216.27-.432.45-.594.522-.468 1.134-.576 1.8-.378.612.198.936.666 1.062 1.278.18.828.018 1.026-.828 1.026-.756 0-1.53-.018-2.286 0-.468.018-.936.054-1.386.162-2.214.522-3.06 3.294-1.53 4.986 1.512 1.656 4.464 1.656 5.976-.036.09-.09.108-.234.162-.342.054.054.126.09.18.144v1.188h2.88zm-4.95-2.052a4 4 0 0 1-.936 0c-.612-.09-.99-.54-.972-1.098.018-.576.378-1.008 1.026-1.026.9-.036 1.782 0 2.7 0 .18.972-.63 1.962-1.818 2.124M108.605 26.282c-3.078-.054-5.454 2.304-5.508 5.454-.054 3.168 2.214 5.652 5.166 5.778 3.132.126 5.778-2.088 5.796-5.58.018-3.294-2.358-5.652-5.454-5.652m1.8 7.56c-1.008 1.494-3.042 1.368-3.798-.27-.234-.504-.306-1.098-.468-1.674.162-.576.234-1.17.468-1.692.324-.756.954-1.224 1.782-1.314.864-.072 1.53.306 2.016 1.026.702 1.062.702 2.88 0 3.924M76.94 29.432c-.846.162-1.692.306-2.52.45-.072.018-.216-.162-.27-.27-.504-1.008-1.53-1.44-2.61-1.062-.396.144-.684.378-.72.828-.054.468.216.756.612.882.972.306 1.962.522 2.916.846.63.216 1.296.468 1.8.864 1.098.882 1.224 2.52.432 3.726-.648.972-1.566 1.494-2.7 1.692-1.728.306-3.402.18-4.896-.846-.684-.468-1.08-1.134-1.242-1.998.81-.198 1.584-.396 2.358-.576.09-.018.288.09.324.198.612 1.224 2.16 1.296 3.024 1.008.396-.126.702-.396.702-.882.018-.486-.324-.666-.684-.774-.882-.252-1.782-.486-2.664-.738-.324-.09-.648-.216-.972-.342-1.098-.45-1.8-1.206-1.872-2.448-.072-1.206.324-2.196 1.35-2.898.9-.63 1.926-.792 2.988-.81 1.314 0 2.574.216 3.564 1.17.54.54.9 1.17 1.08 1.98M77.57 34.526c.792-.198 1.548-.396 2.304-.558.108-.018.324.072.378.162.576 1.134 1.944 1.314 2.934 1.044.504-.126.792-.486.81-.9.018-.504-.342-.684-.72-.792-.882-.27-1.782-.486-2.682-.72-.324-.09-.648-.216-.972-.342-1.08-.45-1.764-1.206-1.854-2.412-.072-1.188.306-2.178 1.296-2.88.9-.648 1.944-.828 3.024-.828 1.314 0 2.556.216 3.564 1.17.54.504.918 1.188 1.098 1.98-.666.126-1.35.216-1.998.378-.45.108-.684.036-.918-.432-.468-.918-1.728-1.242-2.664-.774-.324.162-.522.432-.522.81s.198.648.522.756c.756.252 1.548.45 2.304.702.666.216 1.368.378 1.98.702 1.512.81 1.908 2.646.954 4.086-.63.972-1.566 1.494-2.682 1.692-1.728.306-3.402.18-4.896-.846-.684-.45-1.08-1.116-1.26-1.998M40.395 37.208V26.75h-2.052v-2.466c.432-.072.882-.126 1.314-.234.54-.126.99-.396 1.332-.846.072-.09.18-.198.27-.198.738-.018 1.494 0 2.268 0v14.202zM117.95 26.588v1.71c.036 0 .054.018.09.018.126-.198.234-.414.36-.612.684-1.098 2.034-1.656 3.258-1.368.324.072.522.18.414.612a11.6 11.6 0 0 0-.36 2.07c-.036.45-.252.414-.576.414-.54-.018-1.098 0-1.638.09-.684.108-1.08.576-1.188 1.242-.072.414-.09.828-.09 1.242-.018 1.566 0 3.15 0 4.716v.522h-3.042V26.606c.882-.018 1.782-.018 2.772-.018"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function BetasharesLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 149 60" {...props}>
|
||||
<g fill="currentColor" fillRule="evenodd" clipPath="url(#a)" clipRule="evenodd">
|
||||
<path d="M36.341 30.003c0 7.091-5.748 12.84-12.84 12.84-7.09 0-12.84-5.749-12.84-12.84s5.75-12.84 12.84-12.84c7.092 0 12.84 5.749 12.84 12.84m-15.725-5.14a.085.085 0 0 0-.128.075v10.139a.086.086 0 0 0 .128.074l8.78-5.07a.086.086 0 0 0 0-.148zm1.532-.784c-.078 0-.107.105-.038.145h-.002l7.044 4.067c.07.04.146-.037.107-.105l-2.294-3.99a.23.23 0 0 0-.201-.117zM19.042 34.09c.04.07.146.042.146-.038v-8.103c0-.08-.105-.106-.146-.038l-2.284 3.973a.23.23 0 0 0 0 .233zm10.102-2.362c.07-.04.147.037.107.105l-.001.002-2.286 3.973a.23.23 0 0 1-.202.117h-4.597c-.08 0-.107-.105-.04-.145zm2.437-2.114a.78.78 0 0 1 0 .774l-3.706 6.446a.78.78 0 0 1-.674.39h-7.407a.78.78 0 0 1-.673-.39l-3.706-6.446a.78.78 0 0 1 0-.774l3.706-6.446a.78.78 0 0 1 .673-.39h7.407a.78.78 0 0 1 .674.39zM43.308 32.33c0-1.697 1.253-3.045 3.037-3.045s3.038 1.348 3.038 3.045-1.275 3.066-3.038 3.066c-1.764 0-3.038-1.37-3.038-3.066m3.568 5.17c2.89 0 4.865-2.288 4.865-5.17s-1.976-5.15-4.865-5.15c-1.402 0-2.782.634-3.505 1.758V22.95h-2.358v14.304h2.23v-1.758c.68 1.288 2.168 2.003 3.633 2.003zm7.97-6.336c.362-1.308 1.338-2.125 2.783-2.125s2.465.817 2.805 2.125zM57.8 37.56c2.082 0 3.91-.879 4.823-2.84v-.042c-.638.041-1.593-.389-1.976-.899l-.02.02c-.383 1.023-1.402 1.78-2.783 1.78-1.657 0-2.91-1.044-3.123-2.739h7.966c.382-3.085-1.657-5.722-5.036-5.722-2.996 0-5.163 2.33-5.163 5.21s2.21 5.232 5.312 5.232m5.388-8.112h1.38v7.806h2.381v-7.806h1.509v-2.044h-1.509v-3.148h-2.38v3.148h-1.381zm8.06 2.882c0-1.697 1.253-3.045 3.037-3.045s3.06 1.369 3.06 3.045-1.254 3.066-3.06 3.066-3.038-1.37-3.038-3.066m2.528 5.15c1.381 0 2.89-.675 3.569-1.942v1.716h2.294v-9.85h-2.294v1.716c-.68-1.266-2.188-1.941-3.569-1.941-2.91 0-4.887 2.288-4.887 5.15 0 2.86 1.976 5.149 4.887 5.149zm11.263.081c2.338 0 3.93-1.43 3.93-3.29s-1.487-2.677-3.059-2.984l-.957-.184c-.85-.184-1.487-.409-1.487-1.062 0-.572.552-1.103 1.424-1.103.956 0 1.7.47 1.932 1.471h.02c.404-.47 1.361-.838 1.997-.756l.021-.021c-.383-1.39-1.763-2.533-3.91-2.533-2.146 0-3.782 1.39-3.782 3.066 0 1.512 1.02 2.411 3.08 2.84l.999.204c.786.184 1.402.43 1.402 1.145 0 .717-.552 1.288-1.593 1.288-1.274 0-2.188-.817-2.358-1.84l-.021-.02c-.404.47-1.36.817-1.997.736l-.02.02c.382 1.84 2.082 3.025 4.376 3.025zm5.077-.307h2.359v-5.599c0-1.43 1.02-2.37 2.422-2.37 1.401 0 2.294.899 2.294 2.288v5.681h2.358v-6.518c0-2.084-1.657-3.576-3.846-3.576-1.296 0-2.57.572-3.228 1.736V22.95h-2.359zm12.922-4.924c0-1.697 1.253-3.045 3.037-3.045s3.06 1.369 3.06 3.045-1.253 3.066-3.06 3.066c-1.806 0-3.037-1.37-3.037-3.066m2.528 5.15c1.381 0 2.89-.675 3.569-1.942v1.716h2.294v-9.85h-2.294v1.716c-.679-1.266-2.188-1.941-3.569-1.941-2.91 0-4.887 2.288-4.887 5.15 0 2.86 1.976 5.149 4.887 5.149zm7.716-7.378v7.152h2.358v-4.372c0-1.716.531-3.31 2.252-3.31.51 0 .956.122 1.189.306h.021c.021-.633.574-1.553 1.126-1.9v-.041c-.383-.429-1.062-.694-1.976-.694-1.296 0-2.21.777-2.677 1.839v-1.676h-2.295v2.698zm8.647 1.062c.36-1.308 1.338-2.125 2.782-2.125 1.445 0 2.465.817 2.805 2.125zm2.953 6.396c2.082 0 3.909-.879 4.823-2.84v-.042c-.638.041-1.593-.389-1.976-.899l-.02.02c-.383 1.023-1.402 1.78-2.783 1.78-1.656 0-2.91-1.044-3.123-2.739h7.966c.382-3.085-1.657-5.722-5.036-5.722-2.996 0-5.162 2.33-5.162 5.21s2.209 5.232 5.311 5.232m9.863.001c2.337 0 3.93-1.43 3.93-3.29s-1.487-2.677-3.06-2.984l-.956-.184c-.85-.184-1.488-.409-1.488-1.062 0-.572.552-1.103 1.424-1.103.957 0 1.7.47 1.933 1.471h.02c.404-.47 1.36-.838 1.997-.756l.02-.021c-.382-1.39-1.763-2.533-3.909-2.533s-3.782 1.39-3.782 3.066c0 1.512 1.02 2.411 3.08 2.84l.998.204c.787.184 1.402.43 1.402 1.145 0 .717-.552 1.288-1.593 1.288-1.274 0-2.188-.817-2.358-1.84l-.021-.02c-.403.47-1.36.817-1.996.736l-.021.02c.383 1.84 2.082 3.025 4.377 3.025z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M10.59 17.04h128.13v25.92H10.59z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function BoltLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 114 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M54.241 45.251c-8.584 0-12.785-4.931-12.785-11.141S47.3 20.046 55.885 20.046c8.584 0 12.785 4.931 12.785 11.141s-5.845 14.064-14.429 14.064m.365-7.854c2.923 0 4.932-2.74 4.932-5.662s-1.461-3.653-4.018-3.653c-2.558 0-4.932 2.74-4.932 5.662 0 2.923 1.461 3.653 4.018 3.653m23.197 7.306h-8.95l7.306-33.242h8.95l-7.306 33.06z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M27.76 45.251c-2.739 0-5.479-.913-6.94-3.105l-.548 2.557L10.227 50l1.095-5.297 7.306-33.242h8.95l-2.557 11.69c2.01-2.192 4.018-3.105 6.575-3.105 5.48 0 8.95 3.47 8.95 10.045 0 6.576-4.2 15.16-12.785 15.16m3.471-13.333c0 3.105-2.192 5.48-5.114 5.48s-3.105-.549-4.018-1.645l1.46-6.027c1.097-1.096 2.193-1.644 3.654-1.644 2.192 0 4.018 1.644 4.018 4.018z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M94.414 45.251c-5.115 0-8.95-1.826-8.95-6.027s0-1.279.182-1.827l2.01-8.95h-4.019l1.827-7.67h4.018l1.461-6.576L100.99 10l-1.096 4.2-1.461 6.576h4.931l-1.826 7.672h-4.932l-1.278 5.844v1.096c0 1.096.73 2.01 2.192 2.01 1.46 0 1.095 0 1.278-.183v7.123c-.913.73-2.557.913-4.2.913z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function BrthrsLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 130 40" {...props}>
|
||||
<title>brthrs-logotype-color</title>
|
||||
<defs>
|
||||
<polygon id="path-1" points="0 0 130 0 130 40 0 40"></polygon>
|
||||
</defs>
|
||||
<g id="brthrs-logotype-color" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlinkHref="#path-1"></use>
|
||||
</mask>
|
||||
<g id="Clip-2"></g>
|
||||
<path
|
||||
d="M119.768234,32 C117.898023,32 116.681663,31.1207404 116,30.0152264 C119.41895,25.6778708 121.631357,19 121.631357,19 C122.4409,19 124,24.3952982 124,27.528052 C124,29.7130897 122.63122,32 119.768234,32 Z M125.722294,10.5495228 L117.486497,10.5495228 C118.056172,14.9750306 114.232541,23.6322092 112.742226,26.8614646 L112.733409,26.8431644 C112.733409,26.8431644 110.422135,32.3914833 107.248266,32.3914833 C106.078546,32.3914833 104.972505,31.5571406 105.13317,29.6775217 C105.286242,27.8865142 106.602912,24.0398692 107.021473,22.679397 C108.576936,17.6227745 106.671733,13.5480999 100.056547,13.5480999 C100.056547,13.5480999 100.055567,13.5483407 100.055322,13.5483407 C100.055567,13.5476184 100.056547,13.5442473 100.056547,13.5442473 C100.056547,13.5442473 100.393552,11.3723075 100.206925,10.5495228 L94.3624904,10.5495228 C94.6096108,15.7665125 90.012289,32.4052084 85.1651527,32.4052084 C83.8590147,32.4052084 82.820815,31.7040234 83.4487801,28.8419751 C83.6846344,27.7666 85.7218472,19.2103132 85.7218472,17.5062314 C85.7218472,14.6735596 84.1149521,13.2208642 80.9450019,13.2208642 C77.9229811,13.2208642 75.629096,15.0294495 74.49244,16.0511281 L74.8850407,14.2603613 C75.8590723,10.0183366 76.649172,6.52637762 76.9761351,5.0113172 L71.1287611,5.0113172 C70.9053975,6.00939817 70.0876224,9.44332643 69.2044546,13.3463166 L69.2039648,13.3463166 C69.2039648,13.3463166 69.1983317,13.3725629 69.1878003,13.4207212 C69.106733,13.7787782 69.0254209,14.1404471 68.9436189,14.5047648 C68.9218213,14.6025261 68.9071263,14.6925822 68.8887576,14.7864909 C68.4295398,16.88523 67.5304524,20.9943378 66.6188744,25.1590684 C64.8963788,27.8997577 61.5782543,32.4013557 58.3620149,32.4013557 C55.9243641,32.4013557 56.1866694,29.1451316 56.5770658,27.3818151 C57.1805392,24.6562956 57.8631207,21.5823507 58.5285579,18.5859407 L62.3720277,18.5859407 L63.5615856,13.2283288 L59.7186057,13.2242353 C60.5471572,9.49052157 61.2334123,6.38792248 61.5326999,5.00144475 L55.6564258,5 C55.4286538,6.01782587 54.6929255,9.33472944 53.832535,13.2182155 L50.328764,13.2146036 L49.139206,18.5859407 L52.6434669,18.5859407 C51.9907652,21.5322661 51.3623103,24.370476 50.9057865,26.4333372 C50.4125253,27.2614194 47.2070622,32.4013557 43.5874459,32.4013557 C42.4179711,32.4013557 41.3685052,31.5571406 41.5289253,29.6775217 C41.6819979,27.8865142 42.9989123,24.0398692 43.417474,22.679397 C44.9726918,17.6227745 43.3596738,13.5480999 38.1189572,13.5480999 C38.1189572,13.5480999 38.7123892,11.6932826 38.6663449,10.5495228 L32.7121875,10.5533755 C32.5836065,11.9675442 32.0330349,16.42893 28,19.2404121 L31.3394322,23.6526764 C31.3394322,23.6526764 33.9717914,22.04756 35.5911773,18.9781901 C36.8270243,19.1869564 38.3704862,20.0039621 37.5336075,22.4386055 C37.5336075,22.4386055 35.4831692,28.5484502 35.4510852,31.5585853 C35.4008774,36.2740062 38.9119959,38 41.9883881,38 C46.1311454,38 48.8896364,35.9824076 50.4762035,34.2903654 C51.2378929,36.359728 53.0706007,38 56.7394452,38 C60.0590392,38 62.6953171,36.3435949 64.5534962,34.5973746 C64.1704473,36.3479292 63.9145098,37.5174537 63.8814462,37.6689116 L69.7626186,37.6689116 C70.0988885,36.1572223 71.6014493,28.9659827 72.6129532,24.6170466 C73.4353817,21.0812635 76.1363175,18.6762375 78.5445783,18.6762375 C79.3202279,18.6762375 79.6075146,19.5418831 79.4608098,20.2103205 C79.2905931,20.9856693 78.9626503,22.8238719 78.3648099,25.2276939 C77.6214892,28.2173618 77.2210513,30.6587473 77.2210513,32.2210029 C77.2210513,36.5952221 80.3843888,38 83.6701843,38 C92.6436686,38 97.1067763,24.3538614 98.5287596,18.9175107 C99.74036,18.9300318 102.1376,19.5300844 101.138097,22.4386055 C101.138097,22.4386055 99.0876584,28.5484502 99.0555744,31.5585853 C99.0053665,36.2740062 102.51624,38 105.592387,38 C109.126773,38 111.53185,36.5415256 113.060127,35.0702892 C114.076039,36.2198281 116.303552,38 120.366956,38 C126.607421,38 130,33.7459357 130,27.7661184 C130,21.9066969 126.205513,15.2346039 125.722294,10.5495228 L125.722294,10.5495228 Z"
|
||||
id="Fill-1"
|
||||
fill="#FFFFFF"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M11,33.9811595 C11.425622,32.109806 12.5859338,27.0088547 13.7228352,22 C16.5998306,22.3743664 21,23.591715 21,27.5363166 C21,32.810218 16.086793,34.1830542 11,33.9811595 Z M22.5070236,19.5325178 C26.4719736,17.2493915 29,13.4510219 29,8.81000891 C29,0.997783594 21.3553589,0 18.716838,0 C8.23196781,0 1.30739528,6.99914407 0.750967456,7.63045475 L3.96791626,11.9724259 C4.50358001,11.4229608 10.1929371,5.02898568 17.9752638,5.02898568 C20.6676725,5.02898568 23.2008899,6.23003958 23.2008899,9.65069778 C23.2008899,11.9343127 21.584011,15.2538244 15.1137762,17.1560631 C15.821238,13.9425843 16.3979355,11.3157064 16.597419,10.3873083 L10.6608705,10.3909731 C10.1936787,12.489397 7.01801088,26.9159698 5.59567159,33.3785976 C2.72627389,32.4355406 1.16229394,31.1883111 1.16229394,31.1883111 L0,36.5480996 C0,36.5480996 4.32337749,39.5096894 10.3533644,39.9450593 C17.6909937,40.4749791 27.1307387,37.2275404 27.1307387,27.7603234 C27.1307387,23.5236522 24.9893196,21.0160002 22.5070236,19.5325178 L22.5070236,19.5325178 Z"
|
||||
id="Fill-3"
|
||||
fill="#FFFFFF"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatbaseLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 148 60" {...props}>
|
||||
<g fill="currentColor" clipPath="url(#a)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M17.702 16.5h13.385c3.905 0 7.071 3.105 7.071 6.936v13.128c0 3.83-3.166 6.936-7.071 6.936H17.702c-3.906 0-7.072-3.105-7.072-6.936V23.436c0-3.83 3.166-6.936 7.072-6.936m9.817 11.51h4.452q-.127-1.484-.718-2.633a6 6 0 0 0-1.563-1.935 6.8 6.8 0 0 0-2.257-1.205q-1.287-.411-2.818-.411-2.178 0-3.946.94-1.767.931-2.81 2.758-1.041 1.818-1.041 4.476 0 2.641 1.018 4.468 1.025 1.826 2.786 2.766 1.768.94 3.993.94 1.776 0 3.118-.536t2.249-1.414a6.7 6.7 0 0 0 1.413-1.927q.497-1.05.576-2.06l-4.451-.03q-.08.513-.308.924-.222.405-.576.692-.355.288-.837.443a3.7 3.7 0 0 1-1.09.147q-1.065 0-1.822-.49-.758-.496-1.16-1.475-.396-.987-.395-2.448 0-1.383.387-2.37.394-.987 1.152-1.515.766-.529 1.87-.529.631 0 1.129.18.497.17.845.496.354.32.552.762.205.443.253.987"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="M59.893 27.327h-2.5a3.6 3.6 0 0 0-.537-1.364 3.9 3.9 0 0 0-.962-1 4.2 4.2 0 0 0-1.274-.62 5.1 5.1 0 0 0-1.482-.209q-1.418 0-2.54.69-1.113.69-1.763 2.022-.64 1.334-.64 3.255 0 1.935.64 3.27.65 1.332 1.771 2.014 1.123.681 2.524.682.777 0 1.474-.202.705-.21 1.274-.612.57-.403.962-.984.4-.588.552-1.348l2.5.008a6.3 6.3 0 0 1-.777 2.154 6 6 0 0 1-1.466 1.689 6.7 6.7 0 0 1-2.035 1.092 7.8 7.8 0 0 1-2.5.388q-2.13 0-3.798-.977-1.666-.984-2.628-2.812-.954-1.829-.954-4.363 0-2.541.962-4.362.96-1.829 2.628-2.805 1.667-.983 3.79-.983 1.306-.001 2.436.364 1.138.355 2.043 1.053a5.9 5.9 0 0 1 1.498 1.69q.593.99.801 2.27M63.705 30.968v7.067h-2.396V22.166h2.364v5.904h.152q.433-.96 1.322-1.526t2.324-.566q1.266 0 2.21.504a3.46 3.46 0 0 1 1.475 1.503q.53.992.53 2.48v7.57h-2.397v-7.291q0-1.31-.697-2.03-.697-.729-1.939-.729-.85 0-1.522.349a2.56 2.56 0 0 0-1.05 1.023q-.375.666-.376 1.611M77.11 38.298q-1.17 0-2.115-.418a3.53 3.53 0 0 1-1.499-1.232q-.544-.806-.544-1.976 0-1.007.4-1.658a2.9 2.9 0 0 1 1.082-1.03 5.8 5.8 0 0 1 1.522-.574q.842-.194 1.715-.294l1.795-.202q.69-.085 1.001-.27.313-.187.313-.605v-.054q0-1.015-.593-1.573-.585-.558-1.747-.558-1.21 0-1.907.519-.69.51-.953 1.139l-2.252-.496q.4-1.085 1.17-1.751a4.8 4.8 0 0 1 1.787-.976 7.2 7.2 0 0 1 2.123-.31 7.7 7.7 0 0 1 1.563.17q.832.163 1.554.605.729.441 1.194 1.262.465.814.465 2.116v7.903h-2.34v-1.627h-.096q-.232.45-.697.883-.465.435-1.194.72-.729.288-1.747.287m.521-1.86q.993 0 1.698-.379.714-.38 1.082-.992.377-.62.377-1.325v-1.534q-.129.124-.497.233a8 8 0 0 1-.825.178l-.906.132q-.44.054-.737.093-.697.084-1.274.286-.569.202-.913.581-.337.372-.337.992 0 .86.657 1.302.657.434 1.675.434M90.766 26.134v1.86h-6.722v-1.86zm-4.92-2.852h2.396V34.54q0 .675.208 1.015.21.333.537.458.337.116.73.116.288 0 .504-.04.216-.038.337-.061l.432 1.914a4 4 0 0 1-.593.155 5 5 0 0 1-.961.093 4.3 4.3 0 0 1-1.763-.326 3 3 0 0 1-1.322-1.053q-.505-.713-.505-1.79zM92.518 38.035V22.166h2.396v5.897h.144q.21-.372.601-.86.393-.489 1.09-.853.698-.372 1.843-.372 1.49 0 2.66.729 1.17.727 1.835 2.1.673 1.371.673 3.3 0 1.93-.665 3.309-.665 1.372-1.827 2.115-1.162.736-2.652.736-1.122 0-1.835-.364-.705-.365-1.106-.852a6.4 6.4 0 0 1-.617-.868h-.2v1.852zm2.348-5.951q0 1.255.377 2.2.376.946 1.09 1.48.713.527 1.746.527 1.074 0 1.795-.55.722-.558 1.089-1.51.377-.954.377-2.147 0-1.178-.368-2.115-.361-.938-1.09-1.48-.72-.543-1.803-.543-1.041 0-1.763.52-.713.519-1.081 1.449-.369.93-.369 2.169M108.678 38.298q-1.17 0-2.115-.418a3.54 3.54 0 0 1-1.499-1.232q-.544-.806-.544-1.976 0-1.007.4-1.658a2.9 2.9 0 0 1 1.082-1.03 5.8 5.8 0 0 1 1.522-.574q.842-.194 1.715-.294l1.795-.202q.689-.085 1.001-.27.313-.187.313-.605v-.054q0-1.015-.593-1.573-.585-.558-1.747-.558-1.21 0-1.907.519-.689.51-.953 1.139l-2.252-.496q.4-1.085 1.17-1.751a4.8 4.8 0 0 1 1.787-.976 7.2 7.2 0 0 1 2.123-.31q.737 0 1.563.17.833.163 1.554.605.729.441 1.194 1.262.464.814.465 2.116v7.903h-2.34v-1.627h-.096q-.233.45-.697.883-.465.435-1.194.72-.729.288-1.747.287m.521-1.86q.993 0 1.699-.379.713-.38 1.081-.992a2.5 2.5 0 0 0 .377-1.325v-1.534q-.129.124-.497.233-.36.1-.825.178l-.906.132q-.44.054-.737.093a6 6 0 0 0-1.274.286 2.2 2.2 0 0 0-.913.581q-.337.372-.337.992 0 .86.657 1.302.657.434 1.675.434M125.811 29.04l-2.171.371a2.5 2.5 0 0 0-.433-.767 2.14 2.14 0 0 0-.785-.596q-.498-.233-1.242-.233-1.018 0-1.699.442-.681.433-.681 1.123 0 .597.457.961t1.474.597l1.955.434q1.699.38 2.532 1.17.834.789.833 2.053 0 1.07-.641 1.906-.633.829-1.77 1.302-1.13.472-2.62.472-2.068 0-3.374-.852-1.306-.86-1.602-2.44l2.315-.342q.217.876.89 1.325.672.442 1.754.442 1.178 0 1.883-.473.706-.48.706-1.17a1.2 1.2 0 0 0-.433-.937q-.425-.38-1.306-.574l-2.084-.441q-1.722-.38-2.547-1.21-.818-.828-.818-2.099 0-1.053.609-1.844.609-.79 1.683-1.232 1.074-.45 2.46-.45 1.995 0 3.141.838a3.9 3.9 0 0 1 1.514 2.223M132.636 38.275q-1.82 0-3.133-.751a5.16 5.16 0 0 1-2.019-2.131q-.705-1.38-.705-3.231 0-1.83.705-3.223.713-1.395 1.987-2.178 1.282-.782 2.997-.782 1.041 0 2.019.333.977.333 1.754 1.046.778.712 1.226 1.852.45 1.13.449 2.75v.822h-9.783v-1.736h7.435q0-.915-.384-1.62a2.86 2.86 0 0 0-1.082-1.123q-.689-.41-1.618-.41-1.01 0-1.763.48a3.26 3.26 0 0 0-1.154 1.24 3.5 3.5 0 0 0-.401 1.65v1.356q0 1.193.433 2.03.441.837 1.226 1.278.785.434 1.835.434a4 4 0 0 0 1.242-.186q.561-.193.969-.573.41-.38.625-.937l2.268.395a3.94 3.94 0 0 1-.978 1.697q-.696.72-1.754 1.123-1.05.395-2.396.395" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M10.63 16.5h127.286v27H10.63z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CoComLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 92 60" {...props}>
|
||||
<g clipPath="url(#a)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M81.632 29.3c0-5.052-2.43-10.113-6.03-13.59-3.068-2.967-7.152-5.09-11.425-5.534-3.876-.397-7.76 0-11.327 1.633a20 20 0 0 0-8.218 6.876 20.2 20.2 0 0 0-2.979 6.76c-.363 1.575-.476 3.24-.69 4.847-.092.673-.164 1.35-.31 2.011a10.74 10.74 0 0 1-6.701 7.713c-4.633 1.702-9.942-.266-12.528-4.422a10.702 10.702 0 0 1 7.495-16.233c1.298-.195 2.62-.15 3.902.135.789.174 1.556.436 2.287.782.098.047.524.32.593.283.069-.036.12-.225.152-.28l3.775-6.909.535-.978a20.9 20.9 0 0 0-16.248-1.345 20.18 20.18 0 0 0-12.593 12.763 19.866 19.866 0 0 0 10.306 24 21.8 21.8 0 0 0 8.178 2.036 20.03 20.03 0 0 0 15.113-5.796c.364-.364.727-.749 1.065-1.142 3.637 4.197 9.131 6.96 14.728 6.997 3.153.07 6.283-.55 9.17-1.819a20.26 20.26 0 0 0 8.048-6.581A19.95 19.95 0 0 0 81.632 29.3m-9.433 1.72a10.777 10.777 0 0 1-11.265 9.67 10.774 10.774 0 1 1 11.265-9.67"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M10.364 10h71.273v40H10.364z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FigmaLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 166 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M45.155 10h16.479q.02 1.977-.039 3.945-6.198.03-12.398.02v8.317q4.761-.01 9.522.02a49 49 0 0 1 0 3.886q-4.761.03-9.522.02.01 7.325-.02 14.651-1.98.018-4.022 0zM30.853 10.04q4.604.73 5.81 5.246.727 5.21-3.925 7.579l-.039-.04a5.1 5.1 0 0 1-1.846.467h-.078q-.978.038-1.943.078h-5.247a571 571 0 0 1-.077-13.292q3.171-.014 6.315-.039l1.01.078a.1.1 0 0 0 .02-.078M23.43 23.331q-3.751.004-7.346 0h-.038a.1.1 0 0 0-.04-.058 5.7 5.7 0 0 1-.97-.195 18 18 0 0 1-1.361-.485q-2.18-1.169-3.07-3.46a.11.11 0 0 1 .038-.096 5 5 0 0 1-.388-1.769.56.56 0 0 0-.117-.31v-.506q.09-.105.117-.253.039-1.11.486-2.157l.524-1.049q1.326-1.985 3.615-2.681a.11.11 0 0 1 .097.038 2.8 2.8 0 0 1 1.05-.194q.482-.084.97-.117h6.22q.124.036.252.078-.018 6.608-.04 13.214"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M30.853 10.04a.1.1 0 0 1-.02.077l-1.01-.078q-3.144.025-6.315.04-.039 6.653.078 13.291h-.118q0-.039-.038-.039.021-6.605.039-13.214a7 7 0 0 0-.253-.078h-6.218q-.489.033-.972.117a2.8 2.8 0 0 0-1.049.194.11.11 0 0 0-.097-.038 7.3 7.3 0 0 1 1.554-.292q7.21-.049 14.42.02M68.396 11.089q2.228-.05 2.895 2.06.277 2.112-1.651 3.012-2.031.599-3.207-1.186-.877-1.865.603-3.284a3.2 3.2 0 0 1 1.36-.602M11.265 12.993l-.524 1.05a6 6 0 0 0-.486 2.157.5.5 0 0 1-.117.252q.074-1.328.603-2.565.207-.487.524-.894M10.138 17.346q.082.027.078.116-.09-.017-.078-.116"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M10.138 16.957a.56.56 0 0 1 .117.311q.032.91.388 1.768a.11.11 0 0 0-.038.098 5.3 5.3 0 0 1-.39-1.672q.006-.089-.077-.116zM85.069 19.522q3.542-.109 6.043 2.371.01-.87.059-1.73 2-.028 4.003-.019.01 9.872-.02 19.743-.559 5.993-5.888 8.687-4.269 1.9-8.589 0a9 9 0 0 1-1.166-.68l-.039-.078a224 224 0 0 1 2.002-3.42q2.755 1.86 5.849.719 3.288-1.495 3.79-5.072.009-1.665.058-3.323-.04-.039-.078 0-2.895 2.775-6.918 2.293-4.296-.706-6.471-4.489-1.809-3.474-1.166-7.229.69-3.57 3.44-5.927a8.73 8.73 0 0 1 5.09-1.846m.233 3.965q3.477-.093 5.188 2.992 1.009 2.12.467 4.43-.821 2.838-3.556 3.945-3.36.93-5.655-1.73-2.142-2.931-.816-6.334 1.344-2.81 4.372-3.303M110.059 19.522q3.576-.068 5.849 2.682.252.397.525.777 2.814-3.967 7.656-3.362 3.988.878 5.422 4.723.356 1.003.505 2.06.096 7.227.039 14.457h-3.984q.01-6.608-.019-13.214-.255-3.227-3.362-4.1-2.797-.39-4.372 1.923a4.4 4.4 0 0 0-.622 1.71q-.029 6.84-.019 13.68h-4.042q.01-6.761-.02-13.524-.486-3.05-3.478-3.79-2.781-.333-4.334 2.041a4.7 4.7 0 0 0-.582 1.827q-.03 6.724-.02 13.447h-4.003q-.007-10.344-.039-20.657a71 71 0 0 1 4.042-.058q-.01.914.02 1.827 1.856-2.351 4.838-2.449M143.832 19.522q4.248-.109 7.268 2.876.048-1.126.039-2.215 2-.058 4.003-.039V40.86q-2.002.02-4.003-.039a48 48 0 0 0-.039-2.215q-2.265 2.259-5.402 2.779-6.441.703-9.969-4.722-2.443-4.382-1.322-9.095 1.007-4.027 4.373-6.432a10.25 10.25 0 0 1 5.052-1.613m.233 3.965q3.476-.129 5.616 2.604 2.4 3.48.816 7.462-1.608 3.36-5.266 3.944-3.456.196-5.616-2.507-2.06-2.888-1.282-6.373.92-3.304 4.061-4.722.364-.126.738-.233.495-.061.933-.175M66.687 20.144h4.041q.01 10.338-.019 20.676a104 104 0 0 1-4.023.039zM13.675 22.593q.667.27 1.36.485.477.14.972.195a.1.1 0 0 1 .038.058 8 8 0 0 1-1.904-.486 4 4 0 0 0-.466-.252M16.084 23.331q3.595.005 7.346 0 .039 0 .039.039v13.253h-6.763a.56.56 0 0 1-.272 0 1.5 1.5 0 0 0-.272 0 4 4 0 0 1-.563-.078l-.04.039a6 6 0 0 0-.33-.117.43.43 0 0 0-.272-.077 6 6 0 0 1-2.37-1.244.26.26 0 0 0-.117-.117 4.5 4.5 0 0 1-.855-.855q.005-.09-.078-.116a5.2 5.2 0 0 1-1.088-2.177.44.44 0 0 0-.078-.194.36.36 0 0 1-.039-.194 1.6 1.6 0 0 0-.077-.35 1.1 1.1 0 0 1-.04-.389q.03-.168-.077-.272v-1.01q.104-.087.078-.233a6.4 6.4 0 0 1 .738-2.449q.082-.027.078-.116l.039-.078q.082-.027.078-.117.102-.185.233-.35.082-.027.078-.116 1.757-2.326 4.624-2.682M23.469 23.37h.039q-.02 2.79.039 5.558a.23.23 0 0 1-.078.155z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M30.775 23.292q.02.078.04 0h.038q3.762.616 5.422 4.081 1.385 3.665-.933 6.801-2.445 2.791-5.985 2.488-3.77-.609-5.402-4.042a26 26 0 0 1-.35-1.088 1.8 1.8 0 0 1-.058-.428q.021-.128-.078-.194v-1.827a.23.23 0 0 0 .078-.155q.364-2.41 2.215-4.023a6.8 6.8 0 0 1 3.42-1.516q-.174-.028-.35-.019.964-.04 1.943-.078M11.46 26.013q.005.09-.078.116-.005-.089.078-.116M11.149 26.48q.005.089-.078.116-.005-.09.078-.117M11.032 26.674q.005.089-.078.116-.005-.09.078-.116M10.216 29.239q.025.146-.078.233-.027-.15.078-.234M10.138 30.482q.108.105.078.272a.09.09 0 0 1-.058-.039.7.7 0 0 1-.02-.233M10.255 31.143q.057.165.078.35a.5.5 0 0 1-.078-.35M10.372 31.687q.06.084.077.194-.1-.066-.077-.194M11.537 34.058q.082.027.078.116-.082-.027-.078-.116M12.47 35.03q.077.038.117.116-.117 0-.117-.117M14.958 36.39a.43.43 0 0 1 .272.077.43.43 0 0 1-.273-.077M16.434 36.623a.5.5 0 0 1-.272.039v-.04q.119-.012.272 0"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M16.434 36.623a.56.56 0 0 0 .272 0l6.743.078q.03 3.478.02 6.956.021.397-.078.778a1.9 1.9 0 0 0-.117.466q-.805 3.09-3.692 4.489a2.6 2.6 0 0 1-.583.175q-.189.032-.35.116-.21.024-.388.117a.28.28 0 0 1-.233.039 1.3 1.3 0 0 0-.311.02.09.09 0 0 0-.04.057h-.271a.34.34 0 0 0-.272.078h-.467a.09.09 0 0 0-.038-.058 2 2 0 0 0-.39-.02h-.038a6 6 0 0 0-.777-.116h-.04a1.1 1.1 0 0 0-.388-.117.36.36 0 0 1-.194-.039.23.23 0 0 0-.194-.077 1.1 1.1 0 0 1-.467-.156.3.3 0 0 0-.116-.078 4.8 4.8 0 0 1-1.4-.855.43.43 0 0 0-.233-.194 5.8 5.8 0 0 1-1.476-1.904.6.6 0 0 0-.078-.156 3 3 0 0 1-.389-1.01.5.5 0 0 0-.117-.311v-.117a4.4 4.4 0 0 0-.116-.621v-.117a.5.5 0 0 0-.078-.35v-1.01q.1-.067.078-.195a.8.8 0 0 1 .039-.35q.063-.181.077-.388.095-.178.117-.389v-.039a.7.7 0 0 0 .117-.31 5.98 5.98 0 0 1 1.865-2.76.3.3 0 0 0 .195-.155 5.3 5.3 0 0 1 1.36-.855.6.6 0 0 0 .272-.117q.183-.104.389-.175.152-.048.31-.058.21-.024.389-.117h.039a6 6 0 0 0 .7-.116h.077a.5.5 0 0 0 .272-.04"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M23.469 30.91q.1.066.077.194l-.078 12.553q.01-3.478-.019-6.956l-6.743-.078h6.762zM16.084 36.662q-.34.076-.7.116a.13.13 0 0 1 .079-.058 3.5 3.5 0 0 1 .621-.058M15.346 36.778a1.1 1.1 0 0 1-.389.117.73.73 0 0 1 .39-.117M14.258 37.128a.6.6 0 0 1-.272.117.37.37 0 0 1 .272-.117M12.626 38.1a.3.3 0 0 1-.195.155.3.3 0 0 1 .195-.155M10.566 41.015a.7.7 0 0 1-.117.31.5.5 0 0 1 .117-.31M10.45 41.364q-.024.21-.117.389a.73.73 0 0 1 .116-.389M10.333 41.753q-.015.207-.078.389a1.3 1.3 0 0 1 .02-.311.13.13 0 0 1 .057-.078M10.216 42.491q.021.128-.078.195-.023-.128.078-.195M10.138 43.696a.5.5 0 0 1 .078.35.44.44 0 0 1-.078-.35M10.216 44.163q.079.3.116.621a.13.13 0 0 1-.058-.077 4 4 0 0 1-.058-.544M23.391 44.435q0 .255-.117.466.03-.247.117-.466M10.333 44.9a.5.5 0 0 1 .116.312.7.7 0 0 1-.117-.311M10.838 46.222a.6.6 0 0 1 .078.156q-.098-.04-.078-.156M12.392 48.282q.15.053.233.194a.43.43 0 0 1-.233-.194M14.024 49.331a.3.3 0 0 1 .117.078q-.097.013-.117-.078M14.607 49.565a.23.23 0 0 1 .195.077q-.128.022-.195-.077M19 49.565a.52.52 0 0 1-.35.116q.16-.084.35-.116M14.996 49.681q.21.024.389.117a.73.73 0 0 1-.389-.117M18.65 49.681a.73.73 0 0 1-.39.117q.18-.093.39-.117M18.028 49.837q-.162.067-.35.077a.09.09 0 0 1 .04-.058q.154-.03.31-.02M15.424 49.798q.4.032.777.116a2 2 0 0 1-.777-.116M16.24 49.915q.195-.01.388.019a.1.1 0 0 1 .04.058.64.64 0 0 1-.428-.078M17.406 49.914a.09.09 0 0 1-.039.059.7.7 0 0 1-.233.02.34.34 0 0 1 .272-.079"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GithubLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 139 62" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M82.549 39.079h-.04c.02 0 .02.02.04.02h.02zm0 .02c-.158 0-.572.079-1.006.079-1.36 0-1.834-.631-1.834-1.46v-5.482h2.78c.159 0 .277-.138.277-.335v-2.978c0-.157-.138-.295-.276-.295h-2.78V24.92c0-.138-.1-.217-.257-.217h-3.786c-.158 0-.237.099-.237.217v3.806s-1.913.473-2.03.493a.296.296 0 0 0-.218.296V31.9c0 .197.138.335.296.335h1.933v5.758c0 4.28 2.977 4.713 5.008 4.713.927 0 2.051-.295 2.229-.394.098-.04.157-.158.157-.276v-2.623c0-.158-.098-.276-.256-.315m41.53-3.846c0-3.175-1.282-3.589-2.623-3.45-1.045.058-1.893.591-1.893.591v6.172s.848.592 2.13.631c1.794.04 2.386-.611 2.386-3.944m4.259-.276c0 6.015-1.952 7.73-5.344 7.73-2.879 0-4.417-1.459-4.417-1.459s-.059.809-.158.907c-.059.099-.138.138-.256.138h-2.583c-.178 0-.336-.138-.336-.295l.04-19.464c0-.158.138-.296.296-.296h3.727c.157 0 .296.138.296.296v6.606s1.439-.927 3.549-.927l-.02-.04c2.091 0 5.206.79 5.206 6.804m-15.282-6.33h-3.688c-.197 0-.296.138-.296.335v9.525s-.966.67-2.287.67c-1.302 0-1.696-.591-1.696-1.912v-8.322a.303.303 0 0 0-.296-.296h-3.747a.303.303 0 0 0-.296.296v8.973c0 3.845 2.15 4.811 5.108 4.811 2.445 0 4.417-1.34 4.417-1.34s.099.69.138.788c.04.079.158.158.276.158h2.347c.197 0 .296-.138.296-.296l.039-13.094c.02-.158-.138-.296-.315-.296m-41.53-.02h-3.727c-.158 0-.296.158-.296.355V41.84c0 .355.237.473.532.473h3.372c.355 0 .434-.158.434-.473V28.923c-.02-.157-.158-.295-.315-.295m-1.854-5.935a2.41 2.41 0 0 0-2.425 2.425 2.41 2.41 0 0 0 2.425 2.426c1.321 0 2.386-1.065 2.386-2.426 0-1.34-1.065-2.425-2.386-2.425m28.91-.434h-3.708a.303.303 0 0 0-.296.296v7.158h-5.797v-7.158a.303.303 0 0 0-.296-.296h-3.727a.303.303 0 0 0-.296.296v19.463c0 .158.158.296.296.296h3.727a.303.303 0 0 0 .296-.296v-8.321h5.797l-.04 8.321c0 .158.139.296.297.296h3.727a.303.303 0 0 0 .295-.296V22.554c.02-.158-.118-.296-.276-.296m-33.07 8.618v10.057c0 .059-.02.197-.1.217 0 0-2.188 1.557-5.797 1.557-4.358 0-9.544-1.36-9.544-10.372s4.515-10.866 8.933-10.846c3.825 0 5.363.868 5.6 1.025.06.099.099.158.099.237l-.71 3.135c0 .158-.158.355-.355.296-.631-.197-1.578-.572-3.806-.572-2.583 0-5.344.73-5.344 6.547 0 5.798 2.623 6.488 4.516 6.488 1.617 0 2.189-.197 2.189-.197v-4.023h-2.584c-.197 0-.335-.138-.335-.296v-3.234c0-.157.138-.296.335-.296h6.567c.197-.02.335.119.335.276"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M25.027 16.5C16.723 16.5 10 23.224 10 31.526c0 6.646 4.299 12.266 10.274 14.258.75.118 1.045-.316 1.045-.71 0-.355-.02-1.538-.02-2.8-3.766.69-4.752-.927-5.048-1.755-.177-.434-.907-1.775-1.538-2.13-.532-.276-1.282-.986-.02-1.006 1.184-.02 2.031 1.085 2.307 1.538 1.361 2.268 3.51 1.637 4.378 1.243.138-.967.533-1.637.967-2.012-3.333-.374-6.843-1.676-6.843-7.414 0-1.637.591-2.998 1.538-4.043-.158-.375-.67-1.913.158-3.983 0 0 1.262-.395 4.14 1.538a13.966 13.966 0 0 1 7.494 0c2.88-1.952 4.122-1.538 4.122-1.538.828 2.07.296 3.608.158 3.983.966 1.045 1.538 2.386 1.538 4.043 0 5.758-3.51 7.04-6.863 7.414.552.474 1.026 1.361 1.026 2.78 0 2.012-.02 3.63-.02 4.122 0 .395.276.868 1.025.71a15.065 15.065 0 0 0 10.235-14.257c0-8.283-6.724-15.007-15.026-15.007"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GoodtapeLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 168 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M44.925 43.193H12.81a2.645 2.645 0 0 1-2.652-2.635V22.442a2.645 2.645 0 0 1 2.652-2.635h32.114a2.645 2.645 0 0 1 2.652 2.635v18.116a2.645 2.645 0 0 1-2.652 2.635m-7.246-16.099c-2.482 0-4.495 2-4.495 4.466s2.014 4.466 4.495 4.466 4.495-2 4.495-4.466c0-2.465-2.014-4.466-4.495-4.466m-17.188 0c-2.48 0-4.494 2-4.494 4.466s2.013 4.466 4.494 4.466c2.482 0 4.495-2 4.495-4.466 0-2.465-2.013-4.466-4.495-4.466"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M108.451 25.946h-3.268l-.046 3.781a5 5 0 0 0-.269-.36l-.006-.007-.008-.01-.008-.008-.053-.055c-.736-.653-1.515-.974-2.511-.98l-.177.002c-.427.003-.84.008-1.256.113l-.083.025-.064.02a7 7 0 0 0-.491.163c-.694.3-1.446.796-1.762 1.51l-.035.101h-.093c-.447 1.016-.594 1.941-.567 3.044.05 1.08.456 2.107 1.241 2.852l.018.017.019.017.001.002.037.035a1.5 1.5 0 0 0 .412.304l.032.01.067.022v.093c1.233.557 2.61.87 3.943.421.664-.267 1.37-.656 1.67-1.346l.036-.102h.093c0 .054 0 .097.002.14.005.183.034.365.185 1.305v.002h2.941zm-3.696 5.493c.38.419.498.928.483 1.485-.028.4-.112.715-.335 1.052l-.084.128c-.399.41-.975.528-1.529.541-.645.008-1.21-.066-1.701-.529-.33-.33-.417-.797-.429-1.246.008-.589.143-1.13.569-1.555.821-.645 2.26-.576 3.026.124"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M66.966 26.215h.102c.51.003 1.012.013 1.514.105l.12.021c1.104.206 2.098.587 2.915 1.38l.03.026.058.051c.583.531.983 1.479 1.079 2.256v.374h-3.735l-.047-.158v-.002L69 30.266l-.01-.03-.04-.133-.025-.085-.017-.057h-.093q-.03-.052-.062-.105c-.204-.334-.584-.482-.948-.584-.783-.164-1.754-.142-2.447.298l-.091.064-.095.063-.002.002c-.437.33-.626.867-.71 1.391a5.9 5.9 0 0 0 .2 2.466h.093l.047.12c.235.403.631.612 1.067.747.315.082.628.079.951.076h.209c.728-.005 1.384-.069 1.928-.607.178-.196.273-.367.373-.616l-2.848-.047-.002-.954-.002-.302v-.359q0-.15.004-.3c.064-.062.141-.058.225-.054l.065.002h5.91c.061.004.086.005.106.016a.2.2 0 0 1 .044.037q.006.111.006.223v.479l-.001.231v1.023l-.001.35v.255l-.002.755v.387l-.002 2.039h-2.942v-.001c-.073-.59-.096-.774-.112-.959l-.01-.134-.005-.071-.008-.126-.007-.087-.008-.124-.01-.15-.009-.122-.005-.072-.006-.087-.008-.114-.022-.33-.023-.33-.027.131-.002.012-.022.106-.012.058-.024.112v.006l-.012.054c-.19.807-.626 1.477-1.319 1.939-1.193.712-2.772.736-4.094.405q-1.718-.47-2.611-1.983l-.06-.1c-.827-1.426-.778-3.56-.379-5.114.311-1.119 1.03-2.156 2.026-2.77h.094v-.093c.569-.294 1.112-.529 1.736-.683l.023-.006.026-.006.033-.008.034-.009c.63-.148 1.256-.192 1.9-.186"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M141.643 28.315h.018l.088-.001.088-.002c.405-.003.771.045 1.161.155l.111.03c1.01.285 1.782.972 2.289 1.875.231.462.355.95.448 1.456l.024.13c.184 1.241-.01 2.554-.76 3.577q-.12.156-.244.308l-.103.14c-.161.192-.373.339-.579.483h-.002l-.068.048-.042.03-.1.07c-.968.643-2.262.74-3.383.569a3.2 3.2 0 0 1-.672-.22l-.138-.06c-.575-.265-.897-.584-1.263-1.106l-.046 4.248h-3.315V28.514h2.895c.157 1.064.182 1.23.186 1.396v.098h.094a1 1 0 0 1 .034-.103c.092-.204.246-.36.401-.518l.078-.08.095-.1c.689-.671 1.683-.888 2.612-.891zm.842 4.027c-.097-.463-.276-.8-.654-1.08-.831-.515-2.219-.524-2.944.185-.358.402-.465.927-.469 1.456.021.487.114.91.472 1.26.611.51 1.34.52 2.101.466.491-.087.869-.256 1.19-.644.326-.466.372-1.092.304-1.643M79.26 28.266q.176 0 .351-.003c1.37-.005 2.7.35 3.77 1.231l.102.078c.772.624 1.243 1.658 1.346 2.63.061 1.387-.127 2.57-1.074 3.641l-.024.028-.032.038-.02.023c-.44.484-1.048.773-1.652.997q-.095.035-.188.072c-1.706.663-4.038.465-5.697-.238-.628-.291-1.25-.769-1.584-1.386v-.094h-.093c-.556-1.35-.78-2.86-.204-4.247.2-.442.441-.806.764-1.168l.089-.101a3.8 3.8 0 0 1 1.171-.833h.094v-.093c.963-.35 1.853-.578 2.882-.576m.112 2.652.142-.001c.543.01 1.08.137 1.478.528.426.52.49 1.23.429 1.877a1.8 1.8 0 0 1-.187.467l-.082.149c-.33.413-.708.6-1.225.691-.726.048-1.47.072-2.054-.42-.366-.333-.495-.773-.525-1.257-.015-.579.065-1.06.478-1.497.443-.428.951-.534 1.547-.537M91.516 28.263q-.176.003-.35.002c-.552 0-1.09.018-1.629.154l-.15.045q-.156.045-.31.097c-1.217.428-2.265 1.132-2.847 2.318-.504 1.106-.554 2.52-.147 3.662.417 1.071 1.057 1.801 2.107 2.283 1.585.684 3.932.808 5.553.177l.189-.072c.63-.233 1.23-.537 1.692-1.037l.045-.052.037-.043c.596-.68.988-1.53 1.035-2.44.044-1.347-.17-2.552-1.118-3.574-1.102-1.084-2.588-1.525-4.107-1.52m-.222 2.663h.037l.07-.002c.522 0 .995.112 1.41.437.11.117.196.24.28.374q.025.03.048.062l.035.046c.24.518.227 1.258.053 1.794a1.58 1.58 0 0 1-.782.823c-.383.142-.727.18-1.133.181l-.139.003c-.532.003-1.068-.121-1.475-.481-.265-.282-.414-.69-.434-1.074l-.007-.123c-.017-.533.06-1.057.427-1.465.227-.211.456-.362.747-.466l.053-.023.003-.001.013-.005q.022-.01.047-.02c.227-.061.446-.06.68-.06h.067M152.333 28.266q.19 0 .38-.003c1.33-.005 2.682.271 3.73 1.138l.099.076c.796.642 1.185 1.698 1.348 2.678v1.494h-7.656c.212.58.357.807.887 1.074.46.192.923.196 1.416.198l.179.003c.499.002.949-.064 1.346-.388l.011-.02.01-.018.009-.016.023-.042a.4.4 0 0 1 .087-.077.5.5 0 0 1 .156-.036l.194.001h.108q.177 0 .353.003h.244l.643.005.653.004h.003q.645.003 1.288.01l-.065.165-.068.172-.012.03-.026.067c-.132.333-.312.589-.53.873l-.067.094c-.785 1.013-2.222 1.373-3.423 1.527l-.104.013-.085.01-.026.003-.041.005c-1.739.17-3.75.047-5.176-1.066-.776-.675-1.153-1.597-1.302-2.594-.072-1.31.105-2.645 1.007-3.662l.125-.13.082-.083.004-.004.001-.001.022-.025.048-.055c.54-.595 1.361-.956 2.119-1.175l.058-.018.059-.018c.625-.184 1.242-.214 1.889-.213m.171 2.378.147-.002c.497.003 1.071.08 1.454.426.219.234.424.522.424.854h-4.248c.091-.457.369-.727.747-.98.441-.29.959-.294 1.471-.298zM128.843 28.269h-.132c-.415 0-.83 0-1.243.044l-.034.005-.033.004-.042.005c-1.076.128-2.087.487-2.941 1.167-.634.592-.904 1.299-.981 2.148h3.408l.001-.004c.112-.335.19-.57.486-.772.686-.371 1.734-.376 2.478-.173l.102.035.053.018c.156.051.304.1.396.245.044.188.058.375-.015.557a1.14 1.14 0 0 1-.443.232l-.07.011-.114.019c-.408.066-.819.11-1.23.15q-.366.036-.759.062c-1.037.074-2.168.154-3.038.643l-.042.024-.046.026c-.302.152-.587.306-.834.54-.441.504-.614 1.082-.625 1.748.022.603.213 1.148.657 1.573l.095.069.054.038c.223.173.449.302.712.406 1.58.526 3.243.26 4.703-.452v-.093h.093c.383-.268.672-.582.945-.958l.062-.087.021-.03.1.43.04.178.186.98h2.895v-.005l.002-2.001.001-1.126.001-.9v-.475l.001-.455v-.168c.003-.939-.172-1.807-.843-2.503-1.079-1.004-2.618-1.154-4.027-1.155m1.214 5.988c.281-.35.411-.672.383-1.121-.26 0-.612.12-.967.243-.296.102-.594.204-.84.237q-.105.014-.212.027c-.537.067-1.083.135-1.532.455-.163.15-.181.303-.194.519a.5.5 0 0 0 .021.127c.022.058.07.106.202.238.304.241.304.241 1.142.219a3.03 3.03 0 0 0 1.997-.944"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path fill="currentColor" d="M113.4 26.46h10.084v2.987h-3.361v7.61h-3.455v-7.61H113.4z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GopuffLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 95 60" {...props}>
|
||||
<g clipPath="url(#a)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M61.88 23.943v7.021c0 1.817-.595 2.898-1.994 2.898-1.391 0-1.994-1.08-1.994-2.898v-7.021h-3.968v7.288c0 3.817 2.383 5.393 5.956 5.393 2.95 0 5.955-1.576 5.955-5.393v-7.288zm-30.204-.28c-4.095 0-6.812 2.572-6.812 6.572 0 4.012 2.877 6.39 6.577 6.39 3.413 0 6.725-2.072 6.725-6.605 0-3.74-2.59-6.357-6.49-6.357m-.12 10.219c-1.694 0-2.604-1.602-2.604-3.732 0-1.843.723-3.732 2.616-3.732 1.794 0 2.516 1.889 2.516 3.719.007 2.24-.977 3.745-2.529 3.745M22.04 23.07c-1.332.886-1.68 2.24-1.68 2.24-.722-.885-2.214-1.589-3.96-1.589-2.918 0-5.97 2.534-5.97 6.533 0 3.55 2.577 6.279 5.97 6.279 1.404 0 2.355-.339 3.11-1.068v.267c0 2.254-1.431 3.263-3.312 3.263-1.84 0-3.138-.593-3.947-1.042l-.783 2.93c1.057.535 3.151 1.056 4.83 1.056 1.881 0 3.775-.352 5.16-1.518 1.445-1.237 1.94-3.198 1.94-5.575v-5.49c.027-1.903.723-3.238 1.58-4.182 0-.007-2.938-2.104-2.938-2.104M17.129 33.92c-1.693 0-2.604-1.602-2.604-3.732 0-1.843.723-3.732 2.617-3.732 1.78.013 2.502 1.889 2.502 3.719 0 2.24-.97 3.745-2.515 3.745m28.92-10.258c-4.25 0-6.565 2.742-6.565 6.8v11.203h3.962V35.53c.709.716 1.84 1.114 3.352 1.114 2.837 0 5.801-2.124 5.801-6.644 0-3.973-2.69-6.337-6.55-6.337m-.014 10.219c-1.693 0-2.603-1.602-2.603-3.732 0-1.843.723-3.732 2.617-3.732 1.793 0 2.516 1.889 2.516 3.719-.02 2.24-.984 3.745-2.53 3.745m38.382-12.584v-2.957c-.549-.156-1.231-.267-2.094-.267-1.158 0-2.53.326-3.527 1.238-1.13 1.029-1.606 2.677-1.606 4.149v.462h-4.095v-.625c-.06-2.996 2.951-2.124 3.299-2.013v-2.957c-.555-.156-1.238-.267-2.101-.267-1.158 0-2.53.326-3.526 1.238-1.131 1.029-1.606 2.67-1.606 4.149v.462h-1.693v2.846h1.693v9.588c0 2.631-1.88 2.254-2.717 2.124l-.422 2.846a5 5 0 0 0 1.693.312c1.794.014 3.065-.52 3.921-1.335 1.071-1.029 1.48-2.507 1.48-3.973l.026-9.555h4.062v9.588c.06 2.774-2.168 2.208-2.717 2.123l-.421 2.846a5 5 0 0 0 1.692.313c1.794.013 3.065-.521 3.922-1.335 1.07-1.03 1.472-2.508 1.472-3.973l.026-9.556h3.252v-2.826h-3.299v-.632c.04-3.029 2.938-2.11 3.286-2.013"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M10.43 18.061h74v23.878h-74z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GumloopLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 188 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m66.859 37.304.322 4.353c-.766.242-1.693.403-2.901.403-2.5 0-4.072-1.169-4.636-3.386-1.088 2.016-2.982 3.386-5.844 3.386-4.474 0-6.973-3.225-6.973-7.255V22.109h5.562v11.406c0 2.297 1.17 3.749 3.184 3.749 2.257 0 3.83-1.653 3.83-4.112V22.11h5.521v14.187c0 .766.403 1.21.968 1.21.322 0 .523-.04.967-.202m26.596-15.397c4.393 0 6.731 3.104 6.731 7.497v12.454h-5.562V30.613c0-2.499-1.25-3.91-3.265-3.91-2.217 0-3.748 1.653-3.748 4.112v11.043h-5.522V30.613c0-2.458-1.21-3.91-3.265-3.91-2.216 0-3.748 1.653-3.748 4.112v11.043h-5.522v-19.75h5.32l.162 2.862c1.128-1.813 3.063-3.063 5.803-3.063 3.185 0 5.28 1.653 6.207 4.111 1.008-2.378 3.144-4.11 6.409-4.11m17.571 15.397.323 4.353c-.766.242-1.693.403-2.902.403-3.225 0-4.877-1.935-4.877-5.441V13.242h5.522v23.054c0 .766.402 1.21.967 1.21.322 0 .524-.04.967-.202m11.624-15.397c6.126 0 10.157 4.071 10.157 9.996S128.776 42.06 122.65 42.06c-6.046 0-10.117-4.03-10.117-9.956 0-5.965 4.071-10.197 10.117-10.197m0 4.555c-2.741 0-4.554 2.217-4.554 5.562 0 3.265 1.813 5.481 4.554 5.481 2.821 0 4.554-2.216 4.554-5.481 0-3.346-1.733-5.562-4.554-5.562m21.895-4.555c6.127 0 10.157 4.071 10.157 9.996s-4.03 10.157-10.157 10.157c-6.045 0-10.116-4.03-10.116-9.956 0-5.965 4.071-10.197 10.116-10.197m0 4.555c-2.74 0-4.554 2.217-4.554 5.562 0 3.265 1.814 5.481 4.554 5.481 2.822 0 4.555-2.216 4.555-5.481 0-3.346-1.733-5.562-4.555-5.562m24.476-4.555c4.997 0 8.584 3.507 8.584 9.754 0 6.127-3.426 10.399-8.786 10.399-2.902 0-4.837-1.21-5.925-3.023V50h-5.522V22.068h5.361l.121 2.902c1.128-1.773 3.184-3.063 6.167-3.063m-1.452 15.518c2.62 0 4.434-2.177 4.434-5.522 0-3.265-1.693-5.36-4.393-5.36-3.063 0-4.716 2.579-4.716 4.796v1.25c0 2.377 1.733 4.836 4.675 4.836M21.364 11.038c.754-.32 2.168-.708 3.195-.868.772-.132 1.63-.16 3.91-.17h17.026c-.43 5.188-5.156 5.222-6.03 5.313s-7.65 0-7.65 0c-6.05.076-6.842.17-8.68.944-2.61 1.104-4.579 2.973-5.823 5.52-.744 1.52-.914 2.228-1.008 4.323-.141 2.756.716 5.162 2.648 7.503.999 1.208 3.034 2.595 4.617 3.142 1.329.463 1.678.52 3.251.576 1.357.047 1.677.028 2.526-.15 1.083-.227 2.073-.596 3.175-1.18 1.932-1.03 3.6-2.822 4.58-4.917.923-1.982 1.263-4.72.697-5.616a2.2 2.2 0 0 0-.5-.547c-.621-.453-.697-.453-5.823-.406l-4.815.057-.396.264c-.216.142-.518.462-.678.698-.358.576-.358 1.048-.01 1.737.208.425.34.557.764.764l.518.265h7.406v.226c0 .255-.47 1.321-.838 1.897-1.48 2.331-4.297 3.68-6.992 3.35-1.819-.217-3.27-.915-4.495-2.16-1.15-1.162-1.819-2.474-2.054-4.021-.405-2.614.612-5.295 2.591-6.87.98-.765 1.659-1.124 2.752-1.445l.885-.264 5.183.029c3.016.009 5.437.056 5.795.122.792.123 1.847.434 2.262.67.848.472 1.197.69 1.413.887.132.123.255.218.293.218.103 0 .716.613.933.943.122.18.254.35.282.378.283.255 1.037 2.142 1.197 2.972.254 1.36-.019 4.408-.547 6.06-1.036 3.265-3.213 6.398-5.738 8.276-3.044 2.255-6.314 3.36-9.942 3.36-2.78 0-5.07-.53-7.397-1.7-3.995-2.01-6.945-5.407-8.274-9.531-.442-1.387-.537-1.784-.725-3.114-.48-3.445.273-7.23 2.064-10.287a17 17 0 0 1 5.946-6.002c.951-.566 1.14-.66 1.743-.925.179-.075.528-.217.763-.32"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HappyteamsLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 160 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M57.053 37.301v-5.4h-4.307v5.4h-2.31V25.224h2.31v4.842h4.307v-4.841h2.328V37.3zM68.972 34.854q0 .542.158.804.175.244.543.385l-.473 1.485q-.787-.07-1.296-.384-.49-.315-.752-.962a3 3 0 0 1-1.19 1.05 3.6 3.6 0 0 1-1.577.331q-1.382 0-2.188-.786-.805-.787-.805-2.063 0-1.503 1.085-2.29 1.103-.803 3.134-.803h1.155v-.524q0-.891-.42-1.241-.402-.367-1.313-.367-1.016 0-2.45.49l-.526-1.521q1.734-.664 3.361-.664 3.555 0 3.554 3.18zm-3.991 1.084q1.138 0 1.785-1.136v-1.87h-.927q-2.171 0-2.171 1.608 0 .663.332 1.03.333.368.98.368M76.48 27.794q1.751 0 2.592 1.275.84 1.276.84 3.583 0 1.434-.438 2.552-.42 1.1-1.26 1.73-.823.63-1.96.63-1.471 0-2.33-1.014v4.316l-2.24.245V28.056h1.96l.123 1.136a3.6 3.6 0 0 1 1.243-1.049q.7-.35 1.47-.35m-.857 8.056q1.943 0 1.943-3.18 0-1.695-.438-2.447-.437-.752-1.313-.752-.56 0-1.05.35a2.9 2.9 0 0 0-.84.909v4.16q.648.96 1.698.96M86.91 27.794q1.75 0 2.59 1.275.84 1.276.84 3.583 0 1.434-.437 2.552-.42 1.1-1.26 1.73-.824.63-1.961.63-1.47 0-2.329-1.014v4.316l-2.24.245V28.056h1.96l.123 1.136a3.6 3.6 0 0 1 1.243-1.049q.7-.35 1.47-.35m-.858 8.056q1.943 0 1.943-3.18 0-1.695-.438-2.447t-1.313-.752q-.56 0-1.05.35a2.9 2.9 0 0 0-.84.909v4.16q.647.96 1.698.96M96.92 37.336q-.56 1.713-1.698 2.656-1.137.962-3.116 1.119l-.228-1.625q.911-.141 1.453-.402.56-.245.893-.665t.613-1.118h-.77l-2.941-9.245h2.38l2.014 7.76 2.1-7.76h2.311zM109.137 25.225l-.245 1.834h-3.151v10.242h-2.328V27.059h-3.257v-1.835zM117.218 32.46q0 .175-.053.909h-5.812q.105 1.346.701 1.922.595.577 1.575.577.613 0 1.156-.192.542-.21 1.155-.63l.928 1.276q-1.54 1.242-3.431 1.241-2.12 0-3.274-1.293-1.138-1.294-1.138-3.53 0-1.416.49-2.535.49-1.135 1.418-1.765.928-.646 2.206-.646 1.943 0 3.011 1.24 1.068 1.224 1.068 3.426m-2.223-.647q0-2.43-1.803-2.429-.824 0-1.296.612-.455.594-.543 1.922h3.642zM125.902 34.854q0 .542.158.804.175.244.543.385l-.473 1.485q-.788-.07-1.296-.384-.49-.315-.752-.962a3 3 0 0 1-1.191 1.05 3.6 3.6 0 0 1-1.575.331q-1.383 0-2.189-.786-.805-.787-.805-2.063 0-1.503 1.085-2.29 1.103-.803 3.134-.803h1.155v-.524q0-.891-.42-1.241-.402-.367-1.313-.367-1.016 0-2.451.49l-.525-1.521q1.734-.664 3.361-.664 3.555 0 3.554 3.18zm-3.991 1.084q1.138 0 1.785-1.136v-1.87h-.927q-2.172 0-2.171 1.608 0 .663.332 1.03.333.368.981.368M138.435 27.794q1.12 0 1.786.768.665.753.665 2.045v6.694h-2.241v-6.344q0-1.486-1.015-1.486-.543 0-.946.35-.385.331-.805 1.048v6.432h-2.241v-6.344q0-1.486-1.015-1.486a1.42 1.42 0 0 0-.963.35q-.385.331-.805 1.048v6.432h-2.241v-9.245h1.943l.175 1.206q1.05-1.469 2.679-1.468.805 0 1.383.419.595.402.858 1.136.56-.752 1.225-1.154.683-.401 1.558-.401M146.411 27.794q1.874 0 3.291 1.1l-.875 1.311q-1.191-.75-2.328-.751-.666 0-1.033.262a.76.76 0 0 0-.368.664q0 .315.158.524t.56.402q.42.175 1.225.402 1.506.402 2.224 1.067.735.663.735 1.852 0 1.38-1.12 2.167-1.12.77-2.784.77a6 6 0 0 1-2.083-.35 5 5 0 0 1-1.611-.962l1.121-1.258q1.207.909 2.521.909.752 0 1.19-.297.455-.297.455-.822 0-.401-.175-.629-.157-.244-.595-.42-.42-.191-1.331-.454-1.417-.384-2.083-1.066-.665-.7-.665-1.73 0-.77.438-1.38.454-.613 1.26-.962.823-.35 1.873-.35M41.153 33.963c.184-.96.281-1.95.281-2.963 0-8.642-7.037-15.648-15.717-15.648S10 22.358 10 31s7.037 15.648 15.717 15.648c1.628 0 3.198-.246 4.674-.703a9 9 0 0 1-.548-2.611 13.1 13.1 0 0 1-4.126.662c-7.209 0-13.053-5.818-13.053-12.996s5.844-12.996 13.053-12.996S38.77 23.822 38.77 31c0 .927-.097 1.83-.282 2.703q.222-.01.45-.011c.763 0 1.506.094 2.215.27"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M39.024 34.364c-4.708 0-8.524 3.8-8.524 8.487 0 1.015.253 2.089.507 2.89 1.313-1.434 10.009-11.14 10.009-11.14s-1.378-.237-1.992-.237"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M20.052 29.863c.38-.039.72.237.76.616.238 2.31 2.296 4.16 4.852 4.16 2.553 0 4.61-1.845 4.852-4.15a.692.692 0 0 1 1.378.143c-.321 3.05-3.01 5.386-6.23 5.386-3.223 0-5.915-2.343-6.23-5.399a.69.69 0 0 1 .618-.756M29.283 25.96c-.66 0-1.195.548-1.195 1.222 0 .675.535 1.222 1.195 1.222s1.194-.547 1.194-1.222-.534-1.221-1.194-1.221M22.01 25.96c-.659 0-1.195.548-1.195 1.222 0 .675.536 1.222 1.195 1.222.66 0 1.195-.547 1.195-1.222s-.535-1.221-1.194-1.221"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HumataLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 122 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M13.643 25.095a.83.83 0 0 0-.832.831v9.148c0 .459.373.831.832.831h2.91V38.4h-2.91a3.326 3.326 0 0 1-3.326-3.326v-9.148a3.326 3.326 0 0 1 3.326-3.326h2.91v2.495zM30.274 35.905c.46 0 .832-.372.832-.831v-9.148a.83.83 0 0 0-.832-.831h-2.91V22.6h2.91a3.326 3.326 0 0 1 3.327 3.326v9.148a3.326 3.326 0 0 1-3.327 3.326h-2.91v-2.495z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M29.859 30.463a2.015 2.015 0 0 0-1.729-1.995 5 5 0 0 1-4.135-4.14 2.03 2.03 0 0 0-2-1.728h-.073a2.015 2.015 0 0 0-1.995 1.728 5 5 0 0 1-4.14 4.135 2.03 2.03 0 0 0-1.728 2v.074c0 1.002.735 1.852 1.728 1.995a5 5 0 0 1 4.135 4.14c.148.99.997 1.726 2 1.728h.074a2.015 2.015 0 0 0 1.994-1.728 5 5 0 0 1 4.14-4.135 2.03 2.03 0 0 0 1.729-2zm-2.963.067c0 .02-.027.037-.054.042a5.835 5.835 0 0 0-4.82 4.816c0 .027-.019.05-.034.05h-.062c-.02 0-.037-.028-.041-.055a5.83 5.83 0 0 0-4.817-4.819c-.027 0-.05-.02-.05-.034v-.062c0-.02.028-.037.055-.042a5.835 5.835 0 0 0 4.819-4.816c0-.028.02-.05.034-.05h.062c.02 0 .037.027.042.054a5.83 5.83 0 0 0 4.817 4.82c.027 0 .049.02.049.034zM51.726 22.6h2.717v15.561h-2.717v-6.805h-7.301v6.805h-2.717V22.6h2.717v6.329h7.301zM65.627 26.783v11.379H63.08v-1.214c-.637.758-1.592 1.452-3.035 1.452-2.335 0-3.778-1.625-3.778-4.075v-7.542h2.547v6.979c0 1.387.594 2.276 1.974 2.276 1.146 0 2.292-.867 2.292-2.493v-6.762zM69.98 31.486v6.676h-2.547V26.783h2.547v1.214c.616-.759 1.57-1.43 3.057-1.43 1.337 0 2.334.607 2.95 1.582.679-.737 1.74-1.582 3.46-1.582 2.292 0 3.777 1.647 3.777 4.096v7.499h-2.547v-6.74c0-1.41-.509-2.428-1.867-2.428-1.083 0-2.208.867-2.208 2.492v6.676h-2.547v-6.74c0-1.41-.509-2.428-1.867-2.428-1.083 0-2.208.867-2.208 2.492M88.131 38.357c-2.122 0-3.757-1.322-3.757-3.36 0-2.167 1.592-3.12 3.736-3.576l3.077-.65v-.195c0-1.084-.552-1.756-1.91-1.756-1.21 0-1.846.564-2.144 1.67l-2.398-.564c.552-1.886 2.186-3.36 4.648-3.36 2.674 0 4.288 1.3 4.288 3.923v4.899c0 .65.275.845.976.758v2.016c-1.847.217-2.823-.152-3.205-1.084-.7.802-1.868 1.279-3.311 1.279m3.056-4.205v-1.387l-2.398.52c-1.082.239-1.889.585-1.889 1.604 0 .889.637 1.387 1.613 1.387 1.358 0 2.674-.737 2.674-2.124M96.053 35.3v-6.307h-1.55v-2.21h1.55v-3.338h2.505v3.338h2.334v2.21h-2.334v5.896c0 1.018.573 1.127 1.485 1.127a8 8 0 0 0 1.04-.065v2.189c-.466.086-1.103.151-1.761.151-2.123 0-3.269-.671-3.269-2.99M105.304 38.357c-2.122 0-3.756-1.322-3.756-3.36 0-2.167 1.592-3.12 3.735-3.576l3.077-.65v-.195c0-1.084-.551-1.756-1.909-1.756-1.21 0-1.847.564-2.144 1.67l-2.398-.564c.551-1.886 2.186-3.36 4.647-3.36 2.675 0 4.288 1.3 4.288 3.923v4.899c0 .65.277.845.977.758v2.016c-1.847.217-2.824-.152-3.206-1.084-.7.802-1.867 1.279-3.311 1.279m3.056-4.205v-1.387l-2.397.52c-1.082.239-1.889.585-1.889 1.604 0 .889.637 1.387 1.612 1.387 1.359 0 2.674-.737 2.674-2.124"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function KikiLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 106 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10.637 50V12.764h10.11v15.054h.508l11.2-15.054h11.782L31.655 29.346 44.528 50H32.455l-8.363-13.964-3.346 4.364V50zM45.933 50V22.073h10.036V50zm5.018-30.836q-2.037 0-3.491-1.346t-1.455-3.236q0-1.89 1.455-3.237Q48.915 10 50.95 10q2.055 0 3.492 1.345 1.454 1.345 1.454 3.237 0 1.891-1.454 3.236-1.436 1.346-3.491 1.346M66.9 43.382l.074-11.927h1.309l6.763-9.382H86.32L74.974 36.69h-2.837zM57.884 50V12.764h10.036V50zm17.236 0-6.4-11.054 6.546-7.128L86.61 50z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M85.733 50V22.073h10.036V50zm5.018-30.836q-2.036 0-3.491-1.346t-1.455-3.236q0-1.89 1.455-3.237Q88.715 10 90.75 10q2.055 0 3.492 1.345 1.455 1.345 1.454 3.237 0 1.891-1.454 3.236-1.437 1.346-3.491 1.346"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LangchainLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 164 60" {...props}>
|
||||
<g fill="currentColor" clipPath="url(#a)">
|
||||
<path d="M47.027 24.422c-.634 0-1.23.245-1.676.691L43.55 26.91a2.36 2.36 0 0 0-.673 1.963c.064.53.297 1.007.673 1.381.26.259.546.423.894.537q.027.157.028.316c0 .477-.186.924-.522 1.259l-.11.11a3.9 3.9 0 0 1-1.558-.957c-.602-.6-1-1.358-1.15-2.19l-.02-.116-.092.074q-.093.075-.175.158l-1.802 1.797a2.363 2.363 0 0 0 1.676 4.034c.608 0 1.213-.23 1.675-.691l1.802-1.797a2.363 2.363 0 0 0 0-3.343 2.34 2.34 0 0 0-.872-.55 1.942 1.942 0 0 1 .552-1.705 4.1 4.1 0 0 1 1.59.99c.6.6.997 1.356 1.15 2.19l.02.116.092-.074q.092-.075.177-.16l1.801-1.796a2.362 2.362 0 0 0-1.676-4.034z" />
|
||||
<path d="M46.061 17.85H22.484C15.766 17.85 10.3 23.3 10.3 30s5.466 12.15 12.183 12.15h23.578c6.718 0 12.184-5.45 12.184-12.15S52.779 17.85 46.06 17.85M34.431 36.9c-.388.08-.825.094-1.122-.216-.11.252-.366.12-.563.087l-.052.146c-.656.044-1.148-.624-1.462-1.128-.62-.336-1.326-.54-1.961-.89-.037.578.09 1.294-.462 1.667-.028 1.112 1.686.132 1.843.96-.121.014-.257-.02-.354.074-.447.434-.96-.328-1.475-.013-.692.347-.762.632-1.618.703-.048-.071-.028-.119.012-.162.241-.28.258-.609.67-.728-.424-.067-.779.166-1.136.351-.464.19-.46-.428-1.174.033-.08-.065-.042-.123.004-.173.181-.22.42-.254.69-.241-1.325-.737-1.949.9-2.56.086-.185.048-.254.214-.37.33-.1-.109-.024-.242-.02-.37-.119-.056-.27-.083-.234-.274-.234-.079-.398.06-.572.191-.157-.121.106-.299.155-.425.14-.243.46-.05.621-.225.46-.261 1.103.163 1.63.091.405.051.907-.364.703-.779-.433-.552-.357-1.276-.366-1.936-.054-.386-.981-.877-1.25-1.292-.33-.374-.589-.808-.847-1.235-.932-1.8-.639-4.111-1.813-5.782-.531.293-1.223.154-1.681-.238-.248.224-.259.519-.278.83-.593-.59-.52-1.709-.045-2.367.194-.26.425-.474.683-.663.059-.042.078-.083.077-.149.47-2.104 3.669-1.699 4.68-.208.733.918.955 2.132 1.787 2.983 1.12 1.223 2.395 2.3 3.428 3.6.978 1.19 1.676 2.585 2.285 3.99.248.467.25 1.04.619 1.438.181.24 1.068.896.876 1.128.11.24.94.572.652.805m15.54-7.18-1.802 1.798a4.15 4.15 0 0 1-1.717 1.031l-.033.01-.012.032a4.1 4.1 0 0 1-.947 1.46l-1.802 1.797a4.14 4.14 0 0 1-2.944 1.215 4.15 4.15 0 0 1-2.945-7.089l1.802-1.796a4.1 4.1 0 0 1 1.714-1.028l.033-.01.012-.032c.21-.551.529-1.045.95-1.465l1.801-1.797a4.14 4.14 0 0 1 2.945-1.215c1.113 0 2.159.431 2.945 1.215a4.12 4.12 0 0 1 1.218 2.937c0 1.11-.432 2.154-1.218 2.936z" />
|
||||
<path d="M27.102 34.724c-.158.615-.21 1.661-1.011 1.692-.066.355.246.488.531.374.282-.129.416.102.51.332.436.063 1.079-.145 1.103-.659-.65-.373-.85-1.083-1.132-1.74zM69.532 24.219h-2.14v12.983h9.18v-2.068h-7.04zM87.331 37.202h2.14v-.023h.029l.004-.096c0-.027.008-.287-.031-.688v-4.832c0-1.818 1.325-2.645 2.557-2.645 1.325 0 1.97.714 1.97 2.183v6.1h2.14v-6.384c0-2.426-1.54-3.933-4.02-3.933-1.054 0-1.994.3-2.733.868l-.02-.69h-2.033v10.14zM104.851 27.807c-.744-.604-1.7-.923-2.779-.923-2.886 0-4.68 1.99-4.68 5.194s1.794 5.212 4.68 5.212c1.018 0 1.927-.277 2.642-.804-.061 1.572-1.046 2.505-2.659 2.505-1.358 0-2.125-.432-2.283-1.284l-.02-.106-2.08.635.02.085c.35 1.693 1.934 2.703 4.238 2.703 1.563 0 2.789-.425 3.645-1.265.863-.847 1.3-2.067 1.3-3.626v-9.07h-1.98l-.043.744zm-.134 4.36c0 1.935-.942 3.09-2.521 3.09-1.693 0-2.663-1.158-2.663-3.178s.971-3.161 2.663-3.161c1.541 0 2.506 1.15 2.521 3zM117.421 33.085c-.444 1.435-1.583 2.226-3.207 2.226-2.322 0-3.766-1.763-3.766-4.601s1.457-4.601 3.802-4.601c1.623 0 2.558.636 3.033 2.061l.222.667 2.021-.949-.189-.534c-.77-2.168-2.547-3.314-5.14-3.314-1.762 0-3.258.637-4.326 1.84-1.057 1.19-1.616 2.86-1.616 4.83 0 4.052 2.338 6.67 5.959 6.67 2.551 0 4.527-1.334 5.286-3.57l.193-.572-2.076-.79-.198.638zM125.414 26.884c-1.022 0-1.932.28-2.646.813v-4.723h-2.14v14.229h2.14v-5.639c0-1.83 1.325-2.663 2.558-2.663 1.324 0 1.969.715 1.969 2.184v6.119h2.141V30.8c0-2.378-1.578-3.914-4.021-3.914zM141.946 22.668c-.795 0-1.372.578-1.372 1.373 0 .794.577 1.372 1.372 1.372s1.372-.578 1.372-1.372-.577-1.373-1.372-1.373M149.568 26.884c-1.054 0-1.994.3-2.732.868l-.02-.69h-2.033v10.14h2.141v-5.639c0-1.818 1.325-2.645 2.557-2.645 1.325 0 1.969.714 1.969 2.183v6.1h2.141v-6.384c0-2.426-1.541-3.933-4.021-3.933zM142.986 27.062h-2.13v5.027a6 6 0 0 0-2.073-1.117v-.512c0-2.239-1.47-3.576-3.933-3.576-2 0-3.497.94-4.106 2.577l-.164.441 1.715 1.265.295-.767c.388-1.012 1.107-1.483 2.26-1.483 1.154 0 1.793.555 1.793 1.65v.056l-.208-.006c-2.291-.036-3.963.5-4.968 1.591-1.029 1.117-.939 2.37-.925 2.509l.009.096h.01c.16 1.562 1.524 2.565 3.507 2.565 1.092 0 2.101-.304 2.864-.86l.008.683h1.843v-3.172l-.04-.029c-.26-.19-.715-.437-1.398-.523a4 4 0 0 0-.608-.037h-.096v.292c0 .67-.77 1.614-2.485 1.614-1.266 0-1.455-.532-1.455-.85v-.032c.01-.142.07-.49.384-.815.4-.413 1.299-.896 3.315-.865 1.46.023 2.58.43 3.33 1.213.92.96 1.093 2.275 1.125 2.79v.415h2.129v-10.14zM81.797 26.84c-2 0-3.496.94-4.106 2.576l-.164.442 1.716 1.265.294-.767c.388-1.012 1.107-1.483 2.26-1.483 1.154 0 1.793.555 1.793 1.65v.256l-2.205.389c-2.584.457-3.894 1.562-3.894 3.282 0 1.721 1.383 2.884 3.523 2.884 1.093 0 2.101-.304 2.864-.86l.009.683h1.842v-6.74c0-2.24-1.47-3.577-3.932-3.577m1.793 5.945v.903c0 .671-.772 1.615-2.486 1.615-1.266 0-1.454-.532-1.454-.85 0-.282 0-.942 2.242-1.358z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M10.3 17.76h143.291v24.48H10.3z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoopsLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 126 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M23.783 18.931h-3.245A10.43 10.43 0 0 0 10.12 29.353a10.43 10.43 0 0 0 10.42 10.42h3.256a10.43 10.43 0 0 0 10.422-10.42 10.43 10.43 0 0 0-10.434-10.422M11.409 29.353a9.122 9.122 0 0 1 10.643-8.997 9.08 9.08 0 0 1 7.599 8.992 5.87 5.87 0 0 1-4.89 5.78 7.15 7.15 0 0 0 2.935-5.78 7.15 7.15 0 0 0-12.22-5.059 7.17 7.17 0 0 0-2.095 5.059 10.39 10.39 0 0 0 4.85 8.82 9.134 9.134 0 0 1-6.815-8.815zm10.747-5.629a5.85 5.85 0 0 1 4.233 5.629 5.85 5.85 0 0 1-4.233 5.626 5.85 5.85 0 0 1-4.233-5.626 5.85 5.85 0 0 1 4.233-5.629m1.627 14.746h-.037a9.4 9.4 0 0 1-1.48-.125 9.08 9.08 0 0 1-7.588-8.686v-.306a5.87 5.87 0 0 1 4.889-5.78 7.15 7.15 0 0 0-2.936 5.78 7.15 7.15 0 0 0 5.372 6.935 7.172 7.172 0 0 0 8.957-6.935 10.39 10.39 0 0 0-4.85-8.82 9.12 9.12 0 0 1 6.803 8.82 9.123 9.123 0 0 1-9.13 9.117M55.078 37.955h-14.84v-1.674c1.83-.308 2.19-.952 2.19-1.57V24.225c0-1.184-.36-1.674-2.19-1.803v-1.674h9.327v1.674c-1.83.129-2.19.62-2.19 1.803v11.094c0 .617.18 1.056 1.726 1.056h1.674c.952 0 2.086-.464 2.885-2.988h1.674zm1.338-6.312c0-4.171 3.016-7.263 7.291-7.263 4.51 0 7.11 3.04 7.11 6.748 0 4.15-3.064 7.19-7.263 7.19-4.586 0-7.138-2.964-7.138-6.675m9.56.955c0-4.096-1.418-6.544-2.809-6.544-1.314 0-1.854 1.65-1.854 4.019 0 4.174 1.442 6.57 2.806 6.57 1.292 0 1.856-1.624 1.856-4.045m6.44-.955c0-4.171 3.014-7.263 7.29-7.263 4.51 0 7.11 3.04 7.11 6.748 0 4.15-3.064 7.19-7.266 7.19-4.583 0-7.135-2.964-7.135-6.675m9.558.955c0-4.096-1.418-6.544-2.808-6.544-1.314 0-1.857 1.65-1.857 4.019 0 4.174 1.445 6.57 2.808 6.57 1.29 0 1.857-1.624 1.857-4.045m20.745-1.674c0 4.507-2.73 7.396-6.672 7.396a5.8 5.8 0 0 1-2.32-.439v1.906c0 .954.387 1.341 2.14 1.598v1.684h-8.425v-1.675c1.34-.308 1.495-.643 1.495-1.597V28.456c0-.671-.232-1.058-1.083-1.443l-.412-.18v-1.341l5.254-1.107h1.031v1.65a3.54 3.54 0 0 1 3.067-1.65c4.095-.005 5.925 3.15 5.925 6.538m-4.946.927c0-2.68-.74-4.998-2.525-4.998-.774 0-.902-.052-1.521.412v5.177c0 3.067.493 3.97 1.83 3.97 1.237 0 2.216-1.521 2.216-4.561m17.397-5.952-2.37 3.247c-1.134-2.268-2.219-3.22-3.503-3.22-.774 0-1.778.463-1.778 1.262 0 2.712 8.062.952 7.909 6.93-.078 2.163-2.164 4.192-5.771 4.192-3.144 0-5.77-1.573-6.411-2.14l2.784-2.91c1.546 2.422 2.808 3.504 4.043 3.504.644 0 1.778-.283 1.805-1.546 0-2.911-7.961-.567-7.936-6.41 0-2.756 2.293-4.438 5.951-4.438 2.88.01 4.737 1.014 5.277 1.529"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LovableLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 221 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M33.095 49.978q-.675 0-1.357-.244a3.16 3.16 0 0 1-1.188-.748l-3.276-3.014q-6.624-6.12-11.63-11.818-5.007-5.699-5.007-12.117 0-5.128 3.444-8.571 3.443-3.444 8.533-3.444 2.901 0 5.652 1.338t4.828 4.164q2.265-2.825 4.904-4.164a12.2 12.2 0 0 1 5.577-1.338q5.09 0 8.534 3.444 3.443 3.444 3.443 8.57 0 6.42-4.997 12.128t-11.66 11.828l-3.255 2.994a3.16 3.16 0 0 1-1.188.748 4 4 0 0 1-1.358.244"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M200.665 24.72c6.084 0 9.736 3.868 9.736 10.735v1.043h-14.996c.305 3.086 2.565 4.868 5.651 4.868 2.868 0 4.868-1.608 5.998-2.738l2.694 3.868c-1.781 1.652-4.476 3.607-8.692 3.607-6.563 0-10.823-4.259-10.823-10.648 0-6.39 4.129-10.735 10.432-10.735m-.261 4.302c-2.565 0-4.433 1.26-4.868 4.043h9.475c-.174-2.565-1.999-4.043-4.607-4.043M181.275 15.03h5.346v30.424h-5.346zM168.063 46.106c-3.26 0-5.433-1.39-6.693-3.477h-.087v2.825h-5.346V15.03h5.346V28.2h.087c1.26-2.087 3.433-3.478 6.693-3.478 5.259 0 9.649 3.825 9.649 10.692s-4.39 10.692-9.649 10.692m-1.348-5.042c3.217 0 5.434-2.26 5.434-5.65s-2.217-5.65-5.434-5.65-5.432 2.26-5.432 5.65 2.216 5.65 5.432 5.65M139.153 46.104c-5.259 0-9.649-3.825-9.649-10.692 0-6.868 4.39-10.692 9.649-10.692 3.26 0 5.433 1.39 6.694 3.477h.087V25.37h5.302v20.08h-5.302v-2.825h-.087c-1.261 2.087-3.434 3.477-6.694 3.477m1.348-5.042c3.216 0 5.433-2.26 5.433-5.65s-2.217-5.65-5.433-5.65c-3.217 0-5.433 2.26-5.433 5.65s2.216 5.65 5.433 5.65M118.604 39.282l4.737-13.908h6.085l-8.215 20.08h-5.215l-8.171-20.08h6.041zM96.647 24.72c6.389 0 11.127 4.39 11.127 10.692s-4.738 10.691-11.127 10.691-11.127-4.39-11.127-10.691c0-6.303 4.738-10.692 11.127-10.692m0 5.041c-3.26 0-5.65 2.217-5.65 5.65s2.39 5.65 5.65 5.65 5.607-2.216 5.607-5.65c0-3.433-2.348-5.65-5.607-5.65M76.562 15.03h5.346v30.424H76.56z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MarkpromptLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 177 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M47.366 22.504a1.293 1.293 0 0 1 1.292-1.292h2.585a1.29 1.29 0 0 1 1.292 1.292v6.462a1.293 1.293 0 0 1-1.292 1.293h-2.585a1.293 1.293 0 0 1-1.292-1.293zm5.686 13.183a3.102 3.102 0 1 1-6.204 0 3.102 3.102 0 0 1 6.204 0M21.775 38.79a1.29 1.29 0 0 1-1.292-1.293V22.504a1.29 1.29 0 0 1 1.292-1.292h3.256c.393 0 .764.178 1.01.485l3.772 4.715a1.292 1.292 0 0 0 2.019 0l3.772-4.715a1.3 1.3 0 0 1 1.01-.485h3.255a1.293 1.293 0 0 1 1.293 1.292v14.992a1.293 1.293 0 0 1-1.293 1.293h-2.584a1.294 1.294 0 0 1-1.293-1.293v-5.104c0-1.221-1.538-1.761-2.301-.807l-1.86 2.324a1.292 1.292 0 0 1-2.018 0l-1.859-2.324c-.763-.954-2.301-.414-2.301.807v5.104a1.294 1.294 0 0 1-1.293 1.293zM72.638 24.01h2.173l3.778 9.224h.139l3.778-9.224h2.172v11.866h-1.703V27.29h-.11l-3.5 8.57h-1.414l-3.5-8.576h-.11v8.593h-1.703zM89.732 36.073a3.6 3.6 0 0 1-1.53-.313 2.6 2.6 0 0 1-1.083-.921q-.394-.603-.394-1.477 0-.754.29-1.24.29-.488.782-.771t1.1-.429q.61-.145 1.24-.22l1.299-.15q.498-.064.724-.203.225-.14.226-.452v-.04q0-.76-.429-1.177-.423-.417-1.263-.417-.875 0-1.379.388-.498.383-.69.852l-1.628-.371q.29-.811.846-1.31.563-.504 1.292-.73a5 5 0 0 1 1.536-.231q.533 0 1.13.127a3.3 3.3 0 0 1 1.124.452q.527.33.863.944.336.609.336 1.582v5.91h-1.692V34.66h-.07q-.168.336-.503.66a2.7 2.7 0 0 1-.864.54q-.527.214-1.263.214m.377-1.39q.718 0 1.228-.284.516-.285.782-.742.273-.465.273-.99v-1.148q-.093.093-.36.174-.26.075-.596.133-.337.053-.655.098-.319.041-.533.07a4.3 4.3 0 0 0-.921.214q-.412.15-.661.435-.243.278-.243.742 0 .642.475.973.475.325 1.21.325M96.432 35.876v-8.9h1.674v1.414h.093a2.15 2.15 0 0 1 .857-1.13q.62-.417 1.402-.417a7 7 0 0 1 .736.04v1.658a3 3 0 0 0-.371-.064 3.5 3.5 0 0 0-.533-.04q-.614 0-1.095.26a1.94 1.94 0 0 0-1.031 1.744v5.435zM104.287 32.857l-.012-2.114h.301l3.546-3.767h2.075l-4.045 4.288h-.272zm-1.594 3.02V24.01h1.733v11.866zm5.621 0-3.187-4.23 1.193-1.211 4.12 5.44zM111.776 39.213V26.976h1.691v1.443h.145q.15-.278.435-.643.284-.365.788-.637.504-.279 1.332-.278 1.078 0 1.924.544.846.545 1.327 1.57.487 1.026.487 2.469t-.481 2.474q-.481 1.024-1.321 1.581-.841.55-1.918.55-.811 0-1.327-.271a2.5 2.5 0 0 1-.8-.638 5 5 0 0 1-.446-.649h-.104v4.722zm1.697-7.787q0 .939.272 1.646.273.707.788 1.106.516.394 1.264.394.776 0 1.297-.411.522-.417.788-1.13.273-.712.273-1.605 0-.88-.267-1.581-.26-.702-.788-1.107-.521-.405-1.303-.406-.754 0-1.275.388-.516.389-.782 1.084-.267.695-.267 1.622M121.846 35.876v-8.9h1.674v1.414h.093q.243-.718.857-1.13a2.46 2.46 0 0 1 1.402-.417 7 7 0 0 1 .736.04v1.658a3 3 0 0 0-.371-.064 3.5 3.5 0 0 0-.533-.04q-.614 0-1.095.26a1.94 1.94 0 0 0-1.031 1.744v5.435zM131.537 36.056q-1.251 0-2.184-.574a3.86 3.86 0 0 1-1.448-1.605q-.516-1.03-.516-2.41 0-1.384.516-2.422a3.85 3.85 0 0 1 1.448-1.61q.933-.574 2.184-.574t2.185.573q.933.575 1.448 1.61.516 1.038.516 2.423 0 1.38-.516 2.41a3.86 3.86 0 0 1-1.448 1.605q-.933.574-2.185.574m.006-1.455q.811 0 1.344-.428a2.5 2.5 0 0 0 .788-1.142q.261-.712.261-1.57 0-.852-.261-1.564a2.54 2.54 0 0 0-.788-1.153q-.532-.435-1.344-.435-.816 0-1.356.435-.532.435-.793 1.153-.255.712-.255 1.564 0 .858.255 1.57.26.713.793 1.142.54.428 1.356.428M137.62 35.876v-8.9h1.663v1.449h.11q.278-.735.909-1.147.631-.417 1.513-.417.892 0 1.494.417.609.417.899 1.147h.092a2.5 2.5 0 0 1 1.014-1.136q.696-.428 1.657-.428 1.212 0 1.976.759.771.759.771 2.288v5.968h-1.733v-5.805q0-.905-.492-1.31a1.8 1.8 0 0 0-1.176-.406q-.846 0-1.316.522-.47.516-.469 1.327v5.672h-1.727V29.96q0-.724-.452-1.164t-1.176-.44q-.492 0-.909.26a1.9 1.9 0 0 0-.667.713 2.2 2.2 0 0 0-.249 1.06v5.487zM152.04 39.213V26.976h1.691v1.443h.145q.152-.278.435-.643t.788-.637q.504-.279 1.333-.278 1.077 0 1.923.544.846.545 1.327 1.57.487 1.026.487 2.469t-.481 2.474q-.481 1.024-1.321 1.581-.84.55-1.918.55-.811 0-1.327-.271a2.5 2.5 0 0 1-.8-.638 5 5 0 0 1-.446-.649h-.104v4.722zm1.697-7.787q0 .939.273 1.646.272.707.788 1.106.514.394 1.263.394.776 0 1.298-.411.52-.417.788-1.13a4.5 4.5 0 0 0 .272-1.605q0-.88-.267-1.581-.26-.702-.788-1.107-.521-.405-1.303-.406-.753 0-1.275.388-.516.389-.782 1.084-.267.695-.267 1.622M166.264 26.976v1.391h-4.861v-1.39zm-3.558-2.132h1.733v8.419q0 .504.15.759.151.249.389.342.243.087.527.087.209 0 .365-.03.156-.027.243-.046l.313 1.431a3 3 0 0 1-.429.116 3.4 3.4 0 0 1-.695.07 3.05 3.05 0 0 1-1.275-.243 2.2 2.2 0 0 1-.956-.788q-.365-.534-.365-1.339z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MdnLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 118 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m21.001 16.004-7.727 24.884h-3.161l7.7-24.884zm2.81 0v24.884h-2.81V16.004zm10.889 0L27 40.888h-3.162l7.7-24.884zm2.81 0v24.884H34.7V16.004zM63.042 35.728h-4.62v-1.486h1.027v-4.269c0-1-.19-1.702-.568-2.134-.378-.433-.891-.622-1.54-.622-.864 0-1.513.297-1.945.919-.432.621-.649 1.243-.676 1.864v4.215h1.622V35.7h-4.62v-1.486h1.026v-4.242c0-1.027-.19-1.73-.567-2.134-.379-.406-.892-.622-1.513-.622-.865 0-1.486.297-1.946.892-.432.594-.648 1.215-.675 1.864v4.269h1.864v1.486h-5.458v-1.486h1.621v-6.836h-1.648v-1.513h3.62v1.73a4.2 4.2 0 0 1 1.27-1.406c.541-.35 1.19-.54 1.973-.54s1.432.19 2.026.567c.595.379 1 .946 1.216 1.73a3.95 3.95 0 0 1 1.324-1.648c.595-.433 1.297-.649 2.135-.649.945 0 1.756.297 2.404.892.649.594 1 1.513 1 2.756v4.917h1.648zm11.483 0h-3.567v-1.945a4.44 4.44 0 0 1-1.323 1.513c-.568.432-1.297.648-2.243.648-1.297 0-2.35-.432-3.134-1.324-.784-.864-1.162-2.08-1.162-3.593 0-1.351.351-2.594 1.08-3.702.703-1.108 1.865-1.675 3.46-1.675 1.593 0 2.647.594 3.323 1.756v-5.322h-2.324V20.57h4.269v13.672h1.621zm-3.594-4.54V30c-.026-.81-.324-1.486-.864-1.972a2.83 2.83 0 0 0-1.945-.757c-.973 0-1.703.351-2.216 1.054-.513.702-.757 1.567-.757 2.594 0 1.107.27 1.972.811 2.566.54.595 1.216.892 2 .892.864 0 1.567-.351 2.107-1.027.567-.702.838-1.432.865-2.161m11.402 4.54v-1.486h1.621v-4.269c0-1-.189-1.702-.594-2.134-.378-.433-.919-.622-1.54-.622-.838 0-1.513.27-2 .81a2.96 2.96 0 0 0-.783 1.892v4.296h1.621V35.7h-5.214v-1.486h1.62v-6.809H75.39v-1.513h3.674v1.675c.703-1.242 1.837-1.864 3.431-1.864.973 0 1.81.297 2.486.892.676.594 1 1.513 1 2.756v4.917h1.62v1.459zM89.088 41.618h18.103v2.378H89.088z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobbinLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 136 61" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M46.046 29.768h-8.978v8.877h8.978zm-35.912 8.877V30.83l8.93-8.83h7.903v7.816l-8.93 8.83z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M23.6 38.646V30.83L32.53 22h7.904v7.816l-8.93 8.83zm41.143-4.952L60.15 23.252h-3.71v13.933h2.77l.007-9.39 4.191 9.39h2.672l4.198-9.387v9.387h2.771V23.252h-3.713zm14.999-6.947c-4.62 0-5.59 2.857-5.59 5.252 0 3.66 1.847 5.438 5.644 5.438s5.608-1.763 5.608-5.388-1.905-5.304-5.66-5.304zm-2.709 5.203c0-1.83.418-2.76 2.709-2.76 2.132 0 2.782.676 2.782 2.897S81.882 35 79.778 35s-2.746-.711-2.746-3.048zm15.644-5.186h-.054c-1.664.008-2.963 1.18-3.305 1.712v-5.224H86.51v13.933h2.808v-.094l.015-1.329c.456.736 1.677 1.678 3.274 1.678 3.49 0 4.98-1.567 4.98-5.236 0-3.67-1.514-5.438-4.91-5.438zM94.76 32c0 2.773-1.293 2.997-2.856 2.997s-2.82-.22-2.82-2.93c0-2.06.8-2.863 2.856-2.863s2.82.704 2.82 2.795zm10.105-5.235h-.054c-1.664.008-2.963 1.18-3.305 1.712v-5.224H98.7v13.933h2.807v-.094l.015-1.329c.456.734 1.676 1.675 3.274 1.675 3.49 0 4.98-1.566 4.98-5.236 0-3.668-1.514-5.437-4.91-5.437M106.95 32c0 2.773-1.293 2.997-2.856 2.997s-2.82-.22-2.82-2.93c0-2.06.801-2.863 2.857-2.863s2.819.704 2.819 2.795zm6.823-5.015h-2.808v10.201h2.808zm7.524-.223c-1.558 0-2.818.967-3.222 1.715v-1.492h-2.807v10.201h2.807V31.24c0-1.333.401-1.969 2.677-1.969 1.724 0 2.336.532 2.336 2.035v5.878h2.807v-5.27c0-3.834-1.16-5.135-4.598-5.156zm-7.524-3.509h-2.808v2.643h2.808z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function MozillaLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 114 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M103.092 37.493a2 2 0 0 1-.545.089c-.608 0-.894-.26-.894-1.008v-5.65c0-2.966-2.364-4.411-5.16-4.411-2.136 0-3.284.26-5.565 1.18l-.51 2.993 2.967.317.421-1.466c.609-.317 1.211-.374 1.99-.374 2.105 0 2.136 1.585 2.136 2.91v.431c-.665-.088-1.413-.114-2.136-.114-2.967 0-6.053.748-6.053 3.95 0 2.706 2.13 3.72 4.006 3.72 2.105 0 3.43-1.268 4.178-2.593.171 1.585 1.122 2.593 2.853 2.593.805 0 1.642-.229 2.333-.608zm-7.981-.083c-1.123 0-1.528-.665-1.528-1.502 0-1.413 1.154-1.787 2.479-1.787.603 0 1.267.088 1.876.171-.089 2.167-1.502 3.118-2.827 3.118m-4.994-17.47-5.623 19.835h-3.663l5.623-19.835zm-7.55 0-5.623 19.835h-3.658l5.623-19.835zm-15.61 6.833h3.892v4.666h-3.892zm0 8.33h3.892v4.672h-3.892zm-5.664-.172 2.941.286-.805 4.558h-11.36l-.374-1.96 7.15-8.215h-4.063l-.577 2.016-2.681-.291.462-4.557h11.417l.29 1.959-7.212 8.22h4.21zm-18.629-8.417c-4.671 0-6.978 3.143-6.978 6.947 0 4.152 2.769 6.6 6.775 6.6 4.152 0 7.15-2.625 7.15-6.776 0-3.633-2.28-6.771-6.947-6.771m-.088 10.693c-2.016 0-3.056-1.73-3.056-3.98 0-2.452 1.18-3.86 3.087-3.86 1.756 0 3.17 1.179 3.17 3.803 0 2.479-1.268 4.037-3.201 4.037m-10.237-.26h1.73v2.828h-5.45v-7.322c0-2.25-.749-3.113-2.22-3.113-1.787 0-2.509 1.268-2.509 3.087v4.52h1.73v2.828h-5.445v-7.322c0-2.25-.749-3.113-2.22-3.113-1.787 0-2.51 1.268-2.51 3.087v4.52h2.48v2.828H10v-2.827h1.73v-7.353H10v-2.827h5.446v1.959c.78-1.382 2.136-2.219 3.95-2.219 1.875 0 3.6.894 4.24 2.796.722-1.73 2.192-2.796 4.24-2.796 2.332 0 4.468 1.414 4.468 4.495v5.945z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NextdoorlendingLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 123 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m19.807 31.797-5.87-8.745v8.745h-3.511V17.62h3.51l5.871 8.785V17.62h3.512v14.177zM36.596 26.992h-7.944q.082 1.049.688 1.606.606.555 1.49.555 1.313 0 1.827-1.091h3.736a5 5 0 0 1-1.036 1.998 5.2 5.2 0 0 1-1.879 1.393q-1.13.504-2.524.504-1.683 0-2.996-.707a5.1 5.1 0 0 1-2.053-2.02q-.74-1.314-.74-3.07 0-1.758.73-3.071a5 5 0 0 1 2.041-2.02q1.311-.7 3.015-.701 1.663.001 2.956.686a5 5 0 0 1 2.022 1.958q.73 1.272.73 2.969 0 .484-.063 1.011m-3.53-1.92q0-.89-.615-1.415-.616-.525-1.54-.525-.883 0-1.49.504-.605.504-.747 1.435zM45.279 31.797l-2.197-3.13-1.846 3.13H37.44l3.777-5.757-3.88-5.512h3.942l2.196 3.11 1.847-3.11h3.796l-3.84 5.674 3.943 5.595zM56.96 28.87v2.93h-1.787q-1.908 0-2.977-.918-1.067-.92-1.069-2.999v-4.487h-1.396v-2.868h1.396v-2.745h3.511v2.748h2.3v2.867h-2.3v4.525q0 .504.247.728c.166.15.437.222.822.222h1.252zM72.643 18.508q1.703.89 2.638 2.495t.933 3.705q0 2.082-.933 3.695-.934 1.617-2.65 2.505c-1.14.593-2.467.889-3.972.889h-5.398V17.62h5.398q2.28 0 3.984.888m-1.11 9.231q1.11-1.069 1.11-3.03t-1.11-3.05q-1.11-1.092-3.099-1.091H66.77v8.24h1.662q1.991 0 3.102-1.069M110.106 20.941a4 4 0 0 1 2.053-.536v3.655h-.965q-1.313 0-1.971.555t-.656 1.95v5.232h-3.512v-11.27h3.512v1.88a4.6 4.6 0 0 1 1.539-1.466M27.459 41.049h2.207v1.28h-3.89v-6.805h1.683zM37.082 36.852v1.377h2.256v1.28h-2.256v1.494h2.551v1.329h-4.235v-6.808h4.235v1.328zM51.84 42.33h-1.684l-2.817-4.197v4.196h-1.683v-6.805h1.683l2.818 4.215v-4.215h1.683zM62.488 35.95q.818.428 1.267 1.198.447.772.447 1.78 0 .999-.447 1.774a3.05 3.05 0 0 1-1.272 1.203q-.823.428-1.906.427h-2.593v-6.808h2.593q1.094.001 1.911.427m-.531 4.432q.532-.513.531-1.454 0-.94-.531-1.465-.533-.524-1.489-.523h-.797v3.954h.797q.958 0 1.489-.512M71.759 35.524v6.805h-1.684v-6.805zM84.093 42.33H82.41l-2.817-4.197v4.196h-1.684v-6.805h1.684l2.817 4.215v-4.215h1.684zM94.83 37.677a1.23 1.23 0 0 0-.536-.518 1.8 1.8 0 0 0-.822-.179q-.817 0-1.31.529-.492.527-.493 1.41 0 .941.518 1.468.517.528 1.423.528.623 0 1.05-.31.426-.308.626-.89H93.15v-1.222h3.666v1.542a3.2 3.2 0 0 1-.634 1.153 3.3 3.3 0 0 1-1.14.861q-.69.33-1.555.331-1.026 0-1.828-.44a3.16 3.16 0 0 1-1.25-1.227q-.448-.784-.448-1.793 0-1.01.448-1.798a3.16 3.16 0 0 1 1.247-1.23q.799-.44 1.822-.44 1.243 0 2.093.592.852.591 1.129 1.638H94.83zM88.817 23.348a5.3 5.3 0 0 0-2.153-2.046q-1.369-.713-3.064-.713-1.697 0-3.064.713a5.3 5.3 0 0 0-2.153 2.046q-.785 1.333-.786 3.087-.001 1.773.775 3.094a5.26 5.26 0 0 0 2.131 2.036q1.356.711 3.053.712 1.696 0 3.075-.712a5.36 5.36 0 0 0 2.174-2.047q.798-1.333.798-3.086 0-1.748-.786-3.084m-1.907 6.32h-2.329V27.22h-1.96v2.447h-2.33V25.78l3.309-2.09 3.308 2.09v3.887zm.689-4.322a.386.386 0 0 1-.531.112L83.6 23.241l-3.468 2.217a.386.386 0 0 1-.532-.112.37.37 0 0 1 .114-.523l3.677-2.35a.39.39 0 0 1 .42 0l3.677 2.35c.176.115.228.35.111.523M102.325 23.348a5.3 5.3 0 0 0-2.153-2.046q-1.366-.713-3.064-.713-1.696 0-3.064.713a5.3 5.3 0 0 0-2.153 2.046q-.785 1.333-.786 3.087 0 1.773.775 3.094a5.27 5.27 0 0 0 2.132 2.036q1.355.711 3.052.712 1.697 0 3.075-.712a5.37 5.37 0 0 0 2.176-2.047q.796-1.333.796-3.086 0-1.748-.786-3.084m-1.906 6.32h-2.332V27.22h-1.96v2.447h-2.33V25.78l3.309-2.09 3.307 2.09v3.887zm.688-4.322a.385.385 0 0 1-.531.112l-3.468-2.217-3.468 2.217a.386.386 0 0 1-.531-.112.37.37 0 0 1 .114-.523l3.677-2.35a.39.39 0 0 1 .42 0l3.676 2.35c.176.115.228.35.111.523"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PebblelyLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 151 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M36.426 17.632c-2.641-.657-5.146.496-6.867 2.196l-2.19 2.163a6.7 6.7 0 0 1-3.007 1.712l-7.913 2.075c-2.34.614-4.963 2.248-5.418 5.187-.377 2.436.1 5.268 2.5 7.533 2.344 2.212 6.33 3.695 12.547 3.986 9.823.458 16.505-1.293 19.845-4.84 1.717-1.822 2.485-4.058 2.35-6.495-.132-2.397-1.13-4.921-2.808-7.438-2.543-3.814-6.344-5.41-9.039-6.08m-4.985 4.101c1.287-1.27 2.879-1.866 4.34-1.503 2.349.584 5.426 1.921 7.456 4.966 1.508 2.263 2.265 4.33 2.362 6.1.096 1.732-.43 3.243-1.625 4.512-2.484 2.638-8.094 4.452-17.771 4-5.924-.276-9.157-1.676-10.834-3.258-1.62-1.528-1.966-3.401-1.691-5.175.215-1.392 1.55-2.508 3.45-3.006l7.914-2.075a9.4 9.4 0 0 0 4.208-2.398zM84.97 20.12v8.75a4.1 4.1 0 0 1 1.216-1.261q.994-.64 2.253-.64 1.326 0 2.453.64 1.126.618 1.811 1.812.685 1.17.685 2.85 0 1.656-.685 2.85-.684 1.192-1.811 1.833a5 5 0 0 1-2.453.619q-1.26 0-2.253-.64a4.1 4.1 0 0 1-1.215-1.261v1.68h-2.409V20.12zm.42 13.918q-.42-.729-.42-1.767 0-1.039.42-1.746.442-.729 1.105-1.104t1.348-.376q.84 0 1.524.376.707.375 1.127 1.104t.42 1.746-.42 1.745-1.127 1.105q-.684.375-1.524.375-.685 0-1.348-.375a3.1 3.1 0 0 1-1.105-1.083M58.858 21.887v15.465h2.607v-5.855h2.054q1.68 0 2.917-.574 1.26-.596 1.944-1.68.685-1.08.685-2.562 0-1.48-.685-2.54-.686-1.083-1.944-1.658-1.237-.597-2.917-.596zm2.607 2.297V29.2h2.054q1.348 0 2.166-.641.816-.64.817-1.878 0-1.238-.817-1.856-.818-.64-2.166-.64zM70.63 35.054a4.6 4.6 0 0 0 1.812 1.856q1.192.663 2.74.663 1.767 0 3.026-.685 1.26-.684 2.055-2.054l-2.055-.84q-.486.818-1.215 1.215t-1.701.398q-.928 0-1.59-.398-.642-.398-.973-1.149a4.2 4.2 0 0 1-.3-1.237h7.9q.022-.089.022-.287.022-.2.022-.376 0-1.635-.618-2.784a4.13 4.13 0 0 0-1.724-1.79q-1.126-.618-2.74-.618-1.612 0-2.805.663a4.54 4.54 0 0 0-1.856 1.834q-.64 1.192-.64 2.806 0 1.59.64 2.783m1.863-3.866h5.286a2.7 2.7 0 0 0-.322-1.149 2.24 2.24 0 0 0-.884-.928q-.552-.33-1.303-.331-.906 0-1.547.397a2.5 2.5 0 0 0-.972 1.15q-.17.384-.258.861M97.98 28.87v-8.75h-2.407v17.232h2.408v-1.68q.49.783 1.215 1.26.994.64 2.254.641 1.325 0 2.452-.619 1.127-.64 1.812-1.833.684-1.194.684-2.85 0-1.68-.684-2.85-.686-1.194-1.812-1.812a4.9 4.9 0 0 0-2.452-.64q-1.26 0-2.254.64a4.1 4.1 0 0 0-1.215 1.26m0 3.4q0 1.04.42 1.768.442.706 1.105 1.083.663.375 1.348.375.84 0 1.524-.375.707-.376 1.127-1.105.42-.73.42-1.745 0-1.017-.42-1.746a2.8 2.8 0 0 0-1.127-1.104 3.1 3.1 0 0 0-1.524-.376q-.685 0-1.348.376-.663.375-1.104 1.104-.42.707-.42 1.746"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path fill="currentColor" d="M108.671 20.12h2.386v17.232h-2.386z" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M113.976 35.054a4.6 4.6 0 0 0 1.811 1.856q1.194.663 2.74.663 1.767 0 3.027-.685 1.259-.684 2.054-2.054l-2.054-.84q-.486.818-1.215 1.215t-1.702.398q-.927 0-1.59-.398a2.5 2.5 0 0 1-.972-1.149 4.2 4.2 0 0 1-.301-1.237h7.901q.022-.089.022-.287.022-.2.022-.376 0-1.635-.619-2.784a4.13 4.13 0 0 0-1.723-1.79q-1.126-.618-2.74-.618-1.613 0-2.805.663a4.54 4.54 0 0 0-1.856 1.834q-.64 1.192-.641 2.806 0 1.59.641 2.783m1.862-3.866h5.287a2.7 2.7 0 0 0-.322-1.149 2.24 2.24 0 0 0-.884-.928q-.552-.33-1.304-.331-.906 0-1.546.397-.64.399-.972 1.15a4 4 0 0 0-.259.861"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M125.996 20.12h2.387v17.232h-2.387zM133.797 42.213l6.629-15.024h-2.563l-2.635 6.606-2.668-6.606h-2.739l4.087 8.97-2.651 6.054z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PikaLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 82 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M33.9 20.487h-2.911v3.047h2.91zm37.293 15.506v-7.279c0-4.254-2.663-6.152-8.638-6.152-5.02 0-8.015 2.256-8.015 6.037h2.935c0-2.263 1.567-3.27 5.08-3.27 4.245 0 5.751.94 5.751 3.593-1.037.14-2.022.263-2.984.383-7.214.902-11.568 1.447-11.568 5.622 0 1.637.627 2.798 1.918 3.547 1.155.67 3.214 1.04 5.795 1.04 3.478 0 6.372-.846 7.203-2.103-.076 1.143 0 1.853 0 1.86h2.647c-.004-.013-.124-1.315-.124-3.278m-2.887-3.427c0 1.774-.629 2.992-1.924 3.72-1.157.65-2.897.954-5.47.954-1.578 0-4.223-.303-4.223-2.337 0-.992.69-1.598 2.381-2.088 1.823-.53 4.886-.94 9.238-1.426v1.177zm-11.47 2.302h-.073v.069h.072zM20.88 20.487H10.43V39.27h2.911v-6.683h7.54c7.041 0 7.802-4.231 7.802-6.049-.002-1.82-.763-6.051-7.803-6.051m-.525 9.312H13.34v-6.524h7.016c3.645 0 5.2.975 5.2 3.261C25.555 28.823 24 29.8 20.355 29.8m33.022-7.124h-4.032L39.3 30.465v-9.978h-2.91V39.27h2.91v-7.93l10.14 7.93h4.222l-10.83-8.573zM33.9 24.852h-2.911V39.27h2.91z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PwcLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 113 60" {...props}>
|
||||
<g clipPath="url(#a)">
|
||||
<mask
|
||||
id="b"
|
||||
width="96"
|
||||
height="56"
|
||||
x="7"
|
||||
y="18"
|
||||
maskUnits="userSpaceOnUse"
|
||||
style={{ maskType: 'luminance' as const }}
|
||||
>
|
||||
<path fill="#fff" d="M102.931 18.71H7.608v54.48h95.323z" />
|
||||
</mask>
|
||||
<g mask="url(#b)">
|
||||
<path fill="currentColor" d="M102.931 42.104H76.766V35.42h26.165z" />
|
||||
<path fill="currentColor" d="M99.532 42.104H76.766V25.39h22.766z" />
|
||||
<path fill="currentColor" d="M90.126 42.103h-13.36V18.711h13.36z" />
|
||||
<path fill="currentColor" d="M92.755 42.103h-15.99V21.321h15.99z" />
|
||||
<path fill="currentColor" d="M76.762 21.325v20.776h13.363V21.325z" />
|
||||
<path fill="currentColor" d="M63.368 42.104h13.398v3.34H63.368z" />
|
||||
<path fill="currentColor" d="M90.046 25.386v10.081h2.717v-10.08z" />
|
||||
<path fill="currentColor" d="M76.762 25.386v16.715h13.363V25.386z" />
|
||||
<path fill="currentColor" d="M85.12 35.533h-8.354v-3.461h8.355z" />
|
||||
<path fill="currentColor" d="M76.762 35.426V42.1H99.54v-6.675z" />
|
||||
<path fill="currentColor" d="M90.11 35.426V42.1h2.653v-6.675z" />
|
||||
<path fill="currentColor" d="M76.762 35.426V42.1h13.363v-6.675z" />
|
||||
<path fill="currentColor" d="M76.762 35.426V42.1h8.367v-6.675z" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M65.304 24.755c-2.28.404-3.495 2.123-3.495 5.105 0 3.033 1.62 5.055 4.002 5.055 1.165 0 2.178-.405 4.305-1.466v2.477c-2.583 1.213-4.103 1.516-6.13 1.516-2.279 0-3.9-.556-5.166-1.92-1.317-1.315-2.026-3.033-2.026-5.005 0-4.296 3.242-7.279 7.953-7.279 3.09 0 5.268 1.466 5.268 3.539 0 1.364-1.013 2.325-2.482 2.325-.76 0-1.368-.253-2.23-.708M53.906 30.517c2.077-2.578 2.837-3.64 2.837-4.903 0-1.264-1.014-2.376-2.432-2.376-.81 0-1.57.455-1.925.86v5.307l-3.394 4.55V23.642H45.75l-5.37 8.846v-8.846h-1.823l-4.914 1.112v1.264l2.685.252V37.24h3.394l5.167-8.492v8.492h3.8M23.36 35.521h.76c2.735 0 4.255-1.668 4.255-5.004 0-2.78-1.216-4.296-3.496-4.296-.304 0-.76.05-1.52.202zm0 5.965 2.229.506v1.263h-8.764v-1.264l2.026-.505V26.221h-2.229v-1.264l5.32-1.617h1.418v1.82c2.482-1.517 3.343-1.77 4.66-1.77 2.938 0 5.167 2.73 5.167 6.52 0 4.348-2.989 7.33-7.548 7.33-.506 0-1.368-.05-2.28-.101"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M10.43 18.5h92v27h-92z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuivrLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 114 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M31.228 42.414c.029.132.127.153.226.174.067.014.134.028.179.077q.021.024.047.043l.014.012.012.01q-.012.007-.025.012-.027.01-.05.023a.38.38 0 0 1-.32.044q-.096-.023-.194-.045c-.25-.056-.499-.112-.721-.254-.012-.007-.031-.002-.05.003l-.03.006.001.004v.006l.001.005.001.009c.002.017.003.034.012.043.131.14.21.3.212.497 0 .064.008.126.015.19l.01.081c-.134.005-.137-.079-.14-.159-.002-.035-.003-.069-.015-.095-.127-.267-.351-.424-.576-.581q-.062-.044-.123-.088c-.076-.055-.166-.093-.255-.131l-.047-.02q-.152-.065-.306-.122-.053-.02-.103-.04.023.054.051.107c.04.078.081.157.09.238.02.187.018.376.016.565l-.001.163c0 .015-.013.03-.026.044l-.017.02-.015-.025a.2.2 0 0 1-.024-.047c-.069-.308-.165-.602-.426-.807a5 5 0 0 0-.411-.299.6.6 0 0 0-.153-.06q-.04-.012-.079-.026l.017.065q.02.079.047.156c.149.38.162.593.058.983q-.009.025-.027.047l-.008.011-.008.012q-.006-.013-.017-.025c-.012-.015-.023-.03-.024-.046-.02-.518-.324-.882-.628-1.247q-.06-.07-.117-.142-.062-.074-.128-.146l-.071-.079-.04-.05-.021-.028-.01.024a.2.2 0 0 0-.015.054c-.012.312-.006.618.117.92.073.178.12.367.165.557l.052.207c.004.015-.006.034-.016.053l-.012.024-.024-.011c-.017-.007-.034-.014-.042-.027l-.125-.19v-.002c-.16-.242-.318-.484-.465-.733a.7.7 0 0 1-.08-.321q-.009-.198-.007-.395.001-.168-.004-.336c-.001-.027-.026-.053-.051-.079l-.024-.026.001.236c.002.202.003.405-.009.605-.024.393-.07.785-.23 1.152a.4.4 0 0 1-.05.078l-.03.04q-.005-.02-.012-.037a.2.2 0 0 1-.02-.08q-.001-.118.002-.235a3 3 0 0 0-.007-.41l-.011-.116c-.047-.479-.093-.958-.323-1.397q-.016-.028-.04-.051l-.02-.023-.004.012-.006.012c-.008.018-.016.036-.014.054.026.242-.04.468-.105.693q-.047.15-.08.3l-.015.07c-.143.686-.15.722.016 1.493a.2.2 0 0 1-.005.086l-.006.038-.03-.026a.2.2 0 0 1-.053-.059c-.258-.528-.414-1.06-.263-1.667.049-.194.041-.402.033-.61a4 4 0 0 1-.004-.333 4.8 4.8 0 0 0-.177-1.552l-.032-.118-.01-.044-.016.057c-.014.044-.028.089-.03.134q-.002.11 0 .223.004.125 0 .25l-.019.419c-.015.386-.03.772-.076 1.155a2.38 2.38 0 0 1-.838 1.568q-.02.017-.048.025l-.02.009-.014-.041c-.01-.029-.021-.057-.012-.078q.055-.12.115-.24.063-.126.12-.253c.072-.166.142-.333.186-.507a.5.5 0 0 0-.005-.201q-.008-.045-.012-.09l-.055.081q-.046.066-.09.133l-.085.13q-.083.132-.17.26c-.154.224-.298.45-.27.74a.2.2 0 0 1-.004.085l-.079.229-.03.085-.022-.099c-.016-.068-.032-.136-.035-.204-.016-.384.011-.753.156-1.125.164-.42.218-.876.187-1.334q-.005-.041-.023-.08l-.014-.037-.015.034q-.017.036-.03.074l-.088.285c-.06.2-.12.399-.195.592-.178.46-.485.839-.791 1.217l-.07.088-.486.603c-.023-.27.085-.5.193-.73q.04-.082.075-.165.014-.033.022-.068l.004-.017.002-.008.003-.01-.036.003a.2.2 0 0 0-.079.017c-.407.254-.815.508-1.063.944-.01.017-.038.023-.066.029l-.04.01q.001-.025.005-.05l.002-.02.004-.035c.035-.353.205-.634.45-.883.187-.192.36-.399.528-.608l.004-.005c.083-.102.17-.211.157-.358l-.067.052c-.053.04-.106.079-.149.126-.504.557-1.154.838-1.864 1.021-.369.095-.733.206-1.098.317l-.351.107q-.114.036-.223.086l-.09.038c-.029.011-.052.035-.075.059-.045.047-.09.093-.176.045l.038-.057q.048-.075.097-.147c.271-.396.688-.569 1.118-.71.205-.068.415-.124.624-.18l.304-.083q.031-.008.063-.023l.014-.005-.025-.02a.2.2 0 0 0-.056-.035c-.54-.144-1.055-.145-1.53.215-.415.316-.903.416-1.414.492-.432.064-.863.061-1.293.058-.223-.001-.446-.003-.669.005-.027 0-.054-.01-.081-.02q-.02-.008-.037-.014l.01-.005.016-.009q.037-.021.076-.032l.341-.09h.001c.332-.086.664-.173.998-.248.68-.153 1.326-.386 1.876-.822.365-.29.785-.366 1.224-.39.675-.035 1.271-.24 1.738-.753.443-.486.589-1.04.41-1.684-.228-.817-.758-1.333-1.542-1.617-.33-.12-.658-.235-1.016-.252-.581-.028-1.008.217-1.32.697a8 8 0 0 1-.2.29c-.104.146-.208.292-.297.448-.209.367-.563.508-.923.626-.555.181-1.043-.03-1.51-.311a.5.5 0 0 1-.07-.055q-.031-.028-.065-.05c-.171-.112-.338-.225-.409-.43l.185.089q.17.08.338.165c.456.23.919.252 1.39.052.338-.143.577-.409.765-.708.1-.161.18-.372.17-.555-.012-.212-.084-.45-.353-.526q-.233-.068-.465-.144c-.312-.1-.624-.2-.943-.258-.448-.081-.813.183-1.136.469a6 6 0 0 0-.304.298q-.12.126-.246.246c-.39.362-.835.582-1.384.517a1 1 0 0 1-.235-.066l-.045-.016c-.019-.006-.034-.022-.048-.038l-.024-.023.023-.009q.027-.012.054-.017.078-.01.156-.016c.089-.008.177-.017.264-.035.608-.127 1.024-.512 1.34-1.023.111-.18.22-.362.311-.552.228-.476.858-.78 1.405-.54q.196.085.397.161.212.08.42.172c.158.071.314.115.488.117.161.002.205-.072.186-.209q-.016-.124-.036-.25-.027-.166-.046-.336c-.004-.032.011-.067.026-.1q.012-.026.02-.05.017.014.04.027c.031.02.063.04.069.065.108.474.482.649.885.749q.267.066.534.128h.002v.001h.002c.487.116.974.232 1.454.378.146.045.288.029.4-.073.1-.092.194-.189.118-.362l-.016-.035c-.05-.118-.099-.229-.19-.328-.504-.552-1.117-.959-1.754-1.334-.605-.358-1.236-.335-1.872-.08q-.251.103-.498.216h-.002l-.273.12c-.6.262-1.22.223-1.827.064a1.32 1.32 0 0 0-.977.107c-.2.1-.39.226-.577.35-.33.218-.652.428-1.047.534-.36.095-.646-.036-.942-.173l-.054-.024c-.01-.005-.015-.02-.02-.035l-.008-.02.025-.01a.14.14 0 0 1 .05-.017q.193-.001.387.002c.355.006.659-.111.93Line truncated
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m37.399 30.786.008.002-.007.008zM22.106 43.542l.004-.012v-.001l.008-.01.001.015.002.001h.001l-.002.002v.002l-.002-.002zM22.106 43.542l-.006.016-.012.032-.028-.029.012-.005.02-.008zM38.243 29.84c.15-.011.366-.266.362-.427-.005-.164-.27-.422-.427-.414-.163.007-.42.277-.411.431.012.191.284.427.476.41M10.951 28.999c.185.004.425.265.423.46-.001.145-.256.39-.397.384-.244-.012-.39-.184-.38-.453.006-.213.172-.396.354-.391M13.91 22.882c.24.002.391-.15.389-.393-.002-.224-.153-.386-.352-.39a.43.43 0 0 0-.421.427c-.001.2.163.355.383.356M35.91 23.141c-.2-.003-.394-.197-.377-.375.017-.171.244-.407.387-.402.198.008.458.255.451.432-.007.18-.233.349-.462.345M23.755 18.094c.001-.244-.146-.39-.391-.39-.223 0-.385.149-.385.353-.001.205.186.418.374.427a.39.39 0 0 0 .402-.39M17.66 21.847c.197.008.401.235.38.422-.027.216-.182.353-.396.35-.208-.002-.353-.168-.35-.395s.16-.386.366-.377M15.097 40.509c.122-.014.317-.24.307-.354-.013-.142-.24-.332-.384-.324-.15.01-.298.19-.293.354.006.166.206.342.37.323M38.531 27.65c.003.196-.156.375-.32.362-.163-.014-.294-.171-.293-.354.001-.225.084-.309.303-.31.225-.003.308.078.31.301M34.417 40.51c.115-.007.33-.233.323-.343-.012-.188-.19-.351-.365-.335-.146.013-.311.207-.303.356.006.113.235.327.344.322M10.742 33.466c.154.01.302.186.296.353-.007.164-.181.323-.332.3a.326.326 0 0 1-.28-.32c.005-.17.168-.342.316-.333M38.793 34.143c.138-.006.259-.15.262-.312q.008-.345-.255-.36c-.111-.006-.335.194-.348.312-.014.133.206.365.341.36M28.377 24.593c.162-.008.34.159.336.31-.003.132-.172.29-.313.295-.186.007-.27-.081-.277-.293-.006-.21.07-.304.254-.312M12.629 36.73c.139-.006.303-.185.29-.316-.016-.141-.198-.3-.325-.281-.136.02-.292.216-.267.336.025.123.19.266.302.261M34.21 23.402c.094-.007.265.162.279.276.017.143-.118.318-.25.322-.14.005-.327-.134-.343-.252-.014-.1.202-.338.314-.346M13.007 25.861c-.01-.16-.11-.245-.28-.238-.142.007-.32.168-.31.284.005.087.222.306.3.303.155-.007.301-.183.29-.349M19.215 18.65c.165.01.252.11.244.28-.008.168-.106.256-.273.25-.143-.004-.338-.173-.323-.28.016-.115.216-.256.352-.25M30.892 19.604c-.006.093.195.323.286.325.087.003.294-.217.295-.312.001-.104-.17-.267-.284-.27-.107-.004-.29.155-.297.257M16.121 25.205c-.167.007-.267-.078-.276-.239-.012-.183.088-.291.27-.29q.251.004.255.26c0 .168-.085.261-.249.27M16.623 28.134q.254 0 .259-.258.003-.257-.247-.27c-.171-.007-.27.074-.282.236-.013.188.083.293.27.292M28.487 17.596c-.007.183-.1.295-.238.285-.141-.01-.3-.183-.288-.314.01-.108.18-.26.297-.267.117-.005.235.145.229.296M37.408 30.798l.007-.007.005.012zM37.42 30.803l.052.02-.014.015-.015.017-.014-.032zM77.956 26.023v11.774h3.265V26.023zM78.347 24.022q.52.483 1.25.483.728 0 1.24-.483.522-.49.522-1.173 0-.675-.521-1.157a1.73 1.73 0 0 0-1.242-.49q-.728 0-1.25.49a1.53 1.53 0 0 0-.513 1.157q0 .683.514 1.173"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M61.156 34.317a7 7 0 0 1-1.514 1.948l2.25 2.82h-3.097l-1.264-1.553q-1.27.48-2.753.48-2.093 0-3.756-.95t-2.63-2.752q-.965-1.802-.965-4.362 0-2.568.966-4.37.965-1.801 2.63-2.744 1.662-.95 3.755-.95 2.078 0 3.741.95 1.671.942 2.637 2.744.974 1.802.974 4.37t-.974 4.37m-7.528-1.978h2.821l1.162 1.495a4.3 4.3 0 0 0 .655-1.08q.498-1.143.498-2.806t-.498-2.806Q57.775 26 56.88 25.41q-.897-.59-2.1-.59t-2.101.59-1.395 1.732q-.49 1.143-.49 2.806t.49 2.805q.498 1.143 1.395 1.733t2.1.59q.453 0 .863-.084z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M72.078 32.784v-6.761h3.266v11.774h-3.136V35.66h-.122a3.48 3.48 0 0 1-1.327 1.663q-.92.63-2.246.629-1.181 0-2.077-.537a3.7 3.7 0 0 1-1.403-1.525q-.497-.99-.506-2.37v-7.496h3.266v6.914q.007 1.043.56 1.649.55.605 1.479.605.59 0 1.104-.268.513-.276.828-.813.321-.537.314-1.326M94.814 26.023l-4.116 11.774h-3.68l-4.116-11.774h3.45l2.445 8.424h.123l2.437-8.424zM96.486 26.023v11.774h3.265v-6.661q0-.72.322-1.273.33-.552.889-.858.568-.314 1.288-.314.337 0 .744.053.407.054.636.123v-2.898a4.933 4.933 0 0 0-1.027-.115q-.989 0-1.748.567-.759.56-1.08 1.656h-.124v-2.054z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResendLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 100 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10.983 38.903V21.829h7.561q1.586 0 2.878.732a5.25 5.25 0 0 1 2.025 1.927q.756 1.22.756 2.78 0 1.538-.756 2.805a5.37 5.37 0 0 1-2.025 1.976q-1.291.732-2.878.732H14.52v6.122zm9.683 0-4.341-7.708 3.756-.61 4.83 8.342zm-6.146-9.098h3.853q.66 0 1.147-.293a2.1 2.1 0 0 0 .78-.854q.27-.56.269-1.244 0-.73-.317-1.268a2.1 2.1 0 0 0-.927-.83Q18.715 25 17.91 25h-3.39zM32 39.147q-2.097 0-3.683-.854a6.26 6.26 0 0 1-2.439-2.342q-.854-1.488-.854-3.414 0-1.512.488-2.757.487-1.243 1.366-2.146a5.9 5.9 0 0 1 2.073-1.415 6.7 6.7 0 0 1 2.635-.512q1.316 0 2.439.488 1.122.487 1.927 1.366.829.853 1.268 2.049.44 1.17.39 2.536l-.024 1.074H27.22l-.561-2.122h8.073l-.39.439V31a2.15 2.15 0 0 0-.44-1.17 2.47 2.47 0 0 0-.95-.83 2.7 2.7 0 0 0-1.318-.317q-1.073 0-1.829.415a2.73 2.73 0 0 0-1.122 1.219q-.39.805-.39 1.951 0 1.17.488 2.025.512.853 1.415 1.341.925.465 2.17.464.855 0 1.561-.269.708-.267 1.513-.926l1.658 2.317q-.707.633-1.56 1.073a8.7 8.7 0 0 1-1.757.634 7.5 7.5 0 0 1-1.78.22M43.292 39.147q-1.804 0-3.22-.586-1.389-.61-2.244-1.658L40 35.049a4.4 4.4 0 0 0 1.659 1.17 5.3 5.3 0 0 0 1.854.342q.366 0 .658-.073a2 2 0 0 0 .537-.244q.22-.171.317-.39a1.1 1.1 0 0 0 .122-.512.94.94 0 0 0-.44-.83q-.243-.146-.755-.317-.512-.195-1.317-.414-1.245-.318-2.123-.732-.853-.44-1.365-.976a4.1 4.1 0 0 1-.683-1.097 4 4 0 0 1-.22-1.342q0-.878.39-1.585.39-.732 1.074-1.244a5.3 5.3 0 0 1 1.61-.78 6.1 6.1 0 0 1 1.902-.293q1 0 1.951.243a7 7 0 0 1 1.756.708 6 6 0 0 1 1.44 1.049l-1.854 2.048a6 6 0 0 0-1-.756 5 5 0 0 0-1.122-.536 3.4 3.4 0 0 0-1.05-.195q-.414 0-.755.073-.317.073-.537.244-.22.146-.341.366a1.19 1.19 0 0 0 .024 1q.147.243.39.414.27.147.78.342.538.195 1.44.439 1.17.317 1.976.707.83.39 1.317.903.415.439.61 1t.195 1.244q0 1.194-.683 2.122-.66.926-1.83 1.463t-2.634.537M55.81 39.147q-2.097 0-3.682-.854a6.26 6.26 0 0 1-2.44-2.342q-.853-1.488-.853-3.415 0-1.511.488-2.756.487-1.243 1.366-2.146a5.9 5.9 0 0 1 2.073-1.415 6.7 6.7 0 0 1 2.634-.512 6.05 6.05 0 0 1 2.44.488q1.121.487 1.926 1.366.83.853 1.269 2.049.438 1.17.39 2.536l-.025 1.073H51.03l-.56-2.122h8.073l-.39.44V31a2.15 2.15 0 0 0-.44-1.17 2.47 2.47 0 0 0-.95-.83 2.7 2.7 0 0 0-1.318-.317q-1.073 0-1.83.414a2.73 2.73 0 0 0-1.121 1.22q-.39.805-.39 1.951 0 1.17.487 2.025.513.854 1.415 1.341.927.465 2.17.464.855 0 1.562-.269.706-.268 1.512-.927l1.659 2.318q-.708.633-1.561 1.073a8.7 8.7 0 0 1-1.757.634 7.5 7.5 0 0 1-1.78.22M62.76 38.903V26h3.317l.098 2.634-.683.293a3.9 3.9 0 0 1 .951-1.586 5.4 5.4 0 0 1 1.683-1.17 4.9 4.9 0 0 1 2.05-.44q1.463 0 2.438.586 1 .586 1.488 1.78.512 1.172.512 2.903v7.903h-3.463v-7.635q0-.878-.244-1.463-.245-.585-.756-.854-.488-.268-1.22-.244-.585 0-1.097.195-.489.171-.854.513a3 3 0 0 0-.561.78q-.195.44-.195.951v7.757H62.76M81.764 39.147q-1.708 0-3.05-.854-1.316-.878-2.097-2.366-.756-1.512-.756-3.488 0-1.926.756-3.439.78-1.536 2.098-2.39 1.341-.879 3.048-.878.904 0 1.732.292.854.27 1.512.756.684.488 1.098 1.098.415.585.464 1.22l-.879.17v-8.415h3.488v18.05h-3.292l-.147-2.976.683.073a2.36 2.36 0 0 1-.439 1.171 4.2 4.2 0 0 1-1.049 1.024q-.633.44-1.463.708a5.9 5.9 0 0 1-1.708.244m.78-2.878q.976 0 1.707-.488a3.3 3.3 0 0 0 1.147-1.342q.414-.854.414-2 0-1.121-.414-1.976a3.3 3.3 0 0 0-1.147-1.341q-.73-.488-1.707-.488-.976 0-1.707.488-.708.487-1.122 1.342-.39.853-.39 1.975 0 1.146.39 2 .414.855 1.122 1.342.731.488 1.707.488"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShotgunLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 134 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="m20.34 30.42 4.124-5.95a1.62 1.62 0 0 0 0-1.86 2.16 2.16 0 0 0-1.792-.93h-9.905a2.15 2.15 0 0 0-1.758.913 1.64 1.64 0 0 0 0 1.86l10.548 15.331H13.9l2.687-3.888-1.521-2.231-4.057 5.865a1.64 1.64 0 0 0 0 1.86 2.15 2.15 0 0 0 1.758.93h9.905a2.15 2.15 0 0 0 1.758-.93 1.64 1.64 0 0 0 0-1.86L13.9 24.233h7.674l-2.756 3.955zm16.227-3.854c1.86 0 3.296.27 4.31.828s1.64 1.47 1.893 2.772h-2.687a1.64 1.64 0 0 0-1.133-1.285 8 8 0 0 0-2.417-.287c-1.014 0-1.69.118-2.08.321-.388.203-.574.507-.574.845 0 .203.034.338.102.474.084.135.236.236.473.32s.575.17 1.031.22c.456.051 1.065.102 1.809.136q1.577.078 3.127.389c.794.202 1.403.507 1.775.946.371.423.557.98.557 1.69 0 1.1-.44 1.961-1.318 2.57-.879.625-2.265.93-4.124.93-.71 0-1.353-.034-1.91-.102a8.3 8.3 0 0 1-1.539-.321 4.23 4.23 0 0 1-2.062-1.285 5.1 5.1 0 0 1-1.014-2.248h2.738c.17.71.592 1.183 1.251 1.454.66.253 1.521.389 2.586.389.98 0 1.69-.102 2.164-.288.456-.186.693-.507.693-.93 0-.169-.034-.304-.085-.422a.7.7 0 0 0-.253-.287c-.254-.153-.676-.254-1.251-.305-.592-.067-1.437-.118-2.535-.169a16 16 0 0 1-2.705-.422 3.04 3.04 0 0 1-1.572-.947c-.338-.422-.507-1.014-.507-1.724a2.7 2.7 0 0 1 1.318-2.366 6.93 6.93 0 0 1 3.922-.896zm27.417 0c.625 0 1.217.05 1.758.169a7.5 7.5 0 0 1 1.555.456 4.99 4.99 0 0 1 3.127 4.8v.102c0 .554-.074 1.105-.22 1.64a4.5 4.5 0 0 1-.608 1.352 4.9 4.9 0 0 1-2.266 1.74 8.8 8.8 0 0 1-3.33.592q-.901.006-1.791-.135a7.7 7.7 0 0 1-1.572-.422 4.8 4.8 0 0 1-2.231-1.741 5.3 5.3 0 0 1-.795-3.043v-.068a4.92 4.92 0 0 1 3.094-4.817 8 8 0 0 1 3.279-.625m36.612.253v4.547c.005.753.131 1.501.372 2.215.236.54.591.93 1.065 1.149.857.346 1.795.44 2.704.27q.398-.073.761-.253c.456-.22.794-.609 1.014-1.15a5.75 5.75 0 0 0 .338-2.23v-4.548h2.654v4.615c0 .727-.068 1.369-.169 1.96a5.8 5.8 0 0 1-.524 1.522c-.457.879-1.116 1.521-1.978 1.91a9.2 9.2 0 0 1-4.817.44 7 7 0 0 1-1.454-.44 3.95 3.95 0 0 1-1.944-1.91c-.44-.88-.659-2.028-.659-3.482v-4.615zm-10.92-.253c1.742 0 3.144.304 4.226.93a4 4 0 0 1 2.012 2.94h-2.79c-.118-.557-.54-.98-1.064-1.267a4.94 4.94 0 0 0-2.282-.406 4.56 4.56 0 0 0-2.89.795c-.677.507-1.015 1.369-1.015 2.535v.017c.051 1.37.474 1.86.947 2.316 1.065.997 5.764 1.487 6.305-.846h-3.516v-2.01h6.322v1.757c0 1.352-1.319 4.057-5.714 4.057-.574 0-2.248.135-3.989-.676a5.07 5.07 0 0 1-2.975-4.615v-.068c0-.574.085-1.115.22-1.605.13-.48.336-.937.609-1.353.557-.811 1.335-1.436 2.298-1.859a8.05 8.05 0 0 1 3.313-.676zm24.544.253 6.491 7.015.101.135v-7.15h2.417V37.13h-2.417l-6.592-7.065-.085-.152v7.217h-2.434V26.82zm-32.167 0v2.08H78.03v8.231h-2.586v-8.232h-3.837V26.82zm-35.04 0v4.006h6.22V26.82h2.502V37.13h-2.502v-3.989h-6.22v3.99h-2.535V26.818zm16.988 1.944c-1.184 0-2.113.254-2.79.778q-1.013.76-1.014 2.535c0 1.099.338 1.893 1.015 2.4.676.508 1.605.761 2.772.761q.56.007 1.115-.068c.338-.05.66-.135.93-.236.575-.186 1.014-.507 1.318-.98.322-.457.474-1.082.474-1.86a4.4 4.4 0 0 0-.119-1.099 2.64 2.64 0 0 0-1.673-1.91c-.558-.22-1.234-.338-2.028-.338z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SoshiLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 148 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M24.823 10.698c4.452-1.25 8.044-.547 10.153 0s7.184 1.405 11.323 2.889 5.076 3.983 5.076 6.013.078 12.807.078 16.712-.468 5.858-2.343 6.873-10.62 4.92-13.354 6.013-5.701.937-7.497.313c-1.797-.626-12.73-4.608-15.229-5.545s-2.89-3.67-2.89-6.873V22.96c0-5.857 1.172-7.576 3.437-8.435s6.795-2.576 11.246-3.826m3.075 24.824-.811.01-.155.313c-.064.573-.049.857.253 1.356.776.756 1.454.882 2.5.892.455-.077.67-.205.995-.53.108-.424.074-.822 0-1.25-.227-.351-.37-.436-.762-.566l-.332-.059a6.3 6.3 0 0 0-1.232-.172zm-8.62-1.24c0 .469 0 .469.254.772l.37.322c.453.407.784.608 1.393.675.987.016 1.623-.012 2.395-.656.274-.332.274-.332.274-.957l-.186.195-.283.274-.303.342c-.533.315-.804.336-1.415.351-.969 0-1.465-.295-2.158-.957zm8.108-5.847c-1.35 0-2.444 1.45-2.444 3.239s1.095 3.238 2.444 3.238 2.442-1.45 2.442-3.238-1.093-3.238-2.442-3.239m-13.262 3.66c-.619-.088-1.077-.112-1.406.47.016.76.257 1.178.781 1.717.64.512 1.376.657 2.186.625.47-.312.47-.313.532-.605l-.004-.333.004-.331c-.062-.293-.063-.293-.531-.605a16 16 0 0 1-.938-.312q-.303-.323-.624-.625m2.348-6.124c-1.35 0-2.443 1.45-2.443 3.238s1.094 3.237 2.443 3.237 2.443-1.45 2.443-3.237-1.094-3.238-2.443-3.238m10.592 3.38c.451 0 .817.44.817.984 0 .543-.366.984-.817.984s-.816-.44-.816-.984.366-.984.816-.984m1.567 1.123a.423.423 0 1 1 0 .847.423.423 0 0 1 0-.847m-12.48-3.587c.45 0 .816.44.816.983s-.365.984-.816.984-.816-.44-.816-.984c0-.543.366-.983.816-.983m1.567 1.122a.423.423 0 0 1 0 .845.422.422 0 1 1 0-.844M70.253 41.384q-2.271 0-3.936-.704-1.632-.736-2.592-1.984a5.36 5.36 0 0 1-1.056-2.816h4.768q.128.544.448.992.352.416.928.672.608.224 1.344.224.8 0 1.28-.192.511-.224.768-.576.256-.352.256-.736 0-.608-.384-.928-.352-.32-1.056-.512-.704-.224-1.696-.416a46 46 0 0 1-2.304-.576 10.3 10.3 0 0 1-2.016-.864 4.4 4.4 0 0 1-1.408-1.312q-.512-.833-.512-2.016 0-1.44.8-2.592.8-1.184 2.304-1.856 1.505-.705 3.648-.704 3.04 0 4.768 1.344t2.048 3.616h-4.48a1.55 1.55 0 0 0-.8-.96q-.608-.352-1.536-.352-1.056 0-1.6.352-.543.352-.544.928 0 .383.352.704.384.288 1.088.512t1.728.448q1.952.416 3.36.896 1.44.48 2.24 1.408.801.896.768 2.624.032 1.568-.832 2.784-.833 1.215-2.4 1.92-1.568.672-3.744.672m16.225 0q-2.335 0-4.192-1.088a8.04 8.04 0 0 1-2.944-2.976q-1.088-1.92-1.088-4.352 0-2.496 1.088-4.384a8 8 0 0 1 2.944-3.008q1.888-1.088 4.192-1.088 2.368 0 4.224 1.088a8 8 0 0 1 2.944 3.008q1.089 1.889 1.088 4.352t-1.088 4.384a8.04 8.04 0 0 1-2.944 2.976q-1.855 1.088-4.224 1.088m0-4.16q.96 0 1.696-.48.769-.48 1.216-1.44.448-.96.448-2.368t-.448-2.368-1.184-1.44-1.696-.48-1.728.48q-.735.48-1.184 1.44-.447.96-.448 2.368 0 1.408.448 2.368.449.96 1.184 1.44.768.48 1.696.48m16.584 4.16q-2.273 0-3.936-.704-1.632-.736-2.592-1.984a5.36 5.36 0 0 1-1.056-2.816h4.768q.128.544.448.992.352.416.928.672.607.224 1.344.224.8 0 1.28-.192.511-.224.768-.576.256-.352.256-.736 0-.608-.384-.928-.352-.32-1.056-.512a15 15 0 0 0-1.696-.416 46 46 0 0 1-2.304-.576 10.2 10.2 0 0 1-2.016-.864 4.4 4.4 0 0 1-1.408-1.312q-.513-.833-.512-2.016 0-1.44.8-2.592.8-1.184 2.304-1.856 1.504-.705 3.648-.704 3.04 0 4.768 1.344t2.048 3.616h-4.48a1.56 1.56 0 0 0-.8-.96q-.608-.352-1.536-.352-1.056 0-1.6.352t-.544.928q0 .383.352.704.384.288 1.088.512t1.728.448q1.951.416 3.36.896 1.44.48 2.24 1.408.8.896.768 2.624.032 1.568-.832 2.784-.833 1.215-2.4 1.92-1.567.672-3.744.672m8.481-.384V17.96h4.8v9.216a5.7 5.7 0 0 1 2.08-1.952q1.313-.736 3.072-.736 2.048 0 3.424.864t2.08 2.528q.704 1.632.704 3.968V41h-4.768v-8.704q0-1.793-.768-2.784-.736-.992-2.368-.992-.96 0-1.76.48t-1.248 1.376q-.448.864-.448 2.08V41zm18.14 0V24.872h4.8V41zm2.4-17.984q-1.28 0-2.08-.736t-.8-1.856q0-1.152.8-1.888.8-.735 2.08-.736 1.313 0 2.112.736.832.736.832 1.888 0 1.12-.832 1.856-.8.735-2.112.736"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SubmagicLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 150 52" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M34.73 31.72c-.593.597-1.352.903-2.196 1.038-.95.15-1.877.03-2.797-.17-.44-.093-.865-.255-1.335-.398l.793-1.323c.375.16.725.321 1.08.462.69.273 1.407.42 2.154.391 1.218-.045 2.089-.628 2.634-1.689.33-.642.456-1.328.39-2.043-.018-.198-.092-.391-.134-.588-.027-.127.102-.24.318-.3.147-.042.193.05.221.156.459 1.692.123 3.203-1.127 4.463l-.002.001Zm1.59-16.22H16.572c-2.667 0-4.848 2.148-4.848 4.774v7.361q.64-.066 1.28-.092c.835-.032 1.671-.069 2.503-.035 1.013.043 2.027.137 3.036.244.767.083 1.536.187 2.29.343 1.097.227 2.19.476 3.268.777.865.242 1.705.57 2.554.869.144.05.236.04.329-.087.897-1.227 2.838-3.913 2.838-3.913l-2.495-.305 2.258-2.38-.606-3.04 2.904 1.371 2.799-1.502-.455 3.099 2.407 2.196-3.25.538-1.336 2.933-1.187-2.2s-7.445 11.378-8.618 13.174c-.519.794-.526.8-1.039 1.6-.08.124-.137.155-.282.053-.49-.348-.994-.676-1.495-1.011-.177-.12-.187-.176-.064-.342.968-1.314 6.246-8.43 6.277-8.49-.241-.055-.463-.123-.69-.158-.882-.14-1.764-.297-2.651-.394-.82-.089-1.648-.13-2.473-.155a27 27 0 0 0-2.254.011c-.726.037-1.45.12-2.17.211a35 35 0 0 0-1.973.323q-.649.123-1.28.305c-.467.132-.927.282-1.387.435q-.386.129-.762.279c.533.307.965.742 1.262 1.253a3.4 3.4 0 0 1 .462 1.705v4.476c0 2.626 2.18 4.774 4.848 4.774H36.32c2.665 0 4.846-2.148 4.846-4.774V20.274c0-2.626-2.18-4.774-4.846-4.774m10.846 17.984 1.578-2.283c.98 1.036 2.677 2.048 4.445 2.048.932 0 1.483-.283 1.483-.894 0-.61-.67-.847-1.913-1.153l-1.578-.377c-2.102-.542-3.633-1.366-3.633-3.39 0-2.26 1.864-3.601 4.732-3.601 2.533 0 4.374 1.082 5.186 2.095l-1.53 2.26c-1.147-1.178-2.58-1.814-3.943-1.814-.836 0-1.338.305-1.338.871 0 .613.502.848 2.056 1.272l1.315.353c2.58.683 3.704 1.67 3.704 3.436 0 2.237-1.912 3.508-4.636 3.508s-4.924-.941-5.928-2.33Zm11.758-1.389v-8.051h3.417v6.874c0 1.412.383 2.283 1.96 2.283s2.126-.894 2.126-2.777v-6.38h3.418v11.535h-3.418v-3.225c-.406 1.836-1.53 3.46-3.896 3.46-2.222 0-3.609-1.342-3.609-3.72h.002Zm13.095 3.484V19.808h3.417v7.533c.334-1.86 1.29-3.531 3.729-3.531 2.724 0 4.516 2.19 4.516 6.003s-1.792 6.026-4.66 6.026c-2.439 0-3.275-1.647-3.585-3.506v3.249h-3.418zm5.64-2.378c1.553 0 2.532-1.177 2.532-3.39s-.98-3.39-2.533-3.39c-1.433 0-2.222.894-2.222 2.778v1.247c0 1.884.789 2.754 2.222 2.754v.001Zm7.575 2.378V24.044h3.417v3.32c.359-1.884 1.41-3.555 3.823-3.555 2.151 0 3.346 1.341 3.346 3.578.359-1.906 1.41-3.578 3.823-3.578 2.151 0 3.346 1.341 3.346 3.719v8.05h-3.417v-6.873c0-1.506-.478-2.284-1.864-2.284s-1.888.894-1.888 2.778v6.38h-3.417v-6.874c0-1.506-.478-2.284-1.864-2.284s-1.888.894-1.888 2.778v6.38zm19.332-2.566c0-1.742 1.123-2.755 3.346-3.509l3.584-1.317c-.12-.966-.573-1.507-1.864-1.507-1.482 0-2.844.824-3.657 1.978l-1.721-2.448a7.7 7.7 0 0 1 5.617-2.401c2.963 0 4.684 1.554 4.684 4.448v3.955c0 .518.191.682.622.682h.693v2.684h-2.008c-1.267 0-2.414-.565-2.414-2.214v-.777c-.383 1.507-1.41 3.25-3.801 3.25-1.816 0-3.083-.966-3.083-2.824zm4.349.353c1.625 0 2.533-.8 2.533-2.213v-.801l-2.486 1.012c-.932.377-1.243.705-1.243 1.153 0 .542.383.847 1.194.847zm10.609-1.671c-.167.259-.31.612-.31.942 0 .542.287.847 1.219.847h4.111c2.605 0 3.776 1.059 3.776 2.754 0 2.165-2.127 3.954-6.788 3.954-3.608 0-5.497-1.059-5.497-2.424 0-1.107 1.171-1.671 2.582-1.836-.981-.21-1.745-.729-1.745-1.741 0-1.177 1.052-2.143 2.175-2.754-1.219-.73-1.935-1.93-1.935-3.414 0-2.494 2.055-4.213 5.114-4.213.693 0 1.339.094 1.935.259l.526-2.777 3.036 1.012-2.27 2.331c1.195.753 1.912 1.93 1.912 3.39 0 2.494-2.079 4.213-5.138 4.213-1.028 0-1.935-.188-2.7-.542zm5.856 4.944c0-.518-.43-.683-1.339-.683h-4.947c-.144.165-.31.4-.31.683 0 .753.836 1.2 3.036 1.2 2.365 0 3.56-.447 3.56-1.2m-1.219-8.616c0-1.295-.836-1.953-1.935-1.953s-1.936.658-1.936 1.953c0 1.296.861 1.954 1.936 1.954s1.935-.658 1.935-1.954m5.328-5.343v-2.872h3.417v2.872zm0 12.899V24.044h3.417v11.535zm5.041-5.768c0-3.506 2.271-6.002 5.88-6.002 2.796 0 4.732 1.506 5.448 3.484l-2.844 1.624c-.287-1.436-1.052-2.307-2.485-2.307-1.505 0-2.51 1.083-2.51 3.201s1.003 3.202 2.51 3.202 2.199-.872 2.485-2.308l2.844 1.625c-.716 1.978-2.652 3.484-5.448 3.484-3.609 0-5.88-2.472-5.88-6.003"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TempoLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 191 60" {...props}>
|
||||
<g fill="currentColor" clipPath="url(#a)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M19.46 8.274a8.69 8.69 0 0 0-8.691 8.69v26.072c0 4.8 3.89 8.69 8.69 8.69h26.072c4.799 0 8.69-3.89 8.69-8.69V16.965c0-4.8-3.891-8.69-8.69-8.69zm24.084 12.932 1.987-2.793H19.459l1.987 2.793h9.543l-.021 22.422 1.419 1.863 1.418-1.863.021-22.422zM27.659 38.897l2.364 2.794V22.137H22.11l1.655 2.328h3.905zm7.093 2.794 2.355-2.794V24.465h4.12l1.655-2.328h-8.13z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="M73.77 42.601v-21.52h-7.98V17.66h19.703v3.42h-7.981v21.521zM93.816 42.922c-5.63 0-9.3-4.133-9.3-9.478 0-5.416 3.706-9.513 9.264-9.513 5.38 0 8.765 4.24 8.765 9.085v1.675h-14.43c.463 3.492 2.815 5.024 5.7 5.024 2.922 0 4.811-1.782 5.381-3.385l2.744 1.46c-.784 2.032-3.278 5.132-8.124 5.132m-5.594-11.224H98.84c-.036-2.28-1.96-4.632-5.096-4.632-2.707 0-4.917 1.604-5.522 4.632M106.284 42.601v-18.35h3.563v2.316c.855-1.603 2.779-2.636 4.988-2.636 2.459 0 4.454 1.14 5.666 2.85.926-1.211 2.743-2.85 5.879-2.85 4.24 0 7.233 3.064 7.233 6.912v11.758h-3.563V31.698c0-2.743-1.746-4.418-4.133-4.418-2.459 0-4.205 1.817-4.205 4.418v10.903h-3.563V31.698c0-2.743-1.71-4.418-4.133-4.418-2.459 0-4.169 1.817-4.169 4.418v10.903zM137.879 50.76V24.251h3.563v2.352c1.176-1.568 3.385-2.672 6.129-2.672 4.988 0 8.943 3.563 8.943 9.513 0 5.523-3.884 9.478-8.943 9.478-2.744 0-5.06-1.176-6.129-2.744V50.76zm3.492-17.316c0 3.35 2.209 6.164 5.772 6.164s5.772-2.78 5.772-6.164c0-3.42-2.209-6.2-5.772-6.2s-5.772 2.815-5.772 6.2M169.036 42.922c-5.63 0-9.656-3.99-9.656-9.478s4.062-9.513 9.656-9.513 9.656 4.026 9.656 9.513c0 5.451-4.027 9.478-9.656 9.478m-6.057-9.478c0 3.634 2.672 6.2 6.057 6.2 3.42 0 6.057-2.566 6.057-6.2s-2.637-6.2-6.057-6.2-6.057 2.566-6.057 6.2" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M10.769 8.274h169.462v43.452H10.769z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function UdioLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 86 60" {...props}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M41.436 24.392c-1.133-.886-2.678-1.394-4.359-1.394-4.734 0-8.132 3.675-8.132 8.612s3.396 8.613 8.132 8.613c1.715 0 3.293-.58 4.462-1.532v1.19h5.146V15.167l-5.25 1.296zm-3.432 11.131c-2.162 0-3.74-1.634-3.74-3.915 0-2.28 1.578-3.916 3.774-3.916s3.704 1.6 3.704 3.916-1.508 3.915-3.74 3.915zM55.052 22.816l-5.25 1.297v15.765h5.25zM52.444 15.335c-1.648 0-2.848 1.26-2.848 2.86 0 1.602 1.2 2.861 2.848 2.861s2.813-1.259 2.813-2.86c0-1.602-1.166-2.86-2.813-2.86M66.174 22.962c-5.215 0-9.125 3.711-9.125 8.613 0 4.9 3.946 8.681 9.125 8.681s9.126-3.744 9.126-8.681c0-4.938-3.946-8.613-9.126-8.613m0 12.561c-2.229 0-3.773-1.599-3.773-3.915 0-2.317 1.544-3.916 3.773-3.916s3.774 1.566 3.774 3.88-1.508 3.949-3.774 3.949zM21.9 32.867c0 1.737-.96 2.758-2.538 2.758h-3.293v-12.29H10.82v7.08l5.25 5.21h-5.25l5.25 5.208v-5.208l4.58 4.545c3.894-.506 6.5-3.33 6.5-7.267v-9.568H21.9z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function V0Logo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 101 60" {...props}>
|
||||
<g fill="currentColor" clipPath="url(#a)">
|
||||
<path d="M57.015 10h19.054c7.726 0 13.99 6.263 13.99 13.99V42.16h-7.827V23.99a7 7 0 0 0-.022-.555L63.158 42.158q.096.003.193.003h18.882v7.391H63.35c-7.726 0-14.162-6.324-14.162-14.05V17.378h7.827v18.124q0 .523.08 1.032l19.472-19.135a6 6 0 0 0-.498-.02H57.015zM37.765 48.191 10.228 17.375h11.078l16.168 18.093V17.375h8.26V45.15c0 4.192-5.176 6.168-7.969 3.042" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M10.228 10h80v40h-80z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import Footer from '~/components/Footer'
|
||||
import Nav from '~/components/Nav'
|
||||
import { useForceDeepDark } from 'lib/theme.utils'
|
||||
|
||||
export default function HomeLayout({ children }: { children: React.ReactNode }) {
|
||||
useForceDeepDark()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav hideNavbar={false} />
|
||||
<div className="relative w-full [--container-max-w:75rem]">
|
||||
<main className="relative min-h-screen">{children}</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { FrameworksSection } from './_components/FrameworksSection'
|
||||
import { HomeContent } from './_components/HomeContent'
|
||||
|
||||
export default function HomePage() {
|
||||
return <HomeContent frameworksSlot={<FrameworksSection />} />
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface QuoteSectionProps {
|
||||
quote: string
|
||||
highlight?: string
|
||||
author: {
|
||||
name: string
|
||||
role: string
|
||||
image: string
|
||||
link: string
|
||||
}
|
||||
}
|
||||
|
||||
export function QuoteSection({ quote, highlight, author }: QuoteSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 border-x border-border bg-surface-200">
|
||||
<div className="flex flex-col items-center text-center gap-8 md:gap-12 py-16 md:py-24">
|
||||
<q className="text-2xl xl:text-4xl tracking-tight max-w-screen-lg text-foreground-lighter text-balance">
|
||||
{quote}
|
||||
{highlight && (
|
||||
<>
|
||||
{' '}
|
||||
<span className="text-brand">{highlight}</span>
|
||||
</>
|
||||
)}
|
||||
</q>
|
||||
|
||||
<Link href={author.link} className="hover:opacity-90 transition-opacity">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<Image
|
||||
draggable={false}
|
||||
src={author.image}
|
||||
alt={`${author.name}, ${author.role}`}
|
||||
className="w-12 h-12 rounded-full overflow-hidden object-cover"
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="text-foreground text-sm">{author.name}</span>
|
||||
<span className="text-foreground-lighter text-xs">{author.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { createHighlighter, type ThemeRegistration } from 'shiki'
|
||||
import { ApiSectionClient } from './ApiSectionClient'
|
||||
|
||||
const supabaseDark: ThemeRegistration = {
|
||||
name: 'supabase-dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#ffffff',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#bda4ff' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
|
||||
],
|
||||
}
|
||||
|
||||
const supabaseLight: ThemeRegistration = {
|
||||
name: 'supabase-light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#525252',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#6b35dc' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#525252' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
|
||||
],
|
||||
}
|
||||
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
JavaScript: 'javascript',
|
||||
Flutter: 'dart',
|
||||
Python: 'python',
|
||||
'C#': 'csharp',
|
||||
Kotlin: 'kotlin',
|
||||
Swift: 'swift',
|
||||
}
|
||||
|
||||
const API_EXAMPLES = [
|
||||
{
|
||||
icon: 'UserPlus' as const,
|
||||
title: 'Sign up',
|
||||
description: 'Create new accounts with email/password, phone, or third-party providers.',
|
||||
code: {
|
||||
JavaScript: `// Sign up with email and password
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: 'user@example.com',
|
||||
password: 'secure-password',
|
||||
})`,
|
||||
Flutter: `// Sign up with email and password
|
||||
final response = await supabase.auth.signUp(
|
||||
email: 'user@example.com',
|
||||
password: 'secure-password',
|
||||
);`,
|
||||
Python: `# Sign up with email and password
|
||||
response = supabase.auth.sign_up({
|
||||
"email": "user@example.com",
|
||||
"password": "secure-password",
|
||||
})`,
|
||||
'C#': `// Sign up with email and password
|
||||
var session = await supabase.Auth.SignUp(
|
||||
"user@example.com",
|
||||
"secure-password"
|
||||
);`,
|
||||
Kotlin: `// Sign up with email and password
|
||||
supabase.auth.signUpWith(Email) {
|
||||
email = "user@example.com"
|
||||
password = "secure-password"
|
||||
}`,
|
||||
Swift: `// Sign up with email and password
|
||||
try await supabase.auth.signUp(
|
||||
email: "user@example.com",
|
||||
password: "secure-password"
|
||||
)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'LogIn' as const,
|
||||
title: 'Sign in',
|
||||
description: 'Authenticate users with multiple strategies in a single call.',
|
||||
code: {
|
||||
JavaScript: `// Sign in with email
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: 'user@example.com',
|
||||
password: 'secure-password',
|
||||
})
|
||||
|
||||
// Sign in with magic link
|
||||
const { data, error } = await supabase.auth.signInWithOtp({
|
||||
email: 'user@example.com',
|
||||
})`,
|
||||
Flutter: `// Sign in with email
|
||||
final response = await supabase.auth.signInWithPassword(
|
||||
email: 'user@example.com',
|
||||
password: 'secure-password',
|
||||
);
|
||||
|
||||
// Sign in with magic link
|
||||
await supabase.auth.signInWithOtp(
|
||||
email: 'user@example.com',
|
||||
);`,
|
||||
Python: `# Sign in with email
|
||||
response = supabase.auth.sign_in_with_password({
|
||||
"email": "user@example.com",
|
||||
"password": "secure-password",
|
||||
})
|
||||
|
||||
# Sign in with magic link
|
||||
response = supabase.auth.sign_in_with_otp({
|
||||
"email": "user@example.com",
|
||||
})`,
|
||||
'C#': `// Sign in with email
|
||||
var session = await supabase.Auth.SignIn(
|
||||
"user@example.com",
|
||||
"secure-password"
|
||||
);
|
||||
|
||||
// Sign in with magic link
|
||||
await supabase.Auth.SignIn(
|
||||
Constants.SignInType.MagicLink,
|
||||
"user@example.com"
|
||||
);`,
|
||||
Kotlin: `// Sign in with email
|
||||
supabase.auth.signInWith(Email) {
|
||||
email = "user@example.com"
|
||||
password = "secure-password"
|
||||
}
|
||||
|
||||
// Sign in with magic link
|
||||
supabase.auth.signInWith(OTP) {
|
||||
email = "user@example.com"
|
||||
}`,
|
||||
Swift: `// Sign in with email
|
||||
try await supabase.auth.signIn(
|
||||
email: "user@example.com",
|
||||
password: "secure-password"
|
||||
)
|
||||
|
||||
// Sign in with magic link
|
||||
try await supabase.auth.signInWithOTP(
|
||||
email: "user@example.com"
|
||||
)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'Globe' as const,
|
||||
title: 'OAuth logins',
|
||||
description: 'Social login with 20+ providers and custom scopes for extended permissions.',
|
||||
code: {
|
||||
JavaScript: `// Sign in with GitHub
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'github',
|
||||
options: {
|
||||
scopes: 'repo gist notifications',
|
||||
},
|
||||
})`,
|
||||
Flutter: `// Sign in with GitHub
|
||||
await supabase.auth.signInWithOAuth(
|
||||
OAuthProvider.github,
|
||||
scopes: 'repo gist notifications',
|
||||
);`,
|
||||
Python: `# Sign in with GitHub
|
||||
response = supabase.auth.sign_in_with_oauth({
|
||||
"provider": "github",
|
||||
"options": {
|
||||
"scopes": "repo gist notifications",
|
||||
},
|
||||
})`,
|
||||
'C#': `// Sign in with GitHub
|
||||
var session = await supabase.Auth.SignIn(
|
||||
Constants.Provider.Github,
|
||||
new SignInOptions {
|
||||
Scopes = "repo gist notifications"
|
||||
}
|
||||
);`,
|
||||
Kotlin: `// Sign in with GitHub
|
||||
supabase.auth.signInWith(Github) {
|
||||
scopes = "repo gist notifications"
|
||||
}`,
|
||||
Swift: `// Sign in with GitHub
|
||||
try await supabase.auth.signInWithOAuth(
|
||||
provider: .github,
|
||||
scopes: "repo gist notifications"
|
||||
)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'KeyRound' as const,
|
||||
title: 'Session management',
|
||||
description: 'Retrieve, refresh, and manage user sessions with built-in token handling.',
|
||||
code: {
|
||||
JavaScript: `// Get the current user
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
// Listen for auth state changes
|
||||
supabase.auth.onAuthStateChange((event, session) => {
|
||||
console.log(event, session)
|
||||
})`,
|
||||
Flutter: `// Get the current user
|
||||
final user = supabase.auth.currentUser;
|
||||
|
||||
// Listen for auth state changes
|
||||
supabase.auth.onAuthStateChange.listen((data) {
|
||||
final event = data.event;
|
||||
final session = data.session;
|
||||
});`,
|
||||
Python: `# Get the current user
|
||||
user = supabase.auth.get_user()
|
||||
|
||||
# Get the current session
|
||||
session = supabase.auth.get_session()`,
|
||||
'C#': `// Get the current user
|
||||
var user = supabase.Auth.CurrentUser;
|
||||
|
||||
// Listen for auth state changes
|
||||
supabase.Auth.AddStateChangedListener(
|
||||
(sender, changed) => {
|
||||
// Handle state change
|
||||
}
|
||||
);`,
|
||||
Kotlin: `// Get the current user
|
||||
val user = supabase.auth.currentUserOrNull()
|
||||
|
||||
// Listen for auth state changes
|
||||
supabase.auth.sessionStatus.collect { status ->
|
||||
when (status) {
|
||||
is SessionStatus.Authenticated -> { }
|
||||
SessionStatus.NotAuthenticated -> { }
|
||||
}
|
||||
}`,
|
||||
Swift: `// Get the current user
|
||||
let user = try await supabase.auth.user()
|
||||
|
||||
// Listen for auth state changes
|
||||
for await (event, session) in supabase.auth.authStateChanges {
|
||||
print(event, session)
|
||||
}`,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export async function ApiSection() {
|
||||
const hl = await createHighlighter({
|
||||
themes: [supabaseDark, supabaseLight],
|
||||
langs: Object.values(LANG_MAP),
|
||||
})
|
||||
|
||||
const examples = API_EXAMPLES.map((example) => ({
|
||||
icon: example.icon,
|
||||
title: example.title,
|
||||
description: example.description,
|
||||
languages: Object.fromEntries(
|
||||
Object.entries(example.code).map(([lang, code]) => [
|
||||
lang,
|
||||
{
|
||||
darkHtml: hl.codeToHtml(code, { lang: LANG_MAP[lang], theme: 'supabase-dark' }),
|
||||
lightHtml: hl.codeToHtml(code, { lang: LANG_MAP[lang], theme: 'supabase-light' }),
|
||||
},
|
||||
])
|
||||
),
|
||||
}))
|
||||
|
||||
hl.dispose()
|
||||
|
||||
return <ApiSectionClient examples={examples} />
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown, Globe, KeyRound, LogIn, UserPlus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const ICONS = { UserPlus, LogIn, Globe, KeyRound } as const
|
||||
|
||||
type IconName = keyof typeof ICONS
|
||||
|
||||
type ApiExample = {
|
||||
icon: IconName
|
||||
title: string
|
||||
description: string
|
||||
languages: Record<string, { darkHtml: string; lightHtml: string }>
|
||||
}
|
||||
|
||||
const LANGUAGES = ['JavaScript', 'Flutter', 'Python', 'C#', 'Kotlin', 'Swift']
|
||||
|
||||
const INTERVAL_DURATION = 8000
|
||||
|
||||
export function ApiSectionClient({ examples }: { examples: ApiExample[] }) {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [activeLang, setActiveLang] = useState('JavaScript')
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = examples[activeIdx]
|
||||
|
||||
// Auto-size the code panel to the active snippet. We measure an always-mounted,
|
||||
// invisible copy of the current code so the target height is known immediately on
|
||||
// switch (and doesn't collapse during the cross-fade), then animate `height` to it.
|
||||
const measureRef = useRef<HTMLDivElement>(null)
|
||||
const [height, setHeight] = useState<number>()
|
||||
const hasMeasured = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => setHeight(el.offsetHeight))
|
||||
ro.observe(el)
|
||||
setHeight(el.offsetHeight)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (height != null) hasMeasured.current = true
|
||||
}, [height])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % examples.length)
|
||||
}
|
||||
}, [progress, examples.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Simple APIs
|
||||
<br />
|
||||
<span className="text-foreground">powerful libraries</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
APIs that you can understand. With powerful libraries that work on client and
|
||||
server-side applications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tab cards */}
|
||||
<div className="flex flex-col gap-1 items-start justify-end">
|
||||
{examples.map((example, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const Icon = ICONS[example.icon]
|
||||
return (
|
||||
<button
|
||||
key={example.title}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{example.title}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: code block */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={height != null ? { height } : undefined}
|
||||
transition={
|
||||
hasMeasured.current ? { duration: 0.35, ease: [0.22, 1, 0.36, 1] } : { duration: 0 }
|
||||
}
|
||||
className="relative self-end overflow-hidden border border-border rounded-md"
|
||||
>
|
||||
{/* Invisible measuring copy: drives the animated container height */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute inset-x-0 top-0 overflow-x-auto pb-12 [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
dangerouslySetInnerHTML={{ __html: active.languages[activeLang]?.darkHtml ?? '' }}
|
||||
/>
|
||||
|
||||
{/* Language switcher */}
|
||||
<div className="absolute top-3 right-4 z-10 flex items-center">
|
||||
<div className="relative flex items-center gap-1 text-foreground-muted text-xs">
|
||||
<select
|
||||
value={activeLang}
|
||||
onChange={(e) => setActiveLang(e.target.value)}
|
||||
className="appearance-none bg-transparent text-foreground-light text-xs pr-4 cursor-pointer border-none outline-none shadow-none focus:border-none focus:outline-none focus:shadow-none focus:ring-0"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{lang}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={12} className="absolute right-0 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${active.title}-${activeLang}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="overflow-x-auto pb-12"
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.languages[activeLang]?.darkHtml ?? '' }}
|
||||
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: active.languages[activeLang]?.lightHtml ?? '',
|
||||
}}
|
||||
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Link
|
||||
href="/docs/guides/auth"
|
||||
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Documentation
|
||||
<svg
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BuiltWithSupabaseSection } from '~/app/(home)/_components/BuiltWithSupabaseSection'
|
||||
import { CTASection } from '~/app/(home)/_components/CTASection'
|
||||
import { FeaturesSection } from './FeaturesSection'
|
||||
import { Hero } from './Hero'
|
||||
import { HighlightsSection } from './HighlightsSection'
|
||||
import { QuoteSection } from './QuoteSection'
|
||||
|
||||
export function AuthContent({
|
||||
apiSlot,
|
||||
rlsSlot,
|
||||
}: {
|
||||
apiSlot: React.ReactNode
|
||||
rlsSlot: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-clip">
|
||||
<section>
|
||||
<Hero />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<FeaturesSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<QuoteSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<HighlightsSection />
|
||||
</section>
|
||||
<section className="border-t border-border">{apiSlot}</section>
|
||||
<section className="border-t border-border">{rlsSlot}</section>
|
||||
<section className="border-t border-border">
|
||||
<BuiltWithSupabaseSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<CTASection />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Link as LinkIcon } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { ShieldGlow } from './Shaders/ShieldGlow'
|
||||
|
||||
const PROVIDERS = [
|
||||
'github',
|
||||
'google',
|
||||
'apple',
|
||||
'discord',
|
||||
'facebook',
|
||||
'gitlab',
|
||||
'microsoft',
|
||||
'twitter',
|
||||
'twitch',
|
||||
'spotify',
|
||||
'slack',
|
||||
'bitbucket',
|
||||
'twilio',
|
||||
'solana',
|
||||
'ethereum',
|
||||
]
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
title: 'All the social providers',
|
||||
description:
|
||||
'Add social logins with one click. Google, Facebook, GitHub, Azure (Microsoft), GitLab, Twitter, Discord, and many more.',
|
||||
visual: ProvidersSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Fully integrated',
|
||||
description:
|
||||
'Auth without any external services. Built-in authentication, authorization, and user management.',
|
||||
visual: IntegratedSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Own your data',
|
||||
description:
|
||||
'User data stored in your Supabase database. No third-party privacy concerns. Host in 17+ locations.',
|
||||
visual: DataOwnershipSkeleton,
|
||||
},
|
||||
]
|
||||
|
||||
const COLS = 13
|
||||
const ROWS = 13
|
||||
const CELL_SIZE = 48
|
||||
const GAP = 10
|
||||
|
||||
function ProvidersSkeleton() {
|
||||
const [activeCell, setActiveCell] = useState(Math.floor((COLS * ROWS) / 2))
|
||||
const prevCell = useRef(activeCell)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
|
||||
const totalCells = COLS * ROWS
|
||||
const grid = Array.from({ length: totalCells }, (_, i) => PROVIDERS[i % PROVIDERS.length])
|
||||
|
||||
// Keep highlight away from edges so grid always overflows the card
|
||||
const MARGIN = 3
|
||||
const pickNext = () => {
|
||||
const prev = prevCell.current
|
||||
const prevCol = prev % COLS
|
||||
const prevRow = Math.floor(prev / COLS)
|
||||
let next = prev
|
||||
const prevProvider = PROVIDERS[prev % PROVIDERS.length]
|
||||
// Try until we get a cell 5+ tiles away and a different provider
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
const col = MARGIN + Math.floor(Math.random() * (COLS - MARGIN * 2))
|
||||
const row = MARGIN + Math.floor(Math.random() * (ROWS - MARGIN * 2))
|
||||
const candidate = row * COLS + col
|
||||
const dist = Math.abs(col - prevCol) + Math.abs(row - prevRow)
|
||||
if (dist >= 5 && PROVIDERS[candidate % PROVIDERS.length] !== prevProvider) {
|
||||
next = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
prevCell.current = next
|
||||
return next
|
||||
}
|
||||
|
||||
// Compute offset to center the active cell in the viewport
|
||||
const activeCol = activeCell % COLS
|
||||
const activeRow = Math.floor(activeCell / COLS)
|
||||
const centerCol = Math.floor(COLS / 2)
|
||||
const centerRow = Math.floor(ROWS / 2)
|
||||
const offsetX = (centerCol - activeCol) * (CELL_SIZE + GAP)
|
||||
const offsetY = (centerRow - activeRow) * (CELL_SIZE + GAP)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
|
||||
threshold: 0.3,
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return
|
||||
const timer = setInterval(() => setActiveCell(pickNext()), 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [isInView])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="img"
|
||||
aria-label="A grid of social login provider icons — Google, GitHub, Apple, Discord, and more — with one highlighted at a time"
|
||||
className="flex items-center justify-center w-full h-full relative overflow-hidden pointer-events-none select-none"
|
||||
style={{
|
||||
maskImage: 'radial-gradient(ellipse 70% 70% at 50% 50%, black 30%, transparent 75%)',
|
||||
WebkitMaskImage: 'radial-gradient(ellipse 70% 70% at 50% 50%, black 30%, transparent 75%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid transition-transform duration-1000 ease-in-out"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${COLS}, ${CELL_SIZE}px)`,
|
||||
gridTemplateRows: `repeat(${ROWS}, ${CELL_SIZE}px)`,
|
||||
gap: `${GAP}px`,
|
||||
transform: `translate(${offsetX}px, ${offsetY}px)`,
|
||||
}}
|
||||
>
|
||||
{grid.map((provider, i) => {
|
||||
const isActive = i === activeCell
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-12 h-12 rounded-lg border transition-all duration-1000',
|
||||
isActive ? 'bg-surface-200 border-border scale-110' : 'bg-surface-75 border-border'
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={`/images/product/auth/${provider}-icon.svg`}
|
||||
alt={provider}
|
||||
width={20}
|
||||
height={20}
|
||||
className={cn(
|
||||
'transition-all duration-1000',
|
||||
isActive ? 'opacity-100 grayscale-0' : 'opacity-40 grayscale'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegratedSkeleton() {
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Preview of Supabase Auth UI components — magic link sign-in, profile, organization setup, email verification, sign-up, password reset, account chooser, session tokens, and two-factor authentication"
|
||||
className="relative flex h-full w-full items-center justify-center overflow-hidden pointer-events-none select-none"
|
||||
style={{
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, transparent, black 15%, black 65%, transparent), linear-gradient(to right, transparent, black 20%, black 80%, transparent)',
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, transparent, black 15%, black 65%, transparent), linear-gradient(to right, transparent, black 20%, black 80%, transparent)',
|
||||
maskComposite: 'intersect',
|
||||
WebkitMaskComposite: 'destination-in',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Left column */}
|
||||
<div className="flex w-[180px] shrink-0 flex-col gap-3 pt-4">
|
||||
{/* Magic link card */}
|
||||
<div className="rounded-xl border border-border bg-surface-100 p-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-[11px] font-medium text-foreground">
|
||||
Sign in with magic link
|
||||
</span>
|
||||
<div className="flex h-6 w-full items-center rounded border border-border bg-surface-200 px-2">
|
||||
<span className="text-[10px] text-foreground-muted">cameron@gmail.com</span>
|
||||
</div>
|
||||
<div className="flex h-6 w-full items-center justify-center rounded bg-foreground">
|
||||
<span className="text-[9px] font-medium text-background">Send magic link</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Profile card */}
|
||||
<div className="rounded-xl border border-border bg-surface-100 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand/15 text-[10px] font-medium text-brand">
|
||||
CW
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-medium text-foreground">Cameron Walker</span>
|
||||
<span className="text-[10px] text-foreground-muted">cameron@gmail.com</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] text-foreground-muted">Name</span>
|
||||
<div className="flex h-6 items-center rounded border border-border bg-surface-200 px-2">
|
||||
<span className="text-[10px] text-foreground-muted">Cameron Walker</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] text-foreground-muted">Phone number</span>
|
||||
<div className="flex h-6 items-center rounded border border-border bg-surface-200 px-2">
|
||||
<span className="text-[10px] text-foreground-muted">+1 (555) 000-0000</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-foreground-muted">Connected account</span>
|
||||
<span className="text-[9px] text-brand">Google</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Organization card */}
|
||||
<div className="rounded-xl border border-border bg-surface-100 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-[11px] font-medium text-foreground">Create organization</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] text-foreground-muted">Name</span>
|
||||
<div className="flex h-6 items-center rounded border border-border bg-surface-200 px-2">
|
||||
<span className="text-[10px] text-foreground-muted">Acme Inc</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] text-foreground-muted">Logo</span>
|
||||
<div className="flex h-10 items-center justify-center rounded border border-dashed border-border bg-surface-200">
|
||||
<span className="text-[9px] text-foreground-muted">Upload image</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center column */}
|
||||
<div className="flex w-[220px] shrink-0 flex-col gap-3">
|
||||
{/* Verify email card */}
|
||||
<div className="rounded-xl border border-border bg-surface-100 p-4">
|
||||
<div className="flex flex-col items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-brand/10">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#3ECF8E"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M22 7l-10 6L2 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-foreground">Verify your email</span>
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-6 w-5 items-center justify-center rounded border border-border bg-surface-200 text-[10px] text-foreground-muted"
|
||||
>
|
||||
{i < 3 ? '•' : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sign up card */}
|
||||
<div className="rounded-xl border border-border bg-surface-100 p-5 shadow-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-foreground">
|
||||
<svg width="16" height="17" viewBox="0 0 109 113" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
|
||||
fill="#249361"
|
||||
/>
|
||||
<path
|
||||
d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z"
|
||||
fill="#3ECF8E"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-medium text-foreground">Create your account</div>
|
||||
<div className="mt-0.5 text-[10px] text-foreground-muted">
|
||||
Welcome! Please fill in the details.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<div className="flex h-7 items-center justify-center gap-1.5 rounded border border-border bg-surface-200">
|
||||
<span className="text-[10px] text-foreground-muted">Continue with Google</span>
|
||||
</div>
|
||||
<div className="flex h-7 items-center justify-center gap-1.5 rounded border border-border bg-surface-200">
|
||||
<span className="text-[10px] text-foreground-muted">Continue with GitHub</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Password reset card */}
|
||||
<div className="rounded-xl border border-border bg-surface-100 p-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-[11px] font-medium text-foreground">Reset your password</span>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<span className="text-[10px] font-medium text-foreground">New password</span>
|
||||
<div className="flex h-6 items-center rounded border border-border bg-surface-200 px-2">
|
||||
<span className="text-[10px] tracking-widest text-foreground-muted">
|
||||
••••••••
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-6 w-full items-center justify-center rounded bg-foreground">
|
||||
<span className="text-[9px] font-medium text-background">Update password</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="flex w-[180px] shrink-0 flex-col gap-3 pt-10">
|
||||
{/* Account chooser card */}
|
||||
<div className="rounded-xl border border-border bg-surface-100 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-foreground">
|
||||
<svg width="12" height="13" viewBox="0 0 109 113" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z"
|
||||
fill="#249361"
|
||||
/>
|
||||
<path
|
||||
d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z"
|
||||
fill="#3ECF8E"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-foreground">Choose an account</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
{ name: 'Personal', role: '' },
|
||||
{ name: 'Acme Inc', role: 'Admin' },
|
||||
{ name: 'My Project', role: 'Member' },
|
||||
].map((account) => (
|
||||
<div
|
||||
key={account.name}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-surface-200 px-2.5 py-1.5"
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-foreground-muted/15 text-[8px] text-foreground-muted">
|
||||
{account.name[0]}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-foreground">{account.name}</span>
|
||||
{account.role && (
|
||||
<span className="text-[8px] text-foreground-muted">{account.role}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Session card */}
|
||||
<div className="rounded-xl border border-border bg-surface-100 p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-mono text-[10px] text-foreground-muted">Session</span>
|
||||
{[
|
||||
{ key: 'access_token', val: 'eyJhbG...kpXVc' },
|
||||
{ key: 'token_type', val: 'bearer' },
|
||||
{ key: 'user.email', val: 'cameron@gmail.com' },
|
||||
{ key: 'user.role', val: 'authenticated' },
|
||||
].map((row) => (
|
||||
<div key={row.key} className="flex items-center justify-between">
|
||||
<span className="font-mono text-[9px] text-brand">{row.key}</span>
|
||||
<span className="ml-2 truncate font-mono text-[9px] text-foreground-muted">
|
||||
{row.val}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* MFA card */}
|
||||
<div className="rounded-xl border border-border bg-surface-100 p-4">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<span className="text-[11px] font-medium text-foreground">Two-factor auth</span>
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-200 px-2.5 py-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-foreground">Authenticator app</span>
|
||||
<span className="text-[9px] text-foreground-muted">TOTP via auth app</span>
|
||||
</div>
|
||||
<div className="rounded bg-brand/15 px-1.5 py-0.5 text-[8px] font-medium text-brand">
|
||||
On
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-200 px-2.5 py-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-foreground">SMS verification</span>
|
||||
<span className="text-[9px] text-foreground-muted">Code via text message</span>
|
||||
</div>
|
||||
<div className="rounded bg-foreground-muted/10 px-1.5 py-0.5 text-[8px] text-foreground-muted">
|
||||
Off
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataOwnershipSkeleton() {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="A shield icon with a glowing green effect, representing user data ownership and security"
|
||||
className="relative flex h-full w-full items-center justify-center select-none"
|
||||
style={{
|
||||
maskImage: 'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
|
||||
WebkitMaskImage: 'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Construction lines */}
|
||||
<svg
|
||||
className="absolute -inset-16 h-[calc(100%+128px)] w-[calc(100%+128px)] text-foreground-muted/20"
|
||||
viewBox="0 0 480 480"
|
||||
fill="none"
|
||||
>
|
||||
<rect
|
||||
x="60"
|
||||
y="40"
|
||||
width="360"
|
||||
height="400"
|
||||
rx="48"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="30"
|
||||
y="10"
|
||||
width="420"
|
||||
height="460"
|
||||
rx="60"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="0"
|
||||
y="-16"
|
||||
width="480"
|
||||
height="512"
|
||||
rx="72"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="90"
|
||||
y="70"
|
||||
width="300"
|
||||
height="340"
|
||||
rx="36"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line x1="30" y1="10" x2="450" y2="470" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="450" y1="10" x2="30" y2="470" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="240" y1="0" x2="240" y2="480" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="0" y1="240" x2="480" y2="240" stroke="currentColor" strokeWidth="0.5" />
|
||||
<circle cx="240" cy="240" r="180" stroke="currentColor" strokeWidth="0.5" />
|
||||
<circle cx="240" cy="240" r="140" stroke="currentColor" strokeWidth="0.5" />
|
||||
<circle cx="240" cy="240" r="100" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="60" y1="40" x2="60" y2="10" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="60" y1="40" x2="30" y2="40" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="420" y1="40" x2="450" y2="40" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="420" y1="40" x2="420" y2="10" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="60" y1="440" x2="30" y2="440" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="60" y1="440" x2="60" y2="470" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="420" y1="440" x2="450" y2="440" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="420" y1="440" x2="420" y2="470" stroke="currentColor" strokeWidth="0.5" />
|
||||
</svg>
|
||||
|
||||
{/* Highlighted lines on hover */}
|
||||
<svg
|
||||
className="absolute -inset-16 h-[calc(100%+128px)] w-[calc(100%+128px)] transition-opacity duration-500"
|
||||
style={{ opacity: hovered ? 0.35 : 0 }}
|
||||
viewBox="0 0 480 480"
|
||||
fill="none"
|
||||
>
|
||||
<rect
|
||||
x="60"
|
||||
y="40"
|
||||
width="360"
|
||||
height="400"
|
||||
rx="48"
|
||||
stroke="hsl(var(--brand-default))"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<circle
|
||||
cx="240"
|
||||
cy="240"
|
||||
r="140"
|
||||
stroke="hsl(var(--brand-default))"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<line
|
||||
x1="240"
|
||||
y1="0"
|
||||
x2="240"
|
||||
y2="480"
|
||||
stroke="hsl(var(--brand-default))"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="240"
|
||||
x2="480"
|
||||
y2="240"
|
||||
stroke="hsl(var(--brand-default))"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.25"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Shield-user icon + glow */}
|
||||
<div className="relative h-44 w-44">
|
||||
<ShieldGlow hovered={hovered} />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className="relative z-[5] h-44 w-44"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
color: 'hsl(var(--brand-default))',
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={0.25}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeaturesSection() {
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Everything you need
|
||||
<br />
|
||||
<span className="text-foreground">for user authentication</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Social logins, email/password, magic links, phone auth, and more — with enterprise-grade
|
||||
security built on Postgres' Row Level Security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-col grid */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{FEATURES.map((feature) => {
|
||||
const Visual = feature.visual
|
||||
return (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="relative flex items-center justify-center h-[320px]">
|
||||
<Visual />
|
||||
</div>
|
||||
<div className="px-6 py-5 flex flex-col gap-1">
|
||||
<h4 className="text-foreground text-sm font-medium">{feature.title}</h4>
|
||||
<p className="text-foreground-lighter text-sm">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-32 pb-16 md:pt-40 md:pb-32">
|
||||
<div className="flex flex-col gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h1 className="text-foreground text-3xl sm:text-5xl sm:leading-none">
|
||||
<span className="block">Authentication</span>
|
||||
<span className="text-foreground-lighter block">built in, not bolted on</span>
|
||||
</h1>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Every Supabase project comes with a complete user management system. Including
|
||||
Postgres' Row Level Security for fine-grained access control.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="medium">
|
||||
<Link href="https://supabase.com/dashboard">Start your project</Link>
|
||||
</Button>
|
||||
<Button asChild size="medium" type="default">
|
||||
<Link href="/docs/guides/auth">Documentation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
const HIGHLIGHTS = [
|
||||
{
|
||||
title: 'Multi-Factor Authentication',
|
||||
features: [
|
||||
'Time-based one-time passwords (TOTP)',
|
||||
'Works with any authenticator app',
|
||||
'Enforce MFA for specific user roles',
|
||||
'Built-in enrollment and challenge flows',
|
||||
],
|
||||
cta: { label: 'Learn more', href: '/docs/guides/auth/auth-mfa' },
|
||||
},
|
||||
{
|
||||
title: 'Enterprise SSO',
|
||||
features: [
|
||||
'SAML 2.0 support for identity providers',
|
||||
'Connect to Azure AD, Okta, and more',
|
||||
'Automatic user provisioning on sign-in',
|
||||
'Manage SSO from the Dashboard or API',
|
||||
],
|
||||
cta: { label: 'Learn more', href: '/docs/guides/auth/enterprise-sso/auth-sso-saml' },
|
||||
},
|
||||
{
|
||||
title: 'Custom OIDC Providers',
|
||||
features: [
|
||||
'OpenID Connect support for any identity provider',
|
||||
'Connect to Auth0, Keycloak, Amazon Cognito, and more',
|
||||
'PKCE security by default',
|
||||
'Configure providers from the Dashboard or API',
|
||||
],
|
||||
cta: { label: 'Learn more', href: '/features/custom-oidc-providers' },
|
||||
},
|
||||
]
|
||||
|
||||
export function HighlightsSection() {
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Enterprise ready
|
||||
<br />
|
||||
<span className="text-foreground">security at every level</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{HIGHLIGHTS.map((highlight) => (
|
||||
<div
|
||||
key={highlight.title}
|
||||
className="relative flex flex-col overflow-hidden min-h-[400px] bg-surface-75 border border-border rounded-lg"
|
||||
>
|
||||
<div className="relative z-10 flex flex-col justify-between h-full p-6 md:p-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-foreground text-lg font-medium">{highlight.title}</h4>
|
||||
<ul className="flex flex-col text-foreground-lighter text-sm gap-1.5">
|
||||
{highlight.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="w-3.5 h-3.5 mt-0.5 shrink-0 stroke-2 text-brand" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button type="default" size="small" asChild>
|
||||
<Link href={highlight.cta.href}>{highlight.cta.label}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { QuoteSection as SharedQuoteSection } from '../../_shared/QuoteSection'
|
||||
|
||||
export function QuoteSection() {
|
||||
return (
|
||||
<SharedQuoteSection
|
||||
quote="Supabase is not only super easy to get started, but also provides all the backend solutions we require as we continue to grow."
|
||||
highlight="The auth system just works out of the box."
|
||||
author={{
|
||||
name: 'Alfred Lua',
|
||||
role: 'Cofounder of Pebblely',
|
||||
image: '/images/blog/avatars/alfred-lua-pebblely.jpeg',
|
||||
link: '/customers/pebblely',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { createHighlighter, type ThemeRegistration } from 'shiki'
|
||||
import { RLSSectionClient } from './RLSSectionClient'
|
||||
|
||||
const supabaseDark: ThemeRegistration = {
|
||||
name: 'supabase-dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#ffffff',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#bda4ff' },
|
||||
},
|
||||
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#3ecf8e' } },
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
|
||||
],
|
||||
}
|
||||
|
||||
const supabaseLight: ThemeRegistration = {
|
||||
name: 'supabase-light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#525252',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#6b35dc' },
|
||||
},
|
||||
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#15593b' } },
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
|
||||
],
|
||||
}
|
||||
|
||||
const RLS_EXAMPLES = [
|
||||
{
|
||||
title: 'Allow read access',
|
||||
description: 'Public profiles are viewable by everyone.',
|
||||
code: `-- 1. Create table
|
||||
create table profiles (
|
||||
id serial primary key,
|
||||
name text
|
||||
);
|
||||
|
||||
-- 2. Enable RLS
|
||||
alter table profiles enable row level security;
|
||||
|
||||
-- 3. Create Policy
|
||||
create policy "Public profiles are viewable by everyone."
|
||||
on profiles for select
|
||||
using ( true );`,
|
||||
},
|
||||
{
|
||||
title: 'Restrict updates',
|
||||
description: 'Users can only update their own rows.',
|
||||
code: `-- 1. Create table
|
||||
create table profiles (
|
||||
id serial primary key,
|
||||
name text
|
||||
);
|
||||
|
||||
-- 2. Enable RLS
|
||||
alter table profiles enable row level security;
|
||||
|
||||
-- 3. Create Policy
|
||||
create policy "Users can update their own profiles."
|
||||
on profiles for update
|
||||
using ( (select auth.uid()) = id );`,
|
||||
},
|
||||
{
|
||||
title: 'Advanced rules',
|
||||
description: 'Team members can update team details using joins.',
|
||||
code: `create table teams (
|
||||
id bigint primary key generated always as identity,
|
||||
name text
|
||||
);
|
||||
|
||||
create table members (
|
||||
team_id bigint references teams,
|
||||
user_id uuid references auth.users
|
||||
);
|
||||
|
||||
alter table teams enable row level security;
|
||||
|
||||
create policy "Team members can update team details if they belong to the team"
|
||||
on teams
|
||||
for update using (
|
||||
(select auth.uid()) in (
|
||||
select user_id from members
|
||||
where team_id = id
|
||||
)
|
||||
);`,
|
||||
},
|
||||
]
|
||||
|
||||
export async function RLSSection() {
|
||||
const hl = await createHighlighter({
|
||||
themes: [supabaseDark, supabaseLight],
|
||||
langs: ['sql'],
|
||||
})
|
||||
|
||||
const examples = RLS_EXAMPLES.map((example) => ({
|
||||
title: example.title,
|
||||
description: example.description,
|
||||
darkHtml: hl.codeToHtml(example.code, { lang: 'sql', theme: 'supabase-dark' }),
|
||||
lightHtml: hl.codeToHtml(example.code, { lang: 'sql', theme: 'supabase-light' }),
|
||||
}))
|
||||
|
||||
hl.dispose()
|
||||
|
||||
return <RLSSectionClient examples={examples} />
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
type RLSExample = {
|
||||
title: string
|
||||
description: string
|
||||
darkHtml: string
|
||||
lightHtml: string
|
||||
}
|
||||
|
||||
const INTERVAL_DURATION = 10000
|
||||
|
||||
export function RLSSectionClient({ examples }: { examples: RLSExample[] }) {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = examples[activeIdx]
|
||||
|
||||
// Auto-size the code panel to the active snippet. We measure an always-mounted,
|
||||
// invisible copy of the current code so the target height is known immediately on
|
||||
// switch (and doesn't collapse during the cross-fade), then animate `height` to it.
|
||||
const measureRef = useRef<HTMLDivElement>(null)
|
||||
const [height, setHeight] = useState<number>()
|
||||
const hasMeasured = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => setHeight(el.offsetHeight))
|
||||
ro.observe(el)
|
||||
setHeight(el.offsetHeight)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (height != null) hasMeasured.current = true
|
||||
}, [height])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % examples.length)
|
||||
}
|
||||
}, [progress, examples.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
User permissions
|
||||
<br />
|
||||
<span className="text-foreground">without the middleware</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Build authorization rules with Postgres Row Level Security — control who can create,
|
||||
edit, and delete specific rows in your database. No additional servers required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tabs */}
|
||||
<div className="flex flex-col gap-3 items-start justify-end">
|
||||
{examples.map((example, index) => {
|
||||
const isActive = index === activeIdx
|
||||
return (
|
||||
<button
|
||||
key={example.title}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex flex-col gap-0.5 py-2 transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<span className="text-2xl font-medium">{example.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm transition-colors',
|
||||
isActive ? 'text-foreground-lighter' : 'text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
{example.description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: code block */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={height != null ? { height } : undefined}
|
||||
transition={
|
||||
hasMeasured.current ? { duration: 0.35, ease: [0.22, 1, 0.36, 1] } : { duration: 0 }
|
||||
}
|
||||
className="relative self-end overflow-hidden border border-border rounded-md"
|
||||
>
|
||||
{/* Invisible measuring copy: drives the animated container height */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute inset-x-0 top-0 overflow-x-auto pb-12 [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.title}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="overflow-x-auto pb-12"
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
|
||||
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.lightHtml }}
|
||||
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Link
|
||||
href="/docs/guides/database/postgres/row-level-security"
|
||||
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Documentation
|
||||
<svg
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// ─── SVG paths (shield-user) ────────────────────────────────────────────────
|
||||
|
||||
const SHIELD_PATH =
|
||||
'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z'
|
||||
|
||||
// ─── Shaders ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const vert = /* glsl */ `
|
||||
attribute vec2 position;
|
||||
attribute vec2 uv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const frag = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tMap;
|
||||
uniform float uHover;
|
||||
uniform float uTime;
|
||||
uniform vec2 uResolution;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
#define GREEN vec3(0.243, 0.812, 0.557)
|
||||
|
||||
// ── Noise ──
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
|
||||
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x),
|
||||
f.y
|
||||
);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float v = 0.0, a = 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
v += a * noise(p);
|
||||
p *= 2.0;
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── Bayer dithering (recursive 8×8) ──
|
||||
float bayer2(vec2 a) {
|
||||
a = floor(a);
|
||||
return fract(a.x / 2.0 + a.y * a.y * 0.75);
|
||||
}
|
||||
float bayer4(vec2 a) { return bayer2(0.5 * a) * 0.25 + bayer2(a); }
|
||||
float bayer8(vec2 a) { return bayer4(0.5 * a) * 0.25 + bayer2(a); }
|
||||
|
||||
void main() {
|
||||
float shape = texture2D(tMap, vUv).r;
|
||||
|
||||
if (shape < 0.5) { gl_FragColor = vec4(0.0); return; }
|
||||
|
||||
// Animated FBM noise — continuous drift
|
||||
vec2 noiseUv = vUv * 3.0 + vec2(uTime * 0.8, uTime * 0.5);
|
||||
float n = fbm(noiseUv);
|
||||
|
||||
float value = n * 0.7 + 0.3;
|
||||
|
||||
// Bayer dithering — divide resolution for bigger dots
|
||||
vec2 screenPos = vUv * uResolution / 3.0;
|
||||
float dither = bayer8(screenPos);
|
||||
float dithered = smoothstep(dither - 0.04, dither + 0.04, value);
|
||||
|
||||
vec3 col = GREEN * dithered;
|
||||
float alpha = dithered;
|
||||
|
||||
gl_FragColor = vec4(col * alpha, alpha);
|
||||
}
|
||||
`
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildSVGCanvas(size: number): Promise<HTMLCanvasElement> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const svgSrc = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="${size}" height="${size}">
|
||||
<path d="${SHIELD_PATH}" fill="white"/>
|
||||
</svg>`
|
||||
const blob = new Blob([svgSrc], { type: 'image/svg+xml;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, 0, 0)
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(canvas)
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
hovered: boolean
|
||||
}
|
||||
|
||||
export function ShieldGlow({ hovered }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const liveRef = useRef({ hovered: false, hover: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
liveRef.current.hovered = hovered
|
||||
}, [hovered])
|
||||
|
||||
useEffect(() => {
|
||||
const canvasMaybe = canvasRef.current
|
||||
if (!canvasMaybe) return
|
||||
const canvas: HTMLCanvasElement = canvasMaybe
|
||||
|
||||
let running = true
|
||||
let raf: number
|
||||
|
||||
async function init() {
|
||||
const { Renderer, Program, Mesh, Triangle, Texture } = await import('ogl')
|
||||
if (!running) return
|
||||
|
||||
const svgCanvas = await buildSVGCanvas(512)
|
||||
if (!running) return
|
||||
|
||||
const renderer = new Renderer({
|
||||
canvas,
|
||||
alpha: true,
|
||||
premultipliedAlpha: true,
|
||||
dpr: window.devicePixelRatio,
|
||||
})
|
||||
const gl = renderer.gl
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
|
||||
const texture = new Texture(gl, {
|
||||
image: svgCanvas,
|
||||
generateMipmaps: false,
|
||||
minFilter: gl.LINEAR,
|
||||
magFilter: gl.LINEAR,
|
||||
})
|
||||
|
||||
const geometry = new Triangle(gl)
|
||||
const program = new Program(gl, {
|
||||
vertex: vert,
|
||||
fragment: frag,
|
||||
uniforms: {
|
||||
tMap: { value: texture },
|
||||
uHover: { value: 0 },
|
||||
uTime: { value: 0 },
|
||||
uResolution: { value: [canvas.width, canvas.height] },
|
||||
},
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program })
|
||||
|
||||
const container = canvas.parentElement!
|
||||
function resize(entries?: ResizeObserverEntry[]) {
|
||||
const w = entries?.[0]?.contentRect.width ?? container.offsetWidth
|
||||
const h = entries?.[0]?.contentRect.height ?? container.offsetHeight
|
||||
renderer.setSize(w, h)
|
||||
program.uniforms.uResolution.value = [canvas.width, canvas.height]
|
||||
}
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
ro.observe(container)
|
||||
|
||||
function loop() {
|
||||
if (!running) return
|
||||
raf = requestAnimationFrame(loop)
|
||||
|
||||
const live = liveRef.current
|
||||
const target = live.hovered ? 1 : 0
|
||||
live.hover += (target - live.hover) * 0.06
|
||||
|
||||
program.uniforms.uHover.value = live.hover
|
||||
program.uniforms.uTime.value = performance.now() / 1000
|
||||
|
||||
renderer.render({ scene: mesh })
|
||||
}
|
||||
loop()
|
||||
|
||||
return () => ro.disconnect()
|
||||
}
|
||||
|
||||
let cleanupResize: (() => void) | undefined
|
||||
init().then((c) => {
|
||||
cleanupResize = c
|
||||
})
|
||||
|
||||
return () => {
|
||||
running = false
|
||||
cancelAnimationFrame(raf)
|
||||
cleanupResize?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={320}
|
||||
height={320}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
style={{ mixBlendMode: 'screen' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { ApiSection } from './_components/ApiSection'
|
||||
import { AuthContent } from './_components/AuthContent'
|
||||
import { RLSSection } from './_components/RLSSection'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Auth | Supabase',
|
||||
description:
|
||||
'Add user sign ups and logins to your app. Secure your data with Row Level Security. Works with all major OAuth providers.',
|
||||
}
|
||||
|
||||
export default function AuthPage() {
|
||||
return <AuthContent apiSlot={<ApiSection />} rlsSlot={<RLSSection />} />
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { createHighlighter, type ThemeRegistration } from 'shiki'
|
||||
import { ApiSectionClient } from './ApiSectionClient'
|
||||
|
||||
// ── Shiki themes ────────────────────────────────────────────────────────────
|
||||
|
||||
const supabaseDark: ThemeRegistration = {
|
||||
name: 'supabase-dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#ffffff',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#bda4ff' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
|
||||
{ scope: ['markup.underline.link'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['markup.inserted'], settings: { foreground: '#3ecf8e' } },
|
||||
{ scope: ['markup.deleted'], settings: { foreground: '#F06A50' } },
|
||||
],
|
||||
}
|
||||
|
||||
const supabaseLight: ThemeRegistration = {
|
||||
name: 'supabase-light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#525252',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#6b35dc' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#525252' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
|
||||
{ scope: ['markup.underline.link'], settings: { foreground: '#525252' } },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Language → Shiki lang mapping ───────────────────────────────────────────
|
||||
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
JavaScript: 'javascript',
|
||||
Flutter: 'dart',
|
||||
Python: 'python',
|
||||
'C#': 'csharp',
|
||||
Kotlin: 'kotlin',
|
||||
Swift: 'swift',
|
||||
}
|
||||
|
||||
// ── API examples per language ───────────────────────────────────────────────
|
||||
|
||||
const API_EXAMPLES = [
|
||||
{
|
||||
icon: 'Search' as const,
|
||||
title: 'Fetch records',
|
||||
description: 'Query tables with joins, filters, and ordering in a single request.',
|
||||
code: {
|
||||
JavaScript: `// Fetch all public rooms with their messages
|
||||
const { data: rooms } = await supabase
|
||||
.from('rooms')
|
||||
.select(\`
|
||||
id, name, created_at,
|
||||
messages ( id, text, user_id )
|
||||
\`)
|
||||
.eq('public', true)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20)`,
|
||||
Flutter: `// Fetch all public rooms with their messages
|
||||
final rooms = await supabase
|
||||
.from('rooms')
|
||||
.select('id, name, created_at, messages ( id, text, user_id )')
|
||||
.eq('public', true)
|
||||
.order('created_at', ascending: false)
|
||||
.limit(20);`,
|
||||
Python: `# Fetch all public rooms with their messages
|
||||
rooms = supabase.table("rooms") \\
|
||||
.select("id, name, created_at, messages ( id, text, user_id )") \\
|
||||
.eq("public", True) \\
|
||||
.order("created_at", desc=True) \\
|
||||
.limit(20) \\
|
||||
.execute()`,
|
||||
'C#': `// Fetch all public rooms with their messages
|
||||
var rooms = await supabase
|
||||
.From<Room>()
|
||||
.Select("id, name, created_at, messages ( id, text, user_id )")
|
||||
.Filter("public", Operator.Equals, "true")
|
||||
.Order("created_at", Ordering.Descending)
|
||||
.Limit(20)
|
||||
.Get();`,
|
||||
Kotlin: `// Fetch all public rooms with their messages
|
||||
val rooms = supabase.from("rooms")
|
||||
.select(Columns.raw("""
|
||||
id, name, created_at,
|
||||
messages ( id, text, user_id )
|
||||
""")) {
|
||||
filter { eq("public", true) }
|
||||
order("created_at", Order.DESCENDING)
|
||||
limit(20)
|
||||
}`,
|
||||
Swift: `// Fetch all public rooms with their messages
|
||||
let rooms: [Room] = try await supabase
|
||||
.from("rooms")
|
||||
.select("id, name, created_at, messages ( id, text, user_id )")
|
||||
.eq("public", value: true)
|
||||
.order("created_at", ascending: false)
|
||||
.limit(20)
|
||||
.execute()
|
||||
.value`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'Plus' as const,
|
||||
title: 'Insert with relations',
|
||||
description: 'Create rows across related tables with automatic foreign key resolution.',
|
||||
code: {
|
||||
JavaScript: `// Create a room and its first message
|
||||
const { data: room } = await supabase
|
||||
.from('rooms')
|
||||
.insert({ name: 'Design Team', public: false })
|
||||
.select()
|
||||
.single()
|
||||
|
||||
await supabase
|
||||
.from('messages')
|
||||
.insert({
|
||||
room_id: room.id,
|
||||
text: 'Welcome to the team channel!',
|
||||
user_id: session.user.id,
|
||||
})`,
|
||||
Flutter: `// Create a room and its first message
|
||||
final room = await supabase
|
||||
.from('rooms')
|
||||
.insert({'name': 'Design Team', 'public': false})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
await supabase
|
||||
.from('messages')
|
||||
.insert({
|
||||
'room_id': room['id'],
|
||||
'text': 'Welcome to the team channel!',
|
||||
'user_id': session.user.id,
|
||||
});`,
|
||||
Python: `# Create a room and its first message
|
||||
room = supabase.table("rooms") \\
|
||||
.insert({"name": "Design Team", "public": False}) \\
|
||||
.execute()
|
||||
|
||||
supabase.table("messages") \\
|
||||
.insert({
|
||||
"room_id": room.data[0]["id"],
|
||||
"text": "Welcome to the team channel!",
|
||||
"user_id": session.user.id,
|
||||
}) \\
|
||||
.execute()`,
|
||||
'C#': `// Create a room and its first message
|
||||
var room = await supabase
|
||||
.From<Room>()
|
||||
.Insert(new Room { Name = "Design Team", Public = false })
|
||||
.Single();
|
||||
|
||||
await supabase
|
||||
.From<Message>()
|
||||
.Insert(new Message {
|
||||
RoomId = room.Id,
|
||||
Text = "Welcome to the team channel!",
|
||||
UserId = session.User.Id,
|
||||
});`,
|
||||
Kotlin: `// Create a room and its first message
|
||||
val room = supabase.from("rooms")
|
||||
.insert(Room(name = "Design Team", public = false)) {
|
||||
select()
|
||||
}.decodeSingle<Room>()
|
||||
|
||||
supabase.from("messages")
|
||||
.insert(Message(
|
||||
roomId = room.id,
|
||||
text = "Welcome to the team channel!",
|
||||
userId = session.user.id,
|
||||
))`,
|
||||
Swift: `// Create a room and its first message
|
||||
let room: Room = try await supabase
|
||||
.from("rooms")
|
||||
.insert(["name": "Design Team", "public": false])
|
||||
.select()
|
||||
.single()
|
||||
.execute()
|
||||
.value
|
||||
|
||||
try await supabase
|
||||
.from("messages")
|
||||
.insert([
|
||||
"room_id": room.id,
|
||||
"text": "Welcome to the team channel!",
|
||||
"user_id": session.user.id,
|
||||
])
|
||||
.execute()`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'ArrowDownUp' as const,
|
||||
title: 'Update records',
|
||||
description: 'Modify rows with conditions and return the updated data in one call.',
|
||||
code: {
|
||||
JavaScript: `// Update a room and return the updated row
|
||||
const { data: updated } = await supabase
|
||||
.from('rooms')
|
||||
.update({
|
||||
name: 'Engineering Team',
|
||||
public: false,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', roomId)
|
||||
.select()
|
||||
.single()`,
|
||||
Flutter: `// Update a room and return the updated row
|
||||
final updated = await supabase
|
||||
.from('rooms')
|
||||
.update({
|
||||
'name': 'Engineering Team',
|
||||
'public': false,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
})
|
||||
.eq('id', roomId)
|
||||
.select()
|
||||
.single();`,
|
||||
Python: `# Update a room and return the updated row
|
||||
updated = supabase.table("rooms") \\
|
||||
.update({
|
||||
"name": "Engineering Team",
|
||||
"public": False,
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}) \\
|
||||
.eq("id", room_id) \\
|
||||
.execute()`,
|
||||
'C#': `// Update a room and return the updated row
|
||||
var updated = await supabase
|
||||
.From<Room>()
|
||||
.Filter("id", Operator.Equals, roomId)
|
||||
.Set(x => x.Name, "Engineering Team")
|
||||
.Set(x => x.Public, false)
|
||||
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||
.Update()
|
||||
.Single();`,
|
||||
Kotlin: `// Update a room and return the updated row
|
||||
val updated = supabase.from("rooms")
|
||||
.update({
|
||||
Room::name setTo "Engineering Team"
|
||||
Room::public setTo false
|
||||
Room::updatedAt setTo Clock.System.now()
|
||||
}) {
|
||||
filter { eq("id", roomId) }
|
||||
select()
|
||||
}.decodeSingle<Room>()`,
|
||||
Swift: `// Update a room and return the updated row
|
||||
let updated: Room = try await supabase
|
||||
.from("rooms")
|
||||
.update([
|
||||
"name": "Engineering Team",
|
||||
"public": false,
|
||||
"updated_at": ISO8601DateFormatter().string(from: Date()),
|
||||
])
|
||||
.eq("id", value: roomId)
|
||||
.select()
|
||||
.single()
|
||||
.execute()
|
||||
.value`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'Filter' as const,
|
||||
title: 'Filter & paginate',
|
||||
description: 'Full-text search, range queries, and cursor-based pagination built in.',
|
||||
code: {
|
||||
JavaScript: `// Full-text search with pagination
|
||||
const { data, count } = await supabase
|
||||
.from('rooms')
|
||||
.select('id, name, member_count', { count: 'exact' })
|
||||
.textSearch('name', 'design')
|
||||
.gte('member_count', 5)
|
||||
.range(0, 9)
|
||||
.order('member_count', { ascending: false })`,
|
||||
Flutter: `// Full-text search with pagination
|
||||
final response = await supabase
|
||||
.from('rooms')
|
||||
.select('id, name, member_count', const FetchOptions(count: CountOption.exact))
|
||||
.textSearch('name', 'design')
|
||||
.gte('member_count', 5)
|
||||
.range(0, 9)
|
||||
.order('member_count', ascending: false);`,
|
||||
Python: `# Full-text search with pagination
|
||||
response = supabase.table("rooms") \\
|
||||
.select("id, name, member_count", count="exact") \\
|
||||
.text_search("name", "design") \\
|
||||
.gte("member_count", 5) \\
|
||||
.range(0, 9) \\
|
||||
.order("member_count", desc=True) \\
|
||||
.execute()`,
|
||||
'C#': `// Full-text search with pagination
|
||||
var response = await supabase
|
||||
.From<Room>()
|
||||
.Select("id, name, member_count")
|
||||
.Filter("name", Operator.FTS, "design")
|
||||
.Filter("member_count", Operator.GreaterThanOrEqual, "5")
|
||||
.Range(0, 9)
|
||||
.Order("member_count", Ordering.Descending)
|
||||
.Get();`,
|
||||
Kotlin: `// Full-text search with pagination
|
||||
val response = supabase.from("rooms")
|
||||
.select(Columns.list("id", "name", "member_count")) {
|
||||
filter {
|
||||
textSearch("name", "design")
|
||||
gte("member_count", 5)
|
||||
}
|
||||
range(0L..9L)
|
||||
order("member_count", Order.DESCENDING)
|
||||
}`,
|
||||
Swift: `// Full-text search with pagination
|
||||
let response = try await supabase
|
||||
.from("rooms")
|
||||
.select("id, name, member_count", head: false, count: .exact)
|
||||
.textSearch("name", query: "design")
|
||||
.gte("member_count", value: 5)
|
||||
.range(from: 0, to: 9)
|
||||
.order("member_count", ascending: false)
|
||||
.execute()`,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ── Server component: pre-highlights all code ───────────────────────────────
|
||||
|
||||
export async function ApiSection() {
|
||||
const hl = await createHighlighter({
|
||||
themes: [supabaseDark, supabaseLight],
|
||||
langs: Object.values(LANG_MAP),
|
||||
})
|
||||
|
||||
const examples = API_EXAMPLES.map((example) => ({
|
||||
icon: example.icon,
|
||||
title: example.title,
|
||||
description: example.description,
|
||||
languages: Object.fromEntries(
|
||||
Object.entries(example.code).map(([lang, code]) => [
|
||||
lang,
|
||||
{
|
||||
darkHtml: hl.codeToHtml(code, { lang: LANG_MAP[lang], theme: 'supabase-dark' }),
|
||||
lightHtml: hl.codeToHtml(code, { lang: LANG_MAP[lang], theme: 'supabase-light' }),
|
||||
},
|
||||
])
|
||||
),
|
||||
}))
|
||||
|
||||
hl.dispose()
|
||||
|
||||
return <ApiSectionClient examples={examples} />
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowDownUp, ChevronDown, Filter, Plus, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const ICONS = { Search, Plus, ArrowDownUp, Filter } as const
|
||||
|
||||
type IconName = keyof typeof ICONS
|
||||
|
||||
type ApiExample = {
|
||||
icon: IconName
|
||||
title: string
|
||||
description: string
|
||||
languages: Record<string, { darkHtml: string; lightHtml: string }>
|
||||
}
|
||||
|
||||
const LANGUAGES = ['JavaScript', 'Flutter', 'Python', 'C#', 'Kotlin', 'Swift']
|
||||
|
||||
const INTERVAL_DURATION = 8000
|
||||
|
||||
export function ApiSectionClient({ examples }: { examples: ApiExample[] }) {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [activeLang, setActiveLang] = useState('JavaScript')
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = examples[activeIdx]
|
||||
|
||||
// Auto-size the code panel to the active snippet. We measure an always-mounted,
|
||||
// invisible copy of the current code so the target height is known immediately on
|
||||
// switch (and doesn't collapse during the cross-fade), then animate `height` to it.
|
||||
const measureRef = useRef<HTMLDivElement>(null)
|
||||
const [height, setHeight] = useState<number>()
|
||||
const hasMeasured = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => setHeight(el.offsetHeight))
|
||||
ro.observe(el)
|
||||
setHeight(el.offsetHeight)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (height != null) hasMeasured.current = true
|
||||
}, [height])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % examples.length)
|
||||
}
|
||||
}, [progress, examples.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={inViewRef} className="py-24 flex flex-col gap-24">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Never write
|
||||
<br />
|
||||
<span className="text-foreground">an API again</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
We introspect your database and provide instant APIs. Focus on building your product,
|
||||
while Supabase handles the CRUD.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tab cards */}
|
||||
<div className="flex flex-col gap-1 items-start justify-end">
|
||||
{examples.map((example, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const Icon = ICONS[example.icon]
|
||||
return (
|
||||
<button
|
||||
key={example.title}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{example.title}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: code block */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={height != null ? { height } : undefined}
|
||||
transition={
|
||||
hasMeasured.current ? { duration: 0.35, ease: [0.22, 1, 0.36, 1] } : { duration: 0 }
|
||||
}
|
||||
className="relative self-end overflow-hidden border border-border rounded-md"
|
||||
>
|
||||
{/* Invisible measuring copy: drives the animated container height */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute inset-x-0 top-0 overflow-x-auto pb-12 [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
dangerouslySetInnerHTML={{ __html: active.languages[activeLang]?.darkHtml ?? '' }}
|
||||
/>
|
||||
|
||||
{/* Language switcher */}
|
||||
<div className="absolute top-3 right-4 z-10 flex items-center">
|
||||
<div className="relative flex items-center gap-1 text-foreground-muted text-xs">
|
||||
<select
|
||||
value={activeLang}
|
||||
onChange={(e) => setActiveLang(e.target.value)}
|
||||
className="appearance-none bg-transparent text-foreground-light text-xs pr-4 cursor-pointer border-none outline-none shadow-none focus:border-none focus:outline-none focus:shadow-none focus:ring-0"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{lang}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={12} className="absolute right-0 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${active.title}-${activeLang}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="overflow-x-auto pb-12"
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.languages[activeLang]?.darkHtml ?? '' }}
|
||||
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: active.languages[activeLang]?.lightHtml ?? '',
|
||||
}}
|
||||
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Link
|
||||
href="/docs/guides/database"
|
||||
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Documentation
|
||||
<svg
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BuiltWithSupabaseSection } from '~/app/(home)/_components/BuiltWithSupabaseSection'
|
||||
import { CTASection } from '~/app/(home)/_components/CTASection'
|
||||
import { FeaturesSection } from './FeaturesSection'
|
||||
import { Hero } from './Hero'
|
||||
import { HighlightsSection } from './HighlightsSection'
|
||||
import { QuoteSection } from './QuoteSection'
|
||||
import { SqlEditorSection } from './SqlEditorSection'
|
||||
import { TableEditorSection } from './TableEditorSection'
|
||||
import { VisualDemo } from './VisualDemo'
|
||||
|
||||
export function DatabaseContent({ apiSlot }: { apiSlot: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-clip">
|
||||
<section>
|
||||
<Hero />
|
||||
<VisualDemo />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<FeaturesSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<QuoteSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<HighlightsSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<TableEditorSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<SqlEditorSection />
|
||||
</section>
|
||||
<section className="border-t border-border">{apiSlot}</section>
|
||||
<section className="border-t border-border">
|
||||
<BuiltWithSupabaseSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<CTASection />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Key } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { PostgresGlow } from './Shaders/PostgresGlow'
|
||||
|
||||
function SkeletonBar({ className }: { className?: string }) {
|
||||
return <div className={cn('rounded bg-foreground-muted/10', className)} />
|
||||
}
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
title: 'Just Postgres',
|
||||
description: 'A dedicated Postgres database.',
|
||||
detail: '100% portable. Bring your existing Postgres database, or migrate away at any time.',
|
||||
visual: PostgresSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Secure by default',
|
||||
description: "Built on Postgres' proven Row Level Security.",
|
||||
detail: 'Integrated with JWT authentication which controls exactly what your users can access.',
|
||||
visual: RLSSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Realtime enabled',
|
||||
description: 'Data-change listeners over websockets.',
|
||||
detail: 'Subscribe and react to database changes, milliseconds after they happen.',
|
||||
visual: RealtimeSkeleton,
|
||||
},
|
||||
]
|
||||
|
||||
function PostgresSkeleton() {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center w-full h-full relative"
|
||||
style={{
|
||||
maskImage: 'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
|
||||
WebkitMaskImage: 'radial-gradient(ellipse 85% 85% at 50% 50%, black 30%, transparent 75%)',
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Abstract construction lines behind the elephant */}
|
||||
<svg
|
||||
className="absolute -inset-16 w-[calc(100%+128px)] h-[calc(100%+128px)] text-foreground-muted/20"
|
||||
viewBox="0 0 480 480"
|
||||
fill="none"
|
||||
>
|
||||
<rect
|
||||
x="60"
|
||||
y="40"
|
||||
width="360"
|
||||
height="400"
|
||||
rx="48"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="30"
|
||||
y="10"
|
||||
width="420"
|
||||
height="460"
|
||||
rx="60"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="0"
|
||||
y="-16"
|
||||
width="480"
|
||||
height="512"
|
||||
rx="72"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="90"
|
||||
y="70"
|
||||
width="300"
|
||||
height="340"
|
||||
rx="36"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<line x1="30" y1="10" x2="450" y2="470" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="450" y1="10" x2="30" y2="470" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="240" y1="0" x2="240" y2="480" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="0" y1="240" x2="480" y2="240" stroke="currentColor" strokeWidth="0.5" />
|
||||
<circle cx="240" cy="240" r="180" stroke="currentColor" strokeWidth="0.5" />
|
||||
<circle cx="240" cy="240" r="140" stroke="currentColor" strokeWidth="0.5" />
|
||||
<circle cx="240" cy="240" r="100" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="60" y1="40" x2="60" y2="10" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="60" y1="40" x2="30" y2="40" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="420" y1="40" x2="450" y2="40" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="420" y1="40" x2="420" y2="10" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="60" y1="440" x2="30" y2="440" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="60" y1="440" x2="60" y2="470" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="420" y1="440" x2="450" y2="440" stroke="currentColor" strokeWidth="0.5" />
|
||||
<line x1="420" y1="440" x2="420" y2="470" stroke="currentColor" strokeWidth="0.5" />
|
||||
</svg>
|
||||
|
||||
{/* Highlighted lines on hover */}
|
||||
<svg
|
||||
className="absolute -inset-16 w-[calc(100%+128px)] h-[calc(100%+128px)] transition-opacity duration-500"
|
||||
style={{ opacity: hovered ? 0.35 : 0 }}
|
||||
viewBox="0 0 480 480"
|
||||
fill="none"
|
||||
>
|
||||
<rect
|
||||
x="60"
|
||||
y="40"
|
||||
width="360"
|
||||
height="400"
|
||||
rx="48"
|
||||
stroke="hsl(var(--brand-default))"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<circle
|
||||
cx="240"
|
||||
cy="240"
|
||||
r="140"
|
||||
stroke="hsl(var(--brand-default))"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<line
|
||||
x1="240"
|
||||
y1="0"
|
||||
x2="240"
|
||||
y2="480"
|
||||
stroke="hsl(var(--brand-default))"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1="240"
|
||||
x2="480"
|
||||
y2="240"
|
||||
stroke="hsl(var(--brand-default))"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.25"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="relative w-80 h-80">
|
||||
<PostgresGlow hovered={hovered} />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 390 430"
|
||||
className="w-80 h-80 relative z-[5]"
|
||||
style={{
|
||||
color: hovered ? 'hsl(var(--brand-default))' : 'hsl(var(--foreground-muted))',
|
||||
opacity: hovered ? 1 : 0.8,
|
||||
transition: 'color 0.5s, opacity 0.5s',
|
||||
}}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
d="M192.144 125.816h-53.465c-8.506 0-16.159 5.17-19.334 13.061L99.0045 189.43c-3.0613 7.608-1.3448 16.306 4.3775 22.181l10.232 10.506c4.792 4.919 7.474 11.516 7.474 18.384l-.001 14.473c0 20.197 16.373 36.569 36.569 36.569 6.16 0 11.154-4.993 11.154-11.153l.001-86.241c0-18.629 7.441-36.486 20.668-49.602 2.746-2.723 7.178-2.704 9.9.041 2.722 2.745 2.703 7.178-.042 9.9-10.577 10.488-16.526 24.766-16.526 39.661l-.001 86.241c0 13.892-11.262 25.153-25.154 25.153-27.928 0-50.569-22.64-50.569-50.569l.001-14.474c0-3.218-1.257-6.309-3.503-8.615L93.353 221.38c-9.5904-9.847-12.4673-24.424-7.3366-37.176l20.3406-50.553c5.308-13.192 18.101-21.835 32.322-21.835h55.729v.084h10.339c49.104 0 88.91 39.806 88.91 88.91v50.842c0 3.866-3.134 7-7 7s-7-3.134-7-7V200.81c0-41.372-33.538-74.91-74.91-74.91H193.23c-.37 0-.732-.029-1.086-.084Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
d="M210.03 283.94c0-3.866-3.134-7-7-7s-7 3.134-7 7v3.113c0 26.959 21.854 48.814 48.813 48.814 26.351 0 47.825-20.879 48.781-46.996h24.614c3.866 0 7-3.134 7-7s-3.134-7-7-7h-26.841c-30.744 0-60.256-12.083-82.173-33.643-2.756-2.711-7.188-2.675-9.899.081-2.711 2.756-2.675 7.188.081 9.9 21.725 21.371 50.116 34.423 80.228 37.134-.679 18.629-15.995 33.524-34.791 33.524-19.227 0-34.813-15.587-34.813-34.814v-3.113Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
d="M238.03 202.145c0 4.792 3.885 8.677 8.677 8.677s8.676-3.885 8.676-8.677-3.884-8.676-8.676-8.676-8.677 3.884-8.677 8.676Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rlsPolicies = [
|
||||
{ name: 'Users can read their own profile', op: 'SELECT', clause: 'auth.uid() = id' },
|
||||
{ name: 'Users can update their own data', op: 'UPDATE', clause: 'auth.uid() = id' },
|
||||
{ name: 'Users can delete their own posts', op: 'DELETE', clause: 'auth.uid() = id' },
|
||||
]
|
||||
|
||||
const rlsColumns = [
|
||||
{ name: 'id', format: 'int8', isPrimaryKey: true },
|
||||
{ name: 'email', format: 'text' },
|
||||
{ name: 'role', format: 'text' },
|
||||
]
|
||||
|
||||
const rlsRows = [
|
||||
{ id: '1', email: 'alice@company.com', role: 'admin' },
|
||||
{ id: '2', email: 'bob@company.com', role: 'user' },
|
||||
]
|
||||
|
||||
function RLSSkeleton() {
|
||||
const [policyIdx, setPolicyIdx] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
|
||||
threshold: 0.3,
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return
|
||||
const timer = setInterval(() => setPolicyIdx((i) => (i + 1) % rlsPolicies.length), 10000)
|
||||
return () => clearInterval(timer)
|
||||
}, [isInView])
|
||||
|
||||
const policy = rlsPolicies[policyIdx]
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full h-full flex flex-col justify-end">
|
||||
<div
|
||||
className="absolute top-12 -right-24"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to right, black 35%, transparent 70%)',
|
||||
WebkitMaskImage: 'linear-gradient(to right, black 35%, transparent 70%)',
|
||||
}}
|
||||
>
|
||||
<div className="border border-border rounded-lg overflow-hidden" style={{ maxHeight: 180 }}>
|
||||
<table className="w-full border-collapse text-[13px] !mt-0" style={{ minWidth: 360 }}>
|
||||
<thead>
|
||||
<tr className="bg-surface-200">
|
||||
{rlsColumns.map((col) => (
|
||||
<th
|
||||
key={col.name}
|
||||
className="border-b border-r last:border-r-0 border-default px-3 py-1.5 text-left font-normal"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
{col.isPrimaryKey && (
|
||||
<Key size={12} strokeWidth={2} className="text-brand rotate-45 shrink-0" />
|
||||
)}
|
||||
<span className="text-foreground text-xs truncate font-medium">
|
||||
{col.name}
|
||||
</span>
|
||||
<span className="text-foreground-light text-xs truncate">{col.format}</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rlsRows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-default last:border-b-0 bg-surface-75">
|
||||
<td className="border-r border-default px-3 py-1.5 text-foreground-muted text-xs">
|
||||
{row.id}
|
||||
</td>
|
||||
<td className="border-r border-default px-3 py-1.5 text-foreground text-xs">
|
||||
{row.email}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-foreground-muted text-xs">{row.role}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SQL snippet — bottom left */}
|
||||
<div className="px-6 pb-4">
|
||||
<pre className="text-sm leading-relaxed font-mono text-[#525252] dark:text-white overflow-hidden">
|
||||
<span className="text-[#6b35dc] dark:text-[#bda4ff]">CREATE POLICY</span>
|
||||
{'\n'}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={policy.name}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="inline-block text-[#f1a10d] dark:text-[#ffcda1]"
|
||||
>
|
||||
"{policy.name}"
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
{'\n'}
|
||||
<span className="text-[#6b35dc] dark:text-[#bda4ff]">ON</span>
|
||||
{' public.users\n'}
|
||||
<span className="text-[#6b35dc] dark:text-[#bda4ff]">FOR </span>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={policy.op}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="inline-block text-[#6b35dc] dark:text-[#bda4ff]"
|
||||
>
|
||||
{policy.op}
|
||||
</motion.span>
|
||||
</AnimatePresence>{' '}
|
||||
<span className="text-[#6b35dc] dark:text-[#bda4ff]">USING</span>
|
||||
{'\n ('}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={policy.clause}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="inline-block"
|
||||
>
|
||||
{policy.clause}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
{');'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ChatMsg = { id: number; user: string; text: string }
|
||||
|
||||
const initialMessages: ChatMsg[] = [
|
||||
{ id: 1, user: 'Alice', text: 'Hey, is the deploy ready?' },
|
||||
{ id: 2, user: 'Bob', text: 'Almost — running final tests now.' },
|
||||
{ id: 3, user: 'Alice', text: 'Nice, let me know when it\u2019s live.' },
|
||||
]
|
||||
|
||||
const incomingMessages: ChatMsg[] = [
|
||||
{ id: 4, user: 'Bob', text: 'All green. Deploying now 🚀' },
|
||||
{ id: 5, user: 'Alice', text: 'Awesome, checking it out.' },
|
||||
{ id: 6, user: 'Bob', text: 'Latency dropped to 42ms!' },
|
||||
{ id: 7, user: 'Alice', text: 'That\u2019s a big improvement.' },
|
||||
{ id: 8, user: 'Bob', text: 'Yeah, the new index helped.' },
|
||||
{ id: 9, user: 'Alice', text: 'Should we update the docs?' },
|
||||
{ id: 10, user: 'Bob', text: 'Already on it.' },
|
||||
]
|
||||
|
||||
const chatCols = ['id', 'user', 'text'] as const
|
||||
|
||||
function RealtimeSkeleton() {
|
||||
const [messages, setMessages] = useState<ChatMsg[]>(initialMessages)
|
||||
const [tableFlashId, setTableFlashId] = useState<number | null>(null)
|
||||
const [chatFlashId, setChatFlashId] = useState<number | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const tableScrollRef = useRef<HTMLDivElement>(null)
|
||||
const chatScrollRef = useRef<HTMLDivElement>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const nextMsgIdx = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
|
||||
threshold: 0.3,
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const msg = incomingMessages[nextMsgIdx.current % incomingMessages.length]
|
||||
// Give each added message a unique id
|
||||
const newMsg = { ...msg, id: Date.now() + nextMsgIdx.current }
|
||||
nextMsgIdx.current++
|
||||
|
||||
// Add to table + flash
|
||||
setMessages((prev) => [...prev, newMsg])
|
||||
setTableFlashId(newMsg.id)
|
||||
|
||||
// Autoscroll table
|
||||
requestAnimationFrame(() => {
|
||||
tableScrollRef.current?.scrollTo({
|
||||
top: tableScrollRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
|
||||
// Chat updates after short delay (realtime propagation)
|
||||
setTimeout(() => {
|
||||
setChatFlashId(newMsg.id)
|
||||
requestAnimationFrame(() => {
|
||||
chatScrollRef.current?.scrollTo({
|
||||
top: chatScrollRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
setTimeout(() => {
|
||||
setTableFlashId(null)
|
||||
setChatFlashId(null)
|
||||
}, 600)
|
||||
}, 200)
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [isInView])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-full flex flex-col justify-end"
|
||||
style={{
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, black 50%, rgba(0,0,0,0.9) 65%, rgba(0,0,0,0.5) 75%, rgba(0,0,0,0.1) 85%, transparent 90%)',
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, black 50%, rgba(0,0,0,0.9) 65%, rgba(0,0,0,0.5) 75%, rgba(0,0,0,0.1) 85%, transparent 90%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-12 -right-24 z-[2]"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to right, black 35%, transparent 70%)',
|
||||
WebkitMaskImage: 'linear-gradient(to right, black 35%, transparent 70%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="border border-border rounded-lg overflow-hidden bg-surface-75"
|
||||
style={{ width: 420 }}
|
||||
>
|
||||
<table className="w-full border-collapse text-[13px] !mt-0 table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: 72 }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="bg-surface-200">
|
||||
<th className="border-b border-r border-default px-3 py-1.5 text-left font-normal">
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
<Key size={12} strokeWidth={2} className="text-brand rotate-45 shrink-0" />
|
||||
<span className="text-foreground text-xs font-medium">id</span>
|
||||
<span className="text-foreground-light text-xs">int8</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="border-b border-r border-default px-3 py-1.5 text-left font-normal">
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
<span className="text-foreground text-xs font-medium">user</span>
|
||||
<span className="text-foreground-light text-xs">text</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="border-b border-default px-3 py-1.5 text-left font-normal">
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
<span className="text-foreground text-xs font-medium">text</span>
|
||||
<span className="text-foreground-light text-xs">text</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div
|
||||
ref={tableScrollRef}
|
||||
className="max-h-[58px] overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<table className="w-full border-collapse text-sm !mt-0 table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: 72 }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody className="[&>tr:last-child>td]:border-b-0">
|
||||
{messages.map((msg) => (
|
||||
<tr
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
'transition-colors duration-500',
|
||||
tableFlashId === msg.id ? 'bg-brand/15 dark:bg-brand/5' : 'bg-surface-75'
|
||||
)}
|
||||
>
|
||||
<td className="border-b border-r border-default px-3 py-1.5 text-foreground-muted text-xs truncate max-w-0">
|
||||
{msg.id}
|
||||
</td>
|
||||
<td className="border-b border-r border-default px-3 py-1.5 text-foreground text-xs truncate max-w-0">
|
||||
{msg.user}
|
||||
</td>
|
||||
<td className="border-b border-default px-3 py-1.5 text-foreground text-xs truncate max-w-0">
|
||||
{msg.text}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connecting dashed lines */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-[140px] flex gap-2 z-[1] text-background-surface-300">
|
||||
<svg width="2" height="52" fill="none" className="overflow-visible">
|
||||
<line
|
||||
x1="1"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="52"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 4"
|
||||
style={{ animation: 'rlDashFlow 1s linear infinite' }}
|
||||
/>
|
||||
</svg>
|
||||
<svg width="2" height="52" fill="none" className="overflow-visible">
|
||||
<line
|
||||
x1="1"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="52"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 4"
|
||||
style={{ animation: 'rlDashFlow 1s linear infinite 0.5s' }}
|
||||
/>
|
||||
</svg>
|
||||
<style>{`@keyframes rlDashFlow { to { stroke-dashoffset: -8; } }`}</style>
|
||||
</div>
|
||||
|
||||
{/* Chat UI */}
|
||||
<div className="px-4 pb-4 relative z-[2]">
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-surface-200 max-w-[90%]">
|
||||
<div
|
||||
ref={chatScrollRef}
|
||||
className="h-[120px] overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden flex flex-col gap-2 px-3 py-3"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, transparent 0%, black 20%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 20%)',
|
||||
}}
|
||||
>
|
||||
{messages.map((msg) => {
|
||||
const isAlice = msg.user === 'Alice'
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex', isAlice ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[75%] px-3 py-1.5 rounded-xl text-xs leading-snug transition-colors duration-500',
|
||||
isAlice
|
||||
? 'bg-brand/15 text-foreground rounded-br-sm'
|
||||
: 'bg-surface-300 text-foreground rounded-bl-sm',
|
||||
chatFlashId === msg.id && 'ring-1 ring-brand/30'
|
||||
)}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeaturesSection() {
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-24">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Everything you need
|
||||
<br />
|
||||
<span className="text-foreground">from your database</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Every Supabase project is a full Postgres database with realtime functionality,
|
||||
fine-grained access controls, and instant APIs — no extra configuration required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-col grid */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{FEATURES.map((feature) => {
|
||||
const Visual = feature.visual
|
||||
return (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Visual with overlaid title */}
|
||||
<div className="relative flex items-center justify-center h-[320px]">
|
||||
<Visual />
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="px-6 py-5 flex flex-col gap-1">
|
||||
<h4 className="text-foreground text-sm font-medium">{feature.description}</h4>
|
||||
<p className="text-foreground-lighter text-sm">{feature.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-32 pb-16 md:pt-40 md:pb-32">
|
||||
<div className="flex flex-col gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h1 className="text-foreground text-3xl sm:text-5xl sm:leading-none">
|
||||
<span className="block">Postgres</span>
|
||||
<span className="text-foreground-lighter block">without the hassle</span>
|
||||
</h1>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Every Supabase project is a dedicated Postgres database. 100% portable with no vendor
|
||||
lock-in.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="medium">
|
||||
<Link href="https://supabase.com/dashboard">Start your project</Link>
|
||||
</Button>
|
||||
<Button asChild size="medium" type="default">
|
||||
<Link href="/docs/guides/database/overview">Documentation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Badge, Button } from 'ui'
|
||||
|
||||
type Highlight = {
|
||||
title: string
|
||||
badge?: string
|
||||
features: string[]
|
||||
cta: { label: string; href: string }
|
||||
image: { dark: string; light: string }
|
||||
}
|
||||
|
||||
const HIGHLIGHTS: Highlight[] = [
|
||||
{
|
||||
title: 'Branching',
|
||||
features: [
|
||||
'Branch your Supabase project',
|
||||
'Sync with your git branches',
|
||||
'Manage every Preview from the Dashboard',
|
||||
'Support for Vercel Previews',
|
||||
],
|
||||
cta: { label: 'Learn more', href: '/docs/guides/platform/branching' },
|
||||
image: {
|
||||
dark: '/images/product/database/branching.svg',
|
||||
light: '/images/product/database/branching-light.svg',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Read Replicas',
|
||||
features: [
|
||||
'Serve data closer to your users',
|
||||
'Provide data redundancy',
|
||||
'Run complex queries without affecting your primary database',
|
||||
'Distribute load across various databases',
|
||||
],
|
||||
cta: { label: 'Learn more', href: '/docs/guides/platform/read-replicas' },
|
||||
image: {
|
||||
dark: '/images/product/database/read-replicas.svg',
|
||||
light: '/images/product/database/read-replicas-light.svg',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function HighlightsSection() {
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-24">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Built for scale
|
||||
<br />
|
||||
<span className="text-foreground">with powerful new features</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{HIGHLIGHTS.map((highlight) => (
|
||||
<div
|
||||
key={highlight.title}
|
||||
className="relative flex flex-col overflow-hidden min-h-[400px] bg-surface-75 border border-border rounded-lg"
|
||||
>
|
||||
<div className="relative z-10 flex flex-col justify-between h-full p-6 md:p-8 pr-0 md:pr-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-foreground text-lg font-medium">{highlight.title}</h4>
|
||||
{highlight.badge && <Badge variant="success">{highlight.badge}</Badge>}
|
||||
</div>
|
||||
<ul className="flex flex-col text-foreground-lighter text-sm gap-1.5">
|
||||
{highlight.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="w-3.5 h-3.5 mt-0.5 shrink-0 stroke-2 text-brand" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button type="default" size="small" asChild>
|
||||
<Link href={highlight.cta.href}>{highlight.cta.label}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Decorative image */}
|
||||
<div className="hidden sm:flex lg:hidden xl:flex absolute top-0 right-0 bottom-0 items-end h-full opacity-50 dark:opacity-20 select-none">
|
||||
<Image
|
||||
src={highlight.image.dark}
|
||||
alt={highlight.title}
|
||||
width={296}
|
||||
height={275}
|
||||
className="hidden dark:block"
|
||||
/>
|
||||
<Image
|
||||
src={highlight.image.light}
|
||||
alt={highlight.title}
|
||||
width={296}
|
||||
height={275}
|
||||
className="dark:hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import { Key } from 'lucide-react'
|
||||
|
||||
const columns = [
|
||||
{ name: 'id', format: 'int8', isPrimaryKey: true },
|
||||
{ name: 'created_at', format: 'timestamptz' },
|
||||
{ name: 'name', format: 'text' },
|
||||
{ name: 'email', format: 'text' },
|
||||
{ name: 'role', format: 'text' },
|
||||
{ name: 'is_active', format: 'bool' },
|
||||
{ name: 'avatar_url', format: 'text' },
|
||||
]
|
||||
|
||||
const rows = [
|
||||
{
|
||||
id: 1,
|
||||
created_at: '2024-01-15 09:23:41+00',
|
||||
name: 'Alice Johnson',
|
||||
email: 'alice@example.com',
|
||||
role: 'admin',
|
||||
is_active: 'true',
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=alice',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
created_at: '2024-01-15 10:45:12+00',
|
||||
name: 'Bob Smith',
|
||||
email: 'bob@example.com',
|
||||
role: 'editor',
|
||||
is_active: 'true',
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=bob',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
created_at: '2024-02-03 14:12:08+00',
|
||||
name: 'Carol Williams',
|
||||
email: 'carol@example.com',
|
||||
role: 'viewer',
|
||||
is_active: 'false',
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=carol',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
created_at: '2024-02-14 08:33:55+00',
|
||||
name: 'David Brown',
|
||||
email: 'david@example.com',
|
||||
role: 'editor',
|
||||
is_active: 'true',
|
||||
avatar_url: 'NULL',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
created_at: '2024-03-01 16:07:29+00',
|
||||
name: 'Eve Martinez',
|
||||
email: 'eve@example.com',
|
||||
role: 'admin',
|
||||
is_active: 'true',
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=eve',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
created_at: '2024-03-10 11:52:44+00',
|
||||
name: 'Frank Lee',
|
||||
email: 'frank@example.com',
|
||||
role: 'viewer',
|
||||
is_active: 'true',
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=frank',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
created_at: '2024-03-22 07:18:03+00',
|
||||
name: 'Grace Kim',
|
||||
email: 'grace@example.com',
|
||||
role: 'editor',
|
||||
is_active: 'false',
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=grace',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
created_at: '2024-04-05 13:41:17+00',
|
||||
name: 'Henry Chen',
|
||||
email: 'henry@example.com',
|
||||
role: 'viewer',
|
||||
is_active: 'true',
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=henry',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
created_at: '2024-04-12 09:05:33+00',
|
||||
name: 'Isla Patel',
|
||||
email: 'isla@example.com',
|
||||
role: 'admin',
|
||||
is_active: 'true',
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=isla',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
created_at: '2024-04-18 15:22:07+00',
|
||||
name: 'Jack Rivera',
|
||||
email: 'jack@example.com',
|
||||
role: 'editor',
|
||||
is_active: 'false',
|
||||
avatar_url: 'NULL',
|
||||
},
|
||||
]
|
||||
|
||||
const EDITED_ROW = 1
|
||||
const EDITED_COL = 'created_at'
|
||||
|
||||
export function SpreadsheetIllustration() {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<table className="w-full border-collapse text-[13px]" style={{ minWidth: 900 }}>
|
||||
<thead>
|
||||
<tr className="bg-surface-200">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.name}
|
||||
className="border-b border-r last:border-r-0 border-default px-3 py-1.5 text-left font-normal"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
{col.isPrimaryKey && (
|
||||
<Key size={12} strokeWidth={2} className="text-brand rotate-45 shrink-0" />
|
||||
)}
|
||||
<span className="text-foreground text-xs truncate font-medium">{col.name}</span>
|
||||
<span className="text-foreground-light text-xs truncate">{col.format}</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="bg-surface-75">
|
||||
{columns.map((col) => {
|
||||
const value = String(row[col.name as keyof typeof row])
|
||||
const isNull = value === 'NULL'
|
||||
const isEdited = row.id === EDITED_ROW && col.name === EDITED_COL
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.name}
|
||||
className={`border-b border-r last:border-r-0 border-secondary px-3 py-1.5 max-w-[200px] ${
|
||||
isEdited ? 'bg-brand/10' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`block truncate ${isNull ? 'text-foreground-muted italic' : 'text-foreground'}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Floating cell editor */}
|
||||
<div className="absolute top-[42px] left-[105px] w-[280px] rounded-lg border border-strong bg-surface-200 shadow-2xl overflow-hidden z-10">
|
||||
<div className="px-3 py-2.5 border-b border-default">
|
||||
<div className="rounded bg-brand-400/20 px-2 py-1 text-[11px] text-foreground font-mono">
|
||||
2024-01-15 09:23:41.206064+00
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 flex flex-col gap-1">
|
||||
<span className="text-[10px] text-foreground-muted">Formatted value:</span>
|
||||
<span className="text-[12px] text-foreground font-mono">
|
||||
15 Jan 2024 09:23:41 (+0000)
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 pb-3 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd className="flex items-center justify-center w-6 h-6 rounded border border-default bg-surface-300 text-foreground-light text-[10px]">
|
||||
↵
|
||||
</kbd>
|
||||
<span className="text-[11px] text-foreground-muted">Save changes</span>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<div className="rounded border border-default bg-surface-100 px-2.5 py-1 text-[10px] text-foreground-light">
|
||||
Set to NOW
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd className="flex items-center justify-center px-1.5 h-6 rounded border border-default bg-surface-300 text-foreground-light text-[10px]">
|
||||
Esc
|
||||
</kbd>
|
||||
<span className="text-[11px] text-foreground-muted">Cancel changes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { QuoteSection as SharedQuoteSection } from '../../_shared/QuoteSection'
|
||||
|
||||
export function QuoteSection() {
|
||||
return (
|
||||
<SharedQuoteSection
|
||||
quote="You can have a really great product, but you need to want to work with the people behind it."
|
||||
highlight="With Supabase, we always felt very aligned."
|
||||
author={{
|
||||
name: 'Howard Haynes',
|
||||
role: 'CPO at Next Door Lending',
|
||||
image: '/images/blog/avatars/howard-haynes.webp',
|
||||
link: '/customers/next-door-lending',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// ─── SVG paths (elephant) ────────────────────────────────────────────────────
|
||||
|
||||
const PATHS = [
|
||||
'M192.144 125.816h-53.465c-8.506 0-16.159 5.17-19.334 13.061L99.0045 189.43c-3.0613 7.608-1.3448 16.306 4.3775 22.181l10.232 10.506c4.792 4.919 7.474 11.516 7.474 18.384l-.001 14.473c0 20.197 16.373 36.569 36.569 36.569 6.16 0 11.154-4.993 11.154-11.153l.001-86.241c0-18.629 7.441-36.486 20.668-49.602 2.746-2.723 7.178-2.704 9.9.041 2.722 2.745 2.703 7.178-.042 9.9-10.577 10.488-16.526 24.766-16.526 39.661l-.001 86.241c0 13.892-11.262 25.153-25.154 25.153-27.928 0-50.569-22.64-50.569-50.569l.001-14.474c0-3.218-1.257-6.309-3.503-8.615L93.353 221.38c-9.5904-9.847-12.4673-24.424-7.3366-37.176l20.3406-50.553c5.308-13.192 18.101-21.835 32.322-21.835h55.729v.084h10.339c49.104 0 88.91 39.806 88.91 88.91v50.842c0 3.866-3.134 7-7 7s-7-3.134-7-7V200.81c0-41.372-33.538-74.91-74.91-74.91H193.23c-.37 0-.732-.029-1.086-.084Z',
|
||||
'M210.03 283.94c0-3.866-3.134-7-7-7s-7 3.134-7 7v3.113c0 26.959 21.854 48.814 48.813 48.814 26.351 0 47.825-20.879 48.781-46.996h24.614c3.866 0 7-3.134 7-7s-3.134-7-7-7h-26.841c-30.744 0-60.256-12.083-82.173-33.643-2.756-2.711-7.188-2.675-9.899.081-2.711 2.756-2.675 7.188.081 9.9 21.725 21.371 50.116 34.423 80.228 37.134-.679 18.629-15.995 33.524-34.791 33.524-19.227 0-34.813-15.587-34.813-34.814v-3.113Z',
|
||||
'M238.03 202.145c0 4.792 3.885 8.677 8.677 8.677s8.676-3.885 8.676-8.677-3.884-8.676-8.676-8.676-8.677 3.884-8.677 8.676Z',
|
||||
]
|
||||
|
||||
// ─── Shaders ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const vert = /* glsl */ `
|
||||
attribute vec2 position;
|
||||
attribute vec2 uv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
// Animated dithered noise over the filled silhouette.
|
||||
const frag = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D tMap;
|
||||
uniform float uHover;
|
||||
uniform float uTime;
|
||||
uniform vec2 uResolution;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
#define GREEN vec3(0.243, 0.812, 0.557)
|
||||
|
||||
// ── Noise ──
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
|
||||
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x),
|
||||
f.y
|
||||
);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float v = 0.0, a = 0.5;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
v += a * noise(p);
|
||||
p *= 2.0;
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── Bayer dithering (recursive 8×8) ──
|
||||
float bayer2(vec2 a) {
|
||||
a = floor(a);
|
||||
return fract(a.x / 2.0 + a.y * a.y * 0.75);
|
||||
}
|
||||
float bayer4(vec2 a) { return bayer2(0.5 * a) * 0.25 + bayer2(a); }
|
||||
float bayer8(vec2 a) { return bayer4(0.5 * a) * 0.25 + bayer2(a); }
|
||||
|
||||
void main() {
|
||||
float shape = texture2D(tMap, vUv).r;
|
||||
|
||||
if (shape < 0.5) { gl_FragColor = vec4(0.0); return; }
|
||||
|
||||
// Animated FBM noise — continuous drift
|
||||
vec2 noiseUv = vUv * 3.0 + vec2(uTime * 0.8, uTime * 0.5);
|
||||
float n = fbm(noiseUv);
|
||||
|
||||
float value = n * 0.7 + 0.3;
|
||||
|
||||
// Bayer dithering — divide resolution for bigger dots
|
||||
vec2 screenPos = vUv * uResolution / 3.0;
|
||||
float dither = bayer8(screenPos);
|
||||
float dithered = smoothstep(dither - 0.04, dither + 0.04, value);
|
||||
|
||||
vec3 col = GREEN * dithered;
|
||||
float alpha = dithered * uHover;
|
||||
|
||||
gl_FragColor = vec4(col * alpha, alpha);
|
||||
}
|
||||
`
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildSVGCanvas(size: number): Promise<HTMLCanvasElement> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const svgSrc = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 390 430" width="${size}" height="${size}">
|
||||
${PATHS.map((d) => `<path d="${d}" fill="white"/>`).join('')}
|
||||
</svg>`
|
||||
const blob = new Blob([svgSrc], { type: 'image/svg+xml;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, 0, 0)
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(canvas)
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
hovered: boolean
|
||||
}
|
||||
|
||||
export function PostgresGlow({ hovered }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const liveRef = useRef({ hovered: false, hover: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
liveRef.current.hovered = hovered
|
||||
}, [hovered])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
let running = true
|
||||
let raf: number
|
||||
|
||||
async function init(canvas: HTMLCanvasElement) {
|
||||
const { Renderer, Program, Mesh, Triangle, Texture } = await import('ogl')
|
||||
if (!running) return
|
||||
|
||||
const svgCanvas = await buildSVGCanvas(512)
|
||||
if (!running) return
|
||||
|
||||
const renderer = new Renderer({
|
||||
canvas,
|
||||
alpha: true,
|
||||
premultipliedAlpha: true,
|
||||
dpr: window.devicePixelRatio,
|
||||
})
|
||||
const gl = renderer.gl
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
|
||||
const texture = new Texture(gl, {
|
||||
image: svgCanvas,
|
||||
generateMipmaps: false,
|
||||
minFilter: gl.LINEAR,
|
||||
magFilter: gl.LINEAR,
|
||||
})
|
||||
|
||||
const geometry = new Triangle(gl)
|
||||
const program = new Program(gl, {
|
||||
vertex: vert,
|
||||
fragment: frag,
|
||||
uniforms: {
|
||||
tMap: { value: texture },
|
||||
uHover: { value: 0 },
|
||||
uTime: { value: 0 },
|
||||
uResolution: { value: [canvas.width, canvas.height] },
|
||||
},
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const mesh = new Mesh(gl, { geometry, program })
|
||||
|
||||
const container = canvas.parentElement!
|
||||
function resize(entries?: ResizeObserverEntry[]) {
|
||||
const w = entries?.[0]?.contentRect.width ?? container.offsetWidth
|
||||
const h = entries?.[0]?.contentRect.height ?? container.offsetHeight
|
||||
renderer.setSize(w, h)
|
||||
program.uniforms.uResolution.value = [canvas.width, canvas.height]
|
||||
}
|
||||
resize()
|
||||
const ro = new ResizeObserver(resize)
|
||||
ro.observe(container)
|
||||
|
||||
function loop() {
|
||||
if (!running) return
|
||||
raf = requestAnimationFrame(loop)
|
||||
|
||||
const live = liveRef.current
|
||||
const target = live.hovered ? 1 : 0
|
||||
live.hover += (target - live.hover) * 0.06
|
||||
|
||||
program.uniforms.uHover.value = live.hover
|
||||
program.uniforms.uTime.value = performance.now() / 1000
|
||||
|
||||
renderer.render({ scene: mesh })
|
||||
}
|
||||
loop()
|
||||
|
||||
return () => ro.disconnect()
|
||||
}
|
||||
|
||||
let cleanupResize: (() => void) | undefined
|
||||
init(canvas).then((c) => {
|
||||
cleanupResize = c
|
||||
})
|
||||
|
||||
return () => {
|
||||
running = false
|
||||
cancelAnimationFrame(raf)
|
||||
cleanupResize?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={320}
|
||||
height={320}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ mixBlendMode: 'screen' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Code, FileDown, Heart, MousePointerClick } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const ICONS = { Code, MousePointerClick, Heart, FileDown } as const
|
||||
|
||||
type IconName = keyof typeof ICONS
|
||||
|
||||
const TABS = [
|
||||
{
|
||||
icon: 'Code' as IconName,
|
||||
label: 'Full SQL',
|
||||
description: 'A full SQL editor built right into the dashboard.',
|
||||
image: {
|
||||
dark: '/images/product/database/sql-view/sql-editor.png',
|
||||
light: '/images/product/database/sql-view/sql-editor-light.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'MousePointerClick' as IconName,
|
||||
label: 'Monaco editor',
|
||||
description: 'Built-in Monaco editor, with rich validation and autocomplete.',
|
||||
image: {
|
||||
dark: '/images/product/database/sql-view/monaco-editor.png',
|
||||
light: '/images/product/database/sql-view/monaco-editor-light.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'Heart' as IconName,
|
||||
label: 'Favorite queries',
|
||||
description: 'Save your favorite queries to use again and again.',
|
||||
image: {
|
||||
dark: '/images/product/database/sql-view/favorites.png',
|
||||
light: '/images/product/database/sql-view/favorites-light.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'FileDown' as IconName,
|
||||
label: 'Export to CSV',
|
||||
description: 'Any output can be exported into a CSV.',
|
||||
image: {
|
||||
dark: '/images/product/database/sql-view/export.png',
|
||||
light: '/images/product/database/sql-view/export-csv-light.png',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const INTERVAL_DURATION = 8000
|
||||
|
||||
export function SqlEditorSection() {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = TABS[activeIdx]
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % TABS.length)
|
||||
}
|
||||
}, [progress])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={inViewRef} className="py-24 flex flex-col gap-24">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Write and run queries with a
|
||||
<br />
|
||||
<span className="text-foreground">full SQL Editor</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Write, run, and save SQL queries directly from the dashboard with full autocomplete and
|
||||
syntax highlighting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tabs */}
|
||||
<div className="flex flex-col gap-1 items-start justify-end">
|
||||
{TABS.map((tab, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const Icon = ICONS[tab.icon]
|
||||
return (
|
||||
<button
|
||||
key={tab.label}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: image + footer */}
|
||||
<div className="flex flex-col border border-border rounded-md overflow-clip bg-surface-75">
|
||||
<div className="relative aspect-[16/7.5] shrink-0 overflow-hidden rounded-t-md flex items-end justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.2, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.1 } }}
|
||||
className="absolute inset-0 flex items-end justify-center px-6"
|
||||
>
|
||||
<img
|
||||
src={active.image.dark}
|
||||
alt={active.label}
|
||||
className="absolute inset-0 object-cover pointer-events-none hidden dark:block"
|
||||
/>
|
||||
<img
|
||||
src={active.image.light}
|
||||
alt={active.label}
|
||||
className="absolute inset-0 object-cover pointer-events-none dark:hidden"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer: active tab info */}
|
||||
<div className="border-t border-border px-6 py-4 flex items-center gap-8 justify-between bg-surface-100">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-foreground">{active.label}</h4>
|
||||
<p className="text-sm text-foreground-lighter">{active.description}</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { FileDown, Link2, Table, TableProperties } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const ICONS = { TableProperties, Table, Link2, FileDown } as const
|
||||
|
||||
type IconName = keyof typeof ICONS
|
||||
|
||||
type Tab = {
|
||||
icon: IconName
|
||||
label: string
|
||||
description: string
|
||||
image: { dark: string; light: string }
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{
|
||||
icon: 'TableProperties',
|
||||
label: 'Spreadsheet editing',
|
||||
description: 'Add, edit, and update your data with the simplicity of a no-code tool.',
|
||||
image: {
|
||||
dark: '/images/product/database/table-view/spreadsheet.png',
|
||||
light: '/images/product/database/table-view/table-editor-light.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'Table',
|
||||
label: 'Create tables',
|
||||
description: 'Add tables, columns and rows without writing SQL.',
|
||||
image: {
|
||||
dark: '/images/product/database/table-view/create-tables.png',
|
||||
light: '/images/product/database/table-view/create-table-light.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'Link2',
|
||||
label: 'Foreign keys',
|
||||
description: 'Build connections across tables with the full power of relational data.',
|
||||
image: {
|
||||
dark: '/images/product/database/table-view/foreign-keys.png',
|
||||
light: '/images/product/database/table-view/foreign-keys-light.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'FileDown',
|
||||
label: 'Export to CSV',
|
||||
description: 'Pick the rows you want and export them into a CSV.',
|
||||
image: {
|
||||
dark: '/images/product/database/table-view/export-csv.png',
|
||||
light: '/images/product/database/table-view/export-csv-light.png',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const INTERVAL_DURATION = 8000
|
||||
|
||||
export function TableEditorSection() {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = TABS[activeIdx]
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % TABS.length)
|
||||
}
|
||||
}, [progress])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={inViewRef} className="py-24 flex flex-col gap-24">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Manage your data with a
|
||||
<br />
|
||||
<span className="text-foreground">built-in Table Editor</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Manage your data with a spreadsheet-like interface. Create tables, set up relationships,
|
||||
and export — no SQL required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tabs */}
|
||||
<div className="flex flex-col gap-1 items-start justify-end">
|
||||
{TABS.map((tab, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const Icon = ICONS[tab.icon]
|
||||
return (
|
||||
<button
|
||||
key={tab.label}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: image + footer */}
|
||||
<div className="flex flex-col border border-border rounded-md overflow-clip bg-surface-75">
|
||||
<div className="relative aspect-[16/7.5] shrink-0 overflow-hidden rounded-t-md flex items-end justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.2, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.1 } }}
|
||||
className="absolute inset-0 flex items-end justify-center px-6"
|
||||
>
|
||||
<img
|
||||
src={active.image.dark}
|
||||
alt={active.label}
|
||||
className="absolute inset-0 object-cover pointer-events-none hidden dark:block"
|
||||
/>
|
||||
<img
|
||||
src={active.image.light}
|
||||
alt={active.label}
|
||||
className="absolute inset-0 object-cover pointer-events-none dark:hidden"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer: active tab info */}
|
||||
<div className="border-t border-border px-6 py-4 flex items-center gap-8 justify-between bg-surface-100">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-foreground">{active.label}</h4>
|
||||
<p className="text-sm text-foreground-lighter">{active.description}</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown, Filter, Key, List } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
const columns = [
|
||||
{ name: 'id', format: 'int8', isPrimaryKey: true },
|
||||
{ name: 'created_at', format: 'timestamptz' },
|
||||
{ name: 'name', format: 'text' },
|
||||
{ name: 'email', format: 'text' },
|
||||
{ name: 'role', format: 'text' },
|
||||
{ name: 'is_active', format: 'bool' },
|
||||
{ name: 'avatar_url', format: 'text' },
|
||||
]
|
||||
|
||||
type Row = {
|
||||
id: number
|
||||
created_at: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
is_active: boolean
|
||||
avatar_url: string
|
||||
}
|
||||
|
||||
const initialRows: Row[] = [
|
||||
{
|
||||
id: 1,
|
||||
created_at: '2024-01-15 09:23:41+00',
|
||||
name: 'Alice Johnson',
|
||||
email: 'alice@example.com',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=alice',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
created_at: '2024-01-15 10:45:12+00',
|
||||
name: 'Bob Smith',
|
||||
email: 'bob@example.com',
|
||||
role: 'editor',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=bob',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
created_at: '2024-02-03 14:12:08+00',
|
||||
name: 'Carol Williams',
|
||||
email: 'carol@example.com',
|
||||
role: 'viewer',
|
||||
is_active: false,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=carol',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
created_at: '2024-02-14 08:33:55+00',
|
||||
name: 'David Brown',
|
||||
email: 'david@example.com',
|
||||
role: 'editor',
|
||||
is_active: true,
|
||||
avatar_url: 'NULL',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
created_at: '2024-03-01 16:07:29+00',
|
||||
name: 'Eve Martinez',
|
||||
email: 'eve@example.com',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=eve',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
created_at: '2024-03-10 11:52:44+00',
|
||||
name: 'Frank Lee',
|
||||
email: 'frank@example.com',
|
||||
role: 'viewer',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=frank',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
created_at: '2024-03-22 07:18:03+00',
|
||||
name: 'Grace Kim',
|
||||
email: 'grace@example.com',
|
||||
role: 'editor',
|
||||
is_active: false,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=grace',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
created_at: '2024-04-05 13:41:17+00',
|
||||
name: 'Henry Chen',
|
||||
email: 'henry@example.com',
|
||||
role: 'viewer',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=henry',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
created_at: '2024-04-12 09:05:33+00',
|
||||
name: 'Isla Patel',
|
||||
email: 'isla@example.com',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=isla',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
created_at: '2024-04-18 15:22:07+00',
|
||||
name: 'Jack Rivera',
|
||||
email: 'jack@example.com',
|
||||
role: 'editor',
|
||||
is_active: false,
|
||||
avatar_url: 'NULL',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
created_at: '2024-05-02 11:48:19+00',
|
||||
name: 'Karen Nguyen',
|
||||
email: 'karen@example.com',
|
||||
role: 'viewer',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=karen',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
created_at: '2024-05-09 08:14:52+00',
|
||||
name: "Liam O'Brien",
|
||||
email: 'liam@example.com',
|
||||
role: 'editor',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=liam',
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
created_at: '2024-05-21 17:33:28+00',
|
||||
name: 'Mia Tanaka',
|
||||
email: 'mia@example.com',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=mia',
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
created_at: '2024-06-01 10:07:41+00',
|
||||
name: 'Noah Garcia',
|
||||
email: 'noah@example.com',
|
||||
role: 'viewer',
|
||||
is_active: false,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=noah',
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
created_at: '2024-06-14 14:55:03+00',
|
||||
name: 'Olivia Schmidt',
|
||||
email: 'olivia@example.com',
|
||||
role: 'editor',
|
||||
is_active: true,
|
||||
avatar_url: 'NULL',
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
created_at: '2024-06-23 06:29:17+00',
|
||||
name: 'Pablo Morales',
|
||||
email: 'pablo@example.com',
|
||||
role: 'viewer',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=pablo',
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
created_at: '2024-07-04 12:11:44+00',
|
||||
name: 'Quinn Foster',
|
||||
email: 'quinn@example.com',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=quinn',
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
created_at: '2024-07-15 19:38:56+00',
|
||||
name: 'Ruby Aoki',
|
||||
email: 'ruby@example.com',
|
||||
role: 'editor',
|
||||
is_active: false,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=ruby',
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
created_at: '2024-07-28 07:52:10+00',
|
||||
name: 'Sam Walker',
|
||||
email: 'sam@example.com',
|
||||
role: 'viewer',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=sam',
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
created_at: '2024-08-03 16:04:33+00',
|
||||
name: 'Tara Singh',
|
||||
email: 'tara@example.com',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
avatar_url: 'https://i.pravatar.cc/150?u=tara',
|
||||
},
|
||||
]
|
||||
|
||||
const namePool = [
|
||||
'Uri Petrov',
|
||||
'Vera Costa',
|
||||
'Will Chang',
|
||||
'Xena Müller',
|
||||
'Yuki Sato',
|
||||
'Zara Ahmed',
|
||||
'Felix Braun',
|
||||
'Luna Park',
|
||||
'Oscar Diaz',
|
||||
'Nina Rossi',
|
||||
]
|
||||
const rolePool = ['admin', 'editor', 'viewer']
|
||||
const editableFields = ['name', 'email', 'role', 'is_active'] as const
|
||||
const nameEdits = [
|
||||
'Alex Turner',
|
||||
'Sofia Reyes',
|
||||
'Marcus Cole',
|
||||
'Priya Sharma',
|
||||
'Leo Zhang',
|
||||
'Dana Okafor',
|
||||
]
|
||||
const emailEdits = [
|
||||
'updated@example.com',
|
||||
'new.email@example.com',
|
||||
'changed@example.com',
|
||||
'modified@example.com',
|
||||
]
|
||||
|
||||
function randomTimestamp(): string {
|
||||
const m = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0')
|
||||
const d = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0')
|
||||
const h = String(Math.floor(Math.random() * 24)).padStart(2, '0')
|
||||
const min = String(Math.floor(Math.random() * 60)).padStart(2, '0')
|
||||
const sec = String(Math.floor(Math.random() * 60)).padStart(2, '0')
|
||||
return `2025-${m}-${d} ${h}:${min}:${sec}+00`
|
||||
}
|
||||
|
||||
function pick<T>(arr: readonly T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)]
|
||||
}
|
||||
|
||||
function formatCellValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return 'NULL'
|
||||
if (typeof value === 'boolean') return value.toString()
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// ── Stable identity for each row instance ──
|
||||
// Every row gets a unique _uid that never changes and is never reused.
|
||||
let uidCounter = 0
|
||||
type URow = Row & { _uid: string }
|
||||
|
||||
function stampRow(row: Row): URow {
|
||||
return { ...row, _uid: `u${++uidCounter}` }
|
||||
}
|
||||
|
||||
// ── Flash state: at most one thing highlighted at a time ──
|
||||
type Flash = {
|
||||
type: 'modify' | 'add' | 'delete'
|
||||
uid: string // row _uid
|
||||
col?: string // column name, only for 'modify'
|
||||
} | null
|
||||
|
||||
export function VisualDemo() {
|
||||
// All rows carry a stable _uid used as the sole React key.
|
||||
const [initialStamped] = useState<URow[]>(() => initialRows.map(stampRow))
|
||||
const [rows, setRows] = useState<URow[]>(initialStamped)
|
||||
const [introComplete, setIntroComplete] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
|
||||
threshold: 0.1,
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Map from _uid → stagger order for intro animation
|
||||
const introOrder = useRef<Record<string, number>>({})
|
||||
if (Object.keys(introOrder.current).length === 0) {
|
||||
initialStamped.forEach((r, i) => {
|
||||
introOrder.current[r._uid] = i
|
||||
})
|
||||
}
|
||||
|
||||
// Mutable refs so the action loop never needs to re-create.
|
||||
const rowsRef = useRef(rows)
|
||||
rowsRef.current = rows
|
||||
const nextId = useRef(initialRows.length + 1)
|
||||
const deleted = useRef<URow[]>([])
|
||||
|
||||
// Per-cell version counter: bumped on every modify so the AnimatePresence
|
||||
// key always changes, even if the display value happens to stay the same.
|
||||
const cellVer = useRef<Record<string, number>>({})
|
||||
function bumpCellVer(uid: string, col: string) {
|
||||
const k = `${uid}.${col}`
|
||||
cellVer.current[k] = (cellVer.current[k] || 0) + 1
|
||||
}
|
||||
function getCellVer(uid: string, col: string) {
|
||||
return cellVer.current[`${uid}.${col}`] || 0
|
||||
}
|
||||
|
||||
// ── Intro stagger ──
|
||||
const introMs = (initialRows.length * 0.025 + 0.36) * 1000 + 200
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setIntroComplete(true), introMs)
|
||||
return () => clearTimeout(t)
|
||||
}, [introMs])
|
||||
|
||||
// ── Action loop — one action at a time, only while in view ──
|
||||
useEffect(() => {
|
||||
if (!introComplete || !isInView) return
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
|
||||
function schedule() {
|
||||
const delay = 1500 + Math.random() * 2500
|
||||
timer = setTimeout(() => {
|
||||
if (cancelled) return
|
||||
runAction(() => {
|
||||
if (!cancelled) schedule()
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function runAction(onDone: () => void) {
|
||||
const cur = rowsRef.current
|
||||
const actions = ['modify', 'modify', 'delete', 'add'] as const
|
||||
let action = pick(actions)
|
||||
if (action === 'delete' && cur.length <= 20) action = 'add'
|
||||
|
||||
if (action === 'modify') {
|
||||
const row = pick(cur)
|
||||
const field = pick(editableFields)
|
||||
const updated = { ...row }
|
||||
|
||||
if (field === 'name') updated.name = pick(nameEdits)
|
||||
else if (field === 'email') {
|
||||
const u = updated.name.toLowerCase().split(' ')[0]
|
||||
updated.email = Math.random() > 0.5 ? `${u}@example.com` : pick(emailEdits)
|
||||
} else if (field === 'role') {
|
||||
updated.role = pick(rolePool.filter((r) => r !== updated.role))
|
||||
} else {
|
||||
updated.is_active = !updated.is_active
|
||||
}
|
||||
|
||||
bumpCellVer(row._uid, field)
|
||||
setFlash({ type: 'modify', uid: row._uid, col: field })
|
||||
setRows((prev) => prev.map((r) => (r._uid === row._uid ? updated : r)))
|
||||
|
||||
setTimeout(() => {
|
||||
setFlash(null)
|
||||
onDone()
|
||||
}, 800)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
const row = pick(cur)
|
||||
// Flash red and remove simultaneously — flash stays visible during exit animation
|
||||
setFlash({ type: 'delete', uid: row._uid })
|
||||
deleted.current.push(row)
|
||||
setRows((prev) => prev.filter((r) => r._uid !== row._uid))
|
||||
|
||||
setTimeout(() => {
|
||||
setFlash(null)
|
||||
onDone()
|
||||
}, 600)
|
||||
return
|
||||
}
|
||||
|
||||
// ── add ──
|
||||
let base: Row
|
||||
if (deleted.current.length > 0 && Math.random() > 0.3) {
|
||||
const i = Math.floor(Math.random() * deleted.current.length)
|
||||
base = { ...deleted.current[i], id: nextId.current++ }
|
||||
deleted.current.splice(i, 1)
|
||||
} else {
|
||||
const name = pick(namePool)
|
||||
const u = name.toLowerCase().split(' ')[0]
|
||||
base = {
|
||||
id: nextId.current++,
|
||||
created_at: randomTimestamp(),
|
||||
name,
|
||||
email: `${u}@example.com`,
|
||||
role: pick(rolePool),
|
||||
is_active: Math.random() > 0.3,
|
||||
avatar_url: Math.random() > 0.2 ? `https://i.pravatar.cc/150?u=${u}` : 'NULL',
|
||||
}
|
||||
}
|
||||
|
||||
const newRow = stampRow(base)
|
||||
setFlash({ type: 'add', uid: newRow._uid })
|
||||
setRows((prev) => {
|
||||
const pos = Math.floor(Math.random() * (prev.length + 1))
|
||||
const next = [...prev]
|
||||
next.splice(pos, 0, newRow)
|
||||
return next
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
setFlash(null)
|
||||
onDone()
|
||||
}, 800)
|
||||
}
|
||||
|
||||
schedule()
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [introComplete, isInView])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="border-t border-border relative">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pb-16 md:pb-24">
|
||||
<div className="pt-8 md:pt-0 md:-translate-y-16 relative w-full h-[620px]">
|
||||
{/* Gradient blob behind card */}
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[105%] w-[102%] rounded-[300px] blur-[50px] dark:blur-[24px] dark:saturate-[0.4] dark:brightness-50"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(62, 207, 142, 0.2) 0%, rgba(151, 62, 198, 0.2) 50%, rgba(62, 207, 142, 0.2) 100%)',
|
||||
}}
|
||||
/>
|
||||
{/* Card */}
|
||||
<div
|
||||
className="relative z-10 w-full h-full rounded-xl border border-border bg-surface-75 overflow-hidden flex flex-col"
|
||||
style={{
|
||||
boxShadow:
|
||||
'0 0 0 0.5px var(--shadow-border-color), 0 149px 199px 0 rgba(0,0,0,0.07), 0 62.249px 83.137px 0 rgba(0,0,0,0.05), 0 33.281px 44.449px 0 rgba(0,0,0,0.04), 0 18.657px 24.918px 0 rgba(0,0,0,0.04), 0 9.909px 13.234px 0 rgba(0,0,0,0.03), 0 4.123px 5.507px 0 rgba(0,0,0,0.02)',
|
||||
}}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-1.5 h-10 border-b border-default bg-surface-200 shrink-0">
|
||||
<button className="flex items-center gap-1.5 px-2 py-1 rounded text-xs text-foreground-light hover:text-foreground transition-colors">
|
||||
<Filter size={14} strokeWidth={1.5} />
|
||||
<span>Filter</span>
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 px-2 py-1 rounded text-xs text-foreground-light hover:text-foreground transition-colors">
|
||||
<List size={14} strokeWidth={1.5} />
|
||||
<span>Sort</span>
|
||||
</button>
|
||||
<Button
|
||||
size="tiny"
|
||||
className="ml-auto"
|
||||
iconRight={<ChevronDown size={12} strokeWidth={2} />}
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,0.85) 80%, transparent 100%)',
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, rgba(0,0,0,0.85) 80%, transparent 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<table
|
||||
className="w-full border-collapse text-[13px] !mt-0"
|
||||
style={{ minWidth: 900 }}
|
||||
>
|
||||
<thead>
|
||||
<tr className="bg-surface-200">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.name}
|
||||
className="border-b border-r last:border-r-0 border-default px-3 py-1.5 text-left font-normal"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
{col.isPrimaryKey && (
|
||||
<Key
|
||||
size={12}
|
||||
strokeWidth={2}
|
||||
className="text-brand rotate-45 shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-foreground text-xs truncate font-medium">
|
||||
{col.name}
|
||||
</span>
|
||||
<span className="text-foreground-light text-xs truncate">
|
||||
{col.format}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<AnimatePresence initial={true}>
|
||||
{rows.map((row) => (
|
||||
<motion.tr
|
||||
key={row._uid}
|
||||
layout
|
||||
className="bg-surface-75"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={
|
||||
!introComplete
|
||||
? {
|
||||
opacity: {
|
||||
duration: 0.36,
|
||||
delay: (introOrder.current[row._uid] ?? 0) * 0.025,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
},
|
||||
height: { duration: 0 },
|
||||
}
|
||||
: {
|
||||
layout: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] },
|
||||
opacity: { duration: 0.2 },
|
||||
height: { duration: 0.3 },
|
||||
}
|
||||
}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const value = row[col.name as keyof Row]
|
||||
const displayValue = formatCellValue(value)
|
||||
const isNull = displayValue === 'NULL'
|
||||
const isBool = col.format === 'bool'
|
||||
|
||||
const isModifiedCell =
|
||||
flash?.type === 'modify' &&
|
||||
flash.uid === row._uid &&
|
||||
flash.col === col.name
|
||||
const isAddedRow = flash?.type === 'add' && flash.uid === row._uid
|
||||
const isDeletedRow = flash?.type === 'delete' && flash.uid === row._uid
|
||||
|
||||
const cellBg = isDeletedRow
|
||||
? 'hsl(var(--destructive-default) / 0.15)'
|
||||
: isModifiedCell || isAddedRow
|
||||
? 'hsl(var(--brand-default) / 0.15)'
|
||||
: undefined
|
||||
|
||||
const ver = getCellVer(row._uid, col.name)
|
||||
|
||||
return (
|
||||
<td
|
||||
key={col.name}
|
||||
className="border-b border-r last:border-r-0 border-secondary px-3 py-1.5 max-w-[200px] transition-colors duration-500"
|
||||
style={cellBg ? { backgroundColor: cellBg } : undefined}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={`${row._uid}.${col.name}.${ver}`}
|
||||
className={`block truncate ${
|
||||
isNull ? 'text-foreground-muted italic' : 'text-foreground'
|
||||
}`}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{isNull
|
||||
? 'NULL'
|
||||
: isBool
|
||||
? value
|
||||
? 'true'
|
||||
: 'false'
|
||||
: displayValue}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</motion.tr>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { ApiSection } from './_components/ApiSection'
|
||||
import { DatabaseContent } from './_components/DatabaseContent'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Database | Supabase',
|
||||
description:
|
||||
'Every Supabase project is a dedicated Postgres database. 100% portable with no vendor lock-in.',
|
||||
}
|
||||
|
||||
export default function DatabasePage() {
|
||||
return <DatabaseContent apiSlot={<ApiSection />} />
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion, useInView } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
const TERMINAL_LINES = [
|
||||
{ text: '$ supabase functions new hello-world', delay: 0 },
|
||||
{ text: 'Created function: supabase/functions/hello-world/index.ts', delay: 0.3, dim: true },
|
||||
{ text: '', delay: 0.5 },
|
||||
{ text: '$ supabase functions deploy hello-world', delay: 0.6 },
|
||||
{ text: 'Bundling function...', delay: 0.9, dim: true },
|
||||
{ text: 'Deploying to 30+ regions...', delay: 1.1, dim: true },
|
||||
{ text: '', delay: 1.3 },
|
||||
{ text: '✓ Function deployed:', delay: 1.4, accent: true },
|
||||
{ text: ' https://your-project.supabase.co/functions/v1/hello-world', delay: 1.5, accent: true },
|
||||
]
|
||||
|
||||
function TerminalAnimation() {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const inView = useInView(ref, { once: true, amount: 0.5 })
|
||||
const [visibleCount, setVisibleCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) return
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
TERMINAL_LINES.forEach((line, i) => {
|
||||
timeouts.push(setTimeout(() => setVisibleCount(i + 1), line.delay * 1000))
|
||||
})
|
||||
return () => timeouts.forEach(clearTimeout)
|
||||
}, [inView])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full max-w-xl mx-auto rounded-lg border border-border bg-surface-75 overflow-hidden"
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-2 border-b border-border bg-surface-200">
|
||||
<span className="ml-2 text-xs text-foreground-muted font-mono uppercase">Terminal</span>
|
||||
</div>
|
||||
{/* Lines */}
|
||||
<div className="px-4 md:px-6 font-mono text-xs md:text-sm leading-relaxed h-[280px] flex flex-col justify-start items-start text-left pt-4">
|
||||
<AnimatePresence>
|
||||
{TERMINAL_LINES.slice(0, visibleCount).map((line, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 1 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={
|
||||
line.accent ? 'text-brand' : line.dim ? 'text-foreground-light' : 'text-foreground'
|
||||
}
|
||||
>
|
||||
{line.text || '\u00A0'}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CTASection() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)]">
|
||||
<div className="flex flex-col items-center text-center gap-10 py-24 md:py-32 px-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<h2 className="text-2xl md:text-4xl text-foreground">
|
||||
Deploy your first function
|
||||
<span className="block text-foreground-lighter">in under a minute</span>
|
||||
</h2>
|
||||
<p className="text-foreground-lighter text-sm max-w-md">
|
||||
Two commands. Global deployment. No infrastructure to manage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TerminalAnimation />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="medium">
|
||||
<Link href="https://supabase.com/dashboard">Start your project</Link>
|
||||
</Button>
|
||||
<Button asChild size="medium" type="default">
|
||||
<Link href="/docs/guides/functions/quickstart">Quickstart guide</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { QuoteSection } from '../../_shared/QuoteSection'
|
||||
import { CTASection } from './CTASection'
|
||||
import { ExamplesSection } from './ExamplesSection'
|
||||
import { GlobalPresenceSection } from './GlobalPresenceSection'
|
||||
import { Hero } from './Hero'
|
||||
import { Highlights } from './Highlights'
|
||||
import { IntegratesSection } from './IntegratesSection'
|
||||
import { LocalDXSection } from './LocalDXSection'
|
||||
import { ObservabilitySection } from './ObservabilitySection'
|
||||
|
||||
export function EdgeFunctionsContent() {
|
||||
return (
|
||||
<div className="overflow-x-clip">
|
||||
<section className="pb-16 md:pb-32">
|
||||
<Hero />
|
||||
<Highlights />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<QuoteSection
|
||||
quote="Supabase gave us the flexibility and scalability needed at every growth stage."
|
||||
highlight="It's rare to find a tool that works just as well for startups as it does for large-scale operations."
|
||||
author={{
|
||||
name: 'Zeno Rocha',
|
||||
role: 'CEO at Resend',
|
||||
image: '/images/blog/avatars/zeno-rocha.png',
|
||||
link: '/customers/resend',
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<ExamplesSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<LocalDXSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<GlobalPresenceSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<ObservabilitySection />
|
||||
</section>
|
||||
<section className="border-y border-border">
|
||||
<IntegratesSection />
|
||||
</section>
|
||||
<section className="">
|
||||
<CTASection />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Examples from 'data/Examples'
|
||||
import Link from 'next/link'
|
||||
import { PRODUCT_NAMES } from 'shared-data/products'
|
||||
|
||||
const EXAMPLE_ICON: Record<string, { src: string; invert?: boolean; className?: string }> = {
|
||||
Resend: { src: '/images/logos/resend-wordmark.svg', invert: true, className: 'h-4 w-auto' },
|
||||
OpenAI: { src: '/images/logos/frameworks/openai.svg', invert: true, className: 'h-5 w-5' },
|
||||
Stripe: { src: '/images/logos/frameworks/stripe.svg', className: 'h-7 w-auto' },
|
||||
Postgres: { src: '/images/product/database/postgresql-icon.svg', className: 'h-5 w-5' },
|
||||
Huggingface: { src: '/images/logos/hugging-face-icon.svg', className: 'h-5 w-5' },
|
||||
Supabase: { src: '/images/supabase-logo-icon.svg', className: 'h-5 w-5' },
|
||||
}
|
||||
|
||||
export function ExamplesSection() {
|
||||
const examples = Examples.filter((example) => example.products.includes(PRODUCT_NAMES.FUNCTIONS))
|
||||
const featuredExamples = examples.slice(0, 2)
|
||||
const gridExamples = examples.slice(2, 6)
|
||||
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
What you can build <br />
|
||||
<span className="text-foreground">with Edge Functions</span>
|
||||
</h3>
|
||||
<Link
|
||||
href="/docs/guides/functions#examples"
|
||||
className="text-sm text-foreground-light hover:text-foreground underline"
|
||||
>
|
||||
View all examples
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
{/* Featured row - 2 large cells */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{featuredExamples.map((example) => {
|
||||
const tag = example.tags?.[0]
|
||||
const icon = tag ? EXAMPLE_ICON[tag] : undefined
|
||||
return (
|
||||
<Link
|
||||
key={example.title}
|
||||
href={example.repo_url ?? '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex flex-col bg-surface-75 border border-border rounded-lg px-6 py-8 "
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{icon && (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt=""
|
||||
className={`${icon.className} object-contain${icon.invert ? ' dark:invert' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-foreground text-sm font-medium">{example.title}</h4>
|
||||
<p className="text-foreground-lighter text-sm mt-1 line-clamp-2">
|
||||
{example.description}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grid row - 4 smaller cells */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mt-3">
|
||||
{gridExamples.map((example) => {
|
||||
const tag = example.tags?.[0]
|
||||
const icon = tag ? EXAMPLE_ICON[tag] : undefined
|
||||
return (
|
||||
<Link
|
||||
key={example.title}
|
||||
href={example.repo_url ?? '#'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex flex-col bg-surface-75 border border-border rounded-lg px-6 py-10 "
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{icon && (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt=""
|
||||
className={`${icon.className} object-contain${icon.invert ? ' dark:invert' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-foreground text-sm font-medium">{example.title}</h4>
|
||||
<p className="text-foreground-lighter text-sm mt-1 line-clamp-2">
|
||||
{example.description}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
const features = [
|
||||
{
|
||||
label: 'Global presence',
|
||||
paragraph: (
|
||||
<>
|
||||
Edge functions run globally or can be{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/functions/regional-invocation"
|
||||
className="underline hover:text-foreground-light transition-colors"
|
||||
>
|
||||
pinned to your database's proximity
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Automatic scaling',
|
||||
paragraph: 'Seamlessly scale with usage without any manual tuning',
|
||||
},
|
||||
{
|
||||
label: 'Secure',
|
||||
paragraph: 'Scale with confidence with SSL, Firewall and DDOS protection built in',
|
||||
},
|
||||
]
|
||||
|
||||
export function GlobalPresenceSection() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[75rem] px-6 py-16 md:py-32">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 md:gap-16">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Edge Functions run{' '}
|
||||
<span className="text-foreground">server-side logic geographically close to users</span>,
|
||||
offering low latency and great performance.
|
||||
</h3>
|
||||
<div className="flex flex-col gap-6">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.label}
|
||||
className="flex flex-col gap-1 pl-3 border-l border-border text-sm"
|
||||
>
|
||||
<p className="font-mono uppercase tracking-wide text-foreground">{feature.label}</p>
|
||||
<p className="text-foreground-lighter">{feature.paragraph}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-32 pb-16 md:pt-40 md:pb-32">
|
||||
<div className="flex flex-col gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h1 className="text-foreground text-3xl sm:text-5xl sm:leading-none">
|
||||
<span className="block">Edge Functions</span>
|
||||
<span className="text-foreground-lighter block">globally in seconds</span>
|
||||
</h1>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Author, deploy, and monitor serverless functions distributed globally at the edge, close
|
||||
to your users.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="medium">
|
||||
<Link href="https://supabase.com/dashboard">Start your project</Link>
|
||||
</Button>
|
||||
<Button asChild size="medium" type="default">
|
||||
<Link href="/docs/guides/functions">Documentation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
const highlights = [
|
||||
{
|
||||
title: 'Fully managed',
|
||||
paragraph: <>Edge Functions scale automatically, reducing your devops burden</>,
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11.8217 2.21899C9.75065 2.21899 8.07174 3.89791 8.07174 5.96896V11.783C8.07174 11.8536 8.08148 11.9218 8.0997 11.9866C8.17354 12.3232 8.47349 12.5752 8.83229 12.5752H10.5494C10.8626 12.5752 11.1749 12.8513 11.1749 13.2656V15.0638C11.1749 15.3165 11.2998 15.54 11.4913 15.6759C11.6177 15.7729 11.7759 15.8306 11.9476 15.8306H17.9997C20.0707 15.8306 21.7496 14.1517 21.7496 12.0806V5.96896C21.7496 3.89791 20.0707 2.21899 17.9997 2.21899H11.8217ZM12.6749 14.3307H17.9997C19.2423 14.3307 20.2497 13.3233 20.2497 12.0806L20.2497 5.96896C20.2497 4.7263 19.2423 3.71893 17.9997 3.71893H11.8217C10.579 3.71893 9.57167 4.7263 9.57167 5.96896V11.0752H10.5494C10.9115 11.0752 11.2478 11.1666 11.5412 11.3263C11.5507 11.3157 11.5605 11.3052 11.5708 11.295L15.5026 7.36084L12.6816 7.36066C12.2674 7.36063 11.9316 7.02482 11.9317 6.61061C11.9317 6.1964 12.2675 5.86063 12.6817 5.86066L16.543 5.86091C17.3714 5.86096 18.0429 6.53248 18.0429 7.36085L18.0431 11.2597C18.0431 11.6739 17.7073 12.0097 17.2931 12.0097C16.8789 12.0097 16.5431 11.674 16.5431 11.2598L16.543 8.44186L12.6317 12.3553C12.597 12.3901 12.5597 12.4207 12.5205 12.4472C12.6204 12.7022 12.6749 12.9791 12.6749 13.2656V14.3307Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4.53957 11.0752H6.25666C6.67087 11.0752 7.00666 11.411 7.00666 11.8252C7.00666 12.2394 6.67087 12.5752 6.25666 12.5752H4.53957C4.22636 12.5752 3.91406 12.8513 3.91406 13.2656L3.91406 15.0638C3.91406 15.4781 3.57828 15.8138 3.16406 15.8138C2.74985 15.8138 2.41406 15.4781 2.41406 15.0638V13.2656C2.41406 12.0889 3.33344 11.0752 4.53957 11.0752Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3.16406 17.0111C3.57828 17.0111 3.91406 17.3469 3.91406 17.7611L3.91406 19.5593C3.91406 19.9737 4.22636 20.2498 4.53957 20.2498L6.25666 20.2498C6.67087 20.2498 7.00666 20.5856 7.00666 20.9998C7.00666 21.414 6.67087 21.7498 6.25666 21.7498L4.53957 21.7498C3.33344 21.7498 2.41406 20.7361 2.41406 19.5593V17.7611C2.41406 17.3469 2.74985 17.0111 3.16406 17.0111Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M11.9249 17.0111C12.3391 17.0111 12.6749 17.3469 12.6749 17.7611V19.5593C12.6749 20.7361 11.7555 21.7498 10.5494 21.7498H8.83229C8.41807 21.7498 8.08229 21.414 8.08229 20.9998C8.08229 20.5856 8.41807 20.2498 8.83229 20.2498H10.5494C10.8626 20.2498 11.1749 19.9737 11.1749 19.5593V17.7611C11.1749 17.3469 11.5107 17.0111 11.9249 17.0111Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Global deployments',
|
||||
paragraph: 'Deploy worldwide for maximum resiliency and low latency',
|
||||
icon: <Globe className="w-5 h-5 stroke-[1.4px]" />,
|
||||
},
|
||||
{
|
||||
title: 'Secure and Scalable',
|
||||
paragraph: 'Simply write your code in TypeScript and deploy',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M13.7377 6.5363C14.145 6.61196 14.4138 7.00343 14.3381 7.41068L12.5869 16.8366C12.5112 17.2438 12.1197 17.5126 11.7125 17.437C11.3052 17.3613 11.0364 16.9698 11.1121 16.5626L12.8633 7.13669C12.939 6.72944 13.3305 6.46064 13.7377 6.5363Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.7279 9.10834C16.0208 8.81543 16.4957 8.81539 16.7886 9.10826L19.1499 11.4692C19.2906 11.6099 19.3696 11.8007 19.3696 11.9996C19.3696 12.1985 19.2906 12.3893 19.15 12.5299L16.7886 14.8913C16.4957 15.1842 16.0209 15.1842 15.728 14.8913C15.4351 14.5984 15.4351 14.1235 15.728 13.8306L17.5589 11.9997L15.728 10.169C15.4351 9.87613 15.4351 9.40126 15.7279 9.10834Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.72153 14.8913C9.42866 15.1842 8.95379 15.1842 8.66087 14.8913L6.29954 12.5303C6.15887 12.3897 6.07983 12.1989 6.07983 12C6.07982 11.8011 6.15884 11.6103 6.2995 11.4696L8.66083 9.10831C8.95373 8.81542 9.4286 8.81542 9.72149 9.10831C10.0144 9.4012 10.0144 9.87608 9.72149 10.169L7.89053 11.9999L9.72145 13.8306C10.0144 14.1235 10.0144 14.5983 9.72153 14.8913Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.95312 6C2.95312 3.92893 4.63206 2.25 6.70313 2.25H18.7031C20.7742 2.25 22.4531 3.92893 22.4531 6V18C22.4531 20.0711 20.7742 21.75 18.7031 21.75H6.70313C4.63206 21.75 2.95312 20.0711 2.95312 18V6ZM6.70313 3.75C5.46048 3.75 4.45313 4.75736 4.45313 6V18C4.45313 19.2426 5.46048 20.25 6.70313 20.25H18.7031C19.9458 20.25 20.9531 19.2426 20.9531 18V6C20.9531 4.75736 19.9458 3.75 18.7031 3.75H6.70313Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Node.js Support',
|
||||
paragraph: 'Migrate existing workloads with NPM and Node.js support',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 26 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M11.9233 0.444469C12.6102 0.0512999 13.5049 0.049524 14.1912 0.444469C17.6439 2.39542 21.0976 4.3433 24.5497 6.29543C25.199 6.66117 25.6333 7.38378 25.6267 8.13193V19.8696C25.6315 20.6487 25.1543 21.3886 24.4693 21.746C21.028 23.6862 17.5884 25.6288 14.1477 27.569C13.4466 27.9698 12.5339 27.939 11.8547 27.5052C10.823 26.9072 9.78955 26.312 8.75774 25.7145C8.5469 25.5888 8.30922 25.4888 8.16034 25.2844C8.29195 25.107 8.52726 25.0849 8.71847 25.0074C9.14913 24.8705 9.54467 24.6507 9.9403 24.4373C10.0403 24.3689 10.1625 24.3951 10.2584 24.4564C11.1406 24.9622 12.0151 25.4828 12.9003 25.9838C13.0891 26.0928 13.2803 25.9481 13.4417 25.8581C16.8187 23.9495 20.1999 22.048 23.5763 20.1388C23.7014 20.0786 23.7706 19.9458 23.7604 19.8088C23.7628 15.9368 23.761 12.0641 23.7616 8.19212C23.7759 8.03662 23.6859 7.89366 23.5453 7.83003C20.1159 5.89871 16.6883 3.96444 13.2595 2.03263C13.2001 1.99179 13.1297 1.96988 13.0576 1.96977C12.9855 1.96967 12.9151 1.99138 12.8556 2.03204C9.42677 3.96444 5.99974 5.90049 2.57093 7.8317C2.43083 7.89544 2.3368 8.03603 2.35348 8.19212C2.35407 12.0641 2.35348 15.9368 2.35348 19.8094C2.34753 19.8763 2.36222 19.9433 2.39556 20.0016C2.4289 20.0599 2.47929 20.1065 2.53995 20.1353C3.45494 20.6541 4.37112 21.1694 5.2867 21.6864C5.80251 21.9641 6.43582 22.129 7.00412 21.9163C7.50562 21.7365 7.85715 21.2247 7.84758 20.6922C7.85232 16.8428 7.84521 12.9928 7.85113 9.14401C7.8386 8.97313 8.0007 8.83194 8.16685 8.84802C8.60649 8.84506 9.04672 8.8421 9.48636 8.84921C9.66987 8.84506 9.79616 9.02907 9.77347 9.20123C9.77169 13.075 9.7782 16.9489 9.77051 20.8227C9.77169 21.8551 9.34754 22.9784 8.39259 23.4836C7.21614 24.093 5.76206 23.9638 4.59982 23.3794C3.59366 22.8772 2.63348 22.2845 1.64518 21.7465C0.958393 21.391 0.483532 20.6482 0.488366 19.8697V8.13193C0.481164 7.36829 0.932741 6.63256 1.60226 6.27215C5.04301 4.33038 8.48316 2.38713 11.9233 0.444469Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14.9242 8.57537C16.4248 8.47878 18.0313 8.51814 19.3817 9.25742C20.4272 9.82394 21.0069 11.0129 21.0253 12.1745C20.9961 12.3311 20.8323 12.4176 20.6828 12.4068C20.2474 12.4062 19.8119 12.4127 19.3765 12.4039C19.1918 12.411 19.0844 12.2407 19.0613 12.0774C18.9362 11.5216 18.6331 10.9712 18.11 10.7031C17.3069 10.3011 16.3759 10.3213 15.5002 10.3297C14.861 10.3636 14.1736 10.419 13.6321 10.7949C13.2163 11.0796 13.09 11.6544 13.2384 12.1173C13.3783 12.4497 13.762 12.557 14.076 12.6558C15.8844 13.1288 17.8008 13.0818 19.5748 13.7042C20.3092 13.958 21.0277 14.4513 21.2791 15.2203C21.6079 16.2509 21.4638 17.4828 20.7305 18.3102C20.1359 18.991 19.2698 19.3616 18.406 19.5629C17.2569 19.8191 16.0644 19.8256 14.8974 19.7119C13.8001 19.5868 12.6582 19.2985 11.8111 18.5508C11.0867 17.9218 10.7329 16.9418 10.768 15.996C10.7764 15.8362 10.9354 15.7248 11.0885 15.7379C11.5269 15.7344 11.9654 15.7332 12.4038 15.7385C12.579 15.726 12.7088 15.8774 12.7178 16.0424C12.7987 16.5719 12.9977 17.1277 13.4594 17.4416C14.3505 18.0166 15.4687 17.9772 16.489 17.9933C17.3344 17.9558 18.2833 17.9444 18.9732 17.3857C19.3371 17.067 19.445 16.5339 19.3466 16.0752C19.24 15.6879 18.8349 15.5075 18.487 15.3895C16.7017 14.8247 14.7639 15.0297 12.9959 14.391C12.2781 14.1374 11.584 13.6578 11.3083 12.9204C10.9235 11.8767 11.0998 10.5858 11.9099 9.78635C12.6998 8.99113 13.8401 8.68498 14.9242 8.57537Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function Highlights() {
|
||||
return (
|
||||
<div className="border-t border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6">
|
||||
<div className="-translate-y-16">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{highlights.map((highlight) => (
|
||||
<div
|
||||
key={highlight.title}
|
||||
className="bg-surface-75 border border-border rounded-lg p-6 flex flex-col gap-3"
|
||||
>
|
||||
<div className="text-foreground-lighter">{highlight.icon}</div>
|
||||
<h3 className="text-foreground text-base font-medium">{highlight.title}</h3>
|
||||
<p className="text-foreground-lighter text-sm">{highlight.paragraph}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createHighlighter, type ThemeRegistration } from 'shiki'
|
||||
import { IntegratesSectionClient } from './IntegratesSectionClient'
|
||||
|
||||
const supabaseDark: ThemeRegistration = {
|
||||
name: 'supabase-dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#ffffff',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#bda4ff' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
|
||||
],
|
||||
}
|
||||
|
||||
const supabaseLight: ThemeRegistration = {
|
||||
name: 'supabase-light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#525252',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#6b35dc' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#525252' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
|
||||
],
|
||||
}
|
||||
|
||||
const USE_CASES = [
|
||||
{
|
||||
icon: 'Zap' as const,
|
||||
label: 'Zero configuration',
|
||||
paragraph: 'Pre-populated environment variables required to access your Supabase project',
|
||||
lang: 'javascript' as const,
|
||||
code: `import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
|
||||
Deno.serve(async () => {
|
||||
// These are automatically available — no setup needed
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_ANON_KEY')!
|
||||
)
|
||||
|
||||
const { data } = await supabase.from('todos').select('*')
|
||||
return new Response(JSON.stringify(data))
|
||||
})`,
|
||||
},
|
||||
{
|
||||
icon: 'Database' as const,
|
||||
label: 'Connect to your database',
|
||||
paragraph:
|
||||
'Connect to your Postgres database from an Edge Function using the supabase-js client',
|
||||
lang: 'javascript' as const,
|
||||
code: `const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
||||
{
|
||||
global: {
|
||||
headers: {
|
||||
Authorization: req.headers.get('Authorization')!
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('countries')
|
||||
.select('*')`,
|
||||
},
|
||||
{
|
||||
icon: 'Webhook' as const,
|
||||
label: 'Trigger via webhook',
|
||||
paragraph:
|
||||
'Database Webhooks send real-time data from your database whenever a table event occurs',
|
||||
lang: 'sql' as const,
|
||||
code: `create trigger "my_webhook" after insert
|
||||
on "public"."my_table" for each row
|
||||
execute function "supabase_functions"."http_request"(
|
||||
'http://localhost:3000',
|
||||
'POST',
|
||||
'{"Content-Type":"application/json"}',
|
||||
'{}',
|
||||
'1000'
|
||||
);`,
|
||||
},
|
||||
{
|
||||
icon: 'Shield' as const,
|
||||
label: 'Works with Supabase Auth',
|
||||
paragraph: 'Verify users and enforce permissions directly inside your Edge Functions',
|
||||
lang: 'javascript' as const,
|
||||
code: `import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
// Create supabase client
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
{ global: {
|
||||
headers: {
|
||||
Authorization: req.headers.get('Authorization')!}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Get the session or user object
|
||||
const { data } = await supabase.auth.getUser()
|
||||
const user = data.user
|
||||
})`,
|
||||
},
|
||||
{
|
||||
icon: 'HardDrive' as const,
|
||||
label: 'Works with Supabase Storage',
|
||||
paragraph: 'Upload, transform, and serve files with built-in caching from your Edge Functions',
|
||||
lang: 'javascript' as const,
|
||||
code: `import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
// Create supabase client
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
)
|
||||
|
||||
// Upload image to storage
|
||||
const { data, error } = await supabase.storage
|
||||
.from('images')
|
||||
.upload('filename.png', file, {
|
||||
contentType: 'image/png',
|
||||
cacheControl: '31536000',
|
||||
upsert: false,
|
||||
})
|
||||
})`,
|
||||
},
|
||||
]
|
||||
|
||||
export async function IntegratesSection() {
|
||||
const hl = await createHighlighter({
|
||||
themes: [supabaseDark, supabaseLight],
|
||||
langs: ['javascript', 'sql'],
|
||||
})
|
||||
|
||||
const useCases = USE_CASES.map((useCase) => ({
|
||||
icon: useCase.icon,
|
||||
label: useCase.label,
|
||||
paragraph: useCase.paragraph,
|
||||
darkHtml: hl.codeToHtml(useCase.code, { lang: useCase.lang, theme: 'supabase-dark' }),
|
||||
lightHtml: hl.codeToHtml(useCase.code, { lang: useCase.lang, theme: 'supabase-light' }),
|
||||
}))
|
||||
|
||||
hl.dispose()
|
||||
|
||||
return <IntegratesSectionClient useCases={useCases} />
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Database, HardDrive, Shield, Webhook, Zap } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const ICONS = { Zap, Database, Webhook, Shield, HardDrive } as const
|
||||
|
||||
type IconName = keyof typeof ICONS
|
||||
|
||||
type UseCase = {
|
||||
icon: IconName
|
||||
label: string
|
||||
paragraph: React.ReactNode
|
||||
darkHtml: string
|
||||
lightHtml: string
|
||||
}
|
||||
|
||||
const INTERVAL_DURATION = 6000 // ms per tab
|
||||
|
||||
export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = useCases[activeIdx]
|
||||
|
||||
// Auto-size the code area to the active snippet. We measure an always-mounted,
|
||||
// invisible copy of the current code so the target height is known immediately on
|
||||
// switch (and doesn't collapse during the cross-fade), then animate `height` to it.
|
||||
const measureRef = useRef<HTMLDivElement>(null)
|
||||
const [height, setHeight] = useState<number>()
|
||||
const hasMeasured = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => setHeight(el.offsetHeight))
|
||||
ro.observe(el)
|
||||
setHeight(el.offsetHeight)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (height != null) hasMeasured.current = true
|
||||
}, [height])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
// Auto-advance when progress completes
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % useCases.length)
|
||||
}
|
||||
}, [progress, useCases.length])
|
||||
|
||||
// Start/stop timer based on visibility
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Integrates with the <span className="text-foreground">Supabase ecosystem</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Access your database, auth, storage, and webhooks directly from Edge Functions with zero
|
||||
configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tab cards */}
|
||||
<div className="flex flex-col gap-1 items-start justify-end">
|
||||
{useCases.map((useCase, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const Icon = ICONS[useCase.icon]
|
||||
return (
|
||||
<button
|
||||
key={useCase.label}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex items-center gap-3 py-2 text-2xl transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{useCase.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: code area */}
|
||||
<div className="flex flex-col self-end border border-border rounded-md overflow-clip">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={height != null ? { height } : undefined}
|
||||
transition={
|
||||
hasMeasured.current ? { duration: 0.35, ease: [0.22, 1, 0.36, 1] } : { duration: 0 }
|
||||
}
|
||||
className="relative shrink-0 overflow-hidden"
|
||||
>
|
||||
{/* Invisible measuring copy: drives the animated code-area height */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute inset-x-0 top-0 overflow-x-auto [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="overflow-x-auto"
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
|
||||
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.lightHtml }}
|
||||
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Footer: active tab info */}
|
||||
<div className="border-t border-border px-6 py-4 flex items-center gap-8 justify-between bg-surface-75">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-foreground">{active.label}</h4>
|
||||
<p className="text-sm text-foreground-lighter">{active.paragraph}</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Link
|
||||
href="/docs/guides/functions"
|
||||
className="flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap shrink-0"
|
||||
>
|
||||
Documentation
|
||||
<svg
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
|
||||
const LocalDXImage = dynamic(() => import('~/components/Products/Functions/LocalDXImage'))
|
||||
const ParityImage = dynamic(() => import('~/components/Products/Functions/ParityImage'))
|
||||
const NpmEcosystem = dynamic(() => import('~/components/Products/Functions/NpmEcosystem'))
|
||||
const CI = dynamic(() => import('~/components/Products/Functions/CI'))
|
||||
|
||||
function EcosystemCard() {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden col-span-1 md:row-span-2"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex-1 relative overflow-hidden min-h-[320px] md:min-h-0">
|
||||
<NpmEcosystem isHovered={isHovered} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-6 py-5">
|
||||
<h4 className="text-foreground text-base font-medium">Use any NPM module</h4>
|
||||
<p className="text-foreground-lighter text-sm">Tap into the Deno and NPM ecosystem</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ParityCard() {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden "
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="aspect-[3/2] w-full flex items-center justify-center overflow-hidden">
|
||||
<ParityImage isHovered={isHovered} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-6 py-5">
|
||||
<h4 className="text-foreground text-base font-medium">Dev and Prod parity</h4>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
The open source{' '}
|
||||
<Link
|
||||
href="https://github.com/supabase/edge-runtime/"
|
||||
className="underline hover:text-foreground-light transition-colors"
|
||||
target="_blank"
|
||||
>
|
||||
Edge runtime
|
||||
</Link>{' '}
|
||||
runs locally in dev and powers functions in production
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CICard() {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden md:col-span-2"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center overflow-hidden pl-2 pt-8 md:pt-12"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, black 95%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 95%, transparent 100%)',
|
||||
}}
|
||||
>
|
||||
<CI isHovered={isHovered} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-6 py-5">
|
||||
<h4 className="text-foreground text-base font-medium">Continuous Integration</h4>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Use the{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/functions/cicd-workflow"
|
||||
className="underline hover:text-foreground-light transition-colors"
|
||||
target="_blank"
|
||||
>
|
||||
Supabase CLI with GitHub actions
|
||||
</Link>{' '}
|
||||
to preview and deploy your functions along with the rest of your application
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalDXCard() {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden "
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="aspect-[3/2] w-full flex items-center justify-center overflow-hidden">
|
||||
<LocalDXImage isHovered={isHovered} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-6 py-5">
|
||||
<h4 className="text-foreground text-base font-medium">First-class local dev experience</h4>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Write code with hot code reloading, a fast Language server for autocompletion, type
|
||||
checking and linting
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LocalDXSection() {
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Delightful DX from <br />
|
||||
<span className="text-foreground">local to production</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Edge Functions are developed using{' '}
|
||||
<Link
|
||||
href="https://deno.com/"
|
||||
className="underline hover:text-foreground-light transition-colors"
|
||||
target="_blank"
|
||||
>
|
||||
Deno
|
||||
</Link>
|
||||
, an open source JavaScript runtime that ensures maximum power and flexibility. Migrate
|
||||
in and out at any time with no vendor lock-in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bento grid */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 grid-flow-dense gap-3">
|
||||
<LocalDXCard />
|
||||
<ParityCard />
|
||||
<CICard />
|
||||
<EcosystemCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const RealtimeLogs = dynamic(() => import('~/components/Products/Functions/RealtimeLogs'))
|
||||
const QueryLogs = dynamic(() => import('~/components/Products/Functions/QueryLogs'))
|
||||
const Metrics = dynamic(() => import('~/components/Products/Functions/Metrics'))
|
||||
|
||||
const cards = [
|
||||
{
|
||||
id: 'realtime-logs',
|
||||
label: 'Realtime logs',
|
||||
paragraph: 'Stream logs to the dashboard in realtime with rich metadata to help debugging',
|
||||
render: (isActive: boolean) => <RealtimeLogs isActive={isActive} isInView />,
|
||||
},
|
||||
{
|
||||
id: 'log-explorer',
|
||||
label: 'Query Logs via Log explorer',
|
||||
paragraph: 'Get deeper insights into function behavior by writing SQL queries on function logs',
|
||||
render: (isActive: boolean) => <QueryLogs isActive={isActive} isInView />,
|
||||
},
|
||||
{
|
||||
id: 'metrics',
|
||||
label: 'Metrics',
|
||||
paragraph: 'Dashboards show the health of your functions at all times',
|
||||
render: (isActive: boolean) => <Metrics isActive={isActive} />,
|
||||
},
|
||||
]
|
||||
|
||||
function ObservabilityCard({
|
||||
card,
|
||||
className,
|
||||
}: {
|
||||
card: (typeof cards)[number]
|
||||
className?: string
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden ',
|
||||
className
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="relative flex-1 min-h-[285px] overflow-hidden">{card.render(isHovered)}</div>
|
||||
<div className="flex flex-col gap-2 px-6 py-5">
|
||||
<h4 className="text-foreground text-base font-medium">{card.label}</h4>
|
||||
<p className="text-foreground-lighter text-sm">{card.paragraph}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ObservabilitySection() {
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Debug and monitor with <br />
|
||||
<span className="text-foreground">built-in observability</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Monitor, debug, and optimize your Edge Functions with realtime logs, queryable log
|
||||
explorer, and health dashboards — all built into the Supabase Dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-column grid */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{cards.map((card) => (
|
||||
<ObservabilityCard key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { EdgeFunctionsContent } from './_components/EdgeFunctionsContent'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Edge Functions | Supabase',
|
||||
description: 'Execute your code closest to your users with fast deploy times and low latency.',
|
||||
}
|
||||
|
||||
export default function EdgeFunctionsPage() {
|
||||
return <EdgeFunctionsContent />
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import Footer from '~/components/Footer'
|
||||
import Nav from '~/components/Nav'
|
||||
import { useForceDeepDark } from 'lib/theme.utils'
|
||||
|
||||
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
|
||||
useForceDeepDark()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav hideNavbar={false} />
|
||||
<div className="relative w-full [--container-max-w:75rem]">
|
||||
<main className="relative min-h-screen">{children}</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createHighlighter, type ThemeRegistration } from 'shiki'
|
||||
import { ApiSectionClient } from './ApiSectionClient'
|
||||
|
||||
const supabaseDark: ThemeRegistration = {
|
||||
name: 'supabase-dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#ffffff',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#bda4ff' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
|
||||
],
|
||||
}
|
||||
|
||||
const supabaseLight: ThemeRegistration = {
|
||||
name: 'supabase-light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#525252',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#6b35dc' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#525252' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
|
||||
],
|
||||
}
|
||||
|
||||
const API_EXAMPLES = [
|
||||
{
|
||||
icon: 'Database' as const,
|
||||
title: 'Database changes',
|
||||
description: 'Listen to inserts, updates, and deletes in real time.',
|
||||
code: `import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient('URL', 'ANON_KEY')
|
||||
const channel = supabase
|
||||
.channel('db-changes')
|
||||
.on('postgres_changes', {
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'messages',
|
||||
}, (payload) => {
|
||||
console.log('New message:', payload.new)
|
||||
})
|
||||
.subscribe()`,
|
||||
},
|
||||
{
|
||||
icon: 'Users' as const,
|
||||
title: 'Presence',
|
||||
description: 'Track and synchronize online user state across clients.',
|
||||
code: `import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient('URL', 'ANON_KEY')
|
||||
const channel = supabase.channel('online-users')
|
||||
|
||||
channel
|
||||
.on('presence', { event: 'sync' }, () => {
|
||||
const state = channel.presenceState()
|
||||
console.log('Online users:', state)
|
||||
})
|
||||
.subscribe(async (status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
await channel.track({ user: 'user-123' })
|
||||
}
|
||||
})`,
|
||||
},
|
||||
{
|
||||
icon: 'Radio' as const,
|
||||
title: 'Broadcast',
|
||||
description: 'Send ephemeral messages to all subscribed clients instantly.',
|
||||
code: `import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabase = createClient('URL', 'ANON_KEY')
|
||||
const channel = supabase.channel('cursor-pos')
|
||||
|
||||
channel
|
||||
.on('broadcast', { event: 'cursor' }, (payload) => {
|
||||
console.log('Cursor position:', payload)
|
||||
})
|
||||
.subscribe(async (status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
await channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'cursor',
|
||||
payload: { x: 100, y: 200 },
|
||||
})
|
||||
}
|
||||
})`,
|
||||
},
|
||||
]
|
||||
|
||||
export async function ApiSection() {
|
||||
const hl = await createHighlighter({
|
||||
themes: [supabaseDark, supabaseLight],
|
||||
langs: ['javascript'],
|
||||
})
|
||||
|
||||
const examples = API_EXAMPLES.map((example) => ({
|
||||
icon: example.icon,
|
||||
title: example.title,
|
||||
description: example.description,
|
||||
darkHtml: hl.codeToHtml(example.code, { lang: 'javascript', theme: 'supabase-dark' }),
|
||||
lightHtml: hl.codeToHtml(example.code, { lang: 'javascript', theme: 'supabase-light' }),
|
||||
}))
|
||||
|
||||
hl.dispose()
|
||||
|
||||
return <ApiSectionClient examples={examples} />
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Database, Radio, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const ICONS = { Database, Users, Radio } as const
|
||||
|
||||
type IconName = keyof typeof ICONS
|
||||
|
||||
type ApiExample = {
|
||||
icon: IconName
|
||||
title: string
|
||||
description: string
|
||||
darkHtml: string
|
||||
lightHtml: string
|
||||
}
|
||||
|
||||
const INTERVAL_DURATION = 10000
|
||||
|
||||
export function ApiSectionClient({ examples }: { examples: ApiExample[] }) {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = examples[activeIdx]
|
||||
|
||||
// Auto-size the code panel to the active snippet. We measure an always-mounted,
|
||||
// invisible copy of the current code so the target height is known immediately on
|
||||
// switch (and doesn't collapse during the cross-fade), then animate `height` to it.
|
||||
const measureRef = useRef<HTMLDivElement>(null)
|
||||
const [height, setHeight] = useState<number>()
|
||||
const hasMeasured = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => setHeight(el.offsetHeight))
|
||||
ro.observe(el)
|
||||
setHeight(el.offsetHeight)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (height != null) hasMeasured.current = true
|
||||
}, [height])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % examples.length)
|
||||
}
|
||||
}, [progress, examples.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Simple and convenient
|
||||
<br />
|
||||
<span className="text-foreground">APIs</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
APIs that you can understand. With powerful libraries that work on client and
|
||||
server-side applications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tab cards */}
|
||||
<div className="flex flex-col gap-1 items-start justify-end">
|
||||
{examples.map((example, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const Icon = ICONS[example.icon]
|
||||
return (
|
||||
<button
|
||||
key={example.title}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{example.title}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: code block */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={height != null ? { height } : undefined}
|
||||
transition={
|
||||
hasMeasured.current ? { duration: 0.35, ease: [0.22, 1, 0.36, 1] } : { duration: 0 }
|
||||
}
|
||||
className="relative self-end overflow-hidden border border-border rounded-md"
|
||||
>
|
||||
{/* Invisible measuring copy: drives the animated container height */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute inset-x-0 top-0 overflow-x-auto pb-12 [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.title}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="overflow-x-auto pb-12"
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
|
||||
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.lightHtml }}
|
||||
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Link
|
||||
href="/docs/guides/realtime"
|
||||
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Documentation
|
||||
<svg
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Key } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
title: 'Database changes',
|
||||
description:
|
||||
'Listen to inserts, updates, and deletes in your Postgres database and react instantly.',
|
||||
visual: DatabaseChangesSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Presence',
|
||||
description:
|
||||
'Store and synchronize online user state consistently across all connected clients.',
|
||||
visual: PresenceSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Broadcast',
|
||||
description: 'Send any data to any client subscribed to the same channel with low latency.',
|
||||
visual: BroadcastSkeleton,
|
||||
},
|
||||
]
|
||||
|
||||
type ChatMsg = { id: number; user: string; text: string }
|
||||
|
||||
const MAX_MESSAGES = 20
|
||||
|
||||
const initialMessages: ChatMsg[] = [
|
||||
{ id: 1, user: 'Alice', text: 'Hey, is the deploy ready?' },
|
||||
{ id: 2, user: 'Bob', text: 'Almost — running final tests now.' },
|
||||
{ id: 3, user: 'Alice', text: 'Nice, let me know when it\u2019s live.' },
|
||||
]
|
||||
|
||||
const incomingMessages: ChatMsg[] = [
|
||||
{ id: 4, user: 'Bob', text: 'All green. Deploying now \u{1F680}' },
|
||||
{ id: 5, user: 'Alice', text: 'Awesome, checking it out.' },
|
||||
{ id: 6, user: 'Bob', text: 'Latency dropped to 42ms!' },
|
||||
{ id: 7, user: 'Alice', text: 'That\u2019s a big improvement.' },
|
||||
{ id: 8, user: 'Bob', text: 'Yeah, the new index helped.' },
|
||||
{ id: 9, user: 'Alice', text: 'Should we update the docs?' },
|
||||
{ id: 10, user: 'Bob', text: 'Already on it.' },
|
||||
]
|
||||
|
||||
function DatabaseChangesSkeleton() {
|
||||
const [messages, setMessages] = useState<ChatMsg[]>(initialMessages)
|
||||
const [tableFlashId, setTableFlashId] = useState<number | null>(null)
|
||||
const [chatFlashId, setChatFlashId] = useState<number | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const tableScrollRef = useRef<HTMLDivElement>(null)
|
||||
const chatScrollRef = useRef<HTMLDivElement>(null)
|
||||
const [isInView, setIsInView] = useState(false)
|
||||
const nextMsgIdx = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
|
||||
threshold: 0.3,
|
||||
})
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return
|
||||
|
||||
const innerTimers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const msg = incomingMessages[nextMsgIdx.current % incomingMessages.length]
|
||||
const newMsg = { ...msg, id: Date.now() + nextMsgIdx.current }
|
||||
nextMsgIdx.current++
|
||||
|
||||
setMessages((prev) => [...prev.slice(-(MAX_MESSAGES - 1)), newMsg])
|
||||
setTableFlashId(newMsg.id)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
tableScrollRef.current?.scrollTo({
|
||||
top: tableScrollRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
|
||||
const chatTimer = setTimeout(() => {
|
||||
setChatFlashId(newMsg.id)
|
||||
requestAnimationFrame(() => {
|
||||
chatScrollRef.current?.scrollTo({
|
||||
top: chatScrollRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
const clearTimer = setTimeout(() => {
|
||||
setTableFlashId(null)
|
||||
setChatFlashId(null)
|
||||
}, 600)
|
||||
innerTimers.push(clearTimer)
|
||||
}, 200)
|
||||
innerTimers.push(chatTimer)
|
||||
}, 3000)
|
||||
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
innerTimers.forEach(clearTimeout)
|
||||
}
|
||||
}, [isInView])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-full flex flex-col justify-end"
|
||||
style={{
|
||||
maskImage:
|
||||
'linear-gradient(to bottom, black 50%, rgba(0,0,0,0.9) 65%, rgba(0,0,0,0.5) 75%, rgba(0,0,0,0.1) 85%, transparent 90%)',
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, black 50%, rgba(0,0,0,0.9) 65%, rgba(0,0,0,0.5) 75%, rgba(0,0,0,0.1) 85%, transparent 90%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-12 -right-24 z-[2]"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to right, black 35%, transparent 70%)',
|
||||
WebkitMaskImage: 'linear-gradient(to right, black 35%, transparent 70%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="border border-border rounded-lg overflow-hidden bg-surface-75"
|
||||
style={{ width: 420 }}
|
||||
>
|
||||
<table className="w-full border-collapse text-[13px] !mt-0 table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: 72 }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="bg-surface-200">
|
||||
<th className="border-b border-r border-default px-3 py-1.5 text-left font-normal">
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
<Key size={12} strokeWidth={2} className="text-brand rotate-45 shrink-0" />
|
||||
<span className="text-foreground text-xs font-medium">id</span>
|
||||
<span className="text-foreground-light text-xs">int8</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="border-b border-r border-default px-3 py-1.5 text-left font-normal">
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
<span className="text-foreground text-xs font-medium">user</span>
|
||||
<span className="text-foreground-light text-xs">text</span>
|
||||
</div>
|
||||
</th>
|
||||
<th className="border-b border-default px-3 py-1.5 text-left font-normal">
|
||||
<div className="flex items-center gap-1.5 overflow-hidden">
|
||||
<span className="text-foreground text-xs font-medium">text</span>
|
||||
<span className="text-foreground-light text-xs">text</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div
|
||||
ref={tableScrollRef}
|
||||
className="max-h-[58px] overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<table className="w-full border-collapse text-sm !mt-0 table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: 72 }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody className="[&>tr:last-child>td]:border-b-0 [&>tr:first-child>td]:border-t-0">
|
||||
{messages.map((msg) => (
|
||||
<tr
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
'transition-colors duration-500',
|
||||
tableFlashId === msg.id ? 'bg-brand/15 dark:bg-brand/5' : 'bg-surface-75'
|
||||
)}
|
||||
>
|
||||
<td className="border-b border-r border-default px-3 py-1.5 text-foreground-muted text-xs truncate max-w-0">
|
||||
{msg.id}
|
||||
</td>
|
||||
<td className="border-b border-r border-default px-3 py-1.5 text-foreground text-xs truncate max-w-0">
|
||||
{msg.user}
|
||||
</td>
|
||||
<td className="border-b border-default px-3 py-1.5 text-foreground text-xs truncate max-w-0">
|
||||
{msg.text}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connecting dashed lines */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-[140px] flex gap-2 z-[1] text-background-surface-300">
|
||||
<svg width="2" height="52" fill="none" className="overflow-visible">
|
||||
<line
|
||||
x1="1"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="52"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 4"
|
||||
style={{ animation: 'rlDashFlow 1s linear infinite' }}
|
||||
/>
|
||||
</svg>
|
||||
<svg width="2" height="52" fill="none" className="overflow-visible">
|
||||
<line
|
||||
x1="1"
|
||||
y1="0"
|
||||
x2="1"
|
||||
y2="52"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 4"
|
||||
style={{ animation: 'rlDashFlow 1s linear infinite 0.5s' }}
|
||||
/>
|
||||
</svg>
|
||||
<style>{`@keyframes rlDashFlow { to { stroke-dashoffset: -8; } }`}</style>
|
||||
</div>
|
||||
|
||||
{/* Chat UI */}
|
||||
<div className="px-4 pb-4 relative z-[2]">
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-surface-200 max-w-[90%]">
|
||||
<div
|
||||
ref={chatScrollRef}
|
||||
className="h-[120px] overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden flex flex-col gap-2 px-3 py-3"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, transparent 0%, black 20%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 20%)',
|
||||
}}
|
||||
>
|
||||
{messages.map((msg) => {
|
||||
const isAlice = msg.user === 'Alice'
|
||||
return (
|
||||
<div key={msg.id} className={cn('flex', isAlice ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[75%] px-3 py-1.5 rounded-xl text-xs leading-snug transition-colors duration-500',
|
||||
isAlice
|
||||
? 'bg-brand/15 text-foreground rounded-br-sm'
|
||||
: 'bg-surface-300 text-foreground rounded-bl-sm',
|
||||
chatFlashId === msg.id && 'ring-1 ring-brand/30'
|
||||
)}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PRESENCE_USERS = [
|
||||
{ name: 'Alan', initials: 'AD', color: '#3ECF8E' },
|
||||
{ name: 'Jonny', initials: 'JW', color: '#6c63ff' },
|
||||
{ name: 'Copple', initials: 'CP', color: '#F06A50' },
|
||||
{ name: 'Terry', initials: 'TP', color: '#f1a10d' },
|
||||
]
|
||||
|
||||
// Subtle looping paths — small movements around a home position
|
||||
const CURSOR_PATHS: { x: number; y: number }[][] = [
|
||||
[
|
||||
{ x: 24, y: 38 },
|
||||
{ x: 28, y: 42 },
|
||||
{ x: 22, y: 44 },
|
||||
{ x: 26, y: 36 },
|
||||
],
|
||||
[
|
||||
{ x: 58, y: 30 },
|
||||
{ x: 54, y: 34 },
|
||||
{ x: 60, y: 36 },
|
||||
{ x: 56, y: 28 },
|
||||
],
|
||||
[
|
||||
{ x: 36, y: 64 },
|
||||
{ x: 40, y: 68 },
|
||||
{ x: 34, y: 70 },
|
||||
{ x: 38, y: 62 },
|
||||
],
|
||||
[
|
||||
{ x: 68, y: 56 },
|
||||
{ x: 64, y: 60 },
|
||||
{ x: 70, y: 62 },
|
||||
{ x: 66, y: 54 },
|
||||
],
|
||||
]
|
||||
|
||||
// Different loop durations so cursors drift out of phase
|
||||
const CURSOR_DURATIONS = [6, 7.5, 8.5, 7]
|
||||
|
||||
function PresenceCursor({
|
||||
user,
|
||||
path,
|
||||
duration,
|
||||
}: {
|
||||
user: (typeof PRESENCE_USERS)[number]
|
||||
path: { x: number; y: number }[]
|
||||
duration: number
|
||||
}) {
|
||||
// Build looping keyframes — close the loop by appending the first point
|
||||
const xFrames = [...path.map((p) => `${p.x}%`), `${path[0].x}%`]
|
||||
const yFrames = [...path.map((p) => `${p.y}%`), `${path[0].y}%`]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute"
|
||||
initial={{ left: `${path[0].x}%`, top: `${path[0].y}%` }}
|
||||
animate={{ left: xFrames, top: yFrames }}
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
className="drop-shadow-md"
|
||||
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"
|
||||
fill={user.color}
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="ml-3 -mt-0.5 inline-block rounded px-1.5 py-0.5 text-[9px] font-medium text-white shadow-sm"
|
||||
style={{ backgroundColor: user.color }}
|
||||
>
|
||||
{user.name}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const YOU_COLOR = '#e563f5'
|
||||
|
||||
function PresenceSkeleton() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [mouse, setMouse] = useState({ x: 0, y: 0 })
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setMouse({ x: e.clientX - rect.left, y: e.clientY - rect.top })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full w-full overflow-hidden"
|
||||
style={{ cursor: hovered ? 'none' : undefined }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{/* Stacked avatars — top right */}
|
||||
<div className="absolute right-4 top-4 z-10 flex -space-x-2">
|
||||
{PRESENCE_USERS.map((user) => (
|
||||
<div
|
||||
key={user.name}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-surface-200 text-[9px] font-medium text-foreground"
|
||||
style={{
|
||||
boxShadow: `0 0 0 2px hsl(var(--background-surface-75)), 0 0 0 4px ${user.color}`,
|
||||
}}
|
||||
>
|
||||
{user.initials}
|
||||
</div>
|
||||
))}
|
||||
{/* Your avatar — appears on hover */}
|
||||
<motion.div
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-surface-200 text-[9px] font-medium text-foreground"
|
||||
style={{
|
||||
boxShadow: `0 0 0 2px hsl(var(--background-surface-75)), 0 0 0 4px ${YOU_COLOR}`,
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.5, width: 0, marginLeft: 0 }}
|
||||
animate={
|
||||
hovered
|
||||
? { opacity: 1, scale: 1, width: 28, marginLeft: -8 }
|
||||
: { opacity: 0, scale: 0.5, width: 0, marginLeft: 0 }
|
||||
}
|
||||
transition={{ type: 'spring', duration: 0.47, bounce: 0 }}
|
||||
>
|
||||
You
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Animated cursors */}
|
||||
{PRESENCE_USERS.map((user, i) => (
|
||||
<PresenceCursor
|
||||
key={user.name}
|
||||
user={user}
|
||||
path={CURSOR_PATHS[i]}
|
||||
duration={CURSOR_DURATIONS[i]}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Your cursor — follows mouse on hover */}
|
||||
{hovered && (
|
||||
<div className="pointer-events-none absolute z-20" style={{ left: mouse.x, top: mouse.y }}>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
className="drop-shadow-md"
|
||||
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))' }}
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"
|
||||
fill={YOU_COLOR}
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="ml-3 -mt-0.5 inline-block rounded px-1.5 py-0.5 text-[9px] font-medium text-white shadow-sm"
|
||||
style={{ backgroundColor: YOU_COLOR }}
|
||||
>
|
||||
You
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BROADCAST_PATHS = [
|
||||
{ d: 'M180,58 C180,135 80,175 80,268', delay: 0 },
|
||||
{ d: 'M180,58 C180,135 140,175 140,268', delay: 0.15 },
|
||||
{ d: 'M180,58 C180,135 220,175 220,268', delay: 0.3 },
|
||||
{ d: 'M180,58 C180,135 280,175 280,268', delay: 0.45 },
|
||||
]
|
||||
|
||||
const BROADCAST_CLIENTS = [
|
||||
{
|
||||
// User icon
|
||||
icon: (
|
||||
<>
|
||||
<path
|
||||
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="12" cy="7" r="4" stroke="currentColor" strokeWidth="1.5" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
// Image icon
|
||||
icon: (
|
||||
<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="9" cy="9" r="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path
|
||||
d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
// Layers icon
|
||||
icon: (
|
||||
<>
|
||||
<path
|
||||
d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="m2 12 8.58 3.91a2 2 0 0 0 1.66 0L21 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="m2 17 8.58 3.91a2 2 0 0 0 1.66 0L21 17"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
// Database icon
|
||||
icon: (
|
||||
<>
|
||||
<ellipse cx="12" cy="5" rx="7" ry="3" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M19 12c0 1.66-3.13 3-7 3s-7-1.34-7-3" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M5 5v14c0 1.66 3.13 3 7 3s7-1.34 7-3V5" stroke="currentColor" strokeWidth="1.5" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const PULSE_DURATION = 3000
|
||||
// Time (ms) for pulse leading edge to reach the node
|
||||
const PULSE_ARRIVAL = 350
|
||||
|
||||
function BroadcastSkeleton() {
|
||||
const [flashSet, setFlashSet] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function scheduleFlashes() {
|
||||
BROADCAST_PATHS.forEach((line, i) => {
|
||||
const arrivalOffset = line.delay * 1000 + PULSE_ARRIVAL
|
||||
const t = setTimeout(() => {
|
||||
setFlashSet((prev) => new Set(prev).add(i))
|
||||
const clearT = setTimeout(() => {
|
||||
setFlashSet((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(i)
|
||||
return next
|
||||
})
|
||||
}, 500)
|
||||
timers.push(clearT)
|
||||
}, arrivalOffset)
|
||||
timers.push(t)
|
||||
})
|
||||
}
|
||||
|
||||
scheduleFlashes()
|
||||
const interval = setInterval(scheduleFlashes, PULSE_DURATION)
|
||||
|
||||
return () => {
|
||||
timers.forEach(clearTimeout)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* Source node — top center */}
|
||||
<div className="absolute left-1/2 top-5 z-10 -translate-x-1/2">
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-surface-100 px-3 py-2 shadow-xs dark:shadow-sm">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="text-brand"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="m22 8-6 4 6 4V8Z" stroke="currentColor" strokeWidth="1.5" />
|
||||
<rect
|
||||
x="2"
|
||||
y="6"
|
||||
width="14"
|
||||
height="12"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-xs font-medium text-foreground">channel:room-1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SVG curves */}
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox="0 0 360 320"
|
||||
fill="none"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
{BROADCAST_PATHS.map((line, i) => (
|
||||
<g key={i}>
|
||||
<path
|
||||
d={line.d}
|
||||
stroke="hsl(var(--foreground-muted))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<path
|
||||
d={line.d}
|
||||
stroke="#3ECF8E"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="40 560"
|
||||
style={{
|
||||
animation: `broadcastPulse 3s ${line.delay}s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Destination nodes — positioned to match SVG path endpoints */}
|
||||
{BROADCAST_CLIENTS.map((client, i) => {
|
||||
const xPercent = [23.2, 39.5, 61, 77][i]
|
||||
const isFlashing = flashSet.has(i)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'absolute bottom-6 z-10 flex h-10 w-10 -translate-x-1/2 items-center justify-center rounded-xl border bg-surface-100 shadow-xs dark:shadow-sm transition-all duration-300',
|
||||
isFlashing ? 'border-brand text-brand' : 'border-border text-foreground-muted'
|
||||
)}
|
||||
style={{ left: `${xPercent}%` }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
{client.icon}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<style>{`
|
||||
@keyframes broadcastPulse {
|
||||
from { stroke-dashoffset: 600; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeaturesSection() {
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Three ways
|
||||
<br />
|
||||
<span className="text-foreground">to go realtime</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Database changes, presence tracking, and broadcast messaging — everything you need to
|
||||
build collaborative, real-time applications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-col grid */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{FEATURES.map((feature) => {
|
||||
const Visual = feature.visual
|
||||
return (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="relative flex items-center justify-center h-[320px]">
|
||||
<Visual />
|
||||
</div>
|
||||
<div className="px-6 py-5 flex flex-col gap-1">
|
||||
<h4 className="text-foreground text-sm font-medium">{feature.title}</h4>
|
||||
<p className="text-foreground-lighter text-sm">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-32 pb-16 md:pt-40 md:pb-32">
|
||||
<div className="flex flex-col gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h1 className="text-foreground text-3xl sm:text-5xl sm:leading-none">
|
||||
<span className="block">Realtime</span>
|
||||
<span className="text-foreground-lighter block">sync in milliseconds</span>
|
||||
</h1>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Sync client state globally over WebSockets. Listen to database changes, store user
|
||||
presence, and broadcast messages to all connected clients.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="medium">
|
||||
<Link href="https://supabase.com/dashboard">Start your project</Link>
|
||||
</Button>
|
||||
<Button asChild size="medium" type="default">
|
||||
<Link href="/docs/guides/realtime">Documentation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QuoteSection as SharedQuoteSection } from '../../_shared/QuoteSection'
|
||||
|
||||
export function QuoteSection() {
|
||||
return (
|
||||
<SharedQuoteSection
|
||||
quote="Supabase takes out the mental effort from our back-end infrastructure so we can focus on our customers needs."
|
||||
author={{
|
||||
name: 'Aaron Sullivan',
|
||||
role: 'Principal Software Engineer at Epsilon3',
|
||||
image: '/images/blog/avatars/aaron-epsilon3.png',
|
||||
link: '/customers/epsilon3',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BuiltWithSupabaseSection } from '~/app/(home)/_components/BuiltWithSupabaseSection'
|
||||
import { CTASection } from '~/app/(home)/_components/CTASection'
|
||||
import { FeaturesSection } from './FeaturesSection'
|
||||
import { Hero } from './Hero'
|
||||
import { QuoteSection } from './QuoteSection'
|
||||
|
||||
export function RealtimeContent({ apiSlot }: { apiSlot: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-clip">
|
||||
<section>
|
||||
<Hero />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<FeaturesSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<QuoteSection />
|
||||
</section>
|
||||
<section className="border-t border-border">{apiSlot}</section>
|
||||
<section className="border-t border-border">
|
||||
<BuiltWithSupabaseSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<CTASection />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { ApiSection } from './_components/ApiSection'
|
||||
import { RealtimeContent } from './_components/RealtimeContent'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Realtime | Supabase',
|
||||
description:
|
||||
'Sync client state globally over WebSockets. Listen to database changes, store user presence, and broadcast messages in real time.',
|
||||
}
|
||||
|
||||
export default function RealtimePage() {
|
||||
return <RealtimeContent apiSlot={<ApiSection />} />
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { createHighlighter, type ThemeRegistration } from 'shiki'
|
||||
import { ApiSectionClient } from './ApiSectionClient'
|
||||
|
||||
const supabaseDark: ThemeRegistration = {
|
||||
name: 'supabase-dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#ffffff',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#bda4ff' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
|
||||
],
|
||||
}
|
||||
|
||||
const supabaseLight: ThemeRegistration = {
|
||||
name: 'supabase-light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#525252',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#6b35dc' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'entity.name.function',
|
||||
'support.function',
|
||||
'entity.name.tag',
|
||||
'support.class.component',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
'variable.other.property',
|
||||
'support.type.property-name',
|
||||
'meta.object-literal.key',
|
||||
'entity.other.attribute-name',
|
||||
],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['variable.parameter'], settings: { foreground: '#525252' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
|
||||
],
|
||||
}
|
||||
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
JavaScript: 'javascript',
|
||||
Flutter: 'dart',
|
||||
Python: 'python',
|
||||
'C#': 'csharp',
|
||||
Kotlin: 'kotlin',
|
||||
Swift: 'swift',
|
||||
}
|
||||
|
||||
const API_EXAMPLES = [
|
||||
{
|
||||
icon: 'Upload' as const,
|
||||
title: 'Upload a file',
|
||||
description: 'Upload any file type to your storage buckets with a single call.',
|
||||
code: {
|
||||
JavaScript: `// Upload an image to the "avatars" bucket
|
||||
const spaceCat = event.target.files[0]
|
||||
const { data, error } = await supabase
|
||||
.storage
|
||||
.from('avatars')
|
||||
.upload('space-cat.png', spaceCat)`,
|
||||
Flutter: `// Upload an image to the "avatars" bucket
|
||||
final file = File('space-cat.png');
|
||||
await supabase
|
||||
.storage
|
||||
.from('avatars')
|
||||
.upload('space-cat.png', file);`,
|
||||
Python: `# Upload an image to the "avatars" bucket
|
||||
with open("space-cat.png", "rb") as f:
|
||||
supabase.storage.from_("avatars").upload(
|
||||
"space-cat.png", f
|
||||
)`,
|
||||
'C#': `// Upload an image to the "avatars" bucket
|
||||
var bytes = File.ReadAllBytes("space-cat.png");
|
||||
await supabase.Storage
|
||||
.From("avatars")
|
||||
.Upload(bytes, "space-cat.png");`,
|
||||
Kotlin: `// Upload an image to the "avatars" bucket
|
||||
supabase.storage.from("avatars")
|
||||
.upload("space-cat.png", file)`,
|
||||
Swift: `// Upload an image to the "avatars" bucket
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
try await supabase.storage
|
||||
.from("avatars")
|
||||
.upload("space-cat.png", data: data)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'Download' as const,
|
||||
title: 'Download a file',
|
||||
description: 'Retrieve files from your buckets directly or via signed URLs.',
|
||||
code: {
|
||||
JavaScript: `// Download the "space-cat.png" from "avatars"
|
||||
const { data, error } = await supabase
|
||||
.storage
|
||||
.from('avatars')
|
||||
.download('space-cat.png')`,
|
||||
Flutter: `// Download the "space-cat.png" from "avatars"
|
||||
final bytes = await supabase
|
||||
.storage
|
||||
.from('avatars')
|
||||
.download('space-cat.png');`,
|
||||
Python: `# Download the "space-cat.png" from "avatars"
|
||||
data = supabase.storage.from_("avatars") \\
|
||||
.download("space-cat.png")`,
|
||||
'C#': `// Download the "space-cat.png" from "avatars"
|
||||
var bytes = await supabase.Storage
|
||||
.From("avatars")
|
||||
.Download("space-cat.png");`,
|
||||
Kotlin: `// Download the "space-cat.png" from "avatars"
|
||||
val bytes = supabase.storage.from("avatars")
|
||||
.downloadAuthenticated("space-cat.png")`,
|
||||
Swift: `// Download the "space-cat.png" from "avatars"
|
||||
let data = try await supabase.storage
|
||||
.from("avatars")
|
||||
.download(path: "space-cat.png")`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'List' as const,
|
||||
title: 'List files',
|
||||
description: 'Browse and list all files within a storage bucket.',
|
||||
code: {
|
||||
JavaScript: `// List all the files in the "avatars" bucket
|
||||
const { data, error } = await supabase
|
||||
.storage
|
||||
.from('avatars')
|
||||
.list()`,
|
||||
Flutter: `// List all the files in the "avatars" bucket
|
||||
final files = await supabase
|
||||
.storage
|
||||
.from('avatars')
|
||||
.list();`,
|
||||
Python: `# List all the files in the "avatars" bucket
|
||||
files = supabase.storage.from_("avatars") \\
|
||||
.list()`,
|
||||
'C#': `// List all the files in the "avatars" bucket
|
||||
var files = await supabase.Storage
|
||||
.From("avatars")
|
||||
.List();`,
|
||||
Kotlin: `// List all the files in the "avatars" bucket
|
||||
val files = supabase.storage.from("avatars")
|
||||
.list()`,
|
||||
Swift: `// List all the files in the "avatars" bucket
|
||||
let files = try await supabase.storage
|
||||
.from("avatars")
|
||||
.list()`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'ArrowRightLeft' as const,
|
||||
title: 'Move and rename',
|
||||
description: 'Organize your files by moving and renaming within or across buckets.',
|
||||
code: {
|
||||
JavaScript: `// Move and rename files
|
||||
const { data, error } = await supabase
|
||||
.storage
|
||||
.from('avatars')
|
||||
.move('public/space-cat.png', 'private/space-cat.png')`,
|
||||
Flutter: `// Move and rename files
|
||||
await supabase
|
||||
.storage
|
||||
.from('avatars')
|
||||
.move('public/space-cat.png', 'private/space-cat.png');`,
|
||||
Python: `# Move and rename files
|
||||
supabase.storage.from_("avatars") \\
|
||||
.move("public/space-cat.png", "private/space-cat.png")`,
|
||||
'C#': `// Move and rename files
|
||||
await supabase.Storage
|
||||
.From("avatars")
|
||||
.Move("public/space-cat.png", "private/space-cat.png");`,
|
||||
Kotlin: `// Move and rename files
|
||||
supabase.storage.from("avatars")
|
||||
.move("public/space-cat.png", "private/space-cat.png")`,
|
||||
Swift: `// Move and rename files
|
||||
try await supabase.storage
|
||||
.from("avatars")
|
||||
.move(
|
||||
from: "public/space-cat.png",
|
||||
to: "private/space-cat.png"
|
||||
)`,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export async function ApiSection() {
|
||||
const hl = await createHighlighter({
|
||||
themes: [supabaseDark, supabaseLight],
|
||||
langs: Object.values(LANG_MAP),
|
||||
})
|
||||
|
||||
const examples = API_EXAMPLES.map((example) => ({
|
||||
icon: example.icon,
|
||||
title: example.title,
|
||||
description: example.description,
|
||||
languages: Object.fromEntries(
|
||||
Object.entries(example.code).map(([lang, code]) => [
|
||||
lang,
|
||||
{
|
||||
darkHtml: hl.codeToHtml(code, { lang: LANG_MAP[lang], theme: 'supabase-dark' }),
|
||||
lightHtml: hl.codeToHtml(code, { lang: LANG_MAP[lang], theme: 'supabase-light' }),
|
||||
},
|
||||
])
|
||||
),
|
||||
}))
|
||||
|
||||
hl.dispose()
|
||||
|
||||
return <ApiSectionClient examples={examples} />
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowRightLeft, ChevronDown, Download, List, Upload } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const ICONS = { Upload, Download, List, ArrowRightLeft } as const
|
||||
|
||||
type IconName = keyof typeof ICONS
|
||||
|
||||
type ApiExample = {
|
||||
icon: IconName
|
||||
title: string
|
||||
description: string
|
||||
languages: Record<string, { darkHtml: string; lightHtml: string }>
|
||||
}
|
||||
|
||||
const LANGUAGES = ['JavaScript', 'Flutter', 'Python', 'C#', 'Kotlin', 'Swift']
|
||||
|
||||
const INTERVAL_DURATION = 8000
|
||||
|
||||
export function ApiSectionClient({ examples }: { examples: ApiExample[] }) {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [activeLang, setActiveLang] = useState('JavaScript')
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = examples[activeIdx]
|
||||
|
||||
// Auto-size the code panel to the active snippet. We measure an always-mounted,
|
||||
// invisible copy of the current code so the target height is known immediately on
|
||||
// switch (and doesn't collapse during the cross-fade), then animate `height` to it.
|
||||
const measureRef = useRef<HTMLDivElement>(null)
|
||||
const [height, setHeight] = useState<number>()
|
||||
const hasMeasured = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => setHeight(el.offsetHeight))
|
||||
ro.observe(el)
|
||||
setHeight(el.offsetHeight)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (height != null) hasMeasured.current = true
|
||||
}, [height])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % examples.length)
|
||||
}
|
||||
}, [progress, examples.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Simple and convenient
|
||||
<br />
|
||||
<span className="text-foreground">APIs</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Built from the ground up for interoperable authentication. Fast and easy to implement
|
||||
using our powerful library clients.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tab cards */}
|
||||
<div className="flex flex-col gap-1 items-start justify-end">
|
||||
{examples.map((example, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const Icon = ICONS[example.icon]
|
||||
return (
|
||||
<button
|
||||
key={example.title}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{example.title}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: code block */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={height != null ? { height } : undefined}
|
||||
transition={
|
||||
hasMeasured.current ? { duration: 0.35, ease: [0.22, 1, 0.36, 1] } : { duration: 0 }
|
||||
}
|
||||
className="relative self-end overflow-hidden border border-border rounded-md"
|
||||
>
|
||||
{/* Invisible measuring copy: drives the animated container height */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute inset-x-0 top-0 overflow-x-auto pb-12 [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
dangerouslySetInnerHTML={{ __html: active.languages[activeLang]?.darkHtml ?? '' }}
|
||||
/>
|
||||
|
||||
{/* Language switcher */}
|
||||
<div className="absolute top-3 right-4 z-10 flex items-center">
|
||||
<div className="relative flex items-center gap-1 text-foreground-muted text-xs">
|
||||
<select
|
||||
value={activeLang}
|
||||
onChange={(e) => setActiveLang(e.target.value)}
|
||||
className="appearance-none bg-transparent text-foreground-light text-xs pr-4 cursor-pointer border-none outline-none shadow-none focus:border-none focus:outline-none focus:shadow-none focus:ring-0"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{lang}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={12} className="absolute right-0 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={`${active.title}-${activeLang}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="overflow-x-auto pb-12"
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.languages[activeLang]?.darkHtml ?? '' }}
|
||||
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: active.languages[activeLang]?.lightHtml ?? '',
|
||||
}}
|
||||
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Link
|
||||
href="/docs/guides/storage"
|
||||
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Documentation
|
||||
<svg
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
const BUCKET_TYPES = [
|
||||
{
|
||||
title: 'Files buckets',
|
||||
features: [
|
||||
'Store images, videos, documents, and more',
|
||||
'Serve fast from a global CDN',
|
||||
'Fine-grained access controls',
|
||||
'Image optimizations and transformations',
|
||||
],
|
||||
cta: { label: 'Learn more', href: '/docs/guides/storage' },
|
||||
},
|
||||
{
|
||||
title: 'Analytics buckets',
|
||||
features: [
|
||||
'Apache Iceberg open table format',
|
||||
'Efficient query, partition, and transform',
|
||||
'Historical and time-series data',
|
||||
'Optionally expose via Postgres',
|
||||
],
|
||||
cta: { label: 'Learn more', href: '/docs/guides/storage/analytics-buckets' },
|
||||
},
|
||||
{
|
||||
title: 'Vector buckets',
|
||||
features: [
|
||||
'Store and index vector embeddings',
|
||||
'Multiple distance metrics',
|
||||
'Metadata filtering and similarity queries',
|
||||
'RAG systems and AI-powered search',
|
||||
],
|
||||
cta: { label: 'Learn more', href: '/docs/guides/storage/vector-buckets' },
|
||||
},
|
||||
]
|
||||
|
||||
export function BucketTypesSection() {
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Bucket types
|
||||
<br />
|
||||
<span className="text-foreground">for every application</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||
{BUCKET_TYPES.map((bucket) => (
|
||||
<div
|
||||
key={bucket.title}
|
||||
className="relative flex flex-col overflow-hidden min-h-[320px] bg-surface-75 border border-border rounded-lg"
|
||||
>
|
||||
<div className="relative z-10 flex flex-col justify-between h-full p-6 md:p-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-foreground text-lg font-medium">{bucket.title}</h4>
|
||||
<ul className="flex flex-col text-foreground-lighter text-sm gap-1.5">
|
||||
{bucket.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="w-3.5 h-3.5 mt-0.5 shrink-0 stroke-2 text-brand" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Button type="default" size="small" asChild>
|
||||
<Link href={bucket.cta.href}>{bucket.cta.label}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from 'ui'
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
title: 'Interoperable',
|
||||
description: 'Integrates with the rest of Supabase — Auth and Postgres work together.',
|
||||
visual: InteroperableSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Lightning fast',
|
||||
description:
|
||||
'Thin API server layer built on Postgres permissions with content delivered via global CDN.',
|
||||
visual: CDNSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Multiple bucket types',
|
||||
description:
|
||||
'Files, Analytics, or Vector buckets — choose the right storage model for your application.',
|
||||
visual: BucketsSkeleton,
|
||||
},
|
||||
]
|
||||
|
||||
function InteroperableSkeleton() {
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center overflow-hidden [mask-image:radial-gradient(ellipse_88%_88%_at_50%_50%,black_40%,transparent_80%)] [webkit-mask-image:radial-gradient(ellipse_88%_88%_at_50%_50%,black_40%,transparent_80%)]">
|
||||
<div className="relative h-[220px] w-[310px]">
|
||||
<div className="pointer-events-none absolute inset-0 text-foreground-muted/12">
|
||||
<line x1="155" y1="8" x2="155" y2="212" className="stroke-current" strokeWidth="1" />
|
||||
<line x1="60" y1="8" x2="60" y2="212" className="stroke-current" strokeWidth="1" />
|
||||
<line x1="250" y1="8" x2="250" y2="212" className="stroke-current" strokeWidth="1" />
|
||||
<line x1="8" y1="110" x2="302" y2="110" className="stroke-current" strokeWidth="1" />
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute left-[58px] top-1/2 flex w-[56px] -translate-y-1/2 flex-col gap-1">
|
||||
{[0, 1].map((i) => (
|
||||
<div
|
||||
key={`left-conn-${i}`}
|
||||
className="relative h-[1.5px] w-full overflow-hidden bg-brand/18 [mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)] [webkit-mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)]"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 w-24 [animation:interopLineSweep_1.05s_linear_infinite]"
|
||||
style={{
|
||||
animationDelay: `${i * 100}ms`,
|
||||
background:
|
||||
'linear-gradient(to right, hsl(var(--brand-default)/0), hsl(var(--brand-default)/0.95), hsl(var(--brand-default)/0))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute right-[58px] top-1/2 flex w-[56px] -translate-y-1/2 flex-col gap-1">
|
||||
{[0, 1].map((i) => (
|
||||
<div
|
||||
key={`right-conn-${i}`}
|
||||
className="relative h-[1.5px] w-full overflow-hidden bg-brand/18 [mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)] [webkit-mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)]"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 w-24 [animation:interopLineSweep_1.05s_linear_infinite]"
|
||||
style={{
|
||||
animationDelay: `${140 + i * 100}ms`,
|
||||
background:
|
||||
'linear-gradient(to right, hsl(var(--brand-default)/0), hsl(var(--brand-default)/0.95), hsl(var(--brand-default)/0))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute left-0 top-1/2 flex h-[70px] w-[70px] -translate-y-1/2 items-center justify-center rounded-xl border border-border bg-surface-100/95 shadow-[0_2px_10px_hsl(var(--background-default)/0.15)]">
|
||||
<div className="absolute inset-3 rounded-full border border-foreground-muted/15" />
|
||||
<div className="absolute inset-5 rounded-full border border-foreground-muted/10" />
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="relative z-10 text-foreground-muted"
|
||||
strokeWidth="1.9"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" className="stroke-current" />
|
||||
<circle cx="12" cy="7" r="4" className="stroke-current" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 flex h-[92px] w-[108px] -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-2xl border border-border bg-surface-100/95 shadow-[0_6px_18px_hsl(var(--background-default)/0.16)]">
|
||||
<div className="absolute inset-3 rounded-[14px] border border-foreground-muted/10" />
|
||||
<div className="absolute size-12 rounded-full border border-foreground-muted/15" />
|
||||
<div className="absolute size-16 rounded-full border border-foreground-muted/8" />
|
||||
<svg
|
||||
width="34"
|
||||
height="34"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="relative z-10 text-brand"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
|
||||
className="stroke-current"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 top-1/2 flex h-[70px] w-[70px] -translate-y-1/2 items-center justify-center rounded-xl border border-border bg-surface-100/95 shadow-[0_2px_10px_hsl(var(--background-default)/0.15)]">
|
||||
<div className="absolute inset-3 rounded-full border border-foreground-muted/15" />
|
||||
<div className="absolute inset-5 rounded-full border border-foreground-muted/10" />
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="relative z-10 text-foreground-muted"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<ellipse cx="12" cy="5" rx="7" ry="3" className="stroke-current" />
|
||||
<path d="M19 12c0 1.66-3.13 3-7 3s-7-1.34-7-3" className="stroke-current" />
|
||||
<path d="M5 5v14c0 1.66 3.13 3 7 3s7-1.34 7-3V5" className="stroke-current" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes interopLineSweep {
|
||||
from {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(260%);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CDNSkeleton() {
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center [mask-image:radial-gradient(ellipse_85%_85%_at_50%_50%,black_35%,transparent_78%)] [webkit-mask-image:radial-gradient(ellipse_85%_85%_at_50%_50%,black_35%,transparent_78%)]">
|
||||
<div className="absolute inset-0 z-[1] flex items-center justify-center">
|
||||
<div className="flex w-[290px] flex-col gap-0.5">
|
||||
{[-20, -10, 0, 10, 20].map((offset, i) => (
|
||||
<div
|
||||
key={`flash-${offset}`}
|
||||
className="relative h-px w-full overflow-hidden bg-brand/15 [mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)] [webkit-mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)]"
|
||||
style={{ transform: `translateY(${offset}px)` }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 w-24 [animation:cdnFlashSweep_1.05s_linear_infinite]"
|
||||
style={{
|
||||
animationDelay: `${i * 130}ms`,
|
||||
background:
|
||||
'linear-gradient(to right, hsl(var(--brand-default)/0), hsl(var(--brand-default)/0.9), hsl(var(--brand-default)/0))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 z-10 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="pointer-events-none absolute left-1/2 top-1/2 -z-10 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="absolute -left-[70px] top-1/2 h-[88px] w-[122px] -translate-y-1/2 rounded-[20px] border border-border bg-surface-100 shadow-[0_2px_10px_hsl(var(--background-default)/0.18)]" />
|
||||
<div className="absolute -right-[70px] top-1/2 h-[88px] w-[122px] -translate-y-1/2 rounded-[20px] border border-border bg-surface-100 shadow-[0_2px_10px_hsl(var(--background-default)/0.18)]" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-[116px] w-[116px] items-center justify-center rounded-[24px] border border-foreground/15 bg-surface-200/95 shadow-[0_8px_28px_hsl(var(--background-default)/0.24)]">
|
||||
<div className="absolute inset-0 rounded-[24px] shadow-[inset_0_1px_0_hsl(var(--foreground-default)/0.06)]" />
|
||||
<div className="absolute size-10 rounded-full bg-brand/15 blur-xl" />
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="relative z-10 text-brand"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
|
||||
className="stroke-current"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes cdnFlashSweep {
|
||||
from {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(260%);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BucketsSkeleton() {
|
||||
const buckets = [
|
||||
{
|
||||
name: 'Files',
|
||||
label: 'images, videos, docs',
|
||||
icon: 'folder' as const,
|
||||
colorClass: 'text-brand',
|
||||
},
|
||||
{
|
||||
name: 'Analytics',
|
||||
label: 'Iceberg, time-series',
|
||||
icon: 'chart' as const,
|
||||
colorClass: 'text-blue-700 dark:text-blue-400',
|
||||
},
|
||||
{
|
||||
name: 'Vectors',
|
||||
label: 'embeddings, RAG',
|
||||
icon: 'sparkles' as const,
|
||||
colorClass: 'text-purple-700 dark:text-purple-400',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<div className="flex w-64 flex-col gap-2">
|
||||
{buckets.map((bucket) => (
|
||||
<div
|
||||
key={bucket.name}
|
||||
className="flex items-center justify-between px-4 py-2.5 rounded-lg border border-border bg-surface-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={cn('shrink-0', bucket.colorClass)}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
{bucket.icon === 'folder' && (
|
||||
<path
|
||||
d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
|
||||
className="stroke-current"
|
||||
/>
|
||||
)}
|
||||
{bucket.icon === 'chart' && (
|
||||
<>
|
||||
<line x1="5" y1="20" x2="5" y2="10" className="stroke-current" />
|
||||
<line x1="12" y1="20" x2="12" y2="6" className="stroke-current" />
|
||||
<line x1="19" y1="20" x2="19" y2="13" className="stroke-current" />
|
||||
</>
|
||||
)}
|
||||
{bucket.icon === 'sparkles' && (
|
||||
<>
|
||||
<path
|
||||
d="M12 3l2.3 5.2L20 10.5l-5.7 2.3L12 18l-2.3-5.2L4 10.5l5.7-2.3L12 3z"
|
||||
className="stroke-current"
|
||||
/>
|
||||
<path d="M19 3v4" className="stroke-current" />
|
||||
<path d="M21 5h-4" className="stroke-current" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<span className={cn('text-xs font-medium', bucket.colorClass)}>{bucket.name}</span>
|
||||
</div>
|
||||
<span className="text-foreground-muted text-[11px]">{bucket.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeaturesSection() {
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Everything you need
|
||||
<br />
|
||||
<span className="text-foreground">for storage</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
S3 compatible storage with global CDN, image transformations, and fine-grained access
|
||||
controls powered by Postgres RLS.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-col grid */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{FEATURES.map((feature) => {
|
||||
const Visual = feature.visual
|
||||
return (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="flex flex-col bg-surface-75 border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="relative flex items-center justify-center h-[320px]">
|
||||
<Visual />
|
||||
</div>
|
||||
<div className="px-6 py-5 flex flex-col gap-1">
|
||||
<h4 className="text-foreground text-sm font-medium">{feature.title}</h4>
|
||||
<p className="text-foreground-lighter text-sm">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-32 pb-16 md:pt-40 md:pb-32">
|
||||
<div className="flex flex-col gap-6 lg:gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h1 className="text-foreground text-3xl sm:text-5xl sm:leading-none">
|
||||
<span className="block">Store and serve</span>
|
||||
<span className="text-foreground-lighter block">any type of digital content</span>
|
||||
</h1>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
An open source S3 compatible storage solution with unlimited scalability, for any file
|
||||
type. With custom policies and permissions that are familiar and easy to implement.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="medium">
|
||||
<Link href="https://supabase.com/dashboard">Start your project</Link>
|
||||
</Button>
|
||||
<Button asChild size="medium" type="default">
|
||||
<Link href="/docs/guides/storage">Documentation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createHighlighter, type ThemeRegistration } from 'shiki'
|
||||
|
||||
import { PermissionsSectionClient } from './PermissionsSectionClient'
|
||||
|
||||
const supabaseDark: ThemeRegistration = {
|
||||
name: 'supabase-dark',
|
||||
type: 'dark',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#ffffff',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#bda4ff' },
|
||||
},
|
||||
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#3ecf8e' } },
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#3ecf8e' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#ffcda1' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#ffffff' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#ededed' } },
|
||||
],
|
||||
}
|
||||
|
||||
const supabaseLight: ThemeRegistration = {
|
||||
name: 'supabase-light',
|
||||
type: 'light',
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.foreground': '#525252',
|
||||
},
|
||||
tokenColors: [
|
||||
{
|
||||
scope: ['keyword', 'storage', 'storage.type', 'storage.modifier'],
|
||||
settings: { foreground: '#6b35dc' },
|
||||
},
|
||||
{ scope: ['entity.name.function', 'support.function'], settings: { foreground: '#15593b' } },
|
||||
{
|
||||
scope: ['constant', 'variable.other.constant', 'support.constant'],
|
||||
settings: { foreground: '#15593b' },
|
||||
},
|
||||
{ scope: ['string', 'string.quoted'], settings: { foreground: '#f1a10d' } },
|
||||
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: '#7e7e7e' } },
|
||||
{ scope: ['punctuation'], settings: { foreground: '#a0a0a0' } },
|
||||
{ scope: ['constant.numeric'], settings: { foreground: '#525252' } },
|
||||
],
|
||||
}
|
||||
|
||||
const PERMISSION_EXAMPLES = [
|
||||
{
|
||||
title: 'Public access to a bucket',
|
||||
description: 'Allow any user access to all files in a bucket.',
|
||||
code: `create policy "Public Access"
|
||||
on storage.objects for all
|
||||
using ( bucket_id = 'avatars' );`,
|
||||
},
|
||||
{
|
||||
title: 'Public access to a folder',
|
||||
description: 'Allow public CRUD access to a specific folder within a bucket.',
|
||||
code: `create policy "Public access to a folder"
|
||||
on storage.objects for all
|
||||
using (
|
||||
bucket_id = 'avatars'
|
||||
and (storage.foldername(name))[1] = 'public'
|
||||
);`,
|
||||
},
|
||||
{
|
||||
title: 'Authenticated access',
|
||||
description: 'Restrict access to authenticated users for a specific folder.',
|
||||
code: `create policy "Logged in access"
|
||||
on storage.objects
|
||||
for all using (
|
||||
bucket_id = 'avatars'
|
||||
and auth.role() = 'authenticated'
|
||||
and (storage.foldername(name))[1] = 'authenticated'
|
||||
);`,
|
||||
},
|
||||
{
|
||||
title: 'Individual file access',
|
||||
description: 'Grant a specific user access to a single file by UID.',
|
||||
code: `create policy "Individual access"
|
||||
on storage.objects for all
|
||||
using (
|
||||
bucket_id = 'avatars'
|
||||
and name = 'folder/only_uid.jpg'
|
||||
and (select auth.uid()) = 'd8c7bce9-cfeb-497b-bd61-e66ce2cbdaa2'
|
||||
);`,
|
||||
},
|
||||
]
|
||||
|
||||
export async function PermissionsSection() {
|
||||
const hl = await createHighlighter({
|
||||
themes: [supabaseDark, supabaseLight],
|
||||
langs: ['sql'],
|
||||
})
|
||||
|
||||
const examples = PERMISSION_EXAMPLES.map((example) => ({
|
||||
title: example.title,
|
||||
description: example.description,
|
||||
darkHtml: hl.codeToHtml(example.code, { lang: 'sql', theme: 'supabase-dark' }),
|
||||
lightHtml: hl.codeToHtml(example.code, { lang: 'sql', theme: 'supabase-light' }),
|
||||
}))
|
||||
|
||||
hl.dispose()
|
||||
|
||||
return <PermissionsSectionClient examples={examples} />
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
type PermissionExample = {
|
||||
title: string
|
||||
description: string
|
||||
darkHtml: string
|
||||
lightHtml: string
|
||||
}
|
||||
|
||||
const INTERVAL_DURATION = 10000
|
||||
|
||||
export function PermissionsSectionClient({ examples }: { examples: PermissionExample[] }) {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = examples[activeIdx]
|
||||
|
||||
// Auto-size the code panel to the active snippet. We measure an always-mounted,
|
||||
// invisible copy of the current code so the target height is known immediately on
|
||||
// switch (and doesn't collapse during the cross-fade), then animate `height` to it.
|
||||
const measureRef = useRef<HTMLDivElement>(null)
|
||||
const [height, setHeight] = useState<number>()
|
||||
const hasMeasured = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(() => setHeight(el.offsetHeight))
|
||||
ro.observe(el)
|
||||
setHeight(el.offsetHeight)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (height != null) hasMeasured.current = true
|
||||
}, [height])
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % examples.length)
|
||||
}
|
||||
}, [progress, examples.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={inViewRef} className="py-24 flex flex-col gap-16">
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Integrates natively
|
||||
<br />
|
||||
<span className="text-foreground">with Supabase Auth</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Storage authorization is built around Postgres so that you can use any combination of
|
||||
SQL, Postgres functions, and even your own metadata to write policies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tabs */}
|
||||
<div className="flex flex-col gap-3 items-start justify-end">
|
||||
{examples.map((example, index) => {
|
||||
const isActive = index === activeIdx
|
||||
return (
|
||||
<button
|
||||
key={example.title}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex flex-col gap-0.5 py-2 transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<span className="text-2xl font-medium tracking-tight">{example.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm transition-colors',
|
||||
isActive ? 'text-foreground-lighter' : 'text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
{example.description}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: code block */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={height != null ? { height } : undefined}
|
||||
transition={
|
||||
hasMeasured.current ? { duration: 0.35, ease: [0.22, 1, 0.36, 1] } : { duration: 0 }
|
||||
}
|
||||
className="relative self-end overflow-hidden border border-border rounded-md"
|
||||
>
|
||||
{/* Invisible measuring copy: drives the animated container height */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute inset-x-0 top-0 overflow-x-auto pb-12 [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.title}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="overflow-x-auto pb-12"
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.darkHtml }}
|
||||
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: active.lightHtml }}
|
||||
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Link
|
||||
href="/docs/reference/javascript/storage-createbucket"
|
||||
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Documentation
|
||||
<svg
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QuoteSection as SharedQuoteSection } from '../../_shared/QuoteSection'
|
||||
|
||||
export function QuoteSection() {
|
||||
return (
|
||||
<SharedQuoteSection
|
||||
quote="Supabase is great because it has everything. I don't need a different solution for authentication, a different solution for database, or a different solution for storage."
|
||||
author={{
|
||||
name: 'Yasser Elsaid',
|
||||
role: 'Founder of Chatbase',
|
||||
image: '/images/blog/avatars/yasser-elsaid-chatbase.jpeg',
|
||||
link: '/customers/chatbase',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BuiltWithSupabaseSection } from '~/app/(home)/_components/BuiltWithSupabaseSection'
|
||||
import { CTASection } from '~/app/(home)/_components/CTASection'
|
||||
import { BucketTypesSection } from './BucketTypesSection'
|
||||
import { FeaturesSection } from './FeaturesSection'
|
||||
import { Hero } from './Hero'
|
||||
import { QuoteSection } from './QuoteSection'
|
||||
|
||||
export function StorageContent({
|
||||
apiSlot,
|
||||
permissionsSlot,
|
||||
}: {
|
||||
apiSlot: React.ReactNode
|
||||
permissionsSlot: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-clip">
|
||||
<section>
|
||||
<Hero />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<FeaturesSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<QuoteSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<BucketTypesSection />
|
||||
</section>
|
||||
<section className="border-t border-border">{apiSlot}</section>
|
||||
<section className="border-t border-border">{permissionsSlot}</section>
|
||||
<section className="border-t border-border">
|
||||
<BuiltWithSupabaseSection />
|
||||
</section>
|
||||
<section className="border-t border-border">
|
||||
<CTASection />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { ApiSection } from './_components/ApiSection'
|
||||
import { PermissionsSection } from './_components/PermissionsSection'
|
||||
import { StorageContent } from './_components/StorageContent'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Storage | Supabase',
|
||||
description:
|
||||
'An open source S3 compatible storage solution with unlimited scalability, for any file type.',
|
||||
}
|
||||
|
||||
export default function StoragePage() {
|
||||
return <StorageContent apiSlot={<ApiSection />} permissionsSlot={<PermissionsSection />} />
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { isBrowser, LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { type BlogView } from 'app/blog/blog-view'
|
||||
import BlogFilters from 'components/Blog/BlogFilters'
|
||||
import BlogGridItem from 'components/Blog/BlogGridItem'
|
||||
import BlogListItem from 'components/Blog/BlogListItem'
|
||||
import { useInfiniteScrollWithFetch } from 'hooks/useInfiniteScroll'
|
||||
import { Suspense, useCallback, useState } from 'react'
|
||||
import type PostTypes from 'types/post'
|
||||
import { cn } from 'ui'
|
||||
|
||||
export type BlogView = 'list' | 'grid'
|
||||
|
||||
const POSTS_PER_PAGE = 25
|
||||
const SKELETON_COUNT = 6
|
||||
// Featured + 2 secondary posts shown by the layout hero on the index. Excluded
|
||||
// from the list to avoid duplication — keep in sync with app/blog/layout.tsx.
|
||||
const HERO_POST_COUNT = 3
|
||||
|
||||
function BlogListItemSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-10 xl:grid-cols-12 w-full py-2 sm:py-4 h-full border-b">
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-10 xl:grid-cols-12 w-full py-2 sm:py-4 h-full">
|
||||
<div className="flex w-full lg:col-span-8 xl:col-span-8">
|
||||
<div className="h-6 bg-foreground-muted/20 rounded-sm animate-pulse w-3/4" />
|
||||
</div>
|
||||
@@ -38,20 +38,16 @@ function BlogListItemSkeleton() {
|
||||
|
||||
function BlogGridItemSkeleton() {
|
||||
return (
|
||||
<div className="inline-block min-w-full p-2 sm:p-4 h-full">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="relative mb-3 w-full aspect-2/1 lg:aspect-5/3 overflow-hidden rounded-lg border border-default bg-foreground-muted/20 animate-pulse" />
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<div className="h-4 w-24 bg-foreground-muted/20 rounded-sm animate-pulse" />
|
||||
<div className="h-4 w-4 bg-foreground-muted/20 rounded-sm animate-pulse" />
|
||||
<div className="h-4 w-16 bg-foreground-muted/20 rounded-sm animate-pulse" />
|
||||
</div>
|
||||
<div className="h-6 w-3/4 bg-foreground-muted/20 rounded-sm animate-pulse mt-1" />
|
||||
<div className="h-4 w-full bg-foreground-muted/20 rounded-sm animate-pulse mt-1" />
|
||||
<div className="h-4 w-2/3 bg-foreground-muted/20 rounded-sm animate-pulse" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-6">
|
||||
<div className="relative w-full aspect-[1.91/1] overflow-hidden bg-foreground-muted/20 animate-pulse" />
|
||||
<div className="flex items-center space-x-1.5 mt-2">
|
||||
<div className="h-4 w-24 bg-foreground-muted/20 rounded animate-pulse" />
|
||||
<div className="h-4 w-4 bg-foreground-muted/20 rounded animate-pulse" />
|
||||
<div className="h-4 w-16 bg-foreground-muted/20 rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="h-6 w-3/4 bg-foreground-muted/20 rounded animate-pulse mt-1" />
|
||||
<div className="h-4 w-full bg-foreground-muted/20 rounded animate-pulse mt-1" />
|
||||
<div className="h-4 w-2/3 bg-foreground-muted/20 rounded animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -59,12 +55,11 @@ function BlogGridItemSkeleton() {
|
||||
interface BlogClientProps {
|
||||
initialBlogs: any[]
|
||||
totalPosts: number
|
||||
initialView: BlogView
|
||||
}
|
||||
|
||||
export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps) {
|
||||
const { BLOG_VIEW } = LOCAL_STORAGE_KEYS
|
||||
const localView = isBrowser ? (localStorage?.getItem(BLOG_VIEW) as BlogView) : undefined
|
||||
const [view, setView] = useState<BlogView>(localView ?? 'list')
|
||||
export default function BlogClient({ initialBlogs, totalPosts, initialView }: BlogClientProps) {
|
||||
const [view, setView] = useState<BlogView>(initialView)
|
||||
const [isFiltering, setIsFiltering] = useState(false)
|
||||
const [filterParams, setFilterParams] = useState<{ category?: string; search?: string }>({})
|
||||
const [filteredPosts, setFilteredPosts] = useState<any[] | null>(null)
|
||||
@@ -76,21 +71,13 @@ export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps
|
||||
|
||||
const fetchMorePosts = useCallback(
|
||||
async (offset: number, limit: number) => {
|
||||
const isFiltered =
|
||||
(filterParams.category && filterParams.category !== 'all') || Boolean(filterParams.search)
|
||||
// The featured post is rendered above the list (not in `items`), so the
|
||||
// API offset has to skip past it for unfiltered fetches. Filtered
|
||||
// results come from a separate query and don't share that hero slot.
|
||||
const apiOffset = isFiltered ? offset : offset + 1
|
||||
|
||||
// The hero posts stay in the loaded array (just rendered in the layout
|
||||
// hero, sliced off at render), so `offset` already counts them — no skip.
|
||||
const params = new URLSearchParams({
|
||||
offset: apiOffset.toString(),
|
||||
offset: offset.toString(),
|
||||
limit: limit.toString(),
|
||||
})
|
||||
|
||||
if (filterParams.category && filterParams.category !== 'all') {
|
||||
params.set('category', filterParams.category)
|
||||
}
|
||||
if (filterParams.search) {
|
||||
params.set('q', filterParams.search)
|
||||
}
|
||||
@@ -109,9 +96,7 @@ export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps
|
||||
|
||||
const {
|
||||
items: blogs,
|
||||
setItems: setBlogs,
|
||||
hasMore,
|
||||
isLoading,
|
||||
loadMoreRef,
|
||||
} = useInfiniteScrollWithFetch({
|
||||
initialItems: currentPosts,
|
||||
@@ -160,66 +145,70 @@ export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
<BlogFilters onFilterChange={handleFilterChange} view={view} setView={setView} />
|
||||
</Suspense>
|
||||
// The hero posts (the most recent HERO_POST_COUNT) are rendered by the blog
|
||||
// layout's hero, so drop them from the list to avoid duplication. Filtered
|
||||
// results are a separate query with no hero, so show all.
|
||||
const visibleBlogs = filteredPosts !== null ? blogs : blogs.slice(HERO_POST_COUNT)
|
||||
|
||||
<ol
|
||||
className={cn(
|
||||
'grid -mx-2 sm:-mx-4 py-6 lg:py-6 lg:pb-20',
|
||||
isList ? 'grid-cols-1' : 'grid-cols-12 lg:gap-4'
|
||||
)}
|
||||
>
|
||||
return (
|
||||
<div>
|
||||
<h1 className="sr-only">Supabase blog</h1>
|
||||
|
||||
{/* Filters row */}
|
||||
<div className="sticky top-[65px] z-10 bg-background/80 backdrop-blur-sm border-b border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6">
|
||||
<div className="py-3">
|
||||
<Suspense fallback={null}>
|
||||
<BlogFilters onFilterChange={handleFilterChange} view={view} setView={setView} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blog posts */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)]">
|
||||
{isFiltering ? (
|
||||
isList ? (
|
||||
Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
|
||||
<div
|
||||
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
|
||||
key={`skeleton-list-${idx}`}
|
||||
>
|
||||
<BlogListItemSkeleton />
|
||||
</div>
|
||||
))
|
||||
<div>
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
|
||||
<BlogListItemSkeleton key={`skeleton-list-${idx}`} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
|
||||
<div
|
||||
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
|
||||
key={`skeleton-grid-${idx}`}
|
||||
>
|
||||
<BlogGridItemSkeleton />
|
||||
</div>
|
||||
))
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, idx) => (
|
||||
<div key={`skeleton-grid-${idx}`}>
|
||||
<BlogGridItemSkeleton />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : blogs?.length ? (
|
||||
blogs?.map((blog: PostTypes, idx: number) =>
|
||||
isList ? (
|
||||
<div
|
||||
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
|
||||
key={`list-${idx}-${blog.slug}`}
|
||||
>
|
||||
<BlogListItem post={blog} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
|
||||
key={`grid-${idx}-${blog.slug}`}
|
||||
>
|
||||
<BlogGridItem post={blog} />
|
||||
</div>
|
||||
)
|
||||
) : visibleBlogs?.length ? (
|
||||
isList ? (
|
||||
<div>
|
||||
{visibleBlogs.map((blog: PostTypes, idx: number) => (
|
||||
<BlogListItem post={blog} key={`list-${idx}-${blog.slug}`} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleBlogs.map((blog: PostTypes, idx: number) => (
|
||||
<BlogGridItem post={blog} key={`grid-${idx}-${blog.slug}`} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-light col-span-full">No results</p>
|
||||
<div className="px-6 py-12">
|
||||
<p className="text-sm text-light">No results</p>
|
||||
</div>
|
||||
)}
|
||||
</ol>
|
||||
|
||||
{hasMore && !isFiltering && (
|
||||
<div ref={loadMoreRef} className="flex justify-center py-8" aria-hidden="true">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-foreground-muted border-t-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{hasMore && !isFiltering && (
|
||||
<div ref={loadMoreRef} className="flex justify-center py-8" aria-hidden="true">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-foreground-muted border-t-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import FeaturedThumb from 'components/Blog/FeaturedThumb'
|
||||
import { motion } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import type PostTypes from 'types/post'
|
||||
|
||||
import blogAuthors from '@/lib/authors.json'
|
||||
|
||||
function SecondarySpotlight({ post }: { post: PostTypes }) {
|
||||
const resolveImagePath = (img: string | undefined): string | null => {
|
||||
if (!img) return null
|
||||
return img.startsWith('/') || img.startsWith('http') ? img : `/images/blog/${img}`
|
||||
}
|
||||
const imageUrl =
|
||||
resolveImagePath(post.imgThumb) ||
|
||||
resolveImagePath(post.imgSocial) ||
|
||||
'/images/blog/blog-placeholder.png'
|
||||
|
||||
const authorNames = (post.author?.split(',') ?? [])
|
||||
.map((id) => blogAuthors.find((a: any) => a.author_id === id.trim())?.author)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
|
||||
return (
|
||||
<Link href={post.path} prefetch={false} className="group flex gap-4 items-start">
|
||||
<div className="relative shrink-0 w-36 aspect-[1.91/1] overflow-hidden rounded-md border border-foreground/10">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
fill
|
||||
sizes="112px"
|
||||
quality={80}
|
||||
className="object-cover group-hover:scale-[1.03] transition-transform duration-300"
|
||||
alt={post.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<h3 className="text-foreground text-sm leading-snug group-hover:underline line-clamp-3">
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.formattedDate && (
|
||||
<p className="text-foreground-lighter text-xs">{post.formattedDate}</p>
|
||||
)}
|
||||
{authorNames && <p className="text-foreground-light text-xs">{authorNames}</p>}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Featured hero for the blog. Rendered by the blog route layout so it stays
|
||||
* mounted across navigation between `/blog` and `/blog/categories/*`. It's only
|
||||
* meant for the index, so on other blog routes we animate its height to 0 —
|
||||
* collapsing it smoothly slides the page content up/down instead of jumping.
|
||||
*/
|
||||
export default function BlogHero({
|
||||
featuredPost,
|
||||
secondaryPosts,
|
||||
}: {
|
||||
featuredPost: PostTypes | null
|
||||
secondaryPosts: PostTypes[]
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
// Only mount the hero on the listing routes it animates between (index ⇄
|
||||
// category). Elsewhere (posts, tags, authors) it's irrelevant — rendering it
|
||||
// would needlessly preload the featured image.
|
||||
const isListingRoute = pathname === '/blog' || Boolean(pathname?.startsWith('/blog/categories/'))
|
||||
const expanded = pathname === '/blog'
|
||||
|
||||
if (!featuredPost || !isListingRoute) return null
|
||||
|
||||
// When collapsed, `inert` removes the clipped hero (and its links) from the
|
||||
// a11y tree and tab order entirely.
|
||||
const collapsedProps = expanded ? {} : ({ inert: '' } as any)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
{...collapsedProps}
|
||||
initial={false}
|
||||
animate={{ height: expanded ? 'auto' : 0, opacity: expanded ? 1 : 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
aria-hidden={!expanded}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pt-32 pb-10">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6">
|
||||
<div className="max-w-4xl">
|
||||
<FeaturedThumb key={featuredPost.slug} {...featuredPost} />
|
||||
</div>
|
||||
|
||||
{secondaryPosts.length > 0 && (
|
||||
<div className="mt-14 max-w-4xl grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{secondaryPosts.map((post) => (
|
||||
<SecondarySpotlight key={post.slug} post={post} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import Footer from '~/components/Footer'
|
||||
import Nav from '~/components/Nav'
|
||||
import { useForceDeepDark } from 'lib/theme.utils'
|
||||
import type PostTypes from 'types/post'
|
||||
|
||||
import BlogHero from './BlogHero'
|
||||
|
||||
export default function BlogLayoutShell({
|
||||
featuredPost,
|
||||
secondaryPosts,
|
||||
children,
|
||||
}: {
|
||||
featuredPost: PostTypes | null
|
||||
secondaryPosts: PostTypes[]
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
useForceDeepDark()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav hideNavbar={false} />
|
||||
<div className="relative w-full [--container-max-w:75rem]">
|
||||
<BlogHero featuredPost={featuredPost} secondaryPosts={secondaryPosts} />
|
||||
<main className="relative min-h-screen">{children}</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { isBrowser, LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { type BlogView } from 'app/blog/blog-view'
|
||||
import BlogGridItem from 'components/Blog/BlogGridItem'
|
||||
import BlogListItem from 'components/Blog/BlogListItem'
|
||||
import DefaultLayout from 'components/Layouts/Default'
|
||||
import { AlignJustify, Grid, Search } from 'lucide-react'
|
||||
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 type PostTypes from 'types/post'
|
||||
import { Button, cn, InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
|
||||
|
||||
export type BlogView = 'list' | 'grid'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
|
||||
|
||||
interface Author {
|
||||
author_id: string
|
||||
@@ -25,12 +23,11 @@ interface AuthorClientProps {
|
||||
author: Author | null
|
||||
authorId: string
|
||||
blogs: PostTypes[]
|
||||
initialView: BlogView
|
||||
}
|
||||
|
||||
export default function AuthorClient({ author, authorId, blogs }: AuthorClientProps) {
|
||||
const { BLOG_VIEW } = LOCAL_STORAGE_KEYS
|
||||
const localView = isBrowser ? (localStorage?.getItem(BLOG_VIEW) as BlogView) : undefined
|
||||
const [view, setView] = useState<BlogView>(localView ?? 'list')
|
||||
export default function AuthorClient({ author, authorId, blogs, initialView }: AuthorClientProps) {
|
||||
const [view, setView] = useState<BlogView>(initialView)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const isList = view === 'list'
|
||||
|
||||
@@ -44,17 +41,10 @@ export default function AuthorClient({ author, authorId, blogs }: AuthorClientPr
|
||||
)
|
||||
}, [blogs, searchTerm])
|
||||
|
||||
const handleViewSelection = () => {
|
||||
setView((prevView) => {
|
||||
const newValue = prevView === 'list' ? 'grid' : 'list'
|
||||
localStorage.setItem(BLOG_VIEW, newValue)
|
||||
return newValue
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<div className="container mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 pt-8 md:pt-12 pb-8">
|
||||
<div className="text-foreground-lighter flex space-x-1 mb-8">
|
||||
<h1 className="cursor-pointer">
|
||||
<Link href="/blog">Blog</Link>
|
||||
@@ -64,7 +54,7 @@ export default function AuthorClient({ author, authorId, blogs }: AuthorClientPr
|
||||
</div>
|
||||
|
||||
{author && (
|
||||
<div className="flex items-center gap-6 pb-8">
|
||||
<div className="flex items-center gap-6">
|
||||
{author.author_image_url && (
|
||||
<Image
|
||||
src={author.author_image_url}
|
||||
@@ -98,9 +88,10 @@ export default function AuthorClient({ author, authorId, blogs }: AuthorClientPr
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-default border-t">
|
||||
<div className="container mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
|
||||
<div className="flex flex-row items-center justify-between gap-2 mb-6">
|
||||
{/* 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-[var(--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
|
||||
@@ -116,54 +107,37 @@ export default function AuthorClient({ author, authorId, blogs }: AuthorClientPr
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<Button
|
||||
type="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>
|
||||
<BlogViewToggle view={view} setView={setView} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredBlogs.length > 0 ? (
|
||||
<ol
|
||||
className={cn(
|
||||
'grid -mx-2 sm:-mx-4 py-6 lg:py-6 lg:pb-20',
|
||||
isList ? 'grid-cols-1' : 'grid-cols-12 lg:gap-4'
|
||||
)}
|
||||
>
|
||||
{filteredBlogs.map((blog: PostTypes, idx: number) =>
|
||||
isList ? (
|
||||
<div
|
||||
className="col-span-12 px-2 sm:px-4 [&_a]:last:border-none"
|
||||
key={`list-${idx}-${blog.slug}`}
|
||||
>
|
||||
<BlogListItem post={blog} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="col-span-12 mb-4 md:col-span-12 lg:col-span-6 xl:col-span-4 h-full"
|
||||
key={`grid-${idx}-${blog.slug}`}
|
||||
>
|
||||
<BlogGridItem post={blog} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ol>
|
||||
{/* Posts */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)]">
|
||||
{filteredBlogs.length > 0 ? (
|
||||
isList ? (
|
||||
<div>
|
||||
{filteredBlogs.map((blog, idx) => (
|
||||
<BlogListItem post={blog} key={`list-${idx}-${blog.slug}`} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-foreground-lighter py-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredBlogs.map((blog, idx) => (
|
||||
<BlogGridItem post={blog} key={`grid-${idx}-${blog.slug}`} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="px-6 py-12">
|
||||
<p className="text-sm text-light">
|
||||
{searchTerm
|
||||
? 'No posts found matching your search.'
|
||||
: 'No posts found by this author.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { BLOG_VIEW_COOKIE, isBlogView, type BlogView } from 'app/blog/blog-view'
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import AuthorClient from './AuthorClient'
|
||||
import blogAuthors from '@/lib/authors.json'
|
||||
@@ -21,13 +24,6 @@ function toCanonicalAuthorId(identifier: string): string {
|
||||
return authorIdLookup.get(identifier) ?? identifier
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return blogAuthors.map((author) => ({ author: author.author_id }))
|
||||
}
|
||||
|
||||
export const revalidate = 30
|
||||
export const dynamic = 'force-static'
|
||||
|
||||
export async function generateMetadata({
|
||||
params: paramsPromise,
|
||||
}: {
|
||||
@@ -47,6 +43,15 @@ export default async function AuthorPage({ params: paramsPromise }: { params: Pr
|
||||
const authorId = params.author
|
||||
|
||||
const author = blogAuthors.find((a) => a.author_id === authorId) ?? null
|
||||
if (!author) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Read the list/grid preference from a cookie so the correct view renders on
|
||||
// first paint. Reading a cookie opts this route into dynamic rendering.
|
||||
const cookieStore = await cookies()
|
||||
const cookieView = cookieStore.get(BLOG_VIEW_COOKIE)?.value
|
||||
const initialView: BlogView = isBlogView(cookieView) ? cookieView : 'list'
|
||||
|
||||
// Get static posts where author field contains this author_id (normalize identifiers)
|
||||
const staticPosts = getSortedPosts({ directory: '_blog', limit: 0 }).filter((post: any) => {
|
||||
@@ -58,5 +63,13 @@ export default async function AuthorPage({ params: paramsPromise }: { params: Pr
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
) as unknown as PostTypes[]
|
||||
|
||||
return <AuthorClient author={author} authorId={authorId} blogs={blogs} />
|
||||
return (
|
||||
<AuthorClient
|
||||
key={authorId}
|
||||
author={author}
|
||||
authorId={authorId}
|
||||
blogs={blogs}
|
||||
initialView={initialView}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type BlogView = 'list' | 'grid'
|
||||
|
||||
export const BLOG_VIEW_COOKIE = 'blog-view'
|
||||
|
||||
export function isBlogView(value: string | undefined | null): value is BlogView {
|
||||
return value === 'list' || value === 'grid'
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the list/grid preference in a cookie so the server can render the
|
||||
* correct view on the next load. Storing it client-side (localStorage) caused a
|
||||
* hydration flicker: the server rendered the default `list`, then the client
|
||||
* swapped to `grid`. Client-side only — call from an event handler.
|
||||
*/
|
||||
export function setBlogViewCookie(view: BlogView) {
|
||||
document.cookie = `${BLOG_VIEW_COOKIE}=${view}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { type BlogView } from 'app/blog/blog-view'
|
||||
import BlogFilters from 'components/Blog/BlogFilters'
|
||||
import BlogGridItem from 'components/Blog/BlogGridItem'
|
||||
import BlogListItem from 'components/Blog/BlogListItem'
|
||||
import { Suspense, useMemo, useState } from 'react'
|
||||
import type PostTypes from 'types/post'
|
||||
|
||||
export default function CategoryClient({
|
||||
posts,
|
||||
initialView,
|
||||
}: {
|
||||
posts: PostTypes[]
|
||||
initialView: BlogView
|
||||
}) {
|
||||
const [view, setView] = useState<BlogView>(initialView)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const isList = view === 'list'
|
||||
|
||||
// In-category search: filter the (already loaded) posts client-side.
|
||||
const visiblePosts = useMemo(() => {
|
||||
if (!searchTerm) return posts
|
||||
const term = searchTerm.toLowerCase()
|
||||
return posts.filter(
|
||||
(post) =>
|
||||
(post.title ?? '').toLowerCase().includes(term) ||
|
||||
(post.description ?? '').toLowerCase().includes(term)
|
||||
)
|
||||
}, [posts, searchTerm])
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6">
|
||||
<div className="py-3">
|
||||
<Suspense fallback={null}>
|
||||
<BlogFilters view={view} setView={setView} onSearch={setSearchTerm} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)]">
|
||||
{visiblePosts.length ? (
|
||||
isList ? (
|
||||
<div>
|
||||
{visiblePosts.map((post, idx) => (
|
||||
<BlogListItem post={post} key={`list-${idx}-${post.slug}`} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visiblePosts.map((post, idx) => (
|
||||
<BlogGridItem post={post} key={`grid-${idx}-${post.slug}`} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="px-6 py-12">
|
||||
<p className="text-sm text-light">No results</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,15 @@
|
||||
import { BLOG_VIEW_COOKIE, isBlogView, type BlogView } from 'app/blog/blog-view'
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { cookies } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import BlogGridItem from '@/components/Blog/BlogGridItem'
|
||||
import DefaultLayout from '@/components/Layouts/Default'
|
||||
import CategoryClient from './CategoryClient'
|
||||
import { capitalize } from '@/lib/helpers'
|
||||
import { getAllCategories, getSortedPosts } from '@/lib/posts'
|
||||
import type PostTypes from '@/types/post'
|
||||
|
||||
type Params = { category: string }
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const categories = getAllCategories('_blog')
|
||||
return categories.map((category: string) => ({ category }))
|
||||
}
|
||||
|
||||
export const revalidate = 30
|
||||
export const dynamic = 'force-static'
|
||||
|
||||
export async function generateMetadata({
|
||||
params: paramsPromise,
|
||||
}: {
|
||||
@@ -38,37 +31,24 @@ export default async function CategoriesPage({
|
||||
}) {
|
||||
const params = await paramsPromise
|
||||
|
||||
if (!getAllCategories('_blog').includes(params.category)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Read the list/grid preference from a cookie so the correct view renders on
|
||||
// first paint. Reading a cookie opts this route into dynamic rendering.
|
||||
const cookieStore = await cookies()
|
||||
const cookieView = cookieStore.get(BLOG_VIEW_COOKIE)?.value
|
||||
const initialView: BlogView = isBlogView(cookieView) ? cookieView : 'list'
|
||||
|
||||
const staticPosts = getSortedPosts({
|
||||
directory: '_blog',
|
||||
limit: 0,
|
||||
categories: [params.category],
|
||||
})
|
||||
const blogs = [...staticPosts] as PostTypes[]
|
||||
const capitalizedCategory = capitalize(params?.category.replaceAll('-', ' '))
|
||||
|
||||
return (
|
||||
<>
|
||||
<DefaultLayout>
|
||||
<div className="container mx-auto px-8 py-16 sm:px-16 xl:px-20">
|
||||
<div className="text-foreground-lighter flex space-x-1">
|
||||
<h1 className="cursor-pointer">
|
||||
<Link href="/blog">Blog</Link>
|
||||
<span className="px-2">/</span>
|
||||
<span>{capitalizedCategory}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<ol className="grid grid-cols-12 gap-8 py-16 lg:gap-16">
|
||||
{blogs.map((blog: PostTypes) => (
|
||||
<div
|
||||
className="col-span-12 mb-16 md:col-span-12 lg:col-span-6 xl:col-span-4"
|
||||
key={blog.slug}
|
||||
>
|
||||
<BlogGridItem post={blog} />
|
||||
</div>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
</>
|
||||
)
|
||||
// Key by category so state (search term, view) resets when switching between
|
||||
// category pages rather than persisting across the reused page component.
|
||||
return <CategoryClient key={params.category} posts={blogs} initialView={initialView} />
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import BlogLayoutShell from './BlogLayoutShell'
|
||||
import { getSortedPosts } from '@/lib/posts'
|
||||
import type PostTypes from '@/types/post'
|
||||
|
||||
export default async function BlogLayout({ children }: { children: React.ReactNode }) {
|
||||
// Fetched once in the (persistent) layout so the featured hero survives
|
||||
// navigation between blog routes and can animate its height on change.
|
||||
const staticPosts = getSortedPosts({ directory: '_blog' })
|
||||
const sorted = [...staticPosts].sort((a, b) => {
|
||||
const aDate = new Date(a.date || a.formattedDate).getTime()
|
||||
const bDate = new Date(b.date || b.formattedDate).getTime()
|
||||
return bDate - aDate
|
||||
})
|
||||
|
||||
const featuredPost = (sorted[0] ?? null) as PostTypes | null
|
||||
const secondaryPosts = sorted.slice(1, 3) as PostTypes[]
|
||||
|
||||
return (
|
||||
<BlogLayoutShell featuredPost={featuredPost} secondaryPosts={secondaryPosts}>
|
||||
{children}
|
||||
</BlogLayoutShell>
|
||||
)
|
||||
}
|
||||
+16
-25
@@ -1,15 +1,12 @@
|
||||
import FeaturedThumb from 'components/Blog/FeaturedThumb'
|
||||
import DefaultLayout from 'components/Layouts/Default'
|
||||
import type { Metadata } from 'next'
|
||||
import type PostTypes from 'types/post'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
import { BLOG_VIEW_COOKIE, isBlogView, type BlogView } from './blog-view'
|
||||
import BlogClient from './BlogClient'
|
||||
import { breadcrumbs } from '@/lib/breadcrumbs'
|
||||
import { breadcrumbListSchema, serializeJsonLd } from '@/lib/json-ld'
|
||||
import { getSortedPosts } from '@/lib/posts'
|
||||
|
||||
export const revalidate = 30
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Supabase Blog: the Postgres development platform',
|
||||
description: 'Get all your Supabase News on the Supabase blog.',
|
||||
@@ -32,13 +29,16 @@ export default async function BlogPage() {
|
||||
return dateB - dateA
|
||||
})
|
||||
|
||||
// The featured post + secondary spotlights are derived from the head of this
|
||||
// list inside BlogClient; chrome (nav, footer, container) comes from the blog
|
||||
// route's layout.tsx.
|
||||
const initialPosts = allPosts.slice(0, INITIAL_POSTS_LIMIT)
|
||||
const featuredPost = initialPosts[0]
|
||||
// Featured post is rendered as the hero above the list, so exclude it from
|
||||
// the list to avoid showing it twice. BlogClient compensates the API offset
|
||||
// when scrolling for more posts.
|
||||
const listPosts = initialPosts.slice(1)
|
||||
const totalListPosts = Math.max(0, allPosts.length - 1)
|
||||
|
||||
// Render the list/grid view from a cookie so it matches the user's choice on
|
||||
// first paint (avoids the localStorage hydration flicker).
|
||||
const cookieStore = await cookies()
|
||||
const cookieView = cookieStore.get(BLOG_VIEW_COOKIE)?.value
|
||||
const initialView: BlogView = isBlogView(cookieView) ? cookieView : 'list'
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,20 +48,11 @@ export default async function BlogPage() {
|
||||
__html: serializeJsonLd(breadcrumbListSchema(breadcrumbs.blogIndex)),
|
||||
}}
|
||||
/>
|
||||
<DefaultLayout>
|
||||
<h1 className="sr-only">Supabase blog</h1>
|
||||
<div className="container relative mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
|
||||
{featuredPost && (
|
||||
<FeaturedThumb key={featuredPost.slug} {...(featuredPost as PostTypes)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-default border-t">
|
||||
<div className="container mx-auto px-4 py-4 md:py-8 xl:py-10 sm:px-16 xl:px-20">
|
||||
<BlogClient initialBlogs={listPosts} totalPosts={totalListPosts} />
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
<BlogClient
|
||||
initialBlogs={initialPosts}
|
||||
totalPosts={allPosts.length}
|
||||
initialView={initialView}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import 'config/code-hike.css'
|
||||
import '../styles/globals.css'
|
||||
import '../pages/launch-week/launchWeek.css'
|
||||
|
||||
import { APP_NAME, DEFAULT_META_DESCRIPTION } from '~/lib/constants'
|
||||
import { geistMono, inter, manrope } from '~/lib/fonts'
|
||||
import { Agentation } from 'agentation'
|
||||
import { Metadata } from 'next'
|
||||
import type { Viewport } from 'next'
|
||||
|
||||
import Providers from './providers'
|
||||
import { APP_NAME, DEFAULT_META_DESCRIPTION } from '@/lib/constants'
|
||||
|
||||
const site_title = `${APP_NAME} | The Open Source Firebase Alternative`
|
||||
|
||||
@@ -47,9 +49,14 @@ export const viewport: Viewport = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${manrope.variable} ${inter.variable} ${geistMono.variable}`}
|
||||
>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
{process.env.NODE_ENV === 'development' && <Agentation endpoint="http://localhost:4747" />}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
+31
-23
@@ -12,7 +12,9 @@ import {
|
||||
import { WwwCommandMenu } from 'components/CommandMenu'
|
||||
import { DevToolbar, DevToolbarProvider } from 'dev-tools'
|
||||
import { API_URL } from 'lib/constants'
|
||||
import { TooltipProvider } from 'ui'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import { Suspense } from 'react'
|
||||
import { themes, TooltipProvider } from 'ui'
|
||||
import { CommandProvider } from 'ui-patterns/CommandMenu'
|
||||
import { useConsentToast } from 'ui-patterns/consent'
|
||||
|
||||
@@ -23,28 +25,34 @@ function Providers({ children }: { children: React.ReactNode }) {
|
||||
const { hasAcceptedConsent } = useConsentToast()
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<FeatureFlagProvider API_URL={API_URL} enabled={IS_PLATFORM}>
|
||||
<DevToolbarProvider apiUrl={API_URL}>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<CommandProvider>
|
||||
<TelemetryTagManager />
|
||||
<Toaster />
|
||||
{children}
|
||||
<WwwCommandMenu />
|
||||
<PageTelemetry
|
||||
API_URL={API_URL}
|
||||
hasAcceptedConsent={hasAcceptedConsent}
|
||||
enabled={IS_PLATFORM}
|
||||
/>
|
||||
<DevToolbar />
|
||||
</CommandProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</DevToolbarProvider>
|
||||
</FeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
<NuqsAdapter>
|
||||
<AuthProvider>
|
||||
<FeatureFlagProvider API_URL={API_URL} enabled={IS_PLATFORM}>
|
||||
<DevToolbarProvider apiUrl={API_URL}>
|
||||
<ThemeProvider
|
||||
themes={themes.map((t) => t.value)}
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<CommandProvider>
|
||||
<TelemetryTagManager />
|
||||
<Toaster />
|
||||
<Suspense fallback={null}>{children}</Suspense>
|
||||
<WwwCommandMenu />
|
||||
<PageTelemetry
|
||||
API_URL={API_URL}
|
||||
hasAcceptedConsent={hasAcceptedConsent}
|
||||
enabled={IS_PLATFORM}
|
||||
/>
|
||||
<DevToolbar />
|
||||
</CommandProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</DevToolbarProvider>
|
||||
</FeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
</NuqsAdapter>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { BlogView } from 'app/blog/BlogClient'
|
||||
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,
|
||||
@@ -20,68 +23,73 @@ import {
|
||||
} from 'ui'
|
||||
|
||||
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
|
||||
|
||||
@@ -89,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(() => {
|
||||
@@ -116,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>
|
||||
@@ -162,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}
|
||||
type={
|
||||
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 flex-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"
|
||||
@@ -220,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
|
||||
@@ -229,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 />
|
||||
@@ -249,18 +225,9 @@ function BlogFilters({ onFilterChange, view, setView }: Props) {
|
||||
</InputGroup>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="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 '@/app/(home)/_components/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'
|
||||
|
||||
@@ -82,7 +81,7 @@ const BlogPostRenderer = async ({
|
||||
|
||||
const toc = blogMetaData.toc && (
|
||||
<div>
|
||||
<p className="text-foreground mb-4">On this page</p>
|
||||
<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-[var(--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-16 xl:pt-32 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-32 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)]">
|
||||
<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 pb-8">
|
||||
<div>
|
||||
<Link href={`${blog.path}`} prefetch={false} className="group">
|
||||
<h2 className="h2 lg:!text-xl xl:!text-2xl !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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ const BrowserFrame: React.FC<Props> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-2xl shadow-lg p-2 w-full h-full bg-alternative-200 border flex flex-col',
|
||||
'relative rounded-2xl shadow-lg py-2 w-full h-full bg-alternative-200 border flex flex-col',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{hasFrameButtons && (
|
||||
<div className="w-full px-2 pt-1 pb-3 relative flex items-center gap-1.5 lg:gap-2">
|
||||
<div className="w-full px-4 pt-1 pb-3 relative flex items-center gap-1 border-b">
|
||||
<div className="w-2 h-2 bg-border rounded-full" />
|
||||
<div className="w-2 h-2 bg-border rounded-full" />
|
||||
<div className="w-2 h-2 bg-border rounded-full" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'swiper/css'
|
||||
|
||||
import React, { FC } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRouter } from 'next/compat/router'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import ExampleCard from '../ExampleCard'
|
||||
|
||||
@@ -14,7 +14,8 @@ interface Props {
|
||||
}
|
||||
|
||||
const ExamplesMobile: FC<Props> = ({ examples, className }: any) => {
|
||||
const { basePath } = useRouter()
|
||||
const router = useRouter()
|
||||
const basePath = router?.basePath ?? ''
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Button, cn, IconGitHubSolid } from 'ui'
|
||||
import Link from 'next/link'
|
||||
|
||||
import SectionContainer from '~/components/Layouts/SectionContainer'
|
||||
import ExampleCard from '../ExampleCard'
|
||||
|
||||
import Examples from 'data/Examples'
|
||||
import Link from 'next/link'
|
||||
import { Button, cn, IconGitHubSolid } from 'ui'
|
||||
|
||||
import ExampleCard from '../ExampleCard'
|
||||
import ExamplesMobile from './ExamplesMobile'
|
||||
|
||||
const BuiltWithSupabase = () => {
|
||||
@@ -33,7 +32,7 @@ const BuiltWithSupabase = () => {
|
||||
</div>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
<SectionContainer className="relative w-full px-0! lg:px-16! xl:px-20! pb-0! mb-16 md:mb-12 lg:mb-12 pt-6! max-w-[1400px]">
|
||||
<SectionContainer className="relative w-full !px-6 !pb-0 mb-16 md:mb-12 lg:mb-12 !pt-6 !max-w-[var(--container-max-w,75rem)]">
|
||||
<ExamplesMobile examples={Examples.slice(0, 6)} className="lg:hidden" />
|
||||
<div className="hidden lg:grid grid-cols-12 gap-5 mt-4">
|
||||
{Examples.slice(0, 6).map((example, i) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ const CutomsersSliderDesktop = dynamic(() => import('./CutomsersSliderDesktop'))
|
||||
|
||||
const CustomerStories = () => (
|
||||
<div id="customers" className="overflow-hidden pb-16 md:pb-24">
|
||||
<SectionContainer className="pb-8! w-full flex gap-4 justify-between flex-col xl:flex-row xl:items-end">
|
||||
<SectionContainer className="!pb-16 w-full flex gap-4 justify-between flex-col xl:flex-row xl:items-end !max-w-[var(--container-max-w,75rem)] !px-6">
|
||||
<SectionHeader
|
||||
title="Trusted by the world’s"
|
||||
title_alt=" most innovative companies."
|
||||
@@ -36,6 +36,7 @@ const CustomerStories = () => (
|
||||
</Button>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
|
||||
<CustomersSliderMobile columns={compositionCols} className="md:hidden w-full h-[230px]" />
|
||||
<CutomsersSliderDesktop columns={compositionCols} className="hidden md:flex" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { CheckIcon } from '@heroicons/react/outline'
|
||||
import { REALTIME_CHANNEL_STATES } from '@supabase/supabase-js'
|
||||
import SupabaseWordmark from '~/components/Nav/SupabaseWordmark'
|
||||
import supabase from '~/lib/supabase'
|
||||
@@ -25,6 +24,84 @@ import { ThemeToggle } from 'ui-patterns/ThemeToggle'
|
||||
import useDarkLaunchWeeks from '../../hooks/useDarkLaunchWeeks'
|
||||
import SectionContainer from '../Layouts/SectionContainer'
|
||||
|
||||
function Soc2Seal({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<circle cx="50" cy="50" r="48" stroke="currentColor" strokeWidth="2" />
|
||||
<circle cx="50" cy="50" r="42" stroke="currentColor" strokeWidth="1" />
|
||||
<text
|
||||
x="50"
|
||||
y="34"
|
||||
textAnchor="middle"
|
||||
fill="currentColor"
|
||||
fontSize="10"
|
||||
fontWeight="500"
|
||||
fontFamily="system-ui, sans-serif"
|
||||
letterSpacing="3"
|
||||
>
|
||||
AICPA
|
||||
</text>
|
||||
<text
|
||||
x="50"
|
||||
y="58"
|
||||
textAnchor="middle"
|
||||
fill="currentColor"
|
||||
fontSize="24"
|
||||
fontWeight="800"
|
||||
fontFamily="system-ui, sans-serif"
|
||||
>
|
||||
SOC2
|
||||
</text>
|
||||
<text
|
||||
x="50"
|
||||
y="72"
|
||||
textAnchor="middle"
|
||||
fill="currentColor"
|
||||
fontSize="10"
|
||||
fontWeight="500"
|
||||
fontFamily="system-ui, sans-serif"
|
||||
letterSpacing="3"
|
||||
>
|
||||
TYPE 2
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function HipaaSeal({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<circle cx="50" cy="50" r="48" stroke="currentColor" strokeWidth="2" />
|
||||
<circle cx="50" cy="50" r="42" stroke="currentColor" strokeWidth="1" />
|
||||
|
||||
<text
|
||||
x="50"
|
||||
y="50"
|
||||
textAnchor="middle"
|
||||
fill="currentColor"
|
||||
fontSize="16"
|
||||
fontWeight="700"
|
||||
fontFamily="system-ui, sans-serif"
|
||||
letterSpacing="1"
|
||||
>
|
||||
HIPAA
|
||||
</text>
|
||||
<text
|
||||
x="50"
|
||||
y="64"
|
||||
textAnchor="middle"
|
||||
fill="currentColor"
|
||||
fontSize="7.5"
|
||||
fontWeight="500"
|
||||
fontFamily="system-ui, sans-serif"
|
||||
letterSpacing="1"
|
||||
>
|
||||
COMPLIANT
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
hideFooter?: boolean
|
||||
@@ -85,31 +162,6 @@ const Footer = (props: Props) => {
|
||||
<h2 id="footerHeading" className="sr-only">
|
||||
Footer
|
||||
</h2>
|
||||
<div className="w-full py-0!">
|
||||
<SectionContainer className="grid grid-cols-2 md:flex items-center justify-between text-foreground md:justify-center gap-8 md:gap-16 xl:gap-28 py-6! md:py-10! text-sm">
|
||||
<div className="flex flex-col md:flex-row gap-2 md:items-center">
|
||||
We protect your data.
|
||||
<Link href="/security" className="text-brand-link hover:underline">
|
||||
More on Security
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="flex flex-col md:flex-row gap-2 md:gap-8 justify-center md:items-center">
|
||||
<li className="flex items-center gap-2 whitespace-nowrap flex-nowrap">
|
||||
<CheckIcon className="w-4 h-4" /> SOC2 Type 2{' '}
|
||||
<span className="text-foreground-lighter hidden sm:inline">Certified</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 whitespace-nowrap flex-nowrap">
|
||||
<CheckIcon className="w-4 h-4" /> HIPAA{' '}
|
||||
<span className="text-foreground-lighter hidden sm:inline">Compliant</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 whitespace-nowrap flex-nowrap">
|
||||
<CheckIcon className="w-4 h-4" /> ISO 27001{' '}
|
||||
<span className="text-foreground-lighter hidden sm:inline">Certified</span>
|
||||
</li>
|
||||
</ul>
|
||||
</SectionContainer>
|
||||
<div className="w-full h-px bg-linear-to-r from-transparent via-border to-transparent" />
|
||||
</div>
|
||||
<SectionContainer className="py-8">
|
||||
<div className="xl:grid xl:grid-cols-7 xl:gap-4">
|
||||
<div className="xl:col-span-2 flex flex-col gap-8">
|
||||
@@ -249,8 +301,20 @@ const Footer = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-default mt-32 flex justify-between border-t pt-8">
|
||||
<small className="small">© Supabase Inc</small>
|
||||
<div className="border-muted mt-32 flex flex-wrap items-center justify-between gap-4 border-t pt-8">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<small className="small">© Supabase Inc</small>
|
||||
<span className="text-foreground-muted text-xs">·</span>
|
||||
<span className="flex items-center gap-1.5 text-foreground-muted text-xs">
|
||||
<Soc2Seal className="w-5 h-5 shrink-0" />
|
||||
SOC2 Type 2
|
||||
</span>
|
||||
<span className="text-foreground-muted text-xs">·</span>
|
||||
<span className="flex items-center gap-1.5 text-foreground-muted text-xs">
|
||||
<HipaaSeal className="w-5 h-5 shrink-0" />
|
||||
HIPAA Compliant
|
||||
</span>
|
||||
</div>
|
||||
<div className={cn(forceDark && 'hidden')}>
|
||||
<ThemeToggle forceDark={forceDark} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import classNames from 'classnames'
|
||||
import { Ref, forwardRef } from 'react'
|
||||
import { forwardRef, Ref } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
@@ -19,7 +19,7 @@ const SectionContainer = forwardRef(
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={classNames(
|
||||
`sm:py-18 container relative mx-auto px-6 py-16 md:py-24 lg:px-16 lg:py-24 xl:px-20`,
|
||||
`sm:py-18 relative mx-auto w-full max-w-[var(--container-max-w,75rem)] px-6 py-16 md:py-24 lg:py-24`,
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Ref, forwardRef } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { forwardRef, Ref } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const sectionContainerVariants = cva('max-w-7xl relative mx-auto px-6', {
|
||||
const sectionContainerVariants = cva('relative mx-auto w-full px-6', {
|
||||
variants: {
|
||||
width: {
|
||||
normal: 'lg:px-16 xl:px-20',
|
||||
normal: 'max-w-[var(--container-max-w,75rem)]',
|
||||
smallScreenFull: 'max-w-full lg:container px-0',
|
||||
},
|
||||
height: {
|
||||
|
||||
@@ -112,8 +112,8 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => {
|
||||
isTransparent && 'border-transparent backdrop-blur-none'
|
||||
)}
|
||||
>
|
||||
<div className="relative flex justify-between h-16 mx-auto lg:container lg:px-16 xl:px-20">
|
||||
<div className="flex items-center px-6 lg:px-0 flex-1 sm:items-stretch justify-between">
|
||||
<div className="relative flex justify-between h-16 mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="flex items-center flex-1 sm:items-stretch justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center shrink-0">
|
||||
<RightClickBrandLogo />
|
||||
|
||||
@@ -1,36 +1,129 @@
|
||||
import React from 'react'
|
||||
import CodeWindow from '~/components/CodeWindow'
|
||||
'use client'
|
||||
|
||||
const ciCode = `jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: supabase/setup-cli@v1
|
||||
with:
|
||||
version: latest
|
||||
- run: supabase functions deploy
|
||||
`
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const CI = () => (
|
||||
<div className="w-full h-full relative pl-4 xl:mb-0 pt-4 sm:pt-4 border-b xl:border-none overflow-hidden">
|
||||
<CodeWindow
|
||||
code={ciCode}
|
||||
lang="yaml"
|
||||
className="
|
||||
rounded-r-none rounded-b-none border-r-0 border-b-0
|
||||
h-full text-[13px]! xl:text-sm! leading-4
|
||||
[&_.synthax-highlighter]:rounded-b-none
|
||||
[&_.synthax-highlighter]:rounded-r-none
|
||||
[&_.synthax-highlighter]:border-r-0
|
||||
[&_.synthax-highlighter]:border-b-0
|
||||
[&_.synthax-highlighter]:pb-8!
|
||||
[&_.synthax-highlighter]:xl:min-h-[240px]
|
||||
pr-0 pb-0 -bottom-2 xl:-bottom-4
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
const CODE_LINES = [
|
||||
'jobs:',
|
||||
' deploy:',
|
||||
' runs-on: ubuntu-latest',
|
||||
'',
|
||||
' steps:',
|
||||
' - uses: actions/checkout@v4',
|
||||
' - uses: supabase/setup-cli@v1',
|
||||
' with:',
|
||||
' version: latest',
|
||||
' - run: supabase functions deploy',
|
||||
]
|
||||
|
||||
type TokenType = 'key' | 'value' | 'punct' | 'dim' | 'plain'
|
||||
|
||||
interface Token {
|
||||
text: string
|
||||
type: TokenType
|
||||
}
|
||||
|
||||
// Colors from supabase-light / supabase-dark shiki themes
|
||||
const TOKEN_CLASS: Record<TokenType, string> = {
|
||||
key: '[color:#6b35dc] dark:[color:#bda4ff]',
|
||||
value: '[color:#f1a10d] dark:[color:#ffcda1]',
|
||||
punct: '[color:#a0a0a0] dark:[color:#ffffff99]',
|
||||
dim: '[color:#7e7e7e]',
|
||||
plain: '[color:#525252] dark:[color:#ffffff]',
|
||||
}
|
||||
|
||||
function tokenizeLine(line: string): Token[] {
|
||||
if (!line.trim()) return [{ text: '\u00a0', type: 'plain' }]
|
||||
|
||||
const indent = line.match(/^(\s*)/)?.[1] ?? ''
|
||||
const trimmed = line.trimStart()
|
||||
|
||||
const tokens: Token[] = []
|
||||
if (indent) tokens.push({ text: indent, type: 'plain' })
|
||||
|
||||
// List item: "- ..."
|
||||
if (trimmed.startsWith('- ')) {
|
||||
const rest = trimmed.slice(2)
|
||||
const ci = rest.indexOf(': ')
|
||||
tokens.push({ text: '- ', type: 'dim' })
|
||||
if (ci >= 0) {
|
||||
tokens.push({ text: rest.slice(0, ci), type: 'key' })
|
||||
tokens.push({ text: ': ', type: 'punct' })
|
||||
tokens.push({ text: rest.slice(ci + 2), type: 'value' })
|
||||
} else {
|
||||
tokens.push({ text: rest, type: 'value' })
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// "key: value"
|
||||
const ci = trimmed.indexOf(': ')
|
||||
if (ci >= 0) {
|
||||
tokens.push({ text: trimmed.slice(0, ci), type: 'key' })
|
||||
tokens.push({ text: ': ', type: 'punct' })
|
||||
tokens.push({ text: trimmed.slice(ci + 2), type: 'value' })
|
||||
return tokens
|
||||
}
|
||||
|
||||
// "key:" only
|
||||
if (trimmed.endsWith(':')) {
|
||||
tokens.push({ text: trimmed.slice(0, -1), type: 'key' })
|
||||
tokens.push({ text: ':', type: 'punct' })
|
||||
return tokens
|
||||
}
|
||||
|
||||
tokens.push({ text: trimmed, type: 'plain' })
|
||||
return tokens
|
||||
}
|
||||
|
||||
const containerVariants = {
|
||||
visible: { transition: { staggerChildren: 0.08 } },
|
||||
}
|
||||
|
||||
const lineVariants = {
|
||||
hidden: { opacity: 0, x: -8 },
|
||||
visible: { opacity: 1, x: 0, transition: { duration: 0.15, ease: 'easeOut' } },
|
||||
}
|
||||
|
||||
const CI = ({ isHovered = false }: { isHovered?: boolean }) => {
|
||||
const [hoverCount, setHoverCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (isHovered) setHoverCount((c) => c + 1)
|
||||
}, [isHovered])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative pl-4 xl:-mb-0 pt-4 sm:pt-4 border-b xl:border-none overflow-hidden">
|
||||
{/* CodeWindow shell */}
|
||||
<div className="relative rounded-2xl rounded-r-none rounded-b-none shadow-lg pt-0 w-full h-full bg-alternative-200 border border-r-0 border-b-0 flex flex-col">
|
||||
{/* Title bar */}
|
||||
<div className="w-full px-4 py-3 flex items-center gap-1.5 border-b">
|
||||
<div className="w-2 h-2 bg-border rounded-full" />
|
||||
<div className="w-2 h-2 bg-border rounded-full" />
|
||||
<div className="w-2 h-2 bg-border rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Code area */}
|
||||
<div className="flex-1 rounded-lg rounded-r-none rounded-b-none overflow-hidden p-4 text-[13px] xl:text-sm font-mono leading-6 bg-surface-75">
|
||||
<motion.div
|
||||
key={hoverCount}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
{CODE_LINES.map((line, i) => (
|
||||
<motion.div key={i} variants={lineVariants}>
|
||||
{tokenizeLine(line).map((token, j) => (
|
||||
<span key={j} className={TOKEN_CLASS[token.type]}>
|
||||
{token.text}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default CI
|
||||
@@ -1,31 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
|
||||
const LocalDXImage = () => {
|
||||
const COMMAND = 'supabase functions serve'
|
||||
const OUTPUT = 'Functions listening on 127.0.0.1:54321/functions/v1/'
|
||||
|
||||
const LocalDXImage = ({ isHovered = false }: { isHovered?: boolean }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [visibleChars, setVisibleChars] = useState(COMMAND.length)
|
||||
const [showOutput, setShowOutput] = useState(false)
|
||||
const visibleCharsRef = useRef(COMMAND.length)
|
||||
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
const clearTimeouts = () => {
|
||||
timeoutsRef.current.forEach(clearTimeout)
|
||||
timeoutsRef.current = []
|
||||
}
|
||||
|
||||
const setChars = (n: number) => {
|
||||
visibleCharsRef.current = n
|
||||
setVisibleChars(n)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeouts()
|
||||
if (isHovered) {
|
||||
setShowOutput(false)
|
||||
setChars(0)
|
||||
COMMAND.split('').forEach((_, i) => {
|
||||
const t = setTimeout(() => setChars(i + 1), i * 40)
|
||||
timeoutsRef.current.push(t)
|
||||
})
|
||||
// Show output line shortly after typing completes
|
||||
const t = setTimeout(() => setShowOutput(true), COMMAND.length * 40 + 120)
|
||||
timeoutsRef.current.push(t)
|
||||
} else {
|
||||
setShowOutput(false)
|
||||
const current = visibleCharsRef.current
|
||||
for (let i = current; i < COMMAND.length; i++) {
|
||||
const t = setTimeout(() => setChars(i + 1), (i - current) * 18)
|
||||
timeoutsRef.current.push(t)
|
||||
}
|
||||
}
|
||||
return clearTimeouts
|
||||
}, [isHovered])
|
||||
|
||||
const handleCopy = () => {
|
||||
setCopied(true)
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 1000)
|
||||
setTimeout(() => setCopied(false), 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full flex-1 flex items-center justify-center">
|
||||
<div className="relative w-full h-full flex-1 flex items-center justify-center px-4 xl:px-6">
|
||||
<Image
|
||||
src="/images/product/functions/grid-gradient-dark.svg"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100%"
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
className="object-cover absolute z-0 inset-0 hidden dark:block"
|
||||
/>
|
||||
<Image
|
||||
src="/images/product/functions/grid-gradient-light.svg"
|
||||
src="/images/product/functions/lines-gradient-light.svg"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100%"
|
||||
@@ -33,14 +65,60 @@ const LocalDXImage = () => {
|
||||
draggable={false}
|
||||
className="object-cover absolute z-0 inset-0 dark:hidden block"
|
||||
/>
|
||||
<Image
|
||||
src="/images/product/functions/lines-gradient-dark.svg"
|
||||
alt=""
|
||||
fill
|
||||
sizes="100%"
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
className="object-cover absolute z-0 inset-0 hidden dark:block"
|
||||
/>
|
||||
<CopyToClipboard text="supabase functions serve <function-name>">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-3 relative z-10 group hover:border-strong flex gap-2 xl:gap-4 items-center bg-alternative-200 rounded-xl border"
|
||||
className="p-3 relative z-10 w-full group hover:border-strong flex gap-2 items-center bg-alternative-200 rounded-xl border overflow-hidden"
|
||||
>
|
||||
<div className="text-foreground-muted text-sm font-mono">$</div>
|
||||
<div className="text-foreground text-sm font-mono">supabase functions serve</div>
|
||||
<div className="text-foreground rounded-sm p-1.5 ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="text-foreground-muted text-sm font-mono shrink-0">$</div>
|
||||
|
||||
{/* Slot: command ↔ output, same position */}
|
||||
<div className="relative flex-1 min-w-0 text-sm font-mono h-5">
|
||||
{/* Ghost text stabilises width */}
|
||||
<span className="invisible select-none whitespace-nowrap">{COMMAND}</span>
|
||||
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{!showOutput ? (
|
||||
<motion.span
|
||||
key="cmd"
|
||||
className="absolute inset-0 flex items-center text-foreground"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.1 } }}
|
||||
>
|
||||
{COMMAND.slice(0, visibleChars)}
|
||||
{isHovered && visibleChars < COMMAND.length && (
|
||||
<motion.span
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 0.6, ease: 'linear' }}
|
||||
className="inline-block w-[1.5px] h-[14px] bg-foreground align-middle ml-px shrink-0"
|
||||
/>
|
||||
)}
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="output"
|
||||
className="absolute inset-0 flex items-center gap-1.5 text-foreground-muted truncate"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.2, ease: 'easeOut' } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.1 } }}
|
||||
>
|
||||
<span className="truncate">{OUTPUT}</span>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="text-foreground rounded p-1.5 ml-2 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
{copied ? (
|
||||
<span className="text-brand">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -78,20 +78,24 @@ const Metrics = ({ isActive }: { isActive?: boolean }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 bottom-4 px-4 opacity-50 transition-opacity overflow-hidden',
|
||||
'absolute inset-0 opacity-50 transition-opacity overflow-hidden',
|
||||
isActive && 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<div className="relative rounded-md w-full h-full border border-overlay shadow-sm p-4 min-w-[300px]!">
|
||||
<p className="text-foreground text-sm mb-2">Execution time</p>
|
||||
<p className="text-foreground text-base mb-4">
|
||||
{displayValue ? data[displayValue]?.pv : highlightedValue}ms {displayValue ? '' : '(Avg)'}
|
||||
</p>
|
||||
<ResponsiveContainer minWidth={200} minHeight={200} width="100%" height="90%">
|
||||
<div className="relative w-full h-full py-4 !min-w-[300px]">
|
||||
<div className="px-6 mb-4">
|
||||
<p className="text-foreground text-sm mb-2">Execution time</p>
|
||||
<p className="text-foreground text-base">
|
||||
{displayValue ? data[displayValue]?.pv : highlightedValue}ms{' '}
|
||||
{displayValue ? '' : '(Avg)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer minWidth={200} width="100%" height="70%">
|
||||
<AreaChart
|
||||
className="relative z-20 text-xs"
|
||||
data={data}
|
||||
margin={{ top: 10, right: 0, left: 0, bottom: 40 }}
|
||||
margin={{ top: 10, right: 0, left: 0, bottom: 0 }}
|
||||
onMouseMove={(e: any) => {
|
||||
if (e.activeTooltipIndex !== displayValue) {
|
||||
setDisplayValue(e.activeTooltipIndex)
|
||||
@@ -125,8 +129,9 @@ const Metrics = ({ isActive }: { isActive?: boolean }) => {
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{data && (
|
||||
<div className="absolute inset-4 top-auto text-foreground-lighter flex items-center justify-between text-xs">
|
||||
<div className="absolute bottom-4 inset-x-0 px-6 text-foreground-lighter flex items-center justify-between text-xs">
|
||||
<span>{dayjs(data[0].timestamp).subtract(7, 'days').format(dateFormat)}</span>
|
||||
<span>{dayjs(data[data?.length - 1]?.timestamp).format(dateFormat)}</span>
|
||||
</div>
|
||||
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user