mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
improve some sections
This commit is contained in:
1 parent
bf88e63d7b
commit
d902ddaa46
6 files changed
+700
-341
No files matched your search
@@ -47,12 +47,26 @@ const supabaseLight: ThemeRegistration = {
|
||||
],
|
||||
}
|
||||
|
||||
// ── API examples ────────────────────────────────────────────────────────────
|
||||
// ── Language → Shiki lang mapping ───────────────────────────────────────────
|
||||
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
JavaScript: 'javascript',
|
||||
Flutter: 'dart',
|
||||
Python: 'python',
|
||||
'C#': 'csharp',
|
||||
Kotlin: 'kotlin',
|
||||
Swift: 'swift',
|
||||
}
|
||||
|
||||
// ── API examples per language ───────────────────────────────────────────────
|
||||
|
||||
const API_EXAMPLES = [
|
||||
{
|
||||
icon: 'Search' as const,
|
||||
title: 'Fetch records',
|
||||
code: `// Fetch all public rooms with their messages
|
||||
description: 'Query tables with joins, filters, and ordering in a single request.',
|
||||
code: {
|
||||
JavaScript: `// Fetch all public rooms with their messages
|
||||
const { data: rooms } = await supabase
|
||||
.from('rooms')
|
||||
.select(\`
|
||||
@@ -62,10 +76,55 @@ const { data: rooms } = await supabase
|
||||
.eq('public', true)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20)`,
|
||||
Flutter: `// Fetch all public rooms with their messages
|
||||
final rooms = await supabase
|
||||
.from('rooms')
|
||||
.select('id, name, created_at, messages ( id, text, user_id )')
|
||||
.eq('public', true)
|
||||
.order('created_at', ascending: false)
|
||||
.limit(20);`,
|
||||
Python: `# Fetch all public rooms with their messages
|
||||
rooms = supabase.table("rooms") \\
|
||||
.select("id, name, created_at, messages ( id, text, user_id )") \\
|
||||
.eq("public", True) \\
|
||||
.order("created_at", desc=True) \\
|
||||
.limit(20) \\
|
||||
.execute()`,
|
||||
'C#': `// Fetch all public rooms with their messages
|
||||
var rooms = await supabase
|
||||
.From<Room>()
|
||||
.Select("id, name, created_at, messages ( id, text, user_id )")
|
||||
.Filter("public", Operator.Equals, "true")
|
||||
.Order("created_at", Ordering.Descending)
|
||||
.Limit(20)
|
||||
.Get();`,
|
||||
Kotlin: `// Fetch all public rooms with their messages
|
||||
val rooms = supabase.from("rooms")
|
||||
.select(Columns.raw("""
|
||||
id, name, created_at,
|
||||
messages ( id, text, user_id )
|
||||
""")) {
|
||||
filter { eq("public", true) }
|
||||
order("created_at", Order.DESCENDING)
|
||||
limit(20)
|
||||
}`,
|
||||
Swift: `// Fetch all public rooms with their messages
|
||||
let rooms: [Room] = try await supabase
|
||||
.from("rooms")
|
||||
.select("id, name, created_at, messages ( id, text, user_id )")
|
||||
.eq("public", value: true)
|
||||
.order("created_at", ascending: false)
|
||||
.limit(20)
|
||||
.execute()
|
||||
.value`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'Plus' as const,
|
||||
title: 'Insert with relations',
|
||||
code: `// Create a room and its first message
|
||||
description: 'Create rows across related tables with automatic foreign key resolution.',
|
||||
code: {
|
||||
JavaScript: `// Create a room and its first message
|
||||
const { data: room } = await supabase
|
||||
.from('rooms')
|
||||
.insert({ name: 'Design Team', public: false })
|
||||
@@ -79,10 +138,82 @@ await supabase
|
||||
text: 'Welcome to the team channel!',
|
||||
user_id: session.user.id,
|
||||
})`,
|
||||
Flutter: `// Create a room and its first message
|
||||
final room = await supabase
|
||||
.from('rooms')
|
||||
.insert({'name': 'Design Team', 'public': false})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
await supabase
|
||||
.from('messages')
|
||||
.insert({
|
||||
'room_id': room['id'],
|
||||
'text': 'Welcome to the team channel!',
|
||||
'user_id': session.user.id,
|
||||
});`,
|
||||
Python: `# Create a room and its first message
|
||||
room = supabase.table("rooms") \\
|
||||
.insert({"name": "Design Team", "public": False}) \\
|
||||
.execute()
|
||||
|
||||
supabase.table("messages") \\
|
||||
.insert({
|
||||
"room_id": room.data[0]["id"],
|
||||
"text": "Welcome to the team channel!",
|
||||
"user_id": session.user.id,
|
||||
}) \\
|
||||
.execute()`,
|
||||
'C#': `// Create a room and its first message
|
||||
var room = await supabase
|
||||
.From<Room>()
|
||||
.Insert(new Room { Name = "Design Team", Public = false })
|
||||
.Single();
|
||||
|
||||
await supabase
|
||||
.From<Message>()
|
||||
.Insert(new Message {
|
||||
RoomId = room.Id,
|
||||
Text = "Welcome to the team channel!",
|
||||
UserId = session.User.Id,
|
||||
});`,
|
||||
Kotlin: `// Create a room and its first message
|
||||
val room = supabase.from("rooms")
|
||||
.insert(Room(name = "Design Team", public = false)) {
|
||||
select()
|
||||
}.decodeSingle<Room>()
|
||||
|
||||
supabase.from("messages")
|
||||
.insert(Message(
|
||||
roomId = room.id,
|
||||
text = "Welcome to the team channel!",
|
||||
userId = session.user.id,
|
||||
))`,
|
||||
Swift: `// Create a room and its first message
|
||||
let room: Room = try await supabase
|
||||
.from("rooms")
|
||||
.insert(["name": "Design Team", "public": false])
|
||||
.select()
|
||||
.single()
|
||||
.execute()
|
||||
.value
|
||||
|
||||
try await supabase
|
||||
.from("messages")
|
||||
.insert([
|
||||
"room_id": room.id,
|
||||
"text": "Welcome to the team channel!",
|
||||
"user_id": session.user.id,
|
||||
])
|
||||
.execute()`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'ArrowDownUp' as const,
|
||||
title: 'Update records',
|
||||
code: `// Update a room and return the updated row
|
||||
description: 'Modify rows with conditions and return the updated data in one call.',
|
||||
code: {
|
||||
JavaScript: `// Update a room and return the updated row
|
||||
const { data: updated } = await supabase
|
||||
.from('rooms')
|
||||
.update({
|
||||
@@ -93,10 +224,66 @@ const { data: updated } = await supabase
|
||||
.eq('id', roomId)
|
||||
.select()
|
||||
.single()`,
|
||||
Flutter: `// Update a room and return the updated row
|
||||
final updated = await supabase
|
||||
.from('rooms')
|
||||
.update({
|
||||
'name': 'Engineering Team',
|
||||
'public': false,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
})
|
||||
.eq('id', roomId)
|
||||
.select()
|
||||
.single();`,
|
||||
Python: `# Update a room and return the updated row
|
||||
updated = supabase.table("rooms") \\
|
||||
.update({
|
||||
"name": "Engineering Team",
|
||||
"public": False,
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}) \\
|
||||
.eq("id", room_id) \\
|
||||
.execute()`,
|
||||
'C#': `// Update a room and return the updated row
|
||||
var updated = await supabase
|
||||
.From<Room>()
|
||||
.Filter("id", Operator.Equals, roomId)
|
||||
.Set(x => x.Name, "Engineering Team")
|
||||
.Set(x => x.Public, false)
|
||||
.Set(x => x.UpdatedAt, DateTime.UtcNow)
|
||||
.Update()
|
||||
.Single();`,
|
||||
Kotlin: `// Update a room and return the updated row
|
||||
val updated = supabase.from("rooms")
|
||||
.update({
|
||||
Room::name setTo "Engineering Team"
|
||||
Room::public setTo false
|
||||
Room::updatedAt setTo Clock.System.now()
|
||||
}) {
|
||||
filter { eq("id", roomId) }
|
||||
select()
|
||||
}.decodeSingle<Room>()`,
|
||||
Swift: `// Update a room and return the updated row
|
||||
let updated: Room = try await supabase
|
||||
.from("rooms")
|
||||
.update([
|
||||
"name": "Engineering Team",
|
||||
"public": false,
|
||||
"updated_at": ISO8601DateFormatter().string(from: Date()),
|
||||
])
|
||||
.eq("id", value: roomId)
|
||||
.select()
|
||||
.single()
|
||||
.execute()
|
||||
.value`,
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: 'Filter' as const,
|
||||
title: 'Filter & paginate',
|
||||
code: `// Full-text search with pagination
|
||||
description: 'Full-text search, range queries, and cursor-based pagination built in.',
|
||||
code: {
|
||||
JavaScript: `// Full-text search with pagination
|
||||
const { data, count } = await supabase
|
||||
.from('rooms')
|
||||
.select('id, name, member_count', { count: 'exact' })
|
||||
@@ -104,6 +291,51 @@ const { data, count } = await supabase
|
||||
.gte('member_count', 5)
|
||||
.range(0, 9)
|
||||
.order('member_count', { ascending: false })`,
|
||||
Flutter: `// Full-text search with pagination
|
||||
final response = await supabase
|
||||
.from('rooms')
|
||||
.select('id, name, member_count', const FetchOptions(count: CountOption.exact))
|
||||
.textSearch('name', 'design')
|
||||
.gte('member_count', 5)
|
||||
.range(0, 9)
|
||||
.order('member_count', ascending: false);`,
|
||||
Python: `# Full-text search with pagination
|
||||
response = supabase.table("rooms") \\
|
||||
.select("id, name, member_count", count="exact") \\
|
||||
.text_search("name", "design") \\
|
||||
.gte("member_count", 5) \\
|
||||
.range(0, 9) \\
|
||||
.order("member_count", desc=True) \\
|
||||
.execute()`,
|
||||
'C#': `// Full-text search with pagination
|
||||
var response = await supabase
|
||||
.From<Room>()
|
||||
.Select("id, name, member_count")
|
||||
.Filter("name", Operator.FTS, "design")
|
||||
.Filter("member_count", Operator.GreaterThanOrEqual, "5")
|
||||
.Range(0, 9)
|
||||
.Order("member_count", Ordering.Descending)
|
||||
.Get();`,
|
||||
Kotlin: `// Full-text search with pagination
|
||||
val response = supabase.from("rooms")
|
||||
.select(Columns.list("id", "name", "member_count")) {
|
||||
filter {
|
||||
textSearch("name", "design")
|
||||
gte("member_count", 5)
|
||||
}
|
||||
range(0L..9L)
|
||||
order("member_count", Order.DESCENDING)
|
||||
}`,
|
||||
Swift: `// Full-text search with pagination
|
||||
let response = try await supabase
|
||||
.from("rooms")
|
||||
.select("id, name, member_count", head: false, count: .exact)
|
||||
.textSearch("name", query: "design")
|
||||
.gte("member_count", value: 5)
|
||||
.range(from: 0, to: 9)
|
||||
.order("member_count", ascending: false)
|
||||
.execute()`,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -112,13 +344,22 @@ const { data, count } = await supabase
|
||||
export async function ApiSection() {
|
||||
const hl = await createHighlighter({
|
||||
themes: [supabaseDark, supabaseLight],
|
||||
langs: ['javascript'],
|
||||
langs: Object.values(LANG_MAP),
|
||||
})
|
||||
|
||||
const examples = API_EXAMPLES.map((example) => ({
|
||||
icon: example.icon,
|
||||
title: example.title,
|
||||
darkHtml: hl.codeToHtml(example.code, { lang: 'javascript', theme: 'supabase-dark' }),
|
||||
lightHtml: hl.codeToHtml(example.code, { lang: 'javascript', theme: 'supabase-light' }),
|
||||
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()
|
||||
|
||||
@@ -1,124 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowDownUp, ChevronDown, Filter, Plus, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Badge, cn } from 'ui'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const ICONS = { Search, Plus, ArrowDownUp, Filter } as const
|
||||
|
||||
type IconName = keyof typeof ICONS
|
||||
|
||||
type ApiExample = {
|
||||
icon: IconName
|
||||
title: string
|
||||
darkHtml: string
|
||||
lightHtml: string
|
||||
description: string
|
||||
languages: Record
|
||||
}
|
||||
|
||||
const LIBRARIES = [
|
||||
{ label: 'Javascript', href: '/docs/reference/javascript/introduction' },
|
||||
{ label: 'Flutter', href: '/docs/reference/dart/introduction' },
|
||||
{ label: 'Python', href: '/docs/reference/python/introduction' },
|
||||
{ label: 'C#', href: '/docs/reference/csharp/introduction' },
|
||||
{ label: 'Kotlin', href: '/docs/reference/kotlin/introduction' },
|
||||
{ label: 'Swift', href: '/docs/reference/swift/introduction' },
|
||||
]
|
||||
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 | 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 className="py-24">
|
||||
<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">
|
||||
{/* Left: title + libraries */}
|
||||
<div className="min-w-0 flex flex-col justify-between pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter">
|
||||
Never write an API
|
||||
<span className="block text-foreground">again</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Never write an API
|
||||
<br />
|
||||
<span className="text-foreground">again</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
We introspect your database and provide instant APIs. Focus on building your product,
|
||||
while Supabase handles the CRUD.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-foreground-lighter text-sm mt-4 max-w-md">
|
||||
We introspect your database and provide instant APIs. Focus on building your
|
||||
product, while Supabase handles the CRUD.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Available libraries */}
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<span className="text-foreground-muted text-sm">Available libraries</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{LIBRARIES.map((lib) => (
|
||||
<Link key={lib.label} href={lib.href} target="_blank" className="flex">
|
||||
<Badge>{lib.label}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: tabs + code */}
|
||||
<div className="min-w-0 flex flex-col border border-border rounded-md overflow-clip">
|
||||
{/* Tab row */}
|
||||
<div className="flex border-b border-border">
|
||||
{examples.map((example, index) => (
|
||||
{/* 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={() => setActiveIdx(index)}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center text-xs px-3 py-4 border-r border-border last:border-r-0 transition-colors whitespace-nowrap',
|
||||
index === activeIdx
|
||||
? 'bg-surface-75 text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light hover:bg-surface-75/50'
|
||||
'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>
|
||||
|
||||
{/* Code area */}
|
||||
<div className="relative h-[440px] overflow-auto">
|
||||
<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"
|
||||
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"
|
||||
<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 } }}
|
||||
>
|
||||
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
|
||||
dangerouslySetInnerHTML={{ __html: active.languages[activeLang]?.darkHtml ?? '' }}
|
||||
className="hidden dark:block [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: active.languages[activeLang]?.lightHtml ?? '',
|
||||
}}
|
||||
className="block dark:hidden [&_pre]:!bg-transparent [&_pre]:m-0 [&_pre]:p-6"
|
||||
style={{ fontSize: '0.8125rem', fontWeight: 500, lineHeight: 1.7 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Link
|
||||
href="/docs/guides/database"
|
||||
className="absolute bottom-4 right-4 flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Documentation
|
||||
<svg
|
||||
width={12}
|
||||
height={12}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path
|
||||
d="M3.5 2.5H9.5V8.5M9.5 2.5L2.5 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,118 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Code, FileDown, Heart, MousePointerClick } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const ICONS = { Code, MousePointerClick, Heart, FileDown } as const
|
||||
|
||||
type IconName = keyof typeof ICONS
|
||||
|
||||
const TABS = [
|
||||
{
|
||||
label: 'SQL Editor built in',
|
||||
title: 'Full SQL',
|
||||
icon: 'Code' as IconName,
|
||||
label: 'Full SQL',
|
||||
description:
|
||||
'Create tables, functions, join tables — a full SQL editor built into the dashboard.',
|
||||
image: '/images/product/database/sql-view/sql-editor.png',
|
||||
},
|
||||
{
|
||||
icon: 'MousePointerClick' as IconName,
|
||||
label: 'Monaco editor',
|
||||
title: 'Monaco editor',
|
||||
description: 'Built-in Monaco editor, with rich validation and autocomplete.',
|
||||
image: '/images/product/database/sql-view/manaco-editor.png',
|
||||
},
|
||||
{
|
||||
icon: 'Heart' as IconName,
|
||||
label: 'Favorite queries',
|
||||
title: 'Favorite queries',
|
||||
description: 'Save your favorite queries to use again and again.',
|
||||
image: '/images/product/database/sql-view/favorites.png',
|
||||
},
|
||||
{
|
||||
label: 'Select and Export',
|
||||
title: 'Export to CSV',
|
||||
icon: 'FileDown' as IconName,
|
||||
label: 'Export to CSV',
|
||||
description: 'Any output can be exported into a CSV.',
|
||||
image: '/images/product/database/sql-view/export.png',
|
||||
},
|
||||
]
|
||||
|
||||
const INTERVAL_DURATION = 8000
|
||||
|
||||
export function SqlEditorSection() {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = TABS[activeIdx]
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % TABS.length)
|
||||
}
|
||||
}, [progress])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
<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">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
The power of
|
||||
<br />
|
||||
<span className="text-foreground">SQL Editor</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Write and run queries with a
|
||||
<br />
|
||||
<span className="text-foreground">full SQL Editor</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Write, run, and save SQL queries directly from the dashboard with full autocomplete and
|
||||
syntax highlighting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="border border-border rounded-md overflow-clip">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2">
|
||||
{/* Left: tabs + content */}
|
||||
<div className="flex flex-col border-b lg:border-b-0 lg:border-r border-border">
|
||||
{/* Tab row */}
|
||||
<div className="flex border-b border-border">
|
||||
{TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
onClick={() => setActiveIdx(index)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center text-xs px-3 py-4 border-r border-border last:border-r-0 transition-colors whitespace-nowrap',
|
||||
index === activeIdx
|
||||
? 'bg-surface-75 text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light hover:bg-surface-75/50'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tabs */}
|
||||
<div className="flex flex-col gap-1 items-start justify-end">
|
||||
{TABS.map((tab, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const Icon = ICONS[tab.icon]
|
||||
return (
|
||||
<button
|
||||
key={tab.label}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab content + tweet */}
|
||||
<div className="flex-1 flex flex-col justify-between">
|
||||
{/* Active tab content */}
|
||||
<div className="relative h-[160px] px-6 flex items-center">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0, filter: 'blur(2px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)', transition: { duration: 0.2 } }}
|
||||
exit={{ opacity: 0, filter: 'blur(2px)', transition: { duration: 0.1 } }}
|
||||
className="absolute inset-x-6 flex flex-col gap-3"
|
||||
>
|
||||
<h4 className="text-foreground text-lg font-medium">{active.title}</h4>
|
||||
<p className="text-foreground-lighter text-sm max-w-md">
|
||||
{active.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Tweet card */}
|
||||
<div className="border-t border-border px-6 py-4 bg-surface-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
src="/images/twitter-profiles/rLgwUZSB_400x400.jpg"
|
||||
alt="@jim_bisenius"
|
||||
className="w-12 h-12 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-foreground text-sm font-medium">@jim_bisenius</span>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
@MongoDB or @MySQL?!?! Please, let me introduce you to @supabase and the
|
||||
wonderful world of @Postgres before it's too late!!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: visual */}
|
||||
<div className="relative h-[480px] flex items-center justify-center overflow-hidden">
|
||||
{/* Right: image + footer */}
|
||||
<div className="flex flex-col border border-border rounded-md overflow-clip">
|
||||
<div className="relative h-[400px] shrink-0 overflow-hidden flex items-end justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
@@ -123,14 +145,36 @@ export function SqlEditorSection() {
|
||||
>
|
||||
<Image
|
||||
src={active.image}
|
||||
alt={active.title}
|
||||
alt={active.label}
|
||||
width={600}
|
||||
height={400}
|
||||
className="rounded-lg border border-border shadow-sm object-contain -mb-4"
|
||||
className="rounded-lg object-contain -mb-4"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer: active tab info */}
|
||||
<div className="border-t border-border px-6 py-4 flex items-center gap-8 justify-between bg-surface-100">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = ICONS[active.icon]
|
||||
return <Icon size={14} strokeWidth={2} />
|
||||
})()}
|
||||
{active.label}
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-lighter">{active.description}</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,118 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { FileDown, Link2, Table, TableProperties } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const ICONS = { TableProperties, Table, Link2, FileDown } as const
|
||||
|
||||
type IconName = keyof typeof ICONS
|
||||
|
||||
const TABS = [
|
||||
{
|
||||
label: 'Spreadsheet like editing',
|
||||
title: 'The simplicity of a spreadsheet',
|
||||
icon: 'TableProperties' as IconName,
|
||||
label: 'Spreadsheet editing',
|
||||
description: 'Add, edit, and update your data with the simplicity of a no-code tool.',
|
||||
image: '/images/product/database/table-view/spreadsheet-interface.png',
|
||||
},
|
||||
{
|
||||
icon: 'Table' as IconName,
|
||||
label: 'Create tables',
|
||||
title: 'Create tables',
|
||||
description:
|
||||
'Add tables, columns and rows right in the dashboard. Without a single line of SQL.',
|
||||
image: '/images/product/database/table-view/create-table.png',
|
||||
},
|
||||
{
|
||||
label: 'Set up foreign keys',
|
||||
title: 'Set up foreign keys',
|
||||
description:
|
||||
'Build connections throughout your data with the full power of a Relational Database.',
|
||||
icon: 'Link2' as IconName,
|
||||
label: 'Foreign keys',
|
||||
description: 'Build connections across tables with the full power of relational data.',
|
||||
image: '/images/product/database/table-view/foreign-keys.png',
|
||||
},
|
||||
{
|
||||
label: 'Select and Export',
|
||||
title: 'Select and Export',
|
||||
icon: 'FileDown' as IconName,
|
||||
label: 'Export to CSV',
|
||||
description: 'Pick the rows you want and export them into a CSV.',
|
||||
image: '/images/product/database/table-view/export.png',
|
||||
},
|
||||
]
|
||||
|
||||
const INTERVAL_DURATION = 8000
|
||||
|
||||
export function TableEditorSection() {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = TABS[activeIdx]
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimer()
|
||||
setProgress(0)
|
||||
const updateFrequency = 30
|
||||
const increment = (100 / INTERVAL_DURATION) * updateFrequency
|
||||
intervalRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + increment
|
||||
if (next >= 100) return 100
|
||||
return next
|
||||
})
|
||||
}, updateFrequency)
|
||||
}, [clearTimer])
|
||||
|
||||
useEffect(() => {
|
||||
if (progress >= 100) {
|
||||
setActiveIdx((prev) => (prev + 1) % TABS.length)
|
||||
}
|
||||
}, [progress])
|
||||
|
||||
useEffect(() => {
|
||||
if (inView) {
|
||||
startTimer()
|
||||
} else {
|
||||
clearTimer()
|
||||
}
|
||||
return clearTimer
|
||||
}, [inView, activeIdx, startTimer, clearTimer])
|
||||
|
||||
const handleTabClick = (index: number) => {
|
||||
setActiveIdx(index)
|
||||
startTimer()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-24 flex flex-col gap-16">
|
||||
<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">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Easy to use
|
||||
<br />
|
||||
<span className="text-foreground">Table Editor</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-end">
|
||||
<h3 className="text-2xl md:text-4xl text-foreground-lighter max-w-xl">
|
||||
Manage your data with a
|
||||
<br />
|
||||
<span className="text-foreground">built-in Table Editor</span>
|
||||
</h3>
|
||||
<p className="text-foreground-lighter text-sm lg:text-base">
|
||||
Manage your data with a spreadsheet-like interface. Create tables, set up relationships,
|
||||
and export — no SQL required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="border border-border rounded-md overflow-clip">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2">
|
||||
{/* Left: tabs + content */}
|
||||
<div className="flex flex-col border-b lg:border-b-0 lg:border-r border-border">
|
||||
{/* Tab row */}
|
||||
<div className="flex border-b border-border">
|
||||
{TABS.map((tab, index) => (
|
||||
<button
|
||||
key={tab.label}
|
||||
onClick={() => setActiveIdx(index)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center text-xs px-3 py-4 border-r border-border last:border-r-0 transition-colors whitespace-nowrap',
|
||||
index === activeIdx
|
||||
? 'bg-surface-75 text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light hover:bg-surface-75/50'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tabs */}
|
||||
<div className="flex flex-col gap-1 items-start justify-end">
|
||||
{TABS.map((tab, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const Icon = ICONS[tab.icon]
|
||||
return (
|
||||
<button
|
||||
key={tab.label}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex items-center gap-3 py-2 text-2xl font-medium transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab content + tweet */}
|
||||
<div className="flex-1 flex flex-col justify-between">
|
||||
{/* Active tab content */}
|
||||
<div className="relative h-[160px] px-6 flex items-center">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0, filter: 'blur(2px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)', transition: { duration: 0.2 } }}
|
||||
exit={{ opacity: 0, filter: 'blur(2px)', transition: { duration: 0.1 } }}
|
||||
className="absolute inset-x-6 flex flex-col gap-3"
|
||||
>
|
||||
<h4 className="text-foreground text-lg font-medium">{active.title}</h4>
|
||||
<p className="text-foreground-lighter text-sm max-w-md">
|
||||
{active.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Tweet card */}
|
||||
<div className="border-t border-border px-6 py-4 bg-surface-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
src="/images/twitter-profiles/v6citnk33y2wpeyzrq05_400x400.jpeg"
|
||||
alt="@Elsolo244"
|
||||
className="w-12 h-12 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-foreground text-sm font-medium">@Elsolo244</span>
|
||||
<p className="text-foreground-lighter text-sm">
|
||||
Where has @supabase been all my life?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: visual */}
|
||||
<div className="relative h-[480px] flex items-center justify-center overflow-hidden">
|
||||
{/* Right: image + footer */}
|
||||
<div className="flex flex-col border border-border rounded-md overflow-clip">
|
||||
<div className="relative h-[400px] shrink-0 overflow-hidden flex items-end justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
@@ -123,14 +145,36 @@ export function TableEditorSection() {
|
||||
>
|
||||
<Image
|
||||
src={active.image}
|
||||
alt={active.title}
|
||||
alt={active.label}
|
||||
width={600}
|
||||
height={400}
|
||||
className="rounded-lg border border-border shadow-sm object-contain -mb-4"
|
||||
className="rounded-lg object-contain -mb-4"
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer: active tab info */}
|
||||
<div className="border-t border-border px-6 py-4 flex items-center gap-8 justify-between bg-surface-100">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = ICONS[active.icon]
|
||||
return <Icon size={14} strokeWidth={2} />
|
||||
})()}
|
||||
{active.label}
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-lighter">{active.description}</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Link from 'next/link'
|
||||
import { createHighlighter, type ThemeRegistration } from 'shiki'
|
||||
import { IntegratesSectionClient } from './IntegratesSectionClient'
|
||||
|
||||
@@ -100,17 +99,7 @@ execute function "supabase_functions"."http_request"(
|
||||
{
|
||||
icon: 'Shield' as const,
|
||||
label: 'Works with Supabase Auth',
|
||||
paragraph: (
|
||||
<>
|
||||
Edge Functions work seamlessly with{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/functions/auth"
|
||||
className="underline hover:text-foreground-light transition-colors"
|
||||
>
|
||||
Supabase Auth
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
paragraph: 'Verify users and enforce permissions directly inside your Edge Functions',
|
||||
lang: 'javascript' as const,
|
||||
code: `import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
|
||||
@@ -134,17 +123,7 @@ Deno.serve(async (req: Request) => {
|
||||
{
|
||||
icon: 'HardDrive' as const,
|
||||
label: 'Works with Supabase Storage',
|
||||
paragraph: (
|
||||
<>
|
||||
Edge Functions work seamlessly with{' '}
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/functions/storage-caching"
|
||||
className="underline hover:text-foreground-light transition-colors"
|
||||
>
|
||||
Supabase Storage
|
||||
</Link>
|
||||
</>
|
||||
),
|
||||
paragraph: 'Upload, transform, and serve files with built-in caching from your Edge Functions',
|
||||
lang: 'javascript' as const,
|
||||
code: `import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const INTERVAL_DURATION = 6000 // ms per tab
|
||||
export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) {
|
||||
const [activeIdx, setActiveIdx] = useState(0)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const intervalRef = useRef<ReturnType | null>(null)
|
||||
const { ref: inViewRef, inView } = useInView({ threshold: 0.3 })
|
||||
const active = useCases[activeIdx]
|
||||
|
||||
@@ -82,63 +82,34 @@ export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) {
|
||||
|
||||
{/* Content */}
|
||||
<div className="mx-auto max-w-[var(--container-max-w,75rem)] px-6 w-full">
|
||||
<div className="border border-border rounded-md overflow-clip">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0">
|
||||
{/* Left: vertical tabs */}
|
||||
<div className="flex flex-col">
|
||||
{useCases.map((useCase, index) => {
|
||||
const isActive = index === activeIdx
|
||||
return (
|
||||
<button
|
||||
key={useCase.label}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'relative text-left flex flex-col gap-1 px-6 py-5 transition-colors',
|
||||
isActive
|
||||
? 'bg-surface-75 text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light hover:bg-surface-75/50'
|
||||
)}
|
||||
>
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = ICONS[useCase.icon]
|
||||
return <Icon size={14} strokeWidth={2} />
|
||||
})()}
|
||||
{useCase.label}
|
||||
</h4>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm transition-colors',
|
||||
isActive ? 'text-foreground-lighter' : 'text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
{useCase.paragraph}
|
||||
</p>
|
||||
{/* Progress bar — hide on last item to avoid doubling with section border */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 h-[1px] bg-border-strong overflow-hidden',
|
||||
index === useCases.length - 1 && 'hidden'
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
'absolute inset-0 w-full right-full bg-foreground-muted h-full transition-opacity',
|
||||
progress > 99.5 ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
style={{ x: `${progress - 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: tab cards */}
|
||||
<div className="flex flex-col gap-1 items-start justify-end">
|
||||
{useCases.map((useCase, index) => {
|
||||
const isActive = index === activeIdx
|
||||
const Icon = ICONS[useCase.icon]
|
||||
return (
|
||||
<button
|
||||
key={useCase.label}
|
||||
onClick={() => handleTabClick(index)}
|
||||
className={cn(
|
||||
'text-left flex items-center gap-3 py-2 text-2xl transition-colors',
|
||||
isActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground-muted hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{useCase.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: code area */}
|
||||
<div className="relative min-h-[440px] overflow-auto lg:border-l border-border">
|
||||
<AnimatePresence mode="wait">
|
||||
{/* Right: code area */}
|
||||
<div className="flex flex-col border border-border rounded-md overflow-clip">
|
||||
<div className="relative h-[440px] shrink-0 overflow-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -157,9 +128,31 @@ export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) {
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer: active tab info */}
|
||||
<div className="border-t border-border px-6 py-4 flex items-center gap-8 justify-between bg-surface-75">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={active.label}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { duration: 0.15, delay: 0.05 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.05 } }}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<h4 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = ICONS[active.icon]
|
||||
return <Icon size={14} strokeWidth={2} />
|
||||
})()}
|
||||
{active.label}
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-lighter">{active.paragraph}</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
<Link
|
||||
href="/docs/guides/functions"
|
||||
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"
|
||||
className="flex items-center gap-1.5 rounded-full bg-surface-100 border border-border px-3 py-1.5 text-xs text-foreground-light hover:text-foreground hover:bg-surface-200 transition-colors whitespace-nowrap shrink-0"
|
||||
>
|
||||
Documentation
|
||||
<svg
|
||||
|
||||
Reference in new issue
Block a user