mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
more animated visuals
This commit is contained in:
1 parent
5b3f362a2c
commit
433b7484d5
15 files changed
+587
-192
No files matched your search
@@ -103,7 +103,7 @@ export function BuiltWithSupabaseSection() {
|
||||
{/* Header row */}
|
||||
<div className="border-b border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 border-x border-border">
|
||||
<div className="flex items-end justify-between pt-32 pb-8">
|
||||
<div className="flex items-end justify-between pt-40 pb-8">
|
||||
<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>
|
||||
|
||||
@@ -77,7 +77,7 @@ export function CustomerStoriesSection() {
|
||||
{/* Header row */}
|
||||
<div className="border-b border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 border-x border-border">
|
||||
<div className="flex items-end justify-between pt-16 pb-8">
|
||||
<div className="flex items-end justify-between pt-40 pb-8">
|
||||
<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>
|
||||
|
||||
@@ -87,7 +87,7 @@ export function FeaturesSection() {
|
||||
{/* Header */}
|
||||
<div className="border-b border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 border-x border-border">
|
||||
<div className="pt-32 pb-8">
|
||||
<div className="pt-40 pb-8">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Everything you need
|
||||
<br />
|
||||
|
||||
@@ -40,7 +40,7 @@ export function HighlightsSection() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] border-x border-border">
|
||||
{/* Header */}
|
||||
<div className="pt-32 pb-8 border-b border-border px-6">
|
||||
<div className="pt-40 pb-8 border-b border-border px-6">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Built for scale
|
||||
<br />
|
||||
|
||||
@@ -40,7 +40,7 @@ export function SqlEditorSection() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] border-x border-border">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-32 pb-8 border-b border-border">
|
||||
<div className="px-6 pt-40 pb-8 border-b border-border">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
The power of
|
||||
<br />
|
||||
|
||||
@@ -41,7 +41,7 @@ export function TableEditorSection() {
|
||||
return (
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] border-x border-border">
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-32 pb-8 border-b border-border">
|
||||
<div className="px-6 pt-40 pb-8 border-b border-border">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Easy to use
|
||||
<br />
|
||||
|
||||
@@ -41,7 +41,7 @@ function TerminalAnimation() {
|
||||
<span className="ml-2 text-xs text-foreground-muted font-mono uppercase">Terminal</span>
|
||||
</div>
|
||||
{/* Lines */}
|
||||
<div className="px-4 py-8 md:px-6 md:py-10 font-mono text-xs md:text-sm leading-relaxed min-h-[240px]">
|
||||
<div className="px-4 md:px-6 font-mono text-xs md:text-sm leading-relaxed h-[280px] flex flex-col justify-center">
|
||||
<AnimatePresence>
|
||||
{TERMINAL_LINES.slice(0, visibleCount).map((line, i) => (
|
||||
<motion.div
|
||||
|
||||
@@ -21,7 +21,7 @@ export function ExamplesSection() {
|
||||
{/* Header row */}
|
||||
<div className="border-b border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 border-x border-border">
|
||||
<div className="flex items-end justify-between pt-32 pb-8">
|
||||
<div className="flex items-end justify-between pt-40 pb-8">
|
||||
<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>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) {
|
||||
{/* Header row */}
|
||||
<div className="border-b border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 border-x border-border">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl pt-32 pb-8">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl pt-40 pb-8">
|
||||
Integrates with the <span className="text-foreground">Supabase ecosystem</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const LocalDXImage = dynamic(() => import('~/components/Products/Functions/LocalDXImage'))
|
||||
@@ -9,75 +10,124 @@ const ParityImage = dynamic(() => import('~/components/Products/Functions/Parity
|
||||
const NpmEcosystem = dynamic(() => import('~/components/Products/Functions/NpmEcosystem'))
|
||||
const CI = dynamic(() => import('~/components/Products/Functions/CI'))
|
||||
|
||||
const cards = [
|
||||
{
|
||||
id: 'localDX',
|
||||
label: 'First-class local dev experience',
|
||||
paragraph:
|
||||
'Write code with hot code reloading, a fast Language server for autocompletion, type checking and linting',
|
||||
image: <LocalDXImage />,
|
||||
imageClassname: 'pb-8 md:pb-12',
|
||||
},
|
||||
{
|
||||
id: 'parity',
|
||||
label: 'Dev and Prod parity',
|
||||
paragraph: (
|
||||
<>
|
||||
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
|
||||
</>
|
||||
),
|
||||
image: <ParityImage />,
|
||||
imageClassname: 'pb-8 md:pb-12 pt-7',
|
||||
},
|
||||
{
|
||||
id: 'ecosystem',
|
||||
label: 'Use any NPM module',
|
||||
paragraph: 'Tap into the 2+ million modules in the Deno and NPM ecosystem',
|
||||
image: <NpmEcosystem />,
|
||||
},
|
||||
{
|
||||
id: 'ci',
|
||||
label: 'Continuous Integration',
|
||||
paragraph: (
|
||||
<>
|
||||
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
|
||||
</>
|
||||
),
|
||||
image: <CI />,
|
||||
imageClassname: 'pl-2 pt-8 md:pt-12',
|
||||
},
|
||||
]
|
||||
function EcosystemCard() {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col border-b border-border', 'col-span-1 md:row-span-2 !border-b-0')}
|
||||
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-1 px-6 py-5 border-t border-border bg-surface-200">
|
||||
<h4 className="text-foreground text-sm font-medium">Use any NPM module</h4>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Tap into the 2+ million modules in the Deno and NPM ecosystem
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const GRID_CLASS: Record<string, string> = {
|
||||
localDX: 'col-span-1 md:border-r',
|
||||
parity: 'col-span-1 xl:border-r',
|
||||
ecosystem: 'col-span-1 md:row-span-2 !border-b-0',
|
||||
ci: 'col-span-1 md:col-span-2 xl:border-r !border-b-0',
|
||||
}
|
||||
|
||||
function ParityCard() {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col border-b border-border', GRID_CLASS['parity'])}
|
||||
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-1 px-6 py-5 border-t border-border bg-surface-200">
|
||||
<h4 className="text-foreground text-sm 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={cn('flex flex-col border-b border-border', GRID_CLASS['ci'])}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden pl-2 pt-8 md:pt-12">
|
||||
<CI isHovered={isHovered} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-6 py-5 border-t border-border bg-surface-200">
|
||||
<h4 className="text-foreground text-sm 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={cn('flex flex-col border-b border-border', GRID_CLASS['localDX'])}
|
||||
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-1 px-6 py-5 border-t border-border bg-surface-200">
|
||||
<h4 className="text-foreground text-sm 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>
|
||||
{/* Header row */}
|
||||
<div className="border-b border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 border-x border-border">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end pt-32 pb-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end pt-40 pb-8">
|
||||
<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>
|
||||
@@ -100,26 +150,11 @@ export function LocalDXSection() {
|
||||
|
||||
{/* Bento grid */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] border-x border-border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
|
||||
{cards.map((card, i) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className={cn('flex flex-col border-b border-border', GRID_CLASS[card.id])}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center overflow-hidden',
|
||||
card.imageClassname
|
||||
)}
|
||||
>
|
||||
{card.image}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-6 py-5 border-t border-border bg-surface-200">
|
||||
<h4 className="text-foreground text-sm font-medium">{card.label}</h4>
|
||||
<p className="text-foreground-lighter text-sm">{card.paragraph}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 grid-flow-dense">
|
||||
<LocalDXCard />
|
||||
<ParityCard />
|
||||
<CICard />
|
||||
<EcosystemCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ObservabilitySection() {
|
||||
{/* Header row */}
|
||||
<div className="border-b border-border">
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 border-x border-border">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl pt-32 pb-8">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl pt-40 pb-8">
|
||||
Built-in <span className="text-foreground">observability</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,130 @@
|
||||
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 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" />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react'
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const npmList = [
|
||||
'fetch',
|
||||
@@ -29,11 +31,17 @@ const npmList = [
|
||||
'js-yaml',
|
||||
'typescript',
|
||||
'jest',
|
||||
'vitest',
|
||||
'winston',
|
||||
'pino',
|
||||
'debug',
|
||||
'eslint',
|
||||
'prettier',
|
||||
'nodemon',
|
||||
'tsx',
|
||||
'dotenv',
|
||||
'zod',
|
||||
'valibot',
|
||||
'@octokit/rest',
|
||||
'cross-env',
|
||||
'commander',
|
||||
@@ -42,82 +50,167 @@ const npmList = [
|
||||
'chalk',
|
||||
'colors',
|
||||
'ora',
|
||||
'kleur',
|
||||
'picocolors',
|
||||
'@aws-sdk',
|
||||
'@google-cloud/storage',
|
||||
'axios',
|
||||
'got',
|
||||
'ky',
|
||||
'passport',
|
||||
'nodemailer',
|
||||
'resend',
|
||||
'@sendgrid/mail',
|
||||
'@supabase/auth-helpers-react',
|
||||
'mongoose',
|
||||
'prisma',
|
||||
'drizzle-orm',
|
||||
'kysely',
|
||||
'openai',
|
||||
'@anthropic-ai/sdk',
|
||||
'langchain',
|
||||
'jwt',
|
||||
'react',
|
||||
'solid-js',
|
||||
'svelte',
|
||||
'mocha',
|
||||
'autoprefixer',
|
||||
'@supabase/auth-ui-react',
|
||||
'gray-matter',
|
||||
'marked',
|
||||
'unified',
|
||||
'rehype',
|
||||
'remark',
|
||||
'request',
|
||||
'prop-types',
|
||||
'react-dom',
|
||||
'bluebird',
|
||||
'p-limit',
|
||||
'p-queue',
|
||||
'vue',
|
||||
'nuxt',
|
||||
'next',
|
||||
'tslib',
|
||||
'ts-node',
|
||||
'inquirer',
|
||||
'prompts',
|
||||
'webpack',
|
||||
'esbuild',
|
||||
'rollup',
|
||||
'classnames',
|
||||
'clsx',
|
||||
'body-parser',
|
||||
'rxjs',
|
||||
'xstate',
|
||||
'babel-runtime',
|
||||
'jquery',
|
||||
'fetch',
|
||||
'crypto',
|
||||
'eslint',
|
||||
'atob',
|
||||
'moment',
|
||||
'express',
|
||||
'fastify',
|
||||
'socket.io',
|
||||
'async',
|
||||
'lodash',
|
||||
'underscore',
|
||||
'ramda',
|
||||
'validator',
|
||||
'yup',
|
||||
'day.js',
|
||||
'date-fns',
|
||||
'jsonwebtoken',
|
||||
'bcrypt',
|
||||
'minimist',
|
||||
'chalk',
|
||||
'stripe',
|
||||
'@clerk/clerk-js',
|
||||
'posthog-js',
|
||||
'sentry',
|
||||
'datadog-metrics',
|
||||
'sharp',
|
||||
'jimp',
|
||||
'pdf-lib',
|
||||
'archiver',
|
||||
'node-cron',
|
||||
'bull',
|
||||
'ioredis',
|
||||
'cheerio',
|
||||
'playwright',
|
||||
'puppeteer',
|
||||
'stytch',
|
||||
'twilio',
|
||||
'vonage',
|
||||
'@slack/web-api',
|
||||
'discord.js',
|
||||
'telegraf',
|
||||
'hono',
|
||||
'oak',
|
||||
'itty-router',
|
||||
'zx',
|
||||
'execa',
|
||||
'nanoid',
|
||||
'ms',
|
||||
'qs',
|
||||
'node-fetch',
|
||||
'undici',
|
||||
'msw',
|
||||
'jose',
|
||||
'qrcode',
|
||||
'jsdom',
|
||||
'xlsx',
|
||||
'csv-parse',
|
||||
'papaparse',
|
||||
'yaml',
|
||||
'bullmq',
|
||||
'lru-cache',
|
||||
'keyv',
|
||||
'deepmerge',
|
||||
'immer',
|
||||
'zustand',
|
||||
'jotai',
|
||||
]
|
||||
|
||||
const NpmEcosystem = () => {
|
||||
const center = Math.floor(npmList.length / 2)
|
||||
const maxDistance = center
|
||||
|
||||
const NpmEcosystem = ({ isHovered = false }: { isHovered?: boolean }) => {
|
||||
return (
|
||||
<div className="relative w-full h-full flex justify-center overflow-hidden group">
|
||||
<div
|
||||
className="absolute flex flex-wrap items-start gap-2 opacity-80 dark:opacity-50"
|
||||
style={{ left: -50, right: -150 }}
|
||||
>
|
||||
{npmList.map((module, i) => (
|
||||
<span
|
||||
key={`${module}-${i}`}
|
||||
className="py-1 px-2 rounded-md bg-surface-75 border text-foreground-muted"
|
||||
>
|
||||
{module}
|
||||
</span>
|
||||
))}
|
||||
<div className="absolute inset-0 flex justify-center overflow-hidden">
|
||||
{/* Pills */}
|
||||
<div className="absolute flex flex-wrap items-start gap-2" style={{ left: -50, right: -150 }}>
|
||||
{npmList.map((module, i) => {
|
||||
const distance = Math.abs(i - center)
|
||||
return (
|
||||
<motion.span
|
||||
key={`${module}-${i}`}
|
||||
className="py-1 px-2 rounded-md bg-surface-75 border text-foreground-muted text-sm"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={
|
||||
isHovered
|
||||
? {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
delay: distance * 0.012,
|
||||
duration: 0.2,
|
||||
ease: 'easeOut',
|
||||
},
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.85,
|
||||
transition: {
|
||||
delay: (maxDistance - distance) * 0.008,
|
||||
duration: 0.15,
|
||||
ease: 'easeIn',
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
{module}
|
||||
</motion.span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Radial gradient fade */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
background: `radial-gradient(70% 50% at 50% 50%, transparent, hsl(var(--background-surface-75))`,
|
||||
background: `radial-gradient(70% 50% at 50% 50%, transparent, hsl(var(--background-default)))`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute z-10 inset-0 flex flex-col items-center justify-center font-bold text-7xl uppercase text-foreground-light group-hover:text-foreground transition-colors">
|
||||
|
||||
{/* npm logo */}
|
||||
<div className="absolute z-20 inset-0 flex flex-col items-center justify-center">
|
||||
<svg
|
||||
width="111"
|
||||
height="42"
|
||||
viewBox="0 0 111 42"
|
||||
fill="currentColor"
|
||||
className="text-foreground-light"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M36.1763 37.4452H49.2591V30.8616H62.342V4.52698H36.1763V37.4452ZM49.2591 11.1106H55.8006V24.2779H49.2591V11.1106ZM67.5752 4.52698V30.8616H80.6581V11.1106H87.1995V30.8616H93.7409V11.1106H100.282V30.8616H106.824V4.52698H67.5752ZM4.77734 30.8616H17.8602V11.1106H24.4017V30.8616H30.9431V4.52698H4.77734V30.8616Z" />
|
||||
|
||||
@@ -1,7 +1,57 @@
|
||||
import Image from 'next/image'
|
||||
import React from 'react'
|
||||
'use client'
|
||||
|
||||
const ParityImage = () => (
|
||||
import { motion } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const SYNC_WIDTH = 48
|
||||
|
||||
function SyncLines({ isHovered }: { isHovered: boolean }) {
|
||||
const shimmerTransition = (delay: number) =>
|
||||
isHovered
|
||||
? { duration: 0.7, repeat: Infinity, ease: 'linear' as const, repeatDelay: 1.8, delay }
|
||||
: { duration: 0.4, ease: 'easeOut' as const }
|
||||
|
||||
const tipMask = 'linear-gradient(to right, transparent, black 28%, black 72%, transparent)'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-1.5 items-center justify-center"
|
||||
style={{ width: SYNC_WIDTH, transform: 'skewX(-12deg)' }}
|
||||
>
|
||||
{/* → green shimmer */}
|
||||
<div
|
||||
className="relative w-full h-px bg-border overflow-hidden"
|
||||
style={{ maskImage: tipMask, WebkitMaskImage: tipMask }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'linear-gradient(to right, transparent 0%, #3ecf8e 50%, transparent 100%)',
|
||||
}}
|
||||
animate={isHovered ? { x: ['-100%', '100%'] } : { x: '-100%' }}
|
||||
transition={shimmerTransition(0)}
|
||||
/>
|
||||
</div>
|
||||
{/* ← purple shimmer */}
|
||||
<div
|
||||
className="relative w-full h-px bg-border overflow-hidden"
|
||||
style={{ maskImage: tipMask, WebkitMaskImage: tipMask }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'linear-gradient(to left, transparent 0%, #bda4ff 50%, transparent 100%)',
|
||||
}}
|
||||
animate={isHovered ? { x: ['100%', '-100%'] } : { x: '100%' }}
|
||||
transition={shimmerTransition(1.0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ParityImage = ({ isHovered = false }: { isHovered?: boolean }) => (
|
||||
<div className="relative w-full h-full flex items-center justify-center text-sm">
|
||||
<Image
|
||||
src="/images/product/functions/lines-gradient-light.svg"
|
||||
@@ -21,22 +71,67 @@ const ParityImage = () => (
|
||||
draggable={false}
|
||||
className="object-cover absolute z-0 inset-0 hidden dark:block"
|
||||
/>
|
||||
<div className="relative z-10 p-4 font-mono bg-surface-200 border-2 border-dashed rounded-2xl justify-center items-center gap-1 flex">
|
||||
<div className="py-2 px-4 bg-alternative-200 rounded-lg shadow border flex-col justify-center items-center">
|
||||
<div className="text-foreground uppercase tracking-wide">Dev</div>
|
||||
</div>
|
||||
<svg width="32" height="7" viewBox="0 0 32 7" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M27 5.74425C27 6.0204 27.3382 6.21566 27.5774 6.07759L31.4226 3.8575C31.6618 3.71943 31.6618 3.32891 31.4226 3.19084L27.5774 0.970752C27.3382 0.832681 27 1.02794 27 1.30409V5.74425ZM5 1.30409C5 1.02794 4.6618 0.832681 4.42265 0.970752L0.57735 3.19084C0.338204 3.32891 0.338204 3.71943 0.57735 3.8575L4.42265 6.07759C4.6618 6.21566 5 6.0204 5 5.74425V1.30409ZM27.5 3.02417H4.5V4.02417H27.5V3.02417Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M5 1.30409C5 1.02794 4.6618 0.832681 4.42265 0.970752L0.57735 3.19084C0.338204 3.32891 0.338204 3.71943 0.57735 3.8575L4.42265 6.07759C4.6618 6.21566 5 6.0204 5 5.74425V1.30409ZM27 5.74425C27 6.0204 27.3382 6.21566 27.5774 6.07759L31.4226 3.8575C31.6618 3.71943 31.6618 3.32891 31.4226 3.19084L27.5774 0.970752C27.3382 0.832681 27 1.02794 27 1.30409V5.74425ZM4.5 4.02417H27.5V3.02417H4.5V4.02417Z"
|
||||
fill="currentColor"
|
||||
<div className="relative z-10 p-4 font-mono bg-surface-200 rounded-2xl justify-center items-center gap-3 flex">
|
||||
{/* Animated dashed border */}
|
||||
<svg
|
||||
className={cn(
|
||||
'absolute inset-0 w-full h-full pointer-events-none overflow-visible transition-colors duration-200 text-border'
|
||||
)}
|
||||
>
|
||||
<motion.rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
rx="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="6 4"
|
||||
animate={{ strokeDashoffset: isHovered ? [0, -10] : 0 }}
|
||||
transition={
|
||||
isHovered
|
||||
? { duration: 0.4, repeat: Infinity, ease: 'linear' }
|
||||
: { duration: 0.4, ease: 'easeOut' }
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="py-2 px-4 bg-alternative-200 rounded-lg shadow border flex-col justify-center items-center">
|
||||
{/* Dev badge — green border + shadow on hover */}
|
||||
<div
|
||||
className="py-2 px-4 bg-alternative-200 rounded-lg flex-col justify-center items-center transition-all duration-300"
|
||||
style={
|
||||
isHovered
|
||||
? {
|
||||
border: '1px solid #3ecf8e',
|
||||
boxShadow: '0 0 12px 0 #3ecf8e40',
|
||||
}
|
||||
: {
|
||||
border: '1px solid hsl(var(--border-default))',
|
||||
boxShadow: 'none',
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="text-foreground uppercase tracking-wide">Dev</div>
|
||||
</div>
|
||||
|
||||
<SyncLines isHovered={isHovered} />
|
||||
|
||||
{/* Prod badge — purple border + shadow on hover */}
|
||||
<div
|
||||
className="py-2 px-4 bg-alternative-200 rounded-lg flex-col justify-center items-center transition-all duration-300"
|
||||
style={
|
||||
isHovered
|
||||
? {
|
||||
border: '1px solid #bda4ff',
|
||||
boxShadow: '0 0 12px 0 #bda4ff40',
|
||||
}
|
||||
: {
|
||||
border: '1px solid hsl(var(--border-default))',
|
||||
boxShadow: 'none',
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="text-foreground uppercase tracking-wide">Prod</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user