mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 11:55:05 +03:00
mock other product pages
This commit is contained in:
1 parent
2c739fe8e9
commit
4efb47a6bb
30 files changed
+3160
No files matched your search
@@ -0,0 +1,280 @@
|
||||
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,185 @@
|
||||
'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]
|
||||
|
||||
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 */}
|
||||
<div className="relative min-h-[440px] overflow-auto border border-border rounded-md">
|
||||
{/* 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 } }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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,493 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Link as LinkIcon, Shield } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
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:
|
||||
'Enable social logins with the click of a button. Google, Facebook, GitHub, Azure, and many more.',
|
||||
visual: ProvidersSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Fully integrated',
|
||||
description:
|
||||
'Built-in Authentication, Authorization, and User Management — no external services needed.',
|
||||
visual: IntegratedSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Own your data',
|
||||
description:
|
||||
'User data stored in your Supabase database. No third-party privacy concerns. Host in 16+ 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}
|
||||
className="flex items-center justify-center w-full h-full relative overflow-hidden"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const AUTH_STEPS = [
|
||||
{ label: 'Sign up' },
|
||||
{ label: 'Verify email' },
|
||||
{ label: 'Create session' },
|
||||
{ label: 'Access granted' },
|
||||
]
|
||||
|
||||
function SignUpPreview() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-foreground text-sm font-medium">Create an account</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-8 rounded border border-border bg-surface-200 px-3 flex items-center">
|
||||
<span className="text-foreground-muted text-xs">email@example.com</span>
|
||||
</div>
|
||||
<div className="h-8 rounded border border-border bg-surface-200 px-3 flex items-center">
|
||||
<span className="text-foreground-muted text-xs">Password</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-8 rounded bg-brand flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-background">Sign up</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-foreground-muted text-[10px]">or</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 h-8 rounded border border-border bg-surface-200 flex items-center justify-center">
|
||||
<span className="text-foreground-muted text-[10px]">Google</span>
|
||||
</div>
|
||||
<div className="flex-1 h-8 rounded border border-border bg-surface-200 flex items-center justify-center">
|
||||
<span className="text-foreground-muted text-[10px]">GitHub</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerifyEmailPreview() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 items-center justify-center h-full">
|
||||
<div className="w-10 h-10 rounded-full bg-brand/10 flex items-center justify-center">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#3ECF8E"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="M22 7l-10 6L2 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-foreground text-sm font-medium">Check your email</div>
|
||||
<div className="text-foreground-muted text-xs text-center leading-relaxed">
|
||||
We sent a verification link to
|
||||
<br />
|
||||
<span className="text-foreground">email@example.com</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-1">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="w-7 h-8 rounded border border-border bg-surface-200" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionPreview() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 justify-center h-full">
|
||||
<div className="text-foreground-muted text-[10px] font-mono">Session</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ key: 'access_token', val: 'eyJhbG...kpXVc' },
|
||||
{ key: 'token_type', val: 'bearer' },
|
||||
{ key: 'expires_in', val: '3600' },
|
||||
{ key: 'user.id', val: 'a1b2c3d4-...' },
|
||||
{ key: 'user.email', val: 'email@example.com' },
|
||||
{ key: 'user.role', val: 'authenticated' },
|
||||
].map((row) => (
|
||||
<div key={row.key} className="flex items-center justify-between">
|
||||
<span className="text-[#6b35dc] dark:text-[#bda4ff] text-[10px] font-mono">
|
||||
{row.key}
|
||||
</span>
|
||||
<span className="text-foreground-muted text-[10px] font-mono truncate ml-2">
|
||||
{row.val}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessGrantedPreview() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 items-center justify-center h-full">
|
||||
<div className="w-10 h-10 rounded-full bg-brand/10 flex items-center justify-center">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#3ECF8E"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-foreground text-sm font-medium">Welcome back</div>
|
||||
<div className="text-foreground-muted text-xs text-center">You're now signed in</div>
|
||||
<div className="w-full flex flex-col gap-2 mt-2">
|
||||
<div className="h-2.5 rounded bg-foreground-muted/10 w-full" />
|
||||
<div className="h-2.5 rounded bg-foreground-muted/10 w-4/5" />
|
||||
<div className="h-2.5 rounded bg-foreground-muted/10 w-3/5" />
|
||||
<div className="h-2.5 rounded bg-foreground-muted/10 w-2/5" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STEP_PREVIEWS = [SignUpPreview, VerifyEmailPreview, SessionPreview, AccessGrantedPreview]
|
||||
|
||||
function IntegratedSkeleton() {
|
||||
const [step, setStep] = 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(() => setStep((s) => (s + 1) % AUTH_STEPS.length), 3000)
|
||||
return () => clearInterval(timer)
|
||||
}, [isInView])
|
||||
|
||||
const Preview = STEP_PREVIEWS[step]
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full h-full flex overflow-hidden">
|
||||
{/* Left: steps */}
|
||||
<div className="flex flex-col gap-3 pt-8 pl-6 shrink-0">
|
||||
{AUTH_STEPS.map((s, i) => (
|
||||
<div key={s.label} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-1.5 h-1.5 rounded-full shrink-0 transition-colors duration-500',
|
||||
i <= step ? 'bg-brand' : 'bg-foreground-muted/20'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm transition-colors duration-500 whitespace-nowrap',
|
||||
i <= step ? 'text-foreground' : 'text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: shared card with animated content, overflows card edges */}
|
||||
<div
|
||||
className="absolute -right-16 -top-8 -bottom-8 flex items-center w-[55%]"
|
||||
style={{
|
||||
maskImage: 'radial-gradient(ellipse 80% 70% at 30% 50%, black 30%, transparent 75%)',
|
||||
WebkitMaskImage: 'radial-gradient(ellipse 80% 70% at 30% 50%, black 30%, transparent 75%)',
|
||||
}}
|
||||
>
|
||||
<div className="w-[280px] bg-surface-75 border border-border rounded-lg p-5 relative overflow-hidden">
|
||||
{STEP_PREVIEWS.map((StepPreview, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className={i === 0 ? 'relative' : 'absolute inset-0 p-5'}
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: i === step ? 1 : 0,
|
||||
scale: i === step ? 1 : 0.95,
|
||||
filter: i === step ? 'blur(0px)' : 'blur(4px)',
|
||||
}}
|
||||
transition={{ duration: 0.5, ease: 'easeInOut' }}
|
||||
style={{ pointerEvents: i === step ? 'auto' : 'none' }}
|
||||
>
|
||||
<StepPreview />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataOwnershipSkeleton() {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center w-full h-full relative"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
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%)',
|
||||
}}
|
||||
>
|
||||
{/* Abstract construction lines */}
|
||||
<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="90"
|
||||
y="70"
|
||||
width="300"
|
||||
height="340"
|
||||
rx="36"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
<rect
|
||||
x="60"
|
||||
y="40"
|
||||
width="360"
|
||||
height="400"
|
||||
rx="48"
|
||||
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="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" />
|
||||
</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"
|
||||
>
|
||||
<circle cx="240" cy="240" r="140" stroke="#3ECF8E" strokeWidth="0.75" opacity="0.3" />
|
||||
<rect
|
||||
x="90"
|
||||
y="70"
|
||||
width="300"
|
||||
height="340"
|
||||
rx="36"
|
||||
stroke="#3ECF8E"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.4"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Shield icon */}
|
||||
<div className="relative">
|
||||
<Shield
|
||||
size={80}
|
||||
strokeWidth={0.75}
|
||||
className="transition-colors duration-500"
|
||||
style={{
|
||||
color: hovered ? '#3ECF8E' : 'hsl(var(--foreground-muted))',
|
||||
opacity: hovered ? 1 : 0.6,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center text-xs font-mono transition-colors duration-500"
|
||||
style={{
|
||||
color: hovered ? '#3ECF8E' : 'hsl(var(--foreground-muted))',
|
||||
opacity: hovered ? 0.8 : 0.4,
|
||||
}}
|
||||
>
|
||||
RLS
|
||||
</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 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 PostgreSQL's 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="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<div className="flex flex-col gap-6 lg:gap-8">
|
||||
<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>
|
||||
<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>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Every Supabase project comes with a complete user management system. Including
|
||||
PostgreSQL's Row Level Security for fine-grained access control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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' },
|
||||
},
|
||||
]
|
||||
|
||||
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 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">
|
||||
<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,117 @@
|
||||
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,163 @@
|
||||
'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]
|
||||
|
||||
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 */}
|
||||
<div className="relative min-h-[440px] overflow-auto border border-border rounded-md">
|
||||
<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 } }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,125 @@
|
||||
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,163 @@
|
||||
'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]
|
||||
|
||||
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 */}
|
||||
<div className="relative min-h-[440px] overflow-auto border border-border rounded-md">
|
||||
<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 } }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
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 ChangeEvent = { op: string; table: string; row: string }
|
||||
|
||||
const DB_EVENTS: ChangeEvent[] = [
|
||||
{ op: 'INSERT', table: 'messages', row: "{ id: 42, text: 'Hello!' }" },
|
||||
{ op: 'UPDATE', table: 'users', row: "{ id: 7, status: 'online' }" },
|
||||
{ op: 'DELETE', table: 'messages', row: '{ id: 38 }' },
|
||||
{ op: 'INSERT', table: 'orders', row: "{ id: 99, total: 49.00 }" },
|
||||
{ op: 'UPDATE', table: 'messages', row: "{ id: 42, text: 'Updated' }" },
|
||||
]
|
||||
|
||||
function DatabaseChangesSkeleton() {
|
||||
const [eventIdx, setEventIdx] = 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(() => setEventIdx((i) => (i + 1) % DB_EVENTS.length), 2500)
|
||||
return () => clearInterval(timer)
|
||||
}, [isInView])
|
||||
|
||||
const event = DB_EVENTS[eventIdx]
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full h-full flex flex-col items-center justify-center">
|
||||
<div className="px-6 w-full max-w-[280px]">
|
||||
<pre className="text-sm leading-relaxed font-mono overflow-hidden">
|
||||
<span className="text-[#6b35dc] dark:text-[#bda4ff]">event</span>
|
||||
{': '}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={event.op}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
'inline-block',
|
||||
event.op === 'INSERT' && 'text-[#15593b] dark:text-[#3ecf8e]',
|
||||
event.op === 'UPDATE' && 'text-[#f1a10d] dark:text-[#ffcda1]',
|
||||
event.op === 'DELETE' && 'text-[#e5484d] dark:text-[#F06A50]'
|
||||
)}
|
||||
>
|
||||
{event.op}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
{'\n'}
|
||||
<span className="text-[#6b35dc] dark:text-[#bda4ff]">table</span>
|
||||
{': '}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={event.table}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="inline-block text-foreground"
|
||||
>
|
||||
{event.table}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
{'\n'}
|
||||
<span className="text-[#6b35dc] dark:text-[#bda4ff]">new</span>
|
||||
{': '}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.span
|
||||
key={event.row}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="inline-block text-foreground-lighter"
|
||||
>
|
||||
{event.row}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const USERS = [
|
||||
{ name: 'Alice', color: '#3ECF8E' },
|
||||
{ name: 'Bob', color: '#6c63ff' },
|
||||
{ name: 'Carol', color: '#F06A50' },
|
||||
{ name: 'Dave', color: '#ffcda1' },
|
||||
]
|
||||
|
||||
function PresenceSkeleton() {
|
||||
const [onlineCount, setOnlineCount] = useState(2)
|
||||
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(() => {
|
||||
setOnlineCount((c) => {
|
||||
if (c >= USERS.length) return 1
|
||||
return c + 1
|
||||
})
|
||||
}, 2000)
|
||||
return () => clearInterval(timer)
|
||||
}, [isInView])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex items-center justify-center w-full h-full">
|
||||
<div className="flex flex-col gap-2 w-48">
|
||||
{USERS.map((user, i) => {
|
||||
const isOnline = i < onlineCount
|
||||
return (
|
||||
<div key={user.name} className="flex items-center gap-3 py-1">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-colors duration-500',
|
||||
isOnline ? 'bg-brand' : 'bg-foreground-muted/20'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm transition-colors duration-500',
|
||||
isOnline ? 'text-foreground' : 'text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
{user.name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs ml-auto transition-colors duration-500',
|
||||
isOnline ? 'text-brand' : 'text-foreground-muted/40'
|
||||
)}
|
||||
>
|
||||
{isOnline ? 'online' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BroadcastSkeleton() {
|
||||
const [flash, setFlash] = useState(false)
|
||||
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(() => {
|
||||
setFlash(true)
|
||||
setTimeout(() => setFlash(false), 600)
|
||||
}, 2000)
|
||||
return () => clearInterval(timer)
|
||||
}, [isInView])
|
||||
|
||||
const clients = ['Client A', 'Client B', 'Client C']
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center justify-center w-full h-full"
|
||||
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%)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Channel hub */}
|
||||
<div
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg border text-xs font-medium transition-all duration-300',
|
||||
flash
|
||||
? 'bg-brand/10 border-brand/30 text-brand'
|
||||
: 'bg-surface-75 border-border text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
channel:room-1
|
||||
</div>
|
||||
|
||||
{/* Lines to clients */}
|
||||
<div className="flex items-start gap-8">
|
||||
{clients.map((client) => (
|
||||
<div key={client} className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'w-px h-8 transition-colors duration-300',
|
||||
flash ? 'bg-brand/40' : 'bg-foreground-muted/20'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded border text-xs transition-all duration-500',
|
||||
flash
|
||||
? 'bg-surface-200 border-brand/20 text-foreground'
|
||||
: 'bg-surface-75 border-border text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
{client}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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">
|
||||
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="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<div className="flex flex-col gap-6 lg:gap-8">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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,30 @@
|
||||
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,213 @@
|
||||
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,185 @@
|
||||
'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]
|
||||
|
||||
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 */}
|
||||
<div className="relative min-h-[440px] overflow-auto border border-border rounded-md">
|
||||
{/* 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 } }}
|
||||
>
|
||||
<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>
|
||||
</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,222 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
title: 'Interoperable',
|
||||
description:
|
||||
'Integrates with the rest of Supabase — Auth and Postgres work together seamlessly.',
|
||||
visual: InteroperableSkeleton,
|
||||
},
|
||||
{
|
||||
title: 'Lightning fast',
|
||||
description:
|
||||
'Thin API server layer that leverages Postgres permissions and delivers content 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() {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const products = [
|
||||
{ name: 'Storage', color: '#3ECF8E' },
|
||||
{ name: 'Auth', color: '#6c63ff' },
|
||||
{ name: 'Database', color: '#3ECF8E' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center w-full h-full relative"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
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%)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{products.map((product, i) => (
|
||||
<div key={product.name} className="flex items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'w-16 h-16 rounded-lg border flex items-center justify-center transition-all duration-500',
|
||||
hovered ? 'bg-surface-200 border-brand/30' : 'bg-surface-75 border-border'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-medium transition-colors duration-500"
|
||||
style={{ color: hovered ? product.color : 'hsl(var(--foreground-muted))' }}
|
||||
>
|
||||
{product.name}
|
||||
</span>
|
||||
</div>
|
||||
{i < products.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-px transition-colors duration-500',
|
||||
hovered ? 'bg-brand/40' : 'bg-foreground-muted/20'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CDNSkeleton() {
|
||||
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%)',
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 320 320" className="w-64 h-64" fill="none">
|
||||
{/* Globe outline */}
|
||||
<circle
|
||||
cx="160"
|
||||
cy="160"
|
||||
r="120"
|
||||
stroke="hsl(var(--foreground-muted))"
|
||||
strokeWidth="0.5"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<ellipse
|
||||
cx="160"
|
||||
cy="160"
|
||||
rx="60"
|
||||
ry="120"
|
||||
stroke="hsl(var(--foreground-muted))"
|
||||
strokeWidth="0.5"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<line
|
||||
x1="40"
|
||||
y1="160"
|
||||
x2="280"
|
||||
y2="160"
|
||||
stroke="hsl(var(--foreground-muted))"
|
||||
strokeWidth="0.5"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<line
|
||||
x1="160"
|
||||
y1="40"
|
||||
x2="160"
|
||||
y2="280"
|
||||
stroke="hsl(var(--foreground-muted))"
|
||||
strokeWidth="0.5"
|
||||
opacity="0.3"
|
||||
/>
|
||||
{/* CDN nodes */}
|
||||
{[
|
||||
{ cx: 160, cy: 60 },
|
||||
{ cx: 260, cy: 130 },
|
||||
{ cx: 240, cy: 230 },
|
||||
{ cx: 80, cy: 230 },
|
||||
{ cx: 60, cy: 130 },
|
||||
].map((pos, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1="160"
|
||||
y1="160"
|
||||
x2={pos.cx}
|
||||
y2={pos.cy}
|
||||
stroke="#3ECF8E"
|
||||
strokeWidth="0.5"
|
||||
opacity="0.2"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
<circle cx={pos.cx} cy={pos.cy} r="6" fill="#3ECF8E" opacity="0.3" />
|
||||
<circle cx={pos.cx} cy={pos.cy} r="3" fill="#3ECF8E" opacity="0.6" />
|
||||
</g>
|
||||
))}
|
||||
{/* Center node */}
|
||||
<circle cx="160" cy="160" r="8" fill="#3ECF8E" opacity="0.4" />
|
||||
<circle cx="160" cy="160" r="4" fill="#3ECF8E" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BucketsSkeleton() {
|
||||
const buckets = [
|
||||
{ name: 'Files', label: 'images, videos, docs' },
|
||||
{ name: 'Analytics', label: 'Iceberg, time-series' },
|
||||
{ name: 'Vectors', label: 'embeddings, RAG' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<div className="flex flex-col gap-2 w-52">
|
||||
{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-75"
|
||||
>
|
||||
<span className="text-foreground text-xs font-medium">{bucket.name}</span>
|
||||
<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 object storage</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
S3 compatible object 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="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<div className="flex flex-col gap-6 lg:gap-8">
|
||||
<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>
|
||||
<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>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
An open source S3 Compatible Object Store with unlimited scalability, for any file type.
|
||||
With custom policies and permissions that are familiar and easy to implement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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 objects 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,163 @@
|
||||
'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]
|
||||
|
||||
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">{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 */}
|
||||
<div className="relative min-h-[440px] overflow-auto border border-border rounded-md">
|
||||
<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 } }}
|
||||
>
|
||||
<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>
|
||||
</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,43 @@
|
||||
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 Object Store with unlimited scalability, for any file type.',
|
||||
}
|
||||
|
||||
export default function StoragePage() {
|
||||
return <StorageContent apiSlot={<ApiSection />} permissionsSlot={<PermissionsSection />} />
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user