Feat/learn (#41566)
wip <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit # Release Notes * **New Features** * Added a new Learn application offering foundational Supabase courses with interactive documentation * Courses include Architecture, Authentication, Data Fundamentals, Security, Storage, Realtime, and Edge Functions * Chapter tracking and progress indicators for course completions * Responsive sidebar navigation with search/command menu * Theme switching support (light, dark, classic dark modes) * Mobile-friendly course interface <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alan Daniel <stylesshjs@gmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
No files matched your search
@@ -0,0 +1,38 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.contentlayer
|
||||
@@ -0,0 +1,54 @@
|
||||
Plan:
|
||||
Course 1: Supabase Foundations Learn the basics of Supabase: database, auth, and RLS. 5 chapters.
|
||||
|
||||
-
|
||||
|
||||
Course 2: Project: Smart Office 15 Build a realtime room-booking dashboard using Supabase. 15 chapters.
|
||||
Course 3: Supabase Internals: Performance & Scaling. Learn how to profile queries, tune indexes, and scale Postgres with Supabase. 20 chapters.
|
||||
Course 4: Supabase Internals: Debugging & Operations. Understand how to diagnose slow queries, use read replicas, and manage production workloads. 20 chapters.
|
||||
|
||||
———
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
|
||||
## Supabase types
|
||||
|
||||
To regenerate the Supabase database types, run
|
||||
|
||||
```
|
||||
supabase gen types --local > registry/default/fixtures/database.types.ts
|
||||
```
|
||||
@@ -0,0 +1,158 @@
|
||||
import { metadata as mainMetadata } from '@/app/layout'
|
||||
import { ChapterCompletion } from '@/components/chapter-completion'
|
||||
import { CourseHero } from '@/components/course-hero'
|
||||
import { ExploreMore } from '@/components/explore-more'
|
||||
import { Mdx } from '@/components/mdx-components'
|
||||
import { NextUp } from '@/components/next-up'
|
||||
import { DashboardTableOfContents } from '@/components/toc'
|
||||
import { getTableOfContents } from '@/lib/toc'
|
||||
import { getCurrentChapter } from '@/lib/get-current-chapter'
|
||||
import { getNextPage } from '@/lib/get-next-page'
|
||||
import { absoluteUrl, cn } from '@/lib/utils'
|
||||
import '@/styles/code-block-variables.css'
|
||||
import '@/styles/mdx.css'
|
||||
import { allDocs } from 'contentlayer/generated'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Balancer from 'react-wrap-balancer'
|
||||
import { ScrollArea } from 'ui'
|
||||
|
||||
interface DocPageProps {
|
||||
params: Promise<{
|
||||
slug: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
async function getDocFromParams({ params }: { params: { slug: string[] } }) {
|
||||
const slug = params.slug?.join('/') || ''
|
||||
const doc = allDocs.find((doc) => doc.slugAsParams === slug)
|
||||
|
||||
if (!doc) {
|
||||
return null
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: DocPageProps): Promise<Metadata> {
|
||||
const params = await props.params
|
||||
const doc = await getDocFromParams({ params })
|
||||
// get page params so we can check if it's the introduction page
|
||||
const slugSegments = doc?.slugAsParams.split('/')
|
||||
const isIntroductionPage = slugSegments?.[slugSegments.length - 1] === 'introduction'
|
||||
|
||||
if (!doc) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const metadata: Metadata = {
|
||||
...mainMetadata,
|
||||
title: doc.title,
|
||||
description: doc.description,
|
||||
openGraph: {
|
||||
...(mainMetadata.openGraph || {}),
|
||||
title: doc.title,
|
||||
description: doc.description,
|
||||
type: 'article',
|
||||
url: absoluteUrl(doc.slug),
|
||||
},
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
export async function generateStaticParams(): Promise<{ slug: string[] }[]> {
|
||||
return allDocs.map((doc) => ({
|
||||
slug: doc.slugAsParams.split('/'),
|
||||
}))
|
||||
}
|
||||
|
||||
export default async function DocPage(props: DocPageProps) {
|
||||
const params = await props.params
|
||||
const doc = await getDocFromParams({ params })
|
||||
|
||||
if (!doc) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const toc = await getTableOfContents(doc.body.raw)
|
||||
const nextPage = getNextPage(doc.slugAsParams)
|
||||
const currentChapter = getCurrentChapter(doc.slugAsParams)
|
||||
const slugSegments = doc.slugAsParams.split('/')
|
||||
const isIntroductionPage = slugSegments[slugSegments.length - 1] === 'introduction'
|
||||
|
||||
const exploreItems = (
|
||||
doc as {
|
||||
explore?: Array<{ title: string; link: string; itemType?: string; description?: string }>
|
||||
}
|
||||
).explore
|
||||
|
||||
return (
|
||||
<div className={cn('relative')}>
|
||||
{isIntroductionPage && doc.courseHero && (
|
||||
<CourseHero
|
||||
title={doc.courseHero.title}
|
||||
subtitle={doc.courseHero.subtitle}
|
||||
description={doc.courseHero.description}
|
||||
//instructors={doc.courseHero.instructors}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative lg:gap-10 xl:grid xl:grid-cols-[1fr_200px] py-20',
|
||||
isIntroductionPage ? 'px-0' : 'px-8 md:px-16'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full min-w-0 max-w-4xl">
|
||||
<div className="mb-4 flex items-center space-x-1 text-sm text-foreground-muted">
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap">Learn</div>
|
||||
<ChevronRight className="h-4 w-4 text-foreground-muted" />
|
||||
<div className="text-foreground-lighter">{doc.title}</div>
|
||||
</div>
|
||||
<div className="flex flex-col lg:flex-row lg:items-end justify-between mb-5">
|
||||
<div className="space-y-2">
|
||||
<h1 className={cn('scroll-m-20 text-2xl lg:text-4xl tracking-tight')}>{doc.title}</h1>
|
||||
{doc.description && (
|
||||
<p className="text-base lg:text-lg text-foreground-light">
|
||||
<Balancer>{doc.description}</Balancer>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-12">
|
||||
<Mdx code={doc.body.code} />
|
||||
</div>
|
||||
{exploreItems && exploreItems.length > 0 && <ExploreMore items={exploreItems} />}
|
||||
{currentChapter && nextPage && (
|
||||
<ChapterCompletion
|
||||
chapterNumber={currentChapter.chapterNumber!}
|
||||
completionMessage={currentChapter.completionMessage}
|
||||
/>
|
||||
)}
|
||||
{nextPage && (
|
||||
<div className="flex w-2xl grow">
|
||||
<NextUp
|
||||
title={nextPage.title}
|
||||
description={nextPage.description || ''}
|
||||
href={nextPage.href}
|
||||
chapterNumber={nextPage.chapterNumber}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{doc.toc && (
|
||||
<div className="hidden text-sm xl:block">
|
||||
<div className="sticky top-16 -mt-10 pt-4">
|
||||
<ScrollArea className="pb-10">
|
||||
<div className="sticky top-16 -mt-10 h-[calc(100vh-3.5rem)] py-12">
|
||||
<DashboardTableOfContents toc={toc} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Sidebar } from '@/components/sidebar'
|
||||
import { SiteFooter } from '@/components/site-footer'
|
||||
import { TelemetryWrapper } from './telemetry-wrapper'
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function AppLayout({ children }: AppLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
{/* main container */}
|
||||
<div className="pt-10 md:pt-0">
|
||||
{/* main content */}
|
||||
<main className="flex-1 max-w-site mx-auto w-full p-0">
|
||||
{/* {children} */}
|
||||
<div className="border-b">
|
||||
<div className="flex-1 items-start md:grid md:grid-cols-[240px_minmax(0,1fr)] lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<Sidebar />
|
||||
<div vaul-drawer-wrapper="">
|
||||
<div className="relative flex min-h-screen flex-col bg-background">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
<TelemetryWrapper />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { WhatWillILearn } from '@/components/what-will-i-learn'
|
||||
import Link from 'next/link'
|
||||
import { Activity, BookOpen, Database, Gauge, Wrench } from 'lucide-react'
|
||||
import { Badge, Button, Card, CardDescription, CardHeader } from 'ui'
|
||||
|
||||
// Horizontal grid line component
|
||||
const HorizontalGridLine = () => <div className="col-span-12 h-px bg-border/30" />
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
export default function Home() {
|
||||
const courses = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Supabase Foundations',
|
||||
description: 'Learn the basics of Supabase: database, auth, and RLS.',
|
||||
chapters: 5,
|
||||
icon: Database,
|
||||
level: 'Beginner',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Project: Smart Office',
|
||||
description: 'Build a realtime room-booking dashboard using Supabase.',
|
||||
chapters: 15,
|
||||
icon: Activity,
|
||||
level: 'Intermediate',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Supabase Internals: Performance & Scaling',
|
||||
description: 'Learn how to profile queries, tune indexes, and scale Postgres with Supabase.',
|
||||
chapters: 20,
|
||||
icon: Gauge,
|
||||
level: 'Advanced',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Supabase Internals: Debugging & Operations',
|
||||
description:
|
||||
'Understand how to diagnose slow queries, use read replicas, and manage production workloads.',
|
||||
chapters: 20,
|
||||
icon: Wrench,
|
||||
level: 'Advanced',
|
||||
},
|
||||
]
|
||||
return (
|
||||
<main className="relative lg:-ml-10">
|
||||
<div className="mx-auto w-full min-w-0 flex flex-col gap-16">
|
||||
{/* Component Showcase with Grid */}
|
||||
<div className="relative z-10 h-full w-full overflow-y-auto">
|
||||
{/* Grid Container */}
|
||||
<div className="relative">
|
||||
{/* Grid Lines - Vertical (Columns) */}
|
||||
{Array.from({ length: 13 }).map((_, i) => (
|
||||
<div
|
||||
key={`col-line-${i}`}
|
||||
className="absolute top-0 bottom-0 w-px bg-border/30 z-10 first:hidden last:hidden"
|
||||
style={{
|
||||
left: `${(i / 12) * 100}%`,
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Grid Content */}
|
||||
<div className="grid grid-cols-12 gap-0 relative z-20 pb-32">
|
||||
{/* Heading Section */}
|
||||
<div className="col-start-2 col-span-10 md:col-start-3 md:col-span-8 pt-8 pb-8">
|
||||
<div className="flex flex-col gap-8 justify-start pt-16 md:pt-32">
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="text-4xl text-foreground mb-3 font-medium tracking-tight">
|
||||
Learn Supabase
|
||||
</h1>
|
||||
<h2 className="text-lg text-foreground-light mb-4">
|
||||
Learn how to build your own projects with Supabase. Our courses and projects
|
||||
help you get started no matter your skill level, teaching you how to build
|
||||
production-ready apps.
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 mt-12">
|
||||
<h3 className="text-2xl font-bold mb-6">Courses</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{courses.map((course) => {
|
||||
const Icon = course.icon
|
||||
return (
|
||||
<Card key={course.id} className="hover:border-primary/30 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<h3 className="text-lg font-bold">{course.title}</h3>
|
||||
<Badge variant="secondary" className="flex-shrink-0">
|
||||
{course.level}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-sm mb-3 text-foreground-light">
|
||||
{course.description}
|
||||
</CardDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span className="text-foreground-lighter">
|
||||
{course.chapters} chapters
|
||||
</span>
|
||||
</div>
|
||||
<Button type="text" size="small" asChild>
|
||||
<Link href="/...">Start course ➔</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-16">
|
||||
<WhatWillILearn />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { API_URL } from '@/lib/constants'
|
||||
import { IS_PLATFORM, PageTelemetry } from 'common'
|
||||
import { useConsentToast } from 'ui-patterns/consent'
|
||||
|
||||
export const TelemetryWrapper = () => {
|
||||
const { hasAcceptedConsent } = useConsentToast()
|
||||
return (
|
||||
<PageTelemetry
|
||||
API_URL={API_URL}
|
||||
hasAcceptedConsent={hasAcceptedConsent}
|
||||
enabled={IS_PLATFORM}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { ThemeProviderProps } from 'next-themes/dist/types'
|
||||
|
||||
import { FrameworkProvider } from '@/context/framework-context'
|
||||
import { MobileMenuProvider } from '@/context/mobile-menu-context'
|
||||
import { AuthProvider } from 'common'
|
||||
import { TooltipProvider } from 'ui'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<JotaiProvider>
|
||||
<NextThemesProvider {...props}>
|
||||
<MobileMenuProvider>
|
||||
<FrameworkProvider>
|
||||
<TooltipProvider delayDuration={0}>{children}</TooltipProvider>
|
||||
</FrameworkProvider>
|
||||
</MobileMenuProvider>
|
||||
</NextThemesProvider>
|
||||
</JotaiProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useConfig } from '@/hooks/use-config'
|
||||
import { SonnerToaster as Toaster } from 'ui'
|
||||
|
||||
export function SonnerToaster() {
|
||||
const [config] = useConfig()
|
||||
return <Toaster position={config.sonnerPosition} expand={config.sonnerExpand} />
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,60 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import '@/styles/globals.css'
|
||||
import { API_URL } from '@/lib/constants'
|
||||
import { FeatureFlagProvider, TelemetryTagManager } from 'common'
|
||||
import { genFaviconData } from 'common/MetaFavicons/app-router'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { ThemeProvider } from './Providers'
|
||||
import { SonnerToaster } from './SonnerToast'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
|
||||
|
||||
export const metadata: Metadata = {
|
||||
applicationName: 'Learn Supabase',
|
||||
title: 'Learn Supabase',
|
||||
description: 'Learn Supabase.',
|
||||
metadataBase: new URL('https://supabase.com/learn'),
|
||||
icons: genFaviconData(BASE_PATH),
|
||||
openGraph: {
|
||||
type: 'article',
|
||||
authors: 'Supabase',
|
||||
url: `${BASE_PATH}`,
|
||||
images: `${BASE_PATH}/img/supabase-og-image.png`,
|
||||
publishedTime: new Date().toISOString(),
|
||||
modifiedTime: new Date().toISOString(),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
site: '@supabase',
|
||||
creator: '@supabase',
|
||||
images: `${BASE_PATH}/img/supabase-og-image.png`,
|
||||
},
|
||||
}
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default async function Layout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head />
|
||||
<body className={`${inter.className} antialiased`}>
|
||||
<TelemetryTagManager />
|
||||
<FeatureFlagProvider API_URL={API_URL}>
|
||||
<ThemeProvider
|
||||
themes={['dark', 'light', 'classic-dark']}
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
{children}
|
||||
<SonnerToaster />
|
||||
</ThemeProvider>
|
||||
</FeatureFlagProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui'
|
||||
|
||||
interface CalloutProps {
|
||||
icon?: string
|
||||
title?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function Callout({ title, children, icon, ...props }: CalloutProps) {
|
||||
return (
|
||||
<Alert_Shadcn_ {...props}>
|
||||
{icon && <span className="mr-4 text-2xl">{icon}</span>}
|
||||
{title && <AlertTitle_Shadcn_>{title}</AlertTitle_Shadcn_>}
|
||||
<AlertDescription_Shadcn_>{children}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { Check } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useLocalStorage } from './use-local-storage'
|
||||
|
||||
interface ChapterCompletionProps {
|
||||
chapterNumber: number
|
||||
completionMessage?: string
|
||||
}
|
||||
|
||||
export function ChapterCompletion({ chapterNumber, completionMessage }: ChapterCompletionProps) {
|
||||
const [completedChapters, setCompletedChapters] = useLocalStorage<number[]>(
|
||||
'completed-chapters',
|
||||
[]
|
||||
)
|
||||
const [isCompleted, setIsCompleted] = useState(false)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Check if chapter is already completed on mount
|
||||
useEffect(() => {
|
||||
if (completedChapters.includes(chapterNumber)) {
|
||||
setIsCompleted(true)
|
||||
}
|
||||
}, [chapterNumber, completedChapters])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true)
|
||||
} else {
|
||||
setIsVisible(false)
|
||||
// Reset timer if user scrolls away
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
threshold: 0.5, // Trigger when 50% of the component is visible
|
||||
rootMargin: '0px',
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(container)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && !isCompleted) {
|
||||
// Start timer when component becomes visible
|
||||
timerRef.current = setTimeout(() => {
|
||||
setIsCompleted(true)
|
||||
// Save to local storage
|
||||
if (!completedChapters.includes(chapterNumber)) {
|
||||
setCompletedChapters([...completedChapters, chapterNumber])
|
||||
}
|
||||
}, 5000) // 5 seconds
|
||||
} else if (!isVisible && timerRef.current) {
|
||||
// Clear timer if user scrolls away before completion
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [isVisible, isCompleted, chapterNumber, completedChapters, setCompletedChapters])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mb-16 mt-8">
|
||||
<div className="flex items-center gap-12">
|
||||
{/* Large circle with chapter number */}
|
||||
<div className="relative mb-6">
|
||||
<div
|
||||
className={`w-24 h-24 rounded-full flex items-center justify-center transition-all duration-500 ${
|
||||
isCompleted ? 'bg-green-50' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-4xl font-bold transition-all duration-500 ${
|
||||
isCompleted ? 'text-brand-500' : 'text-foreground-muted'
|
||||
}`}
|
||||
>
|
||||
{chapterNumber}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Small checkmark circle overlapping bottom-right */}
|
||||
<div
|
||||
className={`absolute -bottom-1 -right-1 w-8 h-8 rounded-full flex items-center justify-center shadow-md transition-all duration-500 ${
|
||||
isCompleted ? 'bg-brand-500 scale-100 opacity-100' : 'bg-muted scale-75 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<Check
|
||||
className={`h-6 w-6 text-white transition-all duration-300 ${
|
||||
isCompleted ? 'scale-100 opacity-100' : 'scale-0 opacity-0'
|
||||
}`}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{/* Completion text */}
|
||||
<h3
|
||||
className={`text-2xl font-bold mb-2 transition-all duration-500 ${
|
||||
isCompleted
|
||||
? 'text-foreground opacity-100 translate-y-0'
|
||||
: 'text-foreground-muted opacity-60 translate-y-2'
|
||||
}`}
|
||||
>
|
||||
You've completed Chapter {chapterNumber}
|
||||
</h3>
|
||||
|
||||
{completionMessage && (
|
||||
<p
|
||||
className={`text-base max-w-2xl transition-all duration-500 delay-100 ${
|
||||
isCompleted
|
||||
? 'text-foreground-light opacity-100 translate-y-0'
|
||||
: 'text-foreground-light opacity-0 translate-y-2'
|
||||
}`}
|
||||
>
|
||||
{completionMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useSendTelemetryEvent } from 'lib/telemetry'
|
||||
import { Button_Shadcn_ } from 'ui'
|
||||
|
||||
export function CommandCopyButton({ command }: { command: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const sendTelemetryEvent = useSendTelemetryEvent()
|
||||
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
const timeout = setTimeout(() => setCopied(false), 2000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [copied])
|
||||
|
||||
const parseCommandForTelemetry = (cmd: string) => {
|
||||
// Extract framework from URL (e.g., 'nextjs' from 'password-based-auth-nextjs.json')
|
||||
const frameworkMatch = cmd.match(/ui\/r\/.*?-(nextjs|react|react-router|tanstack)\.json/)
|
||||
|
||||
// if the block doesn't have a framework defined (like infinite query), default to react
|
||||
const framework = frameworkMatch
|
||||
? (frameworkMatch[1] as 'nextjs' | 'react-router' | 'tanstack' | 'react')
|
||||
: 'react'
|
||||
|
||||
// Extract package manager from command prefix (npx, pnpm, yarn, bun)
|
||||
const packageManager = cmd.startsWith('npx')
|
||||
? ('npm' as const)
|
||||
: cmd.startsWith('pnpm')
|
||||
? ('pnpm' as const)
|
||||
: cmd.startsWith('yarn')
|
||||
? ('yarn' as const)
|
||||
: cmd.startsWith('bun')
|
||||
? ('bun' as const)
|
||||
: ('npm' as const)
|
||||
|
||||
// Extract template title from URL (e.g., 'password-based-auth' from 'password-based-auth-nextjs.json')
|
||||
const titleMatch = cmd.match(/\/ui\/r\/(.*?)\.json/)
|
||||
const title = (titleMatch ? titleMatch[1] : '').replaceAll(`-${framework}`, '')
|
||||
|
||||
return {
|
||||
framework,
|
||||
packageManager,
|
||||
title,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button_Shadcn_
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
// Copy command to clipboard
|
||||
navigator.clipboard.writeText(command)
|
||||
setCopied(true)
|
||||
|
||||
// Parse command and send telemetry event
|
||||
const { framework, packageManager, title } = parseCommandForTelemetry(command)
|
||||
|
||||
sendTelemetryEvent({
|
||||
action: 'supabase_ui_command_copy_button_clicked',
|
||||
properties: {
|
||||
templateTitle: title,
|
||||
command: command,
|
||||
framework,
|
||||
packageManager,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import { CircleIcon, LaptopIcon, MoonIcon, SunIcon } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
|
||||
import { COMMAND_ITEMS } from '@/config/docs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Button,
|
||||
CommandDialog,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
CommandSeparator_Shadcn_,
|
||||
DialogProps,
|
||||
} from 'ui'
|
||||
|
||||
export function CommandMenu({ ...props }: DialogProps) {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') {
|
||||
if (
|
||||
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
setOpen((open) => !open)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', down)
|
||||
return () => document.removeEventListener('keydown', down)
|
||||
}, [])
|
||||
|
||||
const runCommand = React.useCallback((command: () => unknown) => {
|
||||
setOpen(false)
|
||||
command()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="outline"
|
||||
className={cn(
|
||||
`relative h-8 w-full justify-start rounded-[0.5rem] bg-background text-sm font-normal text-foreground-muted shadow-none sm:pr-12
|
||||
hover:border-foreground-muted hover:bg-surface-100 hover:text-foreground-lighter
|
||||
`
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden lg:inline-flex">Search...</span>
|
||||
<span className="inline-flex lg:hidden">Search...</span>
|
||||
<kbd className="pointer-events-none absolute right-[0.3rem] top-[0.3rem] hidden h-5 select-none items-center gap-1 rounded border bg-surface-200 px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex text-foreground-light">
|
||||
<span className="text-sm">⌘</span>K
|
||||
</kbd>
|
||||
</Button>
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput_Shadcn_ placeholder="Type a command or search..." />
|
||||
<CommandList_Shadcn_>
|
||||
<CommandEmpty_Shadcn_>No results found.</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_ key="pages" heading="Pages">
|
||||
{COMMAND_ITEMS.map((navItem) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={navItem.href}
|
||||
value={navItem.label}
|
||||
onSelect={() => runCommand(() => router.push(navItem.href as string))}
|
||||
>
|
||||
<div className="mr-2 flex h-4 w-4 items-center justify-center">
|
||||
<CircleIcon className="h-3 w-3" strokeWidth={1} />
|
||||
</div>
|
||||
{navItem.label}
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
</CommandGroup_Shadcn_>
|
||||
<CommandSeparator_Shadcn_ />
|
||||
<CommandGroup_Shadcn_ heading="Theme">
|
||||
<CommandItem_Shadcn_ onSelect={() => runCommand(() => setTheme('light'))}>
|
||||
<SunIcon className="mr-2 h-4 w-4" strokeWidth={1} />
|
||||
Light
|
||||
</CommandItem_Shadcn_>
|
||||
<CommandItem_Shadcn_ onSelect={() => runCommand(() => setTheme('dark'))}>
|
||||
<MoonIcon className="mr-2 h-4 w-4" strokeWidth={1} />
|
||||
Dark
|
||||
</CommandItem_Shadcn_>
|
||||
<CommandItem_Shadcn_ onSelect={() => runCommand(() => setTheme('classic-dark'))}>
|
||||
<MoonIcon className="mr-2 h-4 w-4" strokeWidth={1} />
|
||||
Classic dark
|
||||
</CommandItem_Shadcn_>
|
||||
<CommandItem_Shadcn_ onSelect={() => runCommand(() => setTheme('system'))}>
|
||||
<LaptopIcon className="mr-2 h-4 w-4" strokeWidth={1} />
|
||||
System
|
||||
</CommandItem_Shadcn_>
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</CommandDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
|
||||
import { CommandCopyButton } from './command-copy-button'
|
||||
import { useLocalStorage } from './use-local-storage'
|
||||
|
||||
interface CommandCopyProps {
|
||||
name: string
|
||||
highlight?: boolean
|
||||
// For Vue, we need to use the `shadcn-vue` package instead of `shadcn`
|
||||
framework?: 'react' | 'vue'
|
||||
}
|
||||
|
||||
type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'package-manager-copy-command'
|
||||
|
||||
const getBaseUrl = () => {
|
||||
if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'production') {
|
||||
// we have a special alias for the production environment, added in https://github.com/shadcn-ui/ui/pull/8161
|
||||
return `@supabase`
|
||||
} else if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'preview') {
|
||||
return `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
|
||||
} else {
|
||||
return 'http://localhost:3004'
|
||||
}
|
||||
}
|
||||
|
||||
const getComponentPath = (name: string) => {
|
||||
if (process.env.NEXT_PUBLIC_VERCEL_TARGET_ENV === 'production') {
|
||||
return `/${name}`
|
||||
} else {
|
||||
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/r/${name}.json`
|
||||
}
|
||||
}
|
||||
|
||||
export function Command({ name, highlight, framework = 'react' }: CommandCopyProps) {
|
||||
const [value, setValue] = useLocalStorage(LOCAL_STORAGE_KEY, 'npm')
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const componentPath = getComponentPath(name)
|
||||
|
||||
const commands: Record<PackageManager, string> =
|
||||
framework === 'vue'
|
||||
? {
|
||||
npm: `npx shadcn-vue@latest add ${baseUrl}${componentPath}`,
|
||||
pnpm: `pnpm dlx shadcn-vue@latest add ${baseUrl}${componentPath}`,
|
||||
yarn: `yarn dlx shadcn-vue@latest add ${baseUrl}${componentPath}`,
|
||||
bun: `bunx --bun shadcn-vue@latest add ${baseUrl}${componentPath}`,
|
||||
}
|
||||
: {
|
||||
npm: `npx shadcn@latest add ${baseUrl}${componentPath}`,
|
||||
pnpm: `pnpm dlx shadcn@latest add ${baseUrl}${componentPath}`,
|
||||
yarn: `yarn dlx shadcn@latest add ${baseUrl}${componentPath}`,
|
||||
bun: `bunx --bun shadcn@latest add ${baseUrl}${componentPath}`,
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs_Shadcn_ value={value} onValueChange={setValue} className="w-full">
|
||||
<div className="w-full group relative rounded-lg bg-surface-200 dark:bg-surface-100 px-4 py-2 overflow-hidden">
|
||||
{highlight && (
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-l from-transparent via-[#bbb] dark:via-white to-transparent opacity-10 z-0"
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: '-100%' }}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 2.5,
|
||||
ease: 'linear',
|
||||
repeatType: 'loop',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<TabsList_Shadcn_ className="gap-2 relative mb-2 z-10">
|
||||
{(Object.keys(commands) as PackageManager[]).map((manager) => (
|
||||
<TabsTrigger_Shadcn_ key={manager} value={manager} className="text-xs">
|
||||
{manager}
|
||||
</TabsTrigger_Shadcn_>
|
||||
))}
|
||||
</TabsList_Shadcn_>
|
||||
|
||||
{(Object.keys(commands) as PackageManager[]).map((manager) => (
|
||||
<TabsContent_Shadcn_ key={manager} value={manager} className="m-0">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1 font-mono text-sm text-foreground relative z-10">
|
||||
<span className="mr-2 text-[#888] select-none">$</span>
|
||||
{commands[manager]}
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<CommandCopyButton command={commands[manager]} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent_Shadcn_>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button, cn, copyToClipboard } from 'ui'
|
||||
|
||||
interface CopyButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
value: string
|
||||
src?: string
|
||||
}
|
||||
|
||||
export function CopyButton({ value, className, src, ...props }: CopyButtonProps) {
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setHasCopied(false)
|
||||
}, 2000)
|
||||
}, [hasCopied])
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
type="outline"
|
||||
className={cn(
|
||||
'relative z-10 h-6 w-6 text-foreground-muted hover:bg-surface-100 hover:text-foreground p-0',
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
copyToClipboard(value)
|
||||
setHasCopied(true)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? <Check className="h-3 w-3 text-brand-600" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BookA, Clock, GraduationCap } from 'lucide-react'
|
||||
|
||||
interface CourseHeroProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
description: string
|
||||
stats?: {
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
accent: string
|
||||
}[]
|
||||
instructors?: {
|
||||
name: string
|
||||
icon: React.ReactNode
|
||||
accent: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export function CourseHero({ title, subtitle, description, instructors }: CourseHeroProps) {
|
||||
return (
|
||||
<div className="relative w-full mx-auto py-16 sm:py-24 border-b bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.14),_transparent_55%),radial-gradient(circle_at_bottom,_rgba(34,197,94,0.10),_transparent_50%)]">
|
||||
{/* Chapter label */}
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-muted border border-border text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
Chapter Introduction
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold mb-6 text-balance text-center bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<p className="text-lg sm:text-xl text-center text-muted-foreground mb-4 text-balance max-w-3xl mx-auto">
|
||||
Learn the foundations of Supabase, the Postgres development platform.
|
||||
</p>
|
||||
|
||||
<p className="text-sm sm:text-base text-center text-muted-foreground/80 text-pretty max-w-2xl mx-auto">
|
||||
In this short course, you'll explore how Supabase brings together Database, Auth,
|
||||
Storage, Edge Functions, and Realtime into a unified developer platform.
|
||||
</p>
|
||||
|
||||
{/* Course metadata */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-6 mt-10 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<BookA className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<span className="font-medium text-muted-foreground">5 Chapters</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<Clock className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<span className="font-medium text-muted-foreground">~1 hour</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<GraduationCap className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<span className="font-medium text-muted-foreground">Beginner</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Link from 'next/link'
|
||||
import { BookOpen, Code, Users, ExternalLink, BookA } from 'lucide-react'
|
||||
import { IconYoutubeSolid } from 'ui'
|
||||
|
||||
interface ExploreItem {
|
||||
title: string
|
||||
link: string
|
||||
itemType?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface ExploreMoreProps {
|
||||
items: ExploreItem[]
|
||||
}
|
||||
|
||||
// Icon mapping based on itemType
|
||||
function getIcon(itemType?: string) {
|
||||
if (!itemType) return BookOpen
|
||||
const lowerType = itemType.toLowerCase()
|
||||
if (lowerType === 'doc' || lowerType === 'documentation') {
|
||||
return BookA
|
||||
}
|
||||
if (lowerType === 'reference' || lowerType === 'api') {
|
||||
return Code
|
||||
}
|
||||
if (lowerType === 'community' || lowerType === 'forum') {
|
||||
return Users
|
||||
}
|
||||
if (lowerType === 'video' || lowerType === 'tutorial') {
|
||||
return IconYoutubeSolid
|
||||
}
|
||||
return BookA // default icon
|
||||
}
|
||||
|
||||
// Default descriptions based on itemType
|
||||
function getDescription(itemType?: string): string {
|
||||
if (!itemType) return 'Learn more about this topic'
|
||||
const lowerType = itemType.toLowerCase()
|
||||
if (lowerType === 'doc' || lowerType === 'documentation') {
|
||||
return 'Complete guides and references'
|
||||
}
|
||||
if (lowerType === 'reference' || lowerType === 'api') {
|
||||
return 'Detailed API documentation'
|
||||
}
|
||||
if (lowerType === 'community' || lowerType === 'forum') {
|
||||
return 'Get help from the community'
|
||||
}
|
||||
if (lowerType === 'video' || lowerType === 'tutorial') {
|
||||
return 'Watch video tutorials and guides'
|
||||
}
|
||||
return 'Learn more about this topic'
|
||||
}
|
||||
|
||||
export function ExploreMore({ items }: ExploreMoreProps) {
|
||||
if (!items || items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-16 mb-8">
|
||||
<div className="border-t border-border pt-8">
|
||||
<h3 className="text-sm font-semibold text-foreground-lighter uppercase tracking-wider mb-6">
|
||||
Explore More
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((item, index) => {
|
||||
const isExternal = item.link.startsWith('http://') || item.link.startsWith('https://')
|
||||
const Icon = getIcon(item.itemType)
|
||||
const description = item.description || getDescription(item.itemType)
|
||||
|
||||
const cardContent = (
|
||||
<div className="relative bg-background border border-border rounded-lg shadow-sm p-6 h-full hover:shadow-md transition-shadow group">
|
||||
<div className="absolute top-4 right-4">
|
||||
<ExternalLink className="h-4 w-4 text-foreground-lighter opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Icon className="h-6 w-6 text-foreground" />
|
||||
<div>
|
||||
<h4 className="font-bold text-foreground mb-1">{item.title}</h4>
|
||||
<p className="text-sm text-foreground-light">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
{cardContent}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link key={index} href={item.link} className="block">
|
||||
{cardContent}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useMDXComponent } from 'next-contentlayer2/hooks'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {
|
||||
Accordion_Shadcn_ as Accordion,
|
||||
AccordionContent_Shadcn_ as AccordionContent,
|
||||
AccordionItem_Shadcn_ as AccordionItem,
|
||||
AccordionTrigger_Shadcn_ as AccordionTrigger,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { Callout } from './callout'
|
||||
import { CopyButton } from './copy-button'
|
||||
import TanStackBeta from './tanstack-beta'
|
||||
|
||||
const components = {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
h1: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className={cn('font-heading mt-2 scroll-m-20 text-4xl font-bold', className)} {...props} />
|
||||
),
|
||||
h2: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h2
|
||||
className={cn(
|
||||
'font-heading mt-12 scroll-m-20 text-2xl font-medium tracking-tight first:mt-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3
|
||||
className={cn('font-heading mt-8 scroll-m-20 text-xl tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h4: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h4
|
||||
className={cn('font-heading mt-8 scroll-m-20 text-lg tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h5: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h5 className={cn('mt-8 scroll-m-20 text-lg tracking-tight', className)} {...props} />
|
||||
),
|
||||
h6: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h6 className={cn('mt-8 scroll-m-20 text-base tracking-tight', className)} {...props} />
|
||||
),
|
||||
a: ({ className, ...props }: React.HTMLAttributes<HTMLAnchorElement>) => (
|
||||
<a
|
||||
className={cn(
|
||||
'text-foreground underline decoration-1 decoration-foreground-muted underline-offset-4 transition-colors hover:decoration-brand hover:decoration-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p
|
||||
className={cn('leading-7 [&:not(:first-child)]:mt-6 text-foreground-light', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ className, ...props }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul className={cn('my-6 ml-6 list-disc text-foreground-light', className)} {...props} />
|
||||
),
|
||||
ol: ({ className, ...props }: React.HTMLAttributes<HTMLOListElement>) => (
|
||||
<ol className={cn('my-6 ml-6 list-decimal text-foreground-light', className)} {...props} />
|
||||
),
|
||||
li: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
|
||||
<li className={cn('mt-2', className)} {...props} />
|
||||
),
|
||||
blockquote: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
|
||||
<blockquote className={cn('mt-6 border-l-2 pl-6 italic', className)} {...props} />
|
||||
),
|
||||
img: ({ className, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className={cn('rounded-md', className)} alt={alt} {...props} />
|
||||
),
|
||||
hr: ({ ...props }: React.HTMLAttributes<HTMLHRElement>) => (
|
||||
<hr className="my-4 md:my-8" {...props} />
|
||||
),
|
||||
table: ({ className, ...props }: React.HTMLAttributes<HTMLTableElement>) => (
|
||||
<div className="my-6 w-full overflow-y-auto">
|
||||
<table className={cn('w-full', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
tr: ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
|
||||
<tr className={cn('m-0 border-t p-0 even:bg-surface-75/75', className)} {...props} />
|
||||
),
|
||||
th: ({ className, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th
|
||||
className={cn(
|
||||
'border px-4 py-2 text-left font-normal [&[align=center]]:text-center [&[align=right]]:text-right',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ className, ...props }: React.HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td
|
||||
className={cn(
|
||||
'border text-foreground-light px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
pre: ({
|
||||
className,
|
||||
__rawString__,
|
||||
__withMeta__,
|
||||
__src__,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLPreElement> & {
|
||||
__rawString__?: string
|
||||
__withMeta__?: boolean
|
||||
__src__?: string
|
||||
}) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<pre
|
||||
className={cn(
|
||||
'mb-4 mt-6 max-h-[650px] overflow-x-auto rounded-lg border bg-surface-75/75 py-4 text-foreground-light',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{__rawString__ && (
|
||||
<CopyButton
|
||||
value={__rawString__}
|
||||
src={__src__}
|
||||
className={cn('absolute right-4 top-4', __withMeta__ && 'top-16')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
code: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
|
||||
<code
|
||||
className={cn(
|
||||
'relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
Callout,
|
||||
CopyButton,
|
||||
TanStackBeta,
|
||||
Card: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full flex-col items-center rounded-xl border bg-surface-100 text-card-background py-6 px-4 shadow transition-colors hover:bg-muted/50 sm:p-10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
LinkedCard: ({ className, ...props }: React.ComponentProps<typeof Link>) => (
|
||||
<Link
|
||||
className={cn(
|
||||
'flex w-full flex-col items-center justify-center rounded-xl border bg-surface-100 text-card-background py-6 px-4 shadow transition-colors hover:bg-muted/50 sm:p-10 h-52',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
interface MdxProps {
|
||||
code: string
|
||||
}
|
||||
|
||||
export function Mdx({ code }: MdxProps) {
|
||||
const Component = useMDXComponent(code, {
|
||||
style: 'default',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mdx">
|
||||
<Component components={components} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useMobileMenu } from '@/hooks/use-mobile-menu'
|
||||
import { Sheet } from 'ui'
|
||||
|
||||
interface MobileMenuSheetProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function MobileMenuSheet({ children }: MobileMenuSheetProps) {
|
||||
const { open, setOpen } = useMobileMenu()
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
{children}
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from 'next/link'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
interface NextUpProps {
|
||||
title: string
|
||||
description: string
|
||||
href: string
|
||||
chapterNumber?: number
|
||||
}
|
||||
|
||||
export function NextUp({ title, description, href, chapterNumber }: NextUpProps) {
|
||||
return (
|
||||
<div className="mt-16 mb-8 grow">
|
||||
<div className="bg-background border border-border rounded-lg shadow-sm p-8 max-w-2xl">
|
||||
<div className="grid gap-4">
|
||||
<p className="text-sm text-foreground-lighter">Next Up</p>
|
||||
<h2 className="text-xl font-bold text-foreground">
|
||||
{chapterNumber && <span>{chapterNumber}: </span>}
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-foreground text-base">{description}</p>
|
||||
<div className="mt-2">
|
||||
<Link href={href}>
|
||||
<Button
|
||||
type="default"
|
||||
iconRight={<ArrowRight className="h-4 w-4" />}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Start {chapterNumber ? `Chapter ${chapterNumber}` : 'Next'}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import Link, { LinkProps } from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { ChevronRight, Lock } from 'lucide-react'
|
||||
|
||||
import { useMobileMenu } from '@/hooks/use-mobile-menu'
|
||||
import { SidebarNavItem } from '@/types/nav'
|
||||
import { Badge, cn } from 'ui'
|
||||
|
||||
// We extend:
|
||||
// 1. LinkProps - for Next.js Link component props (prefetch, etc)
|
||||
// 2. AnchorHTMLAttributes - for standard HTML anchor props (className, etc)
|
||||
// We omit href from both since we compute it internally from item.href
|
||||
interface NavigationItemProps
|
||||
extends Omit<LinkProps, 'href'>,
|
||||
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
||||
item: SidebarNavItem
|
||||
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void
|
||||
level?: number
|
||||
internalPaths?: string[]
|
||||
isLoggedIn?: boolean
|
||||
}
|
||||
|
||||
const NavigationItem: React.FC<NavigationItemProps> = ({
|
||||
item,
|
||||
onClick,
|
||||
level = 0,
|
||||
internalPaths,
|
||||
isLoggedIn = true,
|
||||
...props
|
||||
}) => {
|
||||
const { setOpen } = useMobileMenu()
|
||||
const pathname = usePathname()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const pathParts = pathname.split('/')
|
||||
const slug = pathParts[pathParts.length - 1]
|
||||
|
||||
const hasChildren = item.items && item.items.length > 0
|
||||
|
||||
// Check if internal content exists for this item and user is logged in
|
||||
const hasInternal = item.href && internalPaths?.includes(item.href) && isLoggedIn
|
||||
|
||||
// Auto-expand if any child is active
|
||||
useEffect(() => {
|
||||
if (hasChildren) {
|
||||
const hasActiveChild = item.items?.some((child) => {
|
||||
return pathname === child.href
|
||||
})
|
||||
if (hasActiveChild) {
|
||||
setIsOpen(true)
|
||||
}
|
||||
}
|
||||
}, [hasChildren, item.items, pathname])
|
||||
|
||||
// Use item.href if available, otherwise build from slug
|
||||
let href = item.href
|
||||
if (!href && slug) {
|
||||
href = `/docs/${slug}`
|
||||
}
|
||||
|
||||
// Determine if this link represents the current page
|
||||
const isActive = pathname === href
|
||||
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
// Close the mobile menu when navigating
|
||||
setOpen(false)
|
||||
|
||||
// Call the onClick prop if it exists
|
||||
if (onClick) {
|
||||
onClick(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
|
||||
const itemClasses = cn(
|
||||
'flex text-sm rounded-md transition-colors',
|
||||
level === 0 ? 'px-3 py-2' : 'px-3 py-1.5',
|
||||
isActive
|
||||
? 'bg-surface-200 text-foreground'
|
||||
: hasChildren && isOpen
|
||||
? 'bg-surface-100 text-foreground'
|
||||
: 'text-foreground-lighter hover:bg-surface-100 hover:text-foreground'
|
||||
)
|
||||
|
||||
return (
|
||||
<li>
|
||||
{hasChildren ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleButtonClick}
|
||||
className={cn('w-full flex items-center justify-between gap-2 zans', itemClasses)}
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{item.title}
|
||||
{item.new && (
|
||||
<Badge variant="default" className="capitalize flex-shrink-0">
|
||||
NEW
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 transition-transform flex-shrink-0',
|
||||
isOpen && 'rotate-90',
|
||||
(hasChildren && isOpen) || isActive ? 'text-foreground' : 'text-foreground-lighter'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul className="mt-1 ml-3 space-y-1 border-l border-border pl-3">
|
||||
{item.items?.map((childItem, i) => (
|
||||
<NavigationItem
|
||||
item={childItem}
|
||||
key={`${childItem.href}-${i}`}
|
||||
level={level + 1}
|
||||
internalPaths={internalPaths}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href={href || '#'} {...props} onClick={handleLinkClick} className={itemClasses}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate">{item.title}</span>
|
||||
{item.new && (
|
||||
<Badge variant="default" className="capitalize flex-shrink-0">
|
||||
NEW
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
{hasInternal && (
|
||||
<Link
|
||||
href={`/internal${href}`}
|
||||
onClick={handleLinkClick}
|
||||
className={cn(
|
||||
'flex text-sm rounded-md transition-colors mt-1',
|
||||
level === 0 ? 'px-3 py-2' : 'px-3 py-1.5',
|
||||
pathname === `/internal${href}`
|
||||
? 'bg-surface-200 text-foreground'
|
||||
: 'text-foreground-lighter hover:bg-surface-100 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Lock className="w-3 h-3 text-foreground-muted flex-shrink-0" />
|
||||
<span className="truncate">{item.title} (Internal)</span>
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
NavigationItem.displayName = 'NavigationItem'
|
||||
|
||||
export default NavigationItem
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useIsLoggedIn, useUser, logOut } from 'common'
|
||||
import { AuthenticatedDropdownMenu, type menuItem } from 'ui-patterns'
|
||||
import { LogOut, Settings, UserIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import NavigationItem from '@/components/side-navigation-item'
|
||||
import { courses } from '@/config/docs'
|
||||
import { mergeInternalContentIntoSections } from '@/lib/merge-internal-content'
|
||||
import { SidebarNavItem } from '@/types/nav'
|
||||
import { CommandMenu } from './command-menu'
|
||||
import { ThemeSwitcherDropdown } from './theme-switcher-dropdown'
|
||||
|
||||
interface SideNavigationProps {
|
||||
internalPaths: string[]
|
||||
}
|
||||
|
||||
function SideNavigation({ internalPaths }: SideNavigationProps) {
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
const user = useUser()
|
||||
const router = useRouter()
|
||||
|
||||
// First, merge orphaned internal content into their respective sections
|
||||
// Note: All content is visible to everyone. Auth is only for saving progress.
|
||||
const coursesWithInternalContent = {
|
||||
...courses,
|
||||
items: mergeInternalContentIntoSections(courses.items, internalPaths),
|
||||
}
|
||||
|
||||
// User menu for authenticated dropdown
|
||||
const userMenu: menuItem[][] = [
|
||||
[
|
||||
{
|
||||
label: user?.email ?? "You're logged in",
|
||||
type: 'text',
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
label: 'Account Preferences',
|
||||
icon: Settings,
|
||||
href: 'https://supabase.com/dashboard/account/me',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Logout',
|
||||
type: 'button',
|
||||
icon: LogOut,
|
||||
onClick: async () => {
|
||||
await logOut()
|
||||
router.refresh()
|
||||
},
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col h-full min-w-[220px]">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="p-6 flex-shrink-0">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<Link href="/">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="109"
|
||||
height="113"
|
||||
viewBox="0 0 109 113"
|
||||
fill="none"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0625L99.1935 40.0625C107.384 40.0625 111.952 49.5226 106.859 55.9372L63.7076 110.284Z"
|
||||
fill="url(#paint0_linear)"
|
||||
/>
|
||||
<path
|
||||
d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0625L99.1935 40.0625C107.384 40.0625 111.952 49.5226 106.859 55.9372L63.7076 110.284Z"
|
||||
fill="url(#paint1_linear)"
|
||||
fillOpacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z"
|
||||
fill="#3ECF8E"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear"
|
||||
x1="53.9738"
|
||||
y1="54.9738"
|
||||
x2="94.1635"
|
||||
y2="71.8293"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#249361" />
|
||||
<stop offset="1" stopColor="#3ECF8E" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear"
|
||||
x1="36.1558"
|
||||
y1="30.5779"
|
||||
x2="54.4844"
|
||||
y2="65.0804"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</Link>
|
||||
<ThemeSwitcherDropdown />
|
||||
</div>
|
||||
<Link href="/" className="mb-4 block">
|
||||
<h1>Learn Supabase</h1>
|
||||
</Link>
|
||||
{/* <TopNavigationSearch /> */}
|
||||
<CommandMenu />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="pb-6 px-6">
|
||||
<h3 className="text-xs font-semibold text-foreground-lighter/75 uppercase tracking-wider mb-3">
|
||||
{courses.title}
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{coursesWithInternalContent.items.map((item: SidebarNavItem, i: number) => (
|
||||
<NavigationItem
|
||||
item={item}
|
||||
key={`${item.href}-${i}`}
|
||||
internalPaths={internalPaths}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-muted/50 p-4 flex-shrink-0">
|
||||
{isLoggedIn ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<AuthenticatedDropdownMenu menu={userMenu} user={user} site="docs" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="https://supabase.com/dashboard/sign-in"
|
||||
className="text-sm text-foreground-light hover:text-foreground transition-colors underline decoration-1 underline-offset-4"
|
||||
>
|
||||
Sign in to save your progress
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default SideNavigation
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Menu } from 'lucide-react'
|
||||
|
||||
import SideNavigation from '@/components/side-navigation'
|
||||
import { getInternalContentPaths } from '@/lib/get-internal-content'
|
||||
import { Button, ScrollArea, SheetContent, SheetTrigger } from 'ui'
|
||||
import { MobileMenuSheet } from './mobile-menu-sheet'
|
||||
import { ThemeSwitcherDropdown } from './theme-switcher-dropdown'
|
||||
|
||||
export function Sidebar() {
|
||||
// Get internal content paths on server side
|
||||
const internalPaths = Array.from(getInternalContentPaths())
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-50 bg-background justify-between flex items-center px-8 py-3 border-b">
|
||||
<MobileMenuSheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button type="outline" icon={<Menu />} />
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-80" showClose={false}>
|
||||
<ScrollArea className="h-full">
|
||||
<SideNavigation internalPaths={internalPaths} />
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</MobileMenuSheet>
|
||||
<ThemeSwitcherDropdown />
|
||||
</div>
|
||||
|
||||
<aside className="fixed z-30 top-0 hidden h-screen w-full shrink-0 md:sticky md:block bg-200 border-r border-muted/50">
|
||||
<ScrollArea className="h-full">
|
||||
<SideNavigation internalPaths={internalPaths} />
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="py-6 px-4 md:px-8 md:py-0 mx-auto w-full max-w-site">
|
||||
<div className="flex flex-col items-center justify-between gap-4 md:h-24 md:flex-row">
|
||||
<p className="text-balance text-center text-sm leading-loose text-foreground-muted md:text-left">
|
||||
Built by{' '}
|
||||
<a
|
||||
href="https://twitter.com/supabase"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4 hover:text-foreground-lighter"
|
||||
>
|
||||
Supabase
|
||||
</a>
|
||||
. The source code is available on{' '}
|
||||
<a
|
||||
href="https://github.com/supabase/supabase/tree/master/apps/ui-library"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4 hover:text-foreground-lighter"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import { Callout } from './callout'
|
||||
|
||||
export default function TanStackBeta() {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Callout>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<TriangleAlert />
|
||||
Heads up: TanStack Start is in beta.
|
||||
</div>
|
||||
We're excited to support TanStack Start in our UI library! But since it's still
|
||||
in beta, things may change quickly — expect breaking changes and some rough edges.
|
||||
We'll do our best to keep up and make integration with Supabase as smooth as
|
||||
possible. If you run into issues, have a look at the TanStack docs.
|
||||
<a
|
||||
href="https://tanstack.com/start/latest/docs/framework/react/quick-start"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground underline decoration-1 decoration-foreground-muted underline-offset-4 transition-colors hover:decoration-brand hover:decoration-2"
|
||||
>
|
||||
TanStack Quickstart guide
|
||||
</a>
|
||||
</div>
|
||||
</Callout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
RadioGroup_Shadcn_,
|
||||
Theme,
|
||||
singleThemes,
|
||||
} from 'ui'
|
||||
|
||||
const ThemeSwitcherDropdown = () => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme, setTheme, resolvedTheme } = useTheme()
|
||||
|
||||
/**
|
||||
* Avoid Hydration Mismatch
|
||||
* https://github.com/pacocoursey/next-themes?tab=readme-ov-file#avoid-hydration-mismatch
|
||||
*/
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
function SingleThemeSelection() {
|
||||
return (
|
||||
<form>
|
||||
<RadioGroup_Shadcn_
|
||||
name="theme"
|
||||
onValueChange={setTheme}
|
||||
aria-label="Choose a theme"
|
||||
defaultValue={theme}
|
||||
value={theme}
|
||||
className="flex flex-wrap gap-3"
|
||||
></RadioGroup_Shadcn_>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const iconClasses = 'text-foreground-light group-data-[state=open]:text-foreground'
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
className="px-1 group"
|
||||
icon={
|
||||
resolvedTheme?.includes('light') ? (
|
||||
<Sun className={iconClasses} />
|
||||
) : (
|
||||
<Moon className={iconClasses} />
|
||||
)
|
||||
}
|
||||
></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={theme}
|
||||
onValueChange={(themeValue) => setTheme(themeValue)}
|
||||
>
|
||||
{singleThemes.map((theme: Theme) => (
|
||||
<DropdownMenuRadioItem key={theme.value} value={theme.value}>
|
||||
{theme.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { ThemeSwitcherDropdown }
|
||||
@@ -0,0 +1,107 @@
|
||||
// @ts-nocheck
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { TableOfContents } from '@/lib/toc'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMounted } from '@/hooks/use-mounted'
|
||||
|
||||
interface TocProps {
|
||||
toc: TableOfContents
|
||||
}
|
||||
|
||||
export function DashboardTableOfContents({ toc }: TocProps) {
|
||||
const itemIds = React.useMemo(
|
||||
() =>
|
||||
toc.items
|
||||
? toc.items
|
||||
.flatMap((item) => [item.url, item?.items?.map((item) => item.url)])
|
||||
.flat()
|
||||
.filter(Boolean)
|
||||
.map((id) => id?.split('#')[1])
|
||||
: [],
|
||||
[toc]
|
||||
)
|
||||
const activeHeading = useActiveItem(itemIds)
|
||||
const mounted = useMounted()
|
||||
|
||||
if (!toc?.items || !mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-foreground-light">On This Page</p>
|
||||
<Tree tree={toc} activeItem={activeHeading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useActiveItem(itemIds: string[]) {
|
||||
const [activeId, setActiveId] = React.useState(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(entry.target.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ rootMargin: `0% 0% -80% 0%` }
|
||||
)
|
||||
|
||||
itemIds?.forEach((id) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
observer.observe(element)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
itemIds?.forEach((id) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
observer.unobserve(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [itemIds])
|
||||
|
||||
return activeId
|
||||
}
|
||||
|
||||
interface TreeProps {
|
||||
tree: TableOfContents
|
||||
level?: number
|
||||
activeItem?: string
|
||||
}
|
||||
|
||||
function Tree({ tree, level = 1, activeItem }: TreeProps) {
|
||||
return tree?.items?.length && level < 3 ? (
|
||||
<ul className={cn('m-0 list-none', { 'pl-4': level !== 1 })}>
|
||||
{tree.items.map((item, index) => {
|
||||
return (
|
||||
<li key={index} className={cn('mt-0 pt-2')}>
|
||||
<a
|
||||
href={item.url}
|
||||
className={cn(
|
||||
'inline-block no-underline transition-colors hover:text-foreground',
|
||||
item.url === `#${activeItem}`
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
{item.items?.length ? (
|
||||
<Tree tree={item} level={level + 1} activeItem={activeItem} />
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Reference: https://usehooks.com/useLocalStorage/
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
// State to store our value
|
||||
// Pass initial state function to useState so logic is only executed once
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return initialValue
|
||||
}
|
||||
|
||||
try {
|
||||
// Get from local storage by key
|
||||
const item = window.localStorage.getItem(key)
|
||||
// Parse stored json or if none return initialValue
|
||||
return item ? JSON.parse(item) : initialValue
|
||||
} catch (error) {
|
||||
// If error also return initialValue
|
||||
console.log(error)
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
// Return a wrapped version of useState's setter function that ...
|
||||
// ... persists the new value to localStorage.
|
||||
const setValue = useCallback(
|
||||
(value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||
// Save state
|
||||
setStoredValue(valueToStore)
|
||||
// Save to local storage
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||
}
|
||||
} catch (error) {
|
||||
// A more advanced implementation would handle the error case
|
||||
console.log(error)
|
||||
}
|
||||
},
|
||||
[key, storedValue]
|
||||
)
|
||||
|
||||
return [storedValue, setValue] as const
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
BarChart3,
|
||||
CalendarRange,
|
||||
Database,
|
||||
History,
|
||||
Lock,
|
||||
Network,
|
||||
Rocket,
|
||||
Timer,
|
||||
} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from 'ui'
|
||||
|
||||
const learningTracks = [
|
||||
{
|
||||
icon: Database,
|
||||
title: 'Model and manage your data',
|
||||
description: 'Design relational schemas, automate migrations, and keep your project tidy.',
|
||||
takeaways: [
|
||||
'set up tables, relationships, and seed data',
|
||||
'work with realtime subscriptions and triggers',
|
||||
'version and deploy database changes safely',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
title: 'Secure every request',
|
||||
description: 'Master authentication and Row Level Security to keep data scoped correctly.',
|
||||
takeaways: [
|
||||
'configure supabase auth providers',
|
||||
'write row level security policies with confidence',
|
||||
'audit, monitor, and debug access issues fast',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Network,
|
||||
title: 'Go realtime by default',
|
||||
description: 'Stream updates into your UI with channels, presence, and edge functions.',
|
||||
takeaways: [
|
||||
'broadcast updates with realtime channels',
|
||||
'listen for database changes from the client',
|
||||
'connect edge functions to your realtime flows',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: Rocket,
|
||||
title: 'Deploy production-grade apps',
|
||||
description: 'Integrate storage, vector search, and observability for resilient launches.',
|
||||
takeaways: [
|
||||
'serve files securely from supabase storage',
|
||||
'index and query embeddings for ai features',
|
||||
'monitor performance and scale without surprises',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function WhatWillILearn() {
|
||||
return (
|
||||
<section className="grid gap-8">
|
||||
<div className="grid gap-2 max-w-2xl">
|
||||
<h3 className="text-2xl font-bold mb-4">What will I learn?</h3>
|
||||
|
||||
<p className="text-base text-foreground-light">
|
||||
In the fundamental courses, you'll learn all the basics:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{learningTracks.map((track) => {
|
||||
const Icon = track.icon
|
||||
return (
|
||||
<Card key={track.title} className="h-full border-border/60">
|
||||
<CardHeader className="grid gap-2 border-b border-border/40">
|
||||
<Icon className="h-5 w-5 text-brand-500" />
|
||||
<CardTitle className="text-base font-semibold text-foreground">
|
||||
{track.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-foreground-light">
|
||||
{track.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h4 className="text-lg font-bold mb-2">Smart Office project</h4>
|
||||
<p className="text-base text-foreground-light">
|
||||
In the Smart Office project, you'll learn how to build a full-featured Dashboard:
|
||||
</p>
|
||||
<div className="grid grid-cols-12 gap-12 mt-6">
|
||||
<div className="col-span-6">
|
||||
<Image
|
||||
src={'/img/smart-office.png'}
|
||||
alt="Smart Office project dashboard screenshot"
|
||||
className="w-full h-auto"
|
||||
width={1000}
|
||||
height={875}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-6">
|
||||
<ul className="grid gap-6 text-foreground-light ">
|
||||
<li className="grid grid-cols-[auto,1fr] gap-3 border-b border-border/40 pb-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-brand-500/10 text-brand-500">
|
||||
<Timer className="h-5 w-5" />
|
||||
</span>
|
||||
<span>Real-time room monitoring that tracks live occupancy, sensor readings</span>
|
||||
</li>
|
||||
<li className="grid grid-cols-[auto,1fr] gap-3 border-b border-border/40 pb-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-brand-500/10 text-brand-500">
|
||||
<CalendarRange className="h-5 w-5" />
|
||||
</span>
|
||||
<span>Booking management with live status filters, calendar integrations.</span>
|
||||
</li>
|
||||
<li className="grid grid-cols-[auto,1fr] gap-3 border-b border-border/40 pb-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-brand-500/10 text-brand-500">
|
||||
<History className="h-5 w-5" />
|
||||
</span>
|
||||
<span>
|
||||
Advanced analytics that surface 30+ days of utilization trends and patterns.
|
||||
</span>
|
||||
</li>
|
||||
<li className="grid grid-cols-[auto,1fr] gap-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-md bg-brand-500/10 text-brand-500">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</span>
|
||||
<span>
|
||||
Detect capacity issues, open facilities tasks, and track resolution SLAs without
|
||||
leaving the app.
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { SidebarNavGroup } from 'types/nav'
|
||||
|
||||
export const courses: SidebarNavGroup = {
|
||||
title: 'Courses',
|
||||
items: [
|
||||
{
|
||||
title: 'Foundations',
|
||||
href: '/foundations',
|
||||
items: [
|
||||
// ✅ Always visible (no requiresAuth)
|
||||
{
|
||||
title: 'Introduction',
|
||||
href: '/foundations/introduction',
|
||||
},
|
||||
{
|
||||
title: 'Architecture',
|
||||
href: '/foundations/architecture',
|
||||
},
|
||||
{
|
||||
title: 'Data Fundamentals',
|
||||
href: '/foundations/data-fundamentals',
|
||||
},
|
||||
{
|
||||
title: 'Authentication',
|
||||
href: '/foundations/authentication',
|
||||
},
|
||||
{
|
||||
title: 'Security',
|
||||
href: '/foundations/security',
|
||||
},
|
||||
{
|
||||
title: 'Realtime',
|
||||
href: '/foundations/realtime',
|
||||
},
|
||||
{
|
||||
title: 'Storage',
|
||||
href: '/foundations/storage',
|
||||
},
|
||||
{
|
||||
title: 'Edge Functions',
|
||||
href: '/foundations/edge-functions',
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Vector Search',
|
||||
href: '/foundations/vector',
|
||||
},
|
||||
],
|
||||
commandItemLabel: 'Foundations',
|
||||
},
|
||||
{
|
||||
title: 'Project: Smart Office',
|
||||
href: '/projects/smart-office',
|
||||
commandItemLabel: 'Project: Smart Office',
|
||||
},
|
||||
{
|
||||
title: 'Performance & Scaling',
|
||||
href: '/internals/performance-scaling',
|
||||
commandItemLabel: 'Supabase Internals: Performance & Scaling',
|
||||
},
|
||||
{
|
||||
title: 'Debugging & Operations',
|
||||
href: '/internals/debugging-operations',
|
||||
commandItemLabel: 'Supabase Internals: Debugging & Operations',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const COMMAND_ITEMS = [
|
||||
...courses.items.map((item) => ({
|
||||
label: item.commandItemLabel,
|
||||
href: item.href,
|
||||
})),
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
title: Architecture
|
||||
description: Learn how Supabase's core products work together.
|
||||
chapterNumber: 1
|
||||
|
||||
explore:
|
||||
- title: Supabase Docs
|
||||
link: https://supabase.com/docs/guides/database/overview
|
||||
itemType: doc
|
||||
- title: Postgres for Beginners
|
||||
link: https://www.postgresqltutorial.com/
|
||||
itemType: doc
|
||||
---
|
||||
|
||||
This page explains how Supabase is structured and how its main services work
|
||||
together. Supabase is built on top of a powerful open source database called
|
||||
Postgres, but it includes much more than a database. Around Postgres,
|
||||
Supabase adds services for authentication, serverless functions, file storage, realtime updates, AI-powered search, and easy APIs to access it — all designed to work easily together.
|
||||
|
||||
The goal is to give you a complete backend that feels consistent and connected,
|
||||
so you can focus on building your app instead of wiring together separate tools.
|
||||
|
||||
---
|
||||
|
||||
## The mental model
|
||||
|
||||
At the center of Supabase is Postgres. Every other part connects to it.
|
||||
|
||||
```text
|
||||
Clients (web, mobile, scripts)
|
||||
|
|
||||
v
|
||||
API layer (REST via PostgREST, GraphQL)
|
||||
|
|
||||
v
|
||||
Postgres <---- Realtime (listens for changes)
|
||||
| ^
|
||||
| |
|
||||
v |
|
||||
Storage | Auth (manages users)
|
||||
| | Edge Functions (runs code)
|
||||
v | Vector Search (pgvector)
|
||||
Files + URLs Studio, CLI, Policies (RLS)
|
||||
```
|
||||
|
||||
You interact with Supabase mainly through the API layer or the client libraries.
|
||||
The API talks to Postgres, and Postgres enforces security rules that you define.
|
||||
All the other services stay in sync because they share the same database.
|
||||
|
||||
---
|
||||
|
||||
## The main components
|
||||
|
||||
### Database (Postgres)
|
||||
|
||||
Your data lives in Postgres tables. You can design tables, columns, and
|
||||
relationships using SQL or the visual editor in Supabase Studio. Supabase
|
||||
manages backups, scaling, and migrations for you.
|
||||
|
||||
**Why this matters:** Postgres is a full relational database with features like
|
||||
foreign keys, indexes, and extensions. Supabase gives you these tools without
|
||||
the usual setup.
|
||||
|
||||
---
|
||||
|
||||
### API layer (REST and GraphQL)
|
||||
|
||||
Supabase automatically exposes your tables as APIs using PostgREST (for REST)
|
||||
and PostGraphile (for GraphQL). You can read, insert, update, and delete rows
|
||||
with simple HTTP requests. You can also expose your own SQL functions as custom
|
||||
endpoints.
|
||||
|
||||
**Why this matters:** You can connect any frontend or script directly to your
|
||||
database without building a separate API server.
|
||||
|
||||
---
|
||||
|
||||
### Auth (users and tokens)
|
||||
|
||||
Auth manages users and issues tokens that identify them. When someone signs in,
|
||||
their token is sent with every request, and Postgres uses that identity to apply
|
||||
the right access rules.
|
||||
|
||||
**Why this matters:** Authentication is built into your database layer, so you
|
||||
don't need an external service to handle sign-ups or sessions.
|
||||
|
||||
---
|
||||
|
||||
### Row Level Security (RLS) and policies
|
||||
|
||||
RLS lets you write SQL rules that decide who can read or modify each row in your
|
||||
tables. These policies apply everywhere — through REST, GraphQL, or Realtime.
|
||||
|
||||
**Why this matters:** Security lives directly in the database. You define it
|
||||
once, and it's enforced across every API call.
|
||||
|
||||
---
|
||||
|
||||
### Storage (files and buckets)
|
||||
|
||||
Storage provides an S3-style interface for storing files such as images, videos,
|
||||
and documents. File metadata lives in Postgres, so you can apply the same access
|
||||
policies you use for your data.
|
||||
|
||||
**Why this matters:** File access and data access follow the same security rules,
|
||||
which keeps your system consistent and simple.
|
||||
|
||||
---
|
||||
|
||||
### Realtime
|
||||
|
||||
Realtime listens to changes in Postgres and sends them to connected clients over
|
||||
WebSocket. You can subscribe to table events and see updates as they happen.
|
||||
|
||||
**Why this matters:** You can build collaborative or live-updating interfaces
|
||||
without managing your own socket server.
|
||||
|
||||
---
|
||||
|
||||
### Edge Functions
|
||||
|
||||
Edge Functions let you run server-side JavaScript or TypeScript using Deno.
|
||||
They're good for webhooks, background tasks, and any logic that shouldn't run
|
||||
on the client.
|
||||
|
||||
**Why this matters:** You can write custom backend logic without maintaining a
|
||||
full server or worrying about scaling.
|
||||
|
||||
---
|
||||
|
||||
### Vector Search
|
||||
|
||||
The pgvector extension adds support for storing and searching **embeddings** —
|
||||
numerical representations of text, images, or other data. This allows you to
|
||||
search by meaning, not just by keywords.
|
||||
|
||||
**Why this matters:** You can add AI-style semantic search or recommendations
|
||||
directly to your database.
|
||||
|
||||
---
|
||||
|
||||
### Studio and CLI
|
||||
|
||||
Supabase Studio is the web dashboard for managing your project: running SQL,
|
||||
browsing data, editing policies, and managing users. The CLI is for local
|
||||
development and migrations, so your schema can live alongside your code.
|
||||
|
||||
**Why this matters:** You can move smoothly between GUI and code, keeping your
|
||||
database versioned and reproducible.
|
||||
|
||||
---
|
||||
|
||||
## How everything connects
|
||||
|
||||
When your app makes a request, it goes through a simple path:
|
||||
|
||||
1. The client calls a Supabase API endpoint, sending a project key and (usually)
|
||||
a user token.
|
||||
2. The API turns that request into a SQL query.
|
||||
3. Postgres runs the query and applies any RLS policies.
|
||||
4. The API returns the results as JSON.
|
||||
5. If there are changes, Realtime broadcasts them to any connected clients that
|
||||
are allowed to see them.
|
||||
|
||||
Edge Functions can follow the same pattern — they can call the APIs or connect
|
||||
directly to Postgres when they need more control.
|
||||
|
||||
---
|
||||
|
||||
## Why this architecture helps
|
||||
|
||||
- **Everything shares one source of truth:** Postgres is at the center.
|
||||
- **Security is consistent:** RLS policies apply across every service.
|
||||
- **No separate backend required:** APIs are generated automatically from you database.
|
||||
- **You can grow gradually:** Start with just a table, add realtime, storage, or functions later.
|
||||
- **It's just Postgres:** You can use standard SQL, extensions, and tools.
|
||||
|
||||
Supabase gives you the power of Postgres with the convenience of a fully
|
||||
integrated backend platform.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Learn how to manage users, and how signed-in requests connect to the database.
|
||||
chapterNumber: 3
|
||||
---
|
||||
|
||||
Most applications need to know who is using them. Authentication is how we identify
|
||||
a user and attach their actions to their own data. Supabase Authentication works closely with the database, so each request carries the user's identity.
|
||||
|
||||
Authentication in Supabase is handled by the Supabase Auth service. It manages creating accounts, signing in, and identifying the user on each request.
|
||||
|
||||
---
|
||||
|
||||
## Users
|
||||
|
||||
A user in Supabase has an id and some optional profile information (such as
|
||||
email, phone number, name, etc.). When someone signs up, Supabase creates a new user record and assigns
|
||||
them a unique identifier.
|
||||
|
||||
You do not need to create or manage the users table yourself. Supabase stores it
|
||||
internally and keeps it updated.
|
||||
|
||||
Example of the kind of data Supabase stores for a user:
|
||||
|
||||
```text
|
||||
id: 74d23f1b-1c94-3e7b-4d6a-9fa71e2a3c4ba987
|
||||
email: maria@example.com
|
||||
created_at: 2025-01-12 09:44:21
|
||||
updated_at: 2025-01-12 09:44:21
|
||||
last_sign_in_at: 2025-01-12 09:44:21
|
||||
```
|
||||
|
||||
You can reference this user id in your tables when you want to link data to a
|
||||
specific user.
|
||||
|
||||
For example, you can associate notes with a user by storing the user's id in the note table.
|
||||
|
||||
```text
|
||||
| id | user_id | content | created_at |
|
||||
|----|--------------------------------------|------------------|----------------------|
|
||||
| 1 | 9f1b1c94-3e7b-4d6a-9fa7-1e2a3c4ba987 | Buy groceries | 2025-01-10 14:22:11 |
|
||||
| 2 | 9f1b1c94-3e7b-4d6a-9fa7-1e2a3c4ba987 | Call the dentist | 2025-01-10 15:08:54 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tokens and identity
|
||||
|
||||
When a user signs in, Supabase returns a token. This token proves who the user
|
||||
is. This token is stored locally (usually in memory or local
|
||||
storage) and automatically included in each API request.
|
||||
|
||||
Inside the database, this token becomes the value returned by `auth.uid()`.
|
||||
This lets security policies decide which data belongs to which user.
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
select auth.uid();
|
||||
```
|
||||
|
||||
returns the id of the signed-in user who made the request.
|
||||
|
||||
---
|
||||
|
||||
## Sign up and sign in
|
||||
|
||||
Supabase Auth supports several sign-in methods:
|
||||
|
||||
- Email and password
|
||||
- Magic links (sign in by clicking a link in an email)
|
||||
- Social providers (Google, GitHub, etc.)
|
||||
- Phone number (SMS)
|
||||
|
||||
For now, we will use email and password, since it is the simplest starting point.
|
||||
|
||||
---
|
||||
|
||||
## Where authentication connects to data
|
||||
|
||||
Authentication tells us who the user is. But it does not decide what they are
|
||||
allowed to access. To handle that part, we use a Postgres feature called Row Level Security (RLS).
|
||||
|
||||
RLS lets the database check each row before returning it. The database uses the
|
||||
user's id to decide whether that row should be visible or changeable. This means
|
||||
the rules for access live inside the database itself, not in the application
|
||||
code.
|
||||
|
||||
From our previous notes example, RLS would allow a user to select only their own notes, but not the notes of other users.
|
||||
|
||||
So the flow looks like this:
|
||||
|
||||
```text
|
||||
| id | user_id | content | created_at |
|
||||
|----|--------------------------------------|------------------|----------------------|
|
||||
| 1 | 9f1b1c94-3e7b-4d6a-9fa7-1e2a3c4ba987 | Buy groceries | 2025-01-10 14:22:11 |
|
||||
| 2 | 9f1b1c94-3e7b-4d6a-9fa7-1e2a3c4ba987 | Call the dentist | 2025-01-10 15:08:54 |
|
||||
```
|
||||
|
||||
```text
|
||||
User signs in
|
||||
↓
|
||||
Supabase gives the client a user token
|
||||
↓
|
||||
Client sends token with API requests
|
||||
↓
|
||||
Postgres receives token and sets auth.uid()
|
||||
↓
|
||||
RLS policies use auth.uid() to enforce access rules
|
||||
```
|
||||
|
||||
Authentication says who the user is.
|
||||
RLS decides what they are allowed to do.
|
||||
|
||||
---
|
||||
|
||||
## What we learned
|
||||
|
||||
- Supabase Auth manages user accounts and sign-in.
|
||||
- Each user has a unique id that you can reference in your tables.
|
||||
- When a user signs in, Supabase provides a token.
|
||||
- The token tells the database who the user is through `auth.uid()`.
|
||||
- Authentication by itself does not control access. RLS does that part.
|
||||
|
||||
Understanding this identity flow is important before we begin writing policies.
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Data Fundamentals
|
||||
description: 'Learn how data is structured in Postgres: tables, rows, columns, and keys.'
|
||||
chapterNumber: 2
|
||||
|
||||
explore:
|
||||
- title: Supabase Docs
|
||||
link: https://supabase.com/docs/guides/database/overview
|
||||
itemType: doc
|
||||
- title: Postgres for Beginners
|
||||
link: https://www.postgresqltutorial.com/
|
||||
itemType: reference
|
||||
---
|
||||
|
||||
Before we store any information in Supabase, we need to understand how data is
|
||||
organized in Postgres. Supabase uses Postgres as its database, which means data
|
||||
is arranged in a structured and predictable way. This helps keep your app
|
||||
organized, consistent, and easy to query.
|
||||
|
||||
The core ideas are simple: you create tables to hold related data, and each
|
||||
table has rows (records) and columns (fields). Once you understand that pattern,
|
||||
you can model almost anything.
|
||||
|
||||
---
|
||||
|
||||
## Tables, rows, and columns
|
||||
|
||||
A table is like a spreadsheet. It has:
|
||||
|
||||
- **Columns**: the fields that define what kind of data the table stores
|
||||
- **Rows**: the individual entries in the table
|
||||
|
||||
Imagine a table for storing notes:
|
||||
|
||||
```text
|
||||
| id | content | created_at |
|
||||
|----|------------------|----------------------|
|
||||
| 1 | Buy groceries | 2025-01-10 14:22:11 |
|
||||
| 2 | Call the dentist | 2025-01-10 15:08:54 |
|
||||
```
|
||||
|
||||
- Each **row** is a single note.
|
||||
- Each **column** represents one piece of information about the note.
|
||||
|
||||
---
|
||||
|
||||
## Data types
|
||||
|
||||
Every column has a data type. This describes the kind of value that can be
|
||||
stored there.
|
||||
|
||||
Some common data types:
|
||||
|
||||
```text
|
||||
| Data type | Example value | Meaning |
|
||||
| ----------- | ---------------- | ----------------------------- |
|
||||
| text | Hello world | A string of characters |
|
||||
| integer | 42 | A number without decimals |
|
||||
| boolean | true | Yes/no or on/off values |
|
||||
| timestamptz | 2025-01-10 14:22 | Date and time (with timezone) |
|
||||
| json | {"name": "John"} | A JSON object |
|
||||
```
|
||||
|
||||
Choosing correct types makes your data easier to work with and protects you from
|
||||
unexpected values.
|
||||
|
||||
---
|
||||
|
||||
## Primary keys
|
||||
|
||||
Most tables include a primary key. This is a column that uniquely identifies each
|
||||
row. In many Supabase projects, this column is named `id` and uses a type called
|
||||
`uuid`, which automatically generates a unique value.
|
||||
|
||||
You create this column in the table definition like this:
|
||||
|
||||
```text
|
||||
id uuid primary key default gen_random_uuid()
|
||||
```
|
||||
|
||||
You generally don't need to think about it after setting it up. It simply gives
|
||||
each record a stable identity.
|
||||
|
||||
---
|
||||
|
||||
## Relationships between tables
|
||||
|
||||
You can link tables together using foreign keys. This is how you express things
|
||||
like:
|
||||
|
||||
- A user has many notes
|
||||
- A project has many tasks
|
||||
- A team has many members
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
| id | user_id | content | created_at |
|
||||
|----|--------------------------------------|------------------|----------------------|
|
||||
| 1 | 9f1b1c94-3e7b-4d6a-9fa7-1e2a3c4ba987 | Buy groceries | 2025-01-10 14:22:11 |
|
||||
| 2 | 9f1b1c94-3e7b-4d6a-9fa7-1e2a3c4ba987 | Call the dentist | 2025-01-10 15:08:54 |
|
||||
```
|
||||
|
||||
Here, `user_id` refers to a row in a `users` table. This means the note belongs
|
||||
to that user, and we can see that this user has two notes.
|
||||
|
||||
Relationships let you organize data without repeating information.
|
||||
|
||||
---
|
||||
|
||||
## Why relational data is useful in practice
|
||||
|
||||
Relational data is really useful in practice because it allows you to:
|
||||
|
||||
- Keep data valid with constraints <br />
|
||||
_Example: no duplicate emails._
|
||||
|
||||
- Link records safely with foreign keys <br />
|
||||
_Example: delete a user and their notes in one action._
|
||||
|
||||
- Express real permissions with RLS <br />
|
||||
_Example: users can read their own notes; admins can read all._
|
||||
|
||||
- Query exactly what you need with table joins <br />
|
||||
_Example: fetch notes with the author's email._
|
||||
|
||||
---
|
||||
|
||||
## What we learned
|
||||
|
||||
A database in Supabase is made up of tables.
|
||||
Each table has:
|
||||
|
||||
- columns (which describe the type of information stored)
|
||||
- rows (the individual pieces of data in that table)
|
||||
|
||||
You can relate tables to each other using foreign keys.
|
||||
This lets you model real situations, such as:
|
||||
|
||||
- a user has many notes
|
||||
- a team has many members
|
||||
- a project has many tasks
|
||||
|
||||
Thinking in tables, rows, and relationships is the foundation of working with data in Supabase.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Edge Functions
|
||||
description: 'Learn how to run custom backend logic close to your users, using Deno and the Supabase serverless runtime.'
|
||||
chapterNumber: 6
|
||||
---
|
||||
|
||||
Soon...
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Supabase Foundations
|
||||
description: Learn the foundations of Supabase, the Postgres development platform.
|
||||
courseHero:
|
||||
title: Supabase Foundations
|
||||
subtitle: Learn the foundations of Supabase, the Postgres development platform.
|
||||
description: In this short course, you'll explore how Supabase brings together **Database**, **Auth**, **Storage**, **Edge Functions**, and **Realtime** into a unified developer platform.
|
||||
instructors:
|
||||
- name: Jane Doe
|
||||
gradientFrom: '#00c3ff'
|
||||
gradientTo: '#ffff1c'
|
||||
- name: John Doe
|
||||
gradientFrom: '#ff00ea'
|
||||
---
|
||||
|
||||
In this short course, you'll explore how Supabase brings together **Database**, **Auth**, **Storage**, **Edge Functions**, and **Realtime** into a unified developer platform.
|
||||
|
||||
By the end, you'll understand how each piece of Supabase fits together, how to persist and protect your data, and how to start confidently building your own applications.
|
||||
|
||||
## What you'll learn
|
||||
|
||||
- **Architecture**: how Supabase's core products work together.
|
||||
- **Data fundamentals**: create tables, insert and retrieve data, and explore our generated APIs.
|
||||
- **Authentication**: create and sign in users, and make secure requests.
|
||||
- **Security**: enable Row Level Security (RLS) and write policies to protect your data.
|
||||
- **Storage**: upload and serve files using buckets.
|
||||
- **Edge Functions**: run custom backend logic close to your users, using Deno and Supabase's serverless runtime.
|
||||
- **Vector search**: store and query embeddings for AI-powered search and recommendations.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A [Supabase account](https://supabase.com/) (free)
|
||||
- Basic familiarity with a terminal to run queries.
|
||||
|
||||
---
|
||||
|
||||
## Course outline
|
||||
|
||||
The course is split into 7 chapters, each approximately 10 minutes long.
|
||||
|
||||
1. **What is Supabase?**
|
||||
Get an overview of Supabase's architecture and core products. Create your first project and make a simple API call.
|
||||
2. **Your First Table**
|
||||
Create a Postgres table in Supabase, insert data, and query it through the REST API.
|
||||
3. **Authentication Basics**
|
||||
Add users, sign them in, and understand how Supabase secures your APIs with JWTs.
|
||||
4. **Secure Your Data with Row-Level Security**
|
||||
Enable Row-Level Security and write policies to ensure users only access their own data.
|
||||
5. **Storage**
|
||||
Store files in Supabase Storage and upload and serve files from your buckets.
|
||||
6. **Edge Functions**
|
||||
Deploy simple functions to run backend logic close to your users using Deno.
|
||||
7. **Vector Search**
|
||||
Store and query embeddings for AI-powered search and recommendations using Supabase Vector.
|
||||
|
||||
---
|
||||
|
||||
## Before you begin
|
||||
|
||||
Create a new project at [supabase.com](https://supabase.com), open the **Supabase Dashboard**, and grab your **API URL** and **anon key** from the [Connect dialog](https://supabase.com/dashboard/project/_/settings/api?showConnect=true&connectTab=frameworks).
|
||||
You'll use them throughout the course to send real requests to your database and APIs.
|
||||
|
||||
---
|
||||
|
||||
## Duration
|
||||
|
||||
Approx. **1 hour** — a fast, hands-on introduction to the Supabase platform.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Supabase Foundations
|
||||
description: Learn the foundations of Supabase, the Postgres development platform.
|
||||
|
||||
explore:
|
||||
- title: Supabase Docs
|
||||
link: https://supabase.com/docs
|
||||
itemType: doc
|
||||
|
||||
courseHero:
|
||||
title: Supabase Foundations
|
||||
subtitle: Learn the foundations of Supabase, the Postgres development platform.
|
||||
description: In this short course, you'll explore how Supabase brings together **Database**, **Auth**, **Storage**, **Edge Functions**, and **Realtime** into a unified developer platform.
|
||||
stats:
|
||||
- label: 5 Chapters
|
||||
icon: book-open
|
||||
accent: emerald
|
||||
- label: ~1 hour
|
||||
icon: clock
|
||||
accent: blue
|
||||
- label: Beginner
|
||||
icon: zap
|
||||
accent: amber
|
||||
---
|
||||
|
||||
In this short course, you'll explore how Supabase brings together **Database**, **Auth**, **Storage**, **Edge Functions**, and **Realtime** into a unified developer platform.
|
||||
|
||||
By the end, you'll understand how each piece of Supabase fits together, how to persist and protect your data, and how to start confidently building your own applications.
|
||||
|
||||
## What you'll learn
|
||||
|
||||
- **Architecture**: how Supabase's core products work together.
|
||||
- **Data fundamentals**: create tables, insert and retrieve data, and explore our generated APIs.
|
||||
- **Authentication**: create and sign in users, and make secure requests.
|
||||
- **Security**: enable Row Level Security (RLS) and write policies to protect your data.
|
||||
- **Storage**: upload and serve files using buckets.
|
||||
- **Edge Functions**: run custom backend logic close to your users, using Deno and Supabase's serverless runtime.
|
||||
- **Vector search**: store and query embeddings for AI-powered search and recommendations.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A [Supabase account](https://supabase.com/) (free)
|
||||
- Basic familiarity with a terminal to run queries.
|
||||
|
||||
---
|
||||
|
||||
## Course outline
|
||||
|
||||
The course is split into 7 chapters, each approximately 10 minutes long.
|
||||
|
||||
1. **What is Supabase?**
|
||||
Get an overview of Supabase's architecture and core products. Create your first project and make a simple API call.
|
||||
2. **Your First Table**
|
||||
Create a Postgres table in Supabase, insert data, and query it through the REST API.
|
||||
3. **Authentication Basics**
|
||||
Add users, sign them in, and understand how Supabase secures your APIs with JWTs.
|
||||
4. **Secure Your Data with Row-Level Security**
|
||||
Enable Row-Level Security and write policies to ensure users only access their own data.
|
||||
5. **Storage**
|
||||
Store files in Supabase Storage and upload and serve files from your buckets.
|
||||
6. **Edge Functions**
|
||||
Deploy simple functions to run backend logic close to your users using Deno.
|
||||
7. **Vector Search**
|
||||
Store and query embeddings for AI-powered search and recommendations using Supabase Vector.
|
||||
|
||||
---
|
||||
|
||||
## Before you begin
|
||||
|
||||
Create a new project at [supabase.com](https://supabase.com), open the **Supabase Dashboard**, and grab your **API URL** and **anon key** from the [Connect dialog](https://supabase.com/dashboard/project/_/settings/api?showConnect=true&connectTab=frameworks).
|
||||
You'll use them throughout the course to send real requests to your database and APIs.
|
||||
|
||||
---
|
||||
|
||||
## Duration
|
||||
|
||||
Approx. **1 hour** — a fast, hands-on introduction to the Supabase platform.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Install shadcn/ui and use the components in your project
|
||||
---
|
||||
|
||||
## Set up shadcn/ui
|
||||
|
||||
Start by installing [shadcn/ui](https://ui.shadcn.com/) in your project. Use the appropriate setup guide linked below for your framework of choice.
|
||||
|
||||
If you're already using a recent version of shadcn/ui in your existing project, you can skip this step—this library should work with your current setup. However, if you encounter issues, try deleting your existing `components.json` file and follow one of the guides to reset your setup.
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-8 sm:gap-6">
|
||||
<Card>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-10 h-10"
|
||||
fill="currentColor"
|
||||
>
|
||||
<title>Next.js</title>
|
||||
<path d="M11.5725 0c-.1763 0-.3098.0013-.3584.0067-.0516.0053-.2159.021-.3636.0328-3.4088.3073-6.6017 2.1463-8.624 4.9728C1.1004 6.584.3802 8.3666.1082 10.255c-.0962.659-.108.8537-.108 1.7474s.012 1.0884.108 1.7476c.652 4.506 3.8591 8.2919 8.2087 9.6945.7789.2511 1.6.4223 2.5337.5255.3636.04 1.9354.04 2.299 0 1.6117-.1783 2.9772-.577 4.3237-1.2643.2065-.1056.2464-.1337.2183-.1573-.0188-.0139-.8987-1.1938-1.9543-2.62l-1.919-2.592-2.4047-3.5583c-1.3231-1.9564-2.4117-3.556-2.4211-3.556-.0094-.0026-.0187 1.5787-.0235 3.509-.0067 3.3802-.0093 3.5162-.0516 3.596-.061.115-.108.1618-.2064.2134-.075.0374-.1408.0445-.495.0445h-.406l-.1078-.068a.4383.4383 0 01-.1572-.1712l-.0493-.1056.0053-4.703.0067-4.7054.0726-.0915c.0376-.0493.1174-.1125.1736-.143.0962-.047.1338-.0517.5396-.0517.4787 0 .5584.0187.6827.1547.0353.0377 1.3373 1.9987 2.895 4.3608a10760.433 10760.433 0 004.7344 7.1706l1.9002 2.8782.096-.0633c.8518-.5536 1.7525-1.3418 2.4657-2.1627 1.5179-1.7429 2.4963-3.868 2.8247-6.134.0961-.6591.1078-.854.1078-1.7475 0-.8937-.012-1.0884-.1078-1.7476-.6522-4.506-3.8592-8.2919-8.2087-9.6945-.7672-.2487-1.5836-.42-2.4985-.5232-.169-.0176-1.0835-.0366-1.6123-.037zm4.0685 7.217c.3473 0 .4082.0053.4857.047.1127.0562.204.1642.237.2767.0186.061.0234 1.3653.0186 4.3044l-.0067 4.2175-.7436-1.14-.7461-1.14v-3.066c0-1.982.0093-3.0963.0234-3.1502.0375-.1313.1196-.2346.2323-.2955.0961-.0494.1313-.054.4997-.054z" />
|
||||
</svg>
|
||||
<p className="font-medium mt-2 mb-6">Next.js</p>
|
||||
<div className="border rounded-md py-1 px-2 bg-muted flex items-center gap-2">
|
||||
<p>
|
||||
<span className="select-none">`$`</span>`npx create-next-app -e with-supabase`
|
||||
</p>
|
||||
<CopyButton value="npx create-next-app -e with-supabase" />
|
||||
</div>
|
||||
</Card>
|
||||
<LinkedCard target="_blank" href="https://ui.shadcn.com/docs/installation/react-router">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-10 h-10"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12.118 5.466a2.306 2.306 0 0 0-.623.08c-.278.067-.702.332-.953.583-.41.423-.49.609-.662 1.469-.08.423.41 1.43.847 1.734.45.317 1.085.502 2.065.608 1.429.16 1.84.636 1.84 2.197 0 1.377-.385 1.747-1.96 1.906-1.707.172-2.58.834-2.765 2.117-.106.781.41 1.76 1.125 2.091 1.627.768 3.15-.198 3.467-2.196.211-1.284.622-1.642 1.998-1.747 1.588-.133 2.409-.675 2.713-1.787.278-1.02-.304-2.157-1.297-2.554-.264-.106-.873-.238-1.35-.291-1.495-.16-1.879-.424-2.038-1.39-.225-1.337-.317-1.562-.794-2.09a2.174 2.174 0 0 0-1.613-.73zm-4.785 4.36a2.145 2.145 0 0 0-.497.048c-1.469.318-2.17 2.051-1.35 3.295 1.178 1.774 3.944.953 3.97-1.177.012-1.193-.98-2.143-2.123-2.166zM2.089 14.19a2.22 2.22 0 0 0-.427.052c-2.158.476-2.237 3.626-.106 4.182.53.145.582.145 1.111.013 1.191-.318 1.866-1.456 1.549-2.607-.278-1.02-1.144-1.664-2.127-1.64zm19.824.008c-.233.002-.477.058-.784.162-1.39.477-1.866 2.092-.98 3.336.557.794 1.96 1.058 2.82.516 1.416-.874 1.363-3.057-.093-3.746-.38-.186-.663-.271-.963-.268z" />
|
||||
</svg>
|
||||
<p className="font-medium mt-2">React Router</p>
|
||||
</LinkedCard>
|
||||
<LinkedCard target="_blank" href="https://ui.shadcn.com/docs/installation/tanstack">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-10 h-10"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6.93 13.688a.343.343 0 0 1 .468.132l.063.106c.48.851.98 1.66 1.5 2.426a35.65 35.65 0 0 0 2.074 2.742.345.345 0 0 1-.039.484l-.074.066c-2.543 2.223-4.191 2.665-4.953 1.333-.746-1.305-.477-3.672.808-7.11a.344.344 0 0 1 .153-.18ZM17.75 16.3a.34.34 0 0 1 .395.27l.02.1c.628 3.286.187 4.93-1.325 4.93-1.48 0-3.36-1.402-5.649-4.203a.327.327 0 0 1-.074-.222c0-.188.156-.34.344-.34h.121a32.984 32.984 0 0 0 2.809-.098c1.07-.086 2.191-.23 3.359-.437zm.871-6.977a.353.353 0 0 1 .445-.21l.102.034c3.262 1.11 4.504 2.332 3.719 3.664-.766 1.305-2.993 2.254-6.684 2.848a.362.362 0 0 1-.238-.047.343.343 0 0 1-.125-.476l.062-.106a34.07 34.07 0 0 0 1.367-2.523c.477-.989.93-2.051 1.352-3.184zM7.797 8.34a.362.362 0 0 1 .238.047.343.343 0 0 1 .125.476l-.062.106a34.088 34.088 0 0 0-1.367 2.523c-.477.988-.93 2.051-1.352 3.184a.353.353 0 0 1-.445.21l-.102-.034C1.57 13.742.328 12.52 1.113 11.188 1.88 9.883 4.106 8.934 7.797 8.34Zm5.281-3.984c2.543-2.223 4.192-2.664 4.953-1.332.746 1.304.477 3.671-.808 7.109a.344.344 0 0 1-.153.18.343.343 0 0 1-.468-.133l-.063-.106a34.64 34.64 0 0 0-1.5-2.426 35.65 35.65 0 0 0-2.074-2.742.345.345 0 0 1 .039-.484ZM7.285 2.274c1.48 0 3.364 1.402 5.649 4.203a.349.349 0 0 1 .078.218.348.348 0 0 1-.348.344l-.117-.004a34.584 34.584 0 0 0-2.809.102 35.54 35.54 0 0 0-3.363.437.343.343 0 0 1-.394-.273l-.02-.098c-.629-3.285-.188-4.93 1.324-4.93Zm2.871 5.812h3.688a.638.638 0 0 1 .55.316l1.848 3.22a.644.644 0 0 1 0 .628l-1.847 3.223a.638.638 0 0 1-.551.316h-3.688a.627.627 0 0 1-.547-.316L7.758 12.25a.644.644 0 0 1 0-.629L9.61 8.402a.627.627 0 0 1 .546-.316Zm3.23.793a.638.638 0 0 1 .552.316l1.39 2.426a.644.644 0 0 1 0 .629l-1.39 2.43a.638.638 0 0 1-.551.316h-2.774a.627.627 0 0 1-.546-.316l-1.395-2.43a.644.644 0 0 1 0-.629l1.395-2.426a.627.627 0 0 1 .546-.316Zm-.491.867h-1.79a.624.624 0 0 0-.546.316l-.899 1.56a.644.644 0 0 0 0 .628l.899 1.563a.632.632 0 0 0 .547.316h1.789a.632.632 0 0 0 .547-.316l.898-1.563a.644.644 0 0 0 0-.629l-.898-1.558a.624.624 0 0 0-.547-.317Zm-.477.828c.227 0 .438.121.547.317l.422.73a.625.625 0 0 1 0 .629l-.422.734a.627.627 0 0 1-.547.317h-.836a.632.632 0 0 1-.547-.317l-.422-.734a.625.625 0 0 1 0-.629l.422-.73a.632.632 0 0 1 .547-.317zm-.418.817a.548.548 0 0 0-.473.273.547.547 0 0 0 0 .547.544.544 0 0 0 .473.27.544.544 0 0 0 .473-.27.547.547 0 0 0 0-.547.548.548 0 0 0-.473-.273Zm-4.422.546h.98M18.98 7.75c.391-1.895.477-3.344.223-4.398-.148-.63-.422-1.137-.84-1.508-.441-.39-1-.582-1.625-.582-1.035 0-2.12.472-3.281 1.367a14.9 14.9 0 0 0-1.473 1.316 1.206 1.206 0 0 0-.136-.144c-1.446-1.285-2.66-2.082-3.7-2.39-.617-.184-1.195-.2-1.722-.024-.559.187-1.004.574-1.317 1.117-.515.894-.652 2.074-.46 3.527.078.59.214 1.235.402 1.934a1.119 1.119 0 0 0-.215.047C3.008 8.62 1.71 9.269.926 10.015c-.465.442-.77.938-.883 1.481-.113.578 0 1.156.312 1.7.516.894 1.465 1.597 2.817 2.155.543.223 1.156.426 1.844.61a1.023 1.023 0 0 0-.07.226c-.391 1.891-.477 3.344-.223 4.395.148.629.425 1.14.84 1.508.44.39 1 .582 1.625.582 1.035 0 2.12-.473 3.28-1.364.477-.37.973-.816 1.489-1.336a1.2 1.2 0 0 0 .195.227c1.446 1.285 2.66 2.082 3.7 2.39.617.184 1.195.2 1.722.024.559-.187 1.004-.574 1.317-1.117.515-.894.652-2.074.46-3.527a14.941 14.941 0 0 0-.425-2.012 1.225 1.225 0 0 0 .238-.047c1.828-.61 3.125-1.258 3.91-2.004.465-.441.77-.937.883-1.48.113-.578 0-1.157-.313-1.7-.515-.894-1.464-1.597-2.816-2.156a14.576 14.576 0 0 0-1.906-.625.865.865 0 0 0 .059-.195z" />
|
||||
</svg>
|
||||
<p className="font-medium mt-2">TanStack Start</p>
|
||||
</LinkedCard>
|
||||
<LinkedCard target="_blank" href="https://ui.shadcn.com/docs/installation/manual">
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-10 h-10"
|
||||
fill="currentColor"
|
||||
>
|
||||
<title>React SPA</title>
|
||||
<path d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z" />
|
||||
</svg>
|
||||
<p className="font-medium mt-2">React SPA</p>
|
||||
</LinkedCard>
|
||||
</div>
|
||||
|
||||
<TanStackBeta />
|
||||
|
||||
When you've finished setting up shadcn/ui, you can start exploring the components in the sidebar
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues, try deleting your existing `components.json` file and follow one of the guides to reset your setup.
|
||||
|
||||
For issues and questions about specific frameworks, see the [FAQs](/ui/docs/getting-started/faq)
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Realtime
|
||||
description: 'Learn how to build real-time applications with Supabase.'
|
||||
chapterNumber: 6
|
||||
---
|
||||
|
||||
Soon...
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Security
|
||||
description: Learn how Supabase uses Row Level Security (RLS) to control which data each user can access.
|
||||
chapterNumber: 4
|
||||
explore:
|
||||
- title: Supabase Docs
|
||||
link: https://supabase.com/docs/guides/database/postgres/row-level-security
|
||||
itemType: doc
|
||||
- title: RLS and Policies
|
||||
link: https://www.youtube.com/watch?v=WAa3a-HxLVs
|
||||
itemType: video
|
||||
---
|
||||
|
||||
Authentication tells us who the user is. But we still need to decide what data
|
||||
each user is allowed to see or change. Supabase handles this using a Postgres
|
||||
feature called Row Level Security (RLS).
|
||||
|
||||
RLS lets the database check each row before returning it. For every request, the
|
||||
database asks: _should this user be allowed to access this row?_
|
||||
|
||||
Here is an example of a table with some notes:
|
||||
|
||||
```text
|
||||
+--------------------------------------------------------------------------+
|
||||
| user_id | content | created_at |
|
||||
+---------+------------------+---------------------------------------------+
|
||||
| 1 | Buy groceries | 2025-01-10 14:22:11 | ← belongs to user Alice
|
||||
| 2 | Call the dentist | 2025-01-10 15:08:54 | ← belongs to user Bob
|
||||
+--------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
Because this check happens in the database, the rules can be applied to every request:
|
||||
REST, GraphQL, Realtime, and Edge Functions all follow the same security
|
||||
behavior. There is no separate code path to maintain.
|
||||
|
||||
---
|
||||
|
||||
## Enabling RLS
|
||||
|
||||
RLS is turned off by default so you can design your tables first. Once you are
|
||||
ready to secure a table, you turn it on via the Dashboard or via SQL:
|
||||
|
||||
```text
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
```
|
||||
|
||||
Enabling RLS does not allow any access. It simply tells Postgres that row access
|
||||
must now follow policies.
|
||||
|
||||
At this point, your table becomes locked down. No one can read or write data
|
||||
until you add policies.
|
||||
|
||||
---
|
||||
|
||||
## Policies
|
||||
|
||||
A policy is a rule that describes who can do what. Policies are written in SQL,
|
||||
but we will use very small, readable examples.
|
||||
|
||||
Example: Allow a signed-in user to see only their own rows.
|
||||
|
||||
```text
|
||||
create policy "Users can view their own notes"
|
||||
on notes
|
||||
for select
|
||||
using (user_id = auth.uid());
|
||||
```
|
||||
|
||||
Explanation:
|
||||
|
||||
- `for select` means the policy applies to reading rows
|
||||
- `auth.uid()` is the id of the signed-in user
|
||||
- The user is only allowed to read rows where the `user_id` matches theirs
|
||||
|
||||
This gives us personal data isolation without writing any backend code.
|
||||
|
||||
---
|
||||
|
||||
## A mental model for policies
|
||||
|
||||
Think of policies as sentences:
|
||||
`Allow X to do Y when Z is true.`
|
||||
|
||||
For example:
|
||||
|
||||
```text
|
||||
Allow a user to read a note when they created it.
|
||||
Allow a user to update a note when they created it.
|
||||
Allow a user to delete an item when they own the project it belongs to.
|
||||
Allow an admin to do everything.
|
||||
```
|
||||
|
||||
Policies describe access in plain language.
|
||||
SQL just expresses the rule.
|
||||
|
||||
In a typical app, a table may have one policy or several. Each policy represents
|
||||
a small piece of business logic — who can do what and under what conditions.
|
||||
Together, they form the full access rules for that table.
|
||||
|
||||
---
|
||||
|
||||
## What we learned
|
||||
|
||||
- Authentication identifies the user.
|
||||
- RLS decides what data that user can access.
|
||||
- RLS is a Postgres feature that checks every row before returning it.
|
||||
- When RLS is enabled, access is blocked until policies are added.
|
||||
- Policies describe who can read or write data and under what conditions.
|
||||
- `auth.uid()` gives you the id of the signed-in user, which is used in policy
|
||||
conditions.
|
||||
|
||||
Understanding RLS is the foundation of secure Supabase applications.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Storage
|
||||
description: 'Learn how to store files in Supabase using buckets.'
|
||||
chapterNumber: 5
|
||||
---
|
||||
|
||||
Soon...
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Vector Search
|
||||
description: 'Learn how to store and query embeddings for AI-powered search and recommendations.'
|
||||
chapterNumber: 8
|
||||
---
|
||||
|
||||
Soon...
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Grafana
|
||||
description: 'Learn how to use Grafana'
|
||||
chapterNumber: 8
|
||||
---
|
||||
|
||||
This is an internal guide for Grafana.
|
||||
It doesn't have an external equivalent.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Realtime
|
||||
description: 'Learn how to build real-time applications with Supabase.'
|
||||
chapterNumber: 6
|
||||
---
|
||||
|
||||
This is the internal realtime guide.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Platform Kit
|
||||
description: The easiest way to build platforms on top of Supabase
|
||||
---
|
||||
|
||||
<video width="100%" muted autoPlay controls>
|
||||
<source
|
||||
src="https://xguihxuzqibwxjnimxev.supabase.co/storage/v1/object/public/videos/marketing/blog/platform-kit/platform-kit.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
## Installation
|
||||
|
||||
See the [Supabase Platform Kit documentation](https://supabase.com/docs) for installation instructions.
|
||||
|
||||
## Introduction
|
||||
|
||||
The Platform Kit is a collection of customizable API's, hooks and components you can use to provide an embedded Supabase experience within your own platform. It comes in the form of a single dialog that enables the management of database, authentication, storage, users, secrets, logs, and performance monitoring.
|
||||
|
||||
**Features**
|
||||
|
||||
- Database, Auth, Storage, User, Secrets, Logs, and Performance management
|
||||
- Responsive dialog/drawer interface (desktop & mobile)
|
||||
- API proxy for Management API
|
||||
- AI-powered SQL generation (optional)
|
||||
- Customize to your liking
|
||||
|
||||
## Who is it for
|
||||
|
||||
Anyone who is providing Postgres databases to their users.
|
||||
|
||||
## Usage
|
||||
|
||||
Embed the manager dialog in your app and manage its state:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { useMobile } from '@/hooks/use-mobile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SupabaseManagerDialog from '@/components/supabase-manager'
|
||||
|
||||
export default function Example() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const projectRef = 'your-project-ref' // Replace with your actual project ref
|
||||
const isMobile = useMobile()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Open Supabase Manager</Button>
|
||||
<SupabaseManagerDialog
|
||||
projectRef={projectRef}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Set up environment variables:** in your `.env.local` file:
|
||||
|
||||
```bash
|
||||
SUPABASE_MANAGEMENT_API_TOKEN=your-personal-access-token
|
||||
NEXT_PUBLIC_ENABLE_AI_QUERIES=true
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
```
|
||||
|
||||
2. **Add project-level authentication checks** in your API proxy at `app/api/supabase-proxy/[...path]/route.ts` as well as your ai/sql route at `app/api/ai/sql/route.ts` to ensure only authorized users can access their own project resources.
|
||||
|
||||
3. **Add a Toaster for notifications:**
|
||||
Place the following component at the root of your app (e.g., in your `layout.tsx` or `App.tsx`) to enable toast notifications:
|
||||
|
||||
```tsx
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head />
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
That's it! The default setup uses your Supabase personal access token for the Management API.
|
||||
|
||||
## Security
|
||||
|
||||
- Never expose your Management API token to the client
|
||||
- Always implement authentication and permission checks in your proxy
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Supabase Management API](https://supabase.com/docs/reference/api/introduction)
|
||||
@@ -0,0 +1,254 @@
|
||||
import { getHighlighter, loadTheme } from '@shikijs/compat'
|
||||
import { defineDocumentType, defineNestedType, makeSource } from 'contentlayer2/source-files'
|
||||
import path from 'path'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import rehypePrettyCode from 'rehype-pretty-code'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import { codeImport } from 'remark-code-import'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
/** @type {import('contentlayer2/source-files').ComputedFields} */
|
||||
const computedFields = {
|
||||
slug: {
|
||||
type: 'string',
|
||||
resolve: (doc) => `/${doc._raw.flattenedPath}`,
|
||||
},
|
||||
slugAsParams: {
|
||||
type: 'string',
|
||||
resolve: (doc) => doc._raw.flattenedPath,
|
||||
},
|
||||
}
|
||||
|
||||
const LinksProperties = defineNestedType(() => ({
|
||||
name: 'LinksProperties',
|
||||
fields: {
|
||||
doc: {
|
||||
type: 'string',
|
||||
},
|
||||
api: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const ExploreItem = defineNestedType(() => ({
|
||||
name: 'ExploreItem',
|
||||
fields: {
|
||||
title: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
link: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
itemType: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const CourseHero = defineNestedType(() => ({
|
||||
name: 'CourseHero',
|
||||
fields: {
|
||||
title: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const NestedProperties = defineNestedType(() => ({
|
||||
name: 'NestedProperties',
|
||||
fields: {
|
||||
radix: {
|
||||
type: 'boolean',
|
||||
},
|
||||
shadcn: {
|
||||
type: 'boolean',
|
||||
},
|
||||
vaul: {
|
||||
type: 'boolean',
|
||||
},
|
||||
inputOtp: {
|
||||
type: 'boolean',
|
||||
},
|
||||
reactAccessibleTreeview: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
export const Doc = defineDocumentType(() => ({
|
||||
name: 'Doc',
|
||||
filePathPattern: `**/*.mdx`,
|
||||
contentType: 'mdx',
|
||||
fields: {
|
||||
title: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
published: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
links: {
|
||||
type: 'nested',
|
||||
of: LinksProperties,
|
||||
},
|
||||
featured: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
component: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
fragment: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
toc: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
required: false,
|
||||
},
|
||||
chapterNumber: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
},
|
||||
explore: {
|
||||
type: 'list',
|
||||
of: ExploreItem,
|
||||
required: false,
|
||||
},
|
||||
courseHero: {
|
||||
type: 'nested',
|
||||
of: CourseHero,
|
||||
required: false,
|
||||
},
|
||||
source: {
|
||||
type: 'nested',
|
||||
of: NestedProperties,
|
||||
},
|
||||
},
|
||||
computedFields,
|
||||
}))
|
||||
|
||||
export default makeSource({
|
||||
contentDirPath: './content',
|
||||
documentTypes: [Doc],
|
||||
mdx: {
|
||||
remarkPlugins: [remarkGfm, codeImport],
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
() => (tree) => {
|
||||
visit(tree, (node) => {
|
||||
if (node?.type === 'element' && node?.tagName === 'pre') {
|
||||
const [codeEl] = node.children
|
||||
if (codeEl.tagName !== 'code') {
|
||||
return
|
||||
}
|
||||
|
||||
if (codeEl.data?.meta) {
|
||||
// Extract event from meta and pass it down the tree.
|
||||
const regex = /event="([^"]*)"/
|
||||
const match = codeEl.data?.meta.match(regex)
|
||||
if (match) {
|
||||
node.__event__ = match ? match[1] : null
|
||||
codeEl.data.meta = codeEl.data.meta.replace(regex, '')
|
||||
}
|
||||
}
|
||||
|
||||
node.__rawString__ = codeEl.children?.[0].value
|
||||
node.__src__ = node.properties?.__src__
|
||||
node.__style__ = node.properties?.__style__
|
||||
}
|
||||
})
|
||||
},
|
||||
[
|
||||
rehypePrettyCode,
|
||||
// rehypePrettyCodeOptions,
|
||||
{
|
||||
getHighlighter: async () => {
|
||||
const theme = await loadTheme(path.join(process.cwd(), '/lib/themes/supabase-2.json'))
|
||||
return await getHighlighter({ theme })
|
||||
},
|
||||
onVisitLine(node) {
|
||||
// Prevent lines from collapsing in `display: grid` mode, and allow empty
|
||||
// lines to be copy/pasted
|
||||
if (node.children.length === 0) {
|
||||
node.children = [{ type: 'text', value: ' ' }]
|
||||
}
|
||||
},
|
||||
onVisitHighlightedLine(node) {
|
||||
node.properties.className.push('line--highlighted')
|
||||
},
|
||||
onVisitHighlightedWord(node) {
|
||||
node.properties.className = ['word--highlighted']
|
||||
},
|
||||
},
|
||||
],
|
||||
() => (tree) => {
|
||||
visit(tree, (node) => {
|
||||
if (node?.type === 'element' && node?.tagName === 'div') {
|
||||
if (!('data-rehype-pretty-code-fragment' in node.properties)) {
|
||||
return
|
||||
}
|
||||
|
||||
const preElement = node.children.at(-1)
|
||||
if (preElement.tagName !== 'pre') {
|
||||
return
|
||||
}
|
||||
|
||||
preElement.properties['__withMeta__'] = node.children.at(0).tagName === 'div'
|
||||
preElement.properties['__rawString__'] = node.__rawString__
|
||||
|
||||
if (node.__src__) {
|
||||
preElement.properties['__src__'] = node.__src__
|
||||
}
|
||||
|
||||
if (node.__event__) {
|
||||
preElement.properties['__event__'] = node.__event__
|
||||
}
|
||||
|
||||
if (node.__style__) {
|
||||
preElement.properties['__style__'] = node.__style__
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
// rehypeNpmCommand,
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
properties: {
|
||||
className: ['subheading-anchor'],
|
||||
ariaLabel: 'Link to section',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
type Course = 'foundations' | 'smart-office' | 'performance-scaling' | 'debugging-operations'
|
||||
type CourseContextType = {
|
||||
course: Course
|
||||
setCourse: (course: Course) => void
|
||||
}
|
||||
|
||||
const CourseContext = createContext<CourseContextType | undefined>(undefined)
|
||||
|
||||
export function FrameworkProvider({ children }: { children: React.ReactNode }) {
|
||||
const [course, setCourseState] = useState<Course>('foundations')
|
||||
|
||||
// Initialize from localStorage on mount (client-side only)
|
||||
useEffect(() => {
|
||||
const storedCourse = localStorage.getItem('preferredCourse')
|
||||
const validCourses: Course[] = [
|
||||
'foundations',
|
||||
'smart-office',
|
||||
'performance-scaling',
|
||||
'debugging-operations',
|
||||
]
|
||||
if (storedCourse && validCourses.includes(storedCourse as Course)) {
|
||||
setCourseState(storedCourse as Course)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update localStorage when framework changes
|
||||
const setCourse = (newCourse: Course) => {
|
||||
setCourseState(newCourse)
|
||||
localStorage.setItem('preferredCourse', newCourse)
|
||||
}
|
||||
|
||||
return <CourseContext.Provider value={{ course, setCourse }}>{children}</CourseContext.Provider>
|
||||
}
|
||||
|
||||
// Custom hook to use the framework context
|
||||
export function useCourse() {
|
||||
const context = useContext(CourseContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useCourse must be used within a CourseProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import React, { ReactNode, useState, useCallback } from 'react'
|
||||
import { MobileMenuContext, MobileMenuContextType } from '@/hooks/use-mobile-menu'
|
||||
|
||||
interface MobileMenuProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component for mobile menu state
|
||||
* Wraps children with context that provides mobile menu state and controls
|
||||
*/
|
||||
export function MobileMenuProvider({ children }: MobileMenuProviderProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Use useCallback for stable function references
|
||||
const handleSetOpen = useCallback((value: boolean) => {
|
||||
setOpen(value)
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpen((prev) => {
|
||||
return !prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
const value: MobileMenuContextType = {
|
||||
open,
|
||||
setOpen: handleSetOpen,
|
||||
toggle,
|
||||
}
|
||||
|
||||
return <MobileMenuContext.Provider value={value}>{children}</MobileMenuContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
const { defineConfig } = require('eslint/config')
|
||||
const supabaseConfig = require('eslint-config-supabase/next')
|
||||
|
||||
module.exports = defineConfig([supabaseConfig])
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
import { ComponentProps } from 'react'
|
||||
import { SonnerToaster } from 'ui'
|
||||
|
||||
type Config = {
|
||||
style: string
|
||||
radius: number
|
||||
sonnerPosition: ComponentProps<typeof SonnerToaster>['position']
|
||||
sonnerExpand: boolean
|
||||
}
|
||||
|
||||
const configAtom = atomWithStorage<Config>('config', {
|
||||
style: 'default',
|
||||
radius: 0.5,
|
||||
sonnerPosition: 'bottom-right',
|
||||
sonnerExpand: false,
|
||||
})
|
||||
|
||||
export function useConfig() {
|
||||
return useAtom(configAtom)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/**
|
||||
* Context type for mobile menu state and controls
|
||||
*/
|
||||
export interface MobileMenuContextType {
|
||||
/** Current state of the mobile menu */
|
||||
open: boolean
|
||||
/** Function to set the mobile menu state */
|
||||
setOpen: (open: boolean) => void
|
||||
/** Function to toggle the mobile menu state */
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
// Create context with null as default value to indicate it's not provided
|
||||
export const MobileMenuContext = createContext<MobileMenuContextType | null>(null)
|
||||
|
||||
/**
|
||||
* Hook to access the mobile menu state and controls
|
||||
* Must be used within a MobileMenuProvider
|
||||
*/
|
||||
export function useMobileMenu(): MobileMenuContextType {
|
||||
const context = useContext(MobileMenuContext)
|
||||
|
||||
if (context === null) {
|
||||
throw new Error(
|
||||
'useMobileMenu must be used within a MobileMenuProvider. ' +
|
||||
'Ensure your component is wrapped with MobileMenuProvider.'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react'
|
||||
|
||||
export function useMounted() {
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
return mounted
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL!
|
||||
@@ -0,0 +1,36 @@
|
||||
import { allDocs } from 'contentlayer/generated'
|
||||
import { normalizeSlug } from './get-next-page'
|
||||
|
||||
/**
|
||||
* Gets the current chapter number from a doc
|
||||
*/
|
||||
export function getCurrentChapter(
|
||||
slug: string
|
||||
): { chapterNumber?: number; completionMessage?: string } | null {
|
||||
const normalizedSlug = normalizeSlug(slug)
|
||||
const doc = allDocs.find((doc) => normalizeSlug(doc.slugAsParams) === normalizedSlug)
|
||||
|
||||
if (!doc) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get chapterNumber from frontmatter first, then fallback to parsing from title
|
||||
let chapterNumber: number | undefined = (doc as any)?.chapterNumber
|
||||
if (!chapterNumber) {
|
||||
// Try to extract chapter number from title (e.g., "2: CSS Styling" -> 2)
|
||||
const chapterMatch = doc.title.match(/^(\d+):/)
|
||||
chapterNumber = chapterMatch ? parseInt(chapterMatch[1], 10) : undefined
|
||||
}
|
||||
|
||||
if (!chapterNumber) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use description as completion message, or generate a default one
|
||||
const completionMessage = doc.description || undefined
|
||||
|
||||
return {
|
||||
chapterNumber,
|
||||
completionMessage,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { existsSync, readdirSync, statSync } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* Get all internal content file paths recursively
|
||||
* Returns a Set of paths like '/foundations/realtime', '/foundations/grafana'
|
||||
*/
|
||||
export function getInternalContentPaths(): Set<string> {
|
||||
const internalDir = path.join(process.cwd(), 'content/internal')
|
||||
const paths = new Set<string>()
|
||||
|
||||
if (!existsSync(internalDir)) {
|
||||
return paths
|
||||
}
|
||||
|
||||
function scanDirectory(dir: string, prefix: string = '') {
|
||||
const entries = readdirSync(dir)
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry)
|
||||
const stat = statSync(fullPath)
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Recursively scan subdirectories
|
||||
scanDirectory(fullPath, prefix ? `${prefix}/${entry}` : entry)
|
||||
} else if (entry.endsWith('.mdx') || entry.endsWith('.md')) {
|
||||
// Remove file extension to get the path
|
||||
const filename = entry.replace(/\.(mdx|md)$/, '')
|
||||
const contentPath = prefix ? `/${prefix}/${filename}` : `/${filename}`
|
||||
paths.add(contentPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanDirectory(internalDir)
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if internal content exists for a given href
|
||||
*/
|
||||
export function hasInternalContent(href: string, internalPaths: Set<string>): boolean {
|
||||
return internalPaths.has(href)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { allDocs } from 'contentlayer/generated'
|
||||
import { courses } from '@/config/docs'
|
||||
import { SidebarNavItem } from '@/types/nav'
|
||||
|
||||
/**
|
||||
* Flattens the navigation structure to get all pages in order
|
||||
* Only includes items that have an href (leaf nodes)
|
||||
*/
|
||||
function flattenNavItems(items: SidebarNavItem[]): SidebarNavItem[] {
|
||||
const result: SidebarNavItem[] = []
|
||||
|
||||
for (const item of items) {
|
||||
if (item.items && item.items.length > 0) {
|
||||
// Recursively process children first
|
||||
result.push(...flattenNavItems(item.items))
|
||||
} else if (item.href) {
|
||||
// Only add items with href (leaf nodes)
|
||||
result.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a slug/href for comparison
|
||||
*/
|
||||
export function normalizeSlug(slug: string): string {
|
||||
return slug.replace(/^\//, '').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the next page in the navigation structure based on the current page slug
|
||||
*/
|
||||
export function getNextPage(
|
||||
currentSlug: string
|
||||
): { title: string; href: string; description?: string; chapterNumber?: number } | null {
|
||||
// Flatten all navigation items
|
||||
const allPages = flattenNavItems(courses.items)
|
||||
|
||||
// Normalize the current slug for comparison
|
||||
const normalizedCurrentSlug = normalizeSlug(currentSlug)
|
||||
|
||||
// Find the current page index
|
||||
const currentIndex = allPages.findIndex((page) => {
|
||||
if (!page.href) return false
|
||||
const pageSlug = normalizeSlug(page.href)
|
||||
return pageSlug === normalizedCurrentSlug
|
||||
})
|
||||
|
||||
// If current page not found or it's the last page, return null
|
||||
if (currentIndex === -1 || currentIndex === allPages.length - 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get the next page
|
||||
const nextPage = allPages[currentIndex + 1]
|
||||
if (!nextPage || !nextPage.href) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to get description and chapterNumber from the doc
|
||||
const nextPageSlug = normalizeSlug(nextPage.href)
|
||||
const doc = allDocs.find((doc) => normalizeSlug(doc.slugAsParams) === nextPageSlug)
|
||||
const description = doc?.description || nextPage.title
|
||||
|
||||
// Get chapterNumber from frontmatter first, then fallback to parsing from title
|
||||
// Using type assertion since contentlayer types may need regeneration
|
||||
let chapterNumber: number | undefined = (doc as any)?.chapterNumber
|
||||
if (!chapterNumber) {
|
||||
// Try to extract chapter number from title (e.g., "2: CSS Styling" -> 2)
|
||||
const chapterMatch = nextPage.title.match(/^(\d+):/)
|
||||
chapterNumber = chapterMatch ? parseInt(chapterMatch[1], 10) : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
title: nextPage.title,
|
||||
href: nextPage.href,
|
||||
description,
|
||||
chapterNumber,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { SidebarNavItem } from '@/types/nav'
|
||||
|
||||
/**
|
||||
* Get all hrefs from navigation items (only direct children, not nested)
|
||||
*/
|
||||
function getNavHrefsInSection(items?: SidebarNavItem[]): Set<string> {
|
||||
const hrefs = new Set<string>()
|
||||
if (!items) return hrefs
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.href) {
|
||||
hrefs.add(item.href)
|
||||
}
|
||||
})
|
||||
|
||||
return hrefs
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge orphaned internal content into their respective parent sections
|
||||
* For example, if /internal/foundations/grafana exists but /foundations/grafana doesn't,
|
||||
* it will be added to the end of the Foundations section
|
||||
*/
|
||||
export function mergeInternalContentIntoSections(
|
||||
navItems: SidebarNavItem[],
|
||||
internalPaths: string[]
|
||||
): SidebarNavItem[] {
|
||||
return navItems.map((parentItem) => {
|
||||
// If this item doesn't have children, return as is
|
||||
if (!parentItem.items) return parentItem
|
||||
|
||||
// Get the section path (e.g., "/foundations" from parent href)
|
||||
const parentPath = parentItem.href
|
||||
if (!parentPath) return parentItem
|
||||
|
||||
// Get all existing hrefs in this section
|
||||
const existingHrefs = getNavHrefsInSection(parentItem.items)
|
||||
|
||||
// Find orphaned internal items that belong to this section
|
||||
const orphanedItems: SidebarNavItem[] = []
|
||||
|
||||
internalPaths.forEach((internalPath) => {
|
||||
// Check if this internal path belongs to this parent section
|
||||
// e.g., /foundations/grafana belongs to /foundations
|
||||
if (internalPath.startsWith(parentPath + '/')) {
|
||||
// Check if there's no corresponding public item
|
||||
const publicPath = internalPath // e.g., /foundations/grafana
|
||||
if (!existingHrefs.has(publicPath)) {
|
||||
// Extract the title from the path
|
||||
const pathParts = internalPath.split('/').filter(Boolean)
|
||||
const fileName = pathParts[pathParts.length - 1]
|
||||
|
||||
// Convert kebab-case to Title Case
|
||||
const title = fileName
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
|
||||
orphanedItems.push({
|
||||
title,
|
||||
href: `/internal${internalPath}`,
|
||||
requiresAuth: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// If there are orphaned items, add them to the end of this section with a heading
|
||||
if (orphanedItems.length > 0) {
|
||||
// Create a separator item for "Internal Resources"
|
||||
const internalResourcesHeading: SidebarNavItem = {
|
||||
title: 'Internal Resources',
|
||||
href: undefined,
|
||||
items: orphanedItems.sort((a, b) => a.title.localeCompare(b.title)),
|
||||
}
|
||||
|
||||
return {
|
||||
...parentItem,
|
||||
items: [...parentItem.items, internalResourcesHeading],
|
||||
}
|
||||
}
|
||||
|
||||
return parentItem
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { sendTelemetryEvent } from 'common'
|
||||
import { TelemetryEvent } from 'common/telemetry-constants'
|
||||
import { API_URL } from 'lib/constants'
|
||||
|
||||
export function useSendTelemetryEvent() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return useCallback(
|
||||
(event: TelemetryEvent) => {
|
||||
return sendTelemetryEvent(API_URL, event, pathname)
|
||||
},
|
||||
[pathname]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"name": "Supabase Theme",
|
||||
"displayName": "Supabase Theme",
|
||||
"semanticHighlighting": true,
|
||||
"colors": {
|
||||
"editor.background": "var(--background-surface-75) / 0.75",
|
||||
"editor.foreground": "var(--code-foreground)",
|
||||
"editor.lineHighlightBackground": "var(--code-highlight-color)",
|
||||
"editorCursor.foreground": "var(--code-foreground)",
|
||||
"editorWhitespace.foreground": "var(--code-foreground)",
|
||||
"editorIndentGuide.background": "var(--code-foreground)",
|
||||
"editor.selectionBackground": "var(--code-highlight-color)",
|
||||
"editor.selectionHighlightBackground": "var(--code-highlight-color)"
|
||||
},
|
||||
"tokenColors": [
|
||||
{
|
||||
"scope": ["comment", "punctuation.definition.comment", "string.comment"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-comment)",
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["constant", "entity.name.constant", "variable.other.constant"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-constant)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["entity", "entity.name", "entity.name.type", "support.type"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-function)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["variable", "support.variable", "variable.parameter"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-parameter)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["keyword", "storage.type", "storage.modifier", "keyword.control"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-keyword)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["string", "constant.other.symbol", "constant.other.key", "string.quoted"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-string)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["support.function", "entity.name.function"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-function)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["punctuation", "punctuation.separator", "meta.brace", "punctuation.definition"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-punctuation)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.tag", "declaration.tag", "markup.deleted"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-function)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["markup.inserted"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-string)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["markup.changed"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-string-expression)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["invalid", "invalid.deprecated"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-foreground)",
|
||||
"background": "var(--code-token-comment)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["support.type.property-name"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-property)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["entity.other.attribute-name"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-property)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.object-literal.key"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-property)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["entity.other.attribute-name", "entity.other.inherited-class"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-property)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["storage", "storage.type", "storage.modifier"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-keyword)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["support.class"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-function)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["constant.language"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-constant)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.embedded"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-string-expression)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.function-call"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-function)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.method-call"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-function)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.return-type"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-keyword)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": [
|
||||
"meta.import",
|
||||
"meta.import.js",
|
||||
"meta.import.ts",
|
||||
"meta.import.tsx",
|
||||
"meta.import.jsx",
|
||||
"string.quoted.single.js",
|
||||
"string.quoted.double.js",
|
||||
"string.quoted.single.ts",
|
||||
"string.quoted.double.ts",
|
||||
"string.quoted.single.tsx",
|
||||
"string.quoted.double.tsx",
|
||||
"string.quoted.single.jsx",
|
||||
"string.quoted.double.jsx"
|
||||
],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-string-expression)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.var.expr"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-token-variable)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.block"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-foreground)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.delimiter.period"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-foreground)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.brace.round"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-foreground)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scope": ["meta.paren.expr"],
|
||||
"settings": {
|
||||
"foreground": "var(--code-foreground)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// @ts-nocheck
|
||||
// TODO: I'll fix this later.
|
||||
|
||||
import { toc } from 'mdast-util-toc'
|
||||
import { remark } from 'remark'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
const textTypes = ['text', 'emphasis', 'strong', 'inlineCode']
|
||||
|
||||
function flattenNode(node) {
|
||||
const p = []
|
||||
visit(node, (node) => {
|
||||
if (!textTypes.includes(node.type)) return
|
||||
p.push(node.value)
|
||||
})
|
||||
return p.join(``)
|
||||
}
|
||||
|
||||
interface Item {
|
||||
title: string
|
||||
url: string
|
||||
items?: Item[]
|
||||
}
|
||||
|
||||
interface Items {
|
||||
items?: Item[]
|
||||
}
|
||||
|
||||
function getItems(node, current): Items {
|
||||
if (!node) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (node.type === 'paragraph') {
|
||||
visit(node, (item) => {
|
||||
if (item.type === 'link') {
|
||||
current.url = item.url
|
||||
current.title = flattenNode(node)
|
||||
}
|
||||
|
||||
if (item.type === 'text') {
|
||||
current.title = flattenNode(node)
|
||||
}
|
||||
})
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
if (node.type === 'list') {
|
||||
current.items = node.children.map((i) => getItems(i, {}))
|
||||
|
||||
return current
|
||||
} else if (node.type === 'listItem') {
|
||||
const heading = getItems(node.children[0], {})
|
||||
|
||||
if (node.children.length > 1) {
|
||||
getItems(node.children[1], heading)
|
||||
}
|
||||
|
||||
return heading
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
const getToc = () => (node, file) => {
|
||||
const table = toc(node)
|
||||
const items = getItems(table.map, {})
|
||||
|
||||
file.data = items
|
||||
}
|
||||
|
||||
export type TableOfContents = Items
|
||||
|
||||
export async function getTableOfContents(content: string): Promise<TableOfContents> {
|
||||
const result = await remark().use(getToc).process(content)
|
||||
|
||||
return result.data
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cn as uiCN } from 'ui'
|
||||
|
||||
export const cn = uiCN
|
||||
|
||||
export function absoluteUrl(path: string) {
|
||||
return `${process.env.NEXT_PUBLIC_APP_URL}${path}`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { withContentlayer } from 'next-contentlayer2'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ['ui', 'common', 'shared-data', 'icons', 'tsconfig'],
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH,
|
||||
async redirects() {
|
||||
return [
|
||||
...(process.env.NEXT_PUBLIC_BASE_PATH?.length
|
||||
? [
|
||||
{
|
||||
source: '/',
|
||||
destination: process.env.NEXT_PUBLIC_BASE_PATH,
|
||||
basePath: false,
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
},
|
||||
eslint: {
|
||||
// We are already running linting via GH action, this will skip linting during production build on Vercel
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default withContentlayer(nextConfig)
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"name": "learn",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev --port 3007",
|
||||
"build": "pnpm run content:build && next build --turbopack",
|
||||
"internal:sync": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/sync-internal-content.mts",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"lint:mdx": "supa-mdx-lint content --config ../../supa-mdx-lint.config.toml",
|
||||
"content:build": "contentlayer2 build",
|
||||
"clean": "rimraf node_modules .next .turbo",
|
||||
"typecheck": "contentlayer2 build && tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.1.1",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@radix-ui/react-accordion": "*",
|
||||
"@radix-ui/react-alert-dialog": "*",
|
||||
"@radix-ui/react-aspect-ratio": "*",
|
||||
"@radix-ui/react-avatar": "*",
|
||||
"@radix-ui/react-checkbox": "*",
|
||||
"@radix-ui/react-collapsible": "*",
|
||||
"@radix-ui/react-context-menu": "*",
|
||||
"@radix-ui/react-dialog": "*",
|
||||
"@radix-ui/react-dropdown-menu": "*",
|
||||
"@radix-ui/react-hover-card": "*",
|
||||
"@radix-ui/react-label": "*",
|
||||
"@radix-ui/react-menubar": "*",
|
||||
"@radix-ui/react-navigation-menu": "*",
|
||||
"@radix-ui/react-popover": "*",
|
||||
"@radix-ui/react-progress": "*",
|
||||
"@radix-ui/react-radio-group": "*",
|
||||
"@radix-ui/react-scroll-area": "*",
|
||||
"@radix-ui/react-select": "*",
|
||||
"@radix-ui/react-separator": "*",
|
||||
"@radix-ui/react-slider": "*",
|
||||
"@radix-ui/react-slot": "*",
|
||||
"@radix-ui/react-switch": "*",
|
||||
"@radix-ui/react-tabs": "*",
|
||||
"@radix-ui/react-toast": "*",
|
||||
"@radix-ui/react-toggle": "*",
|
||||
"@radix-ui/react-toggle-group": "*",
|
||||
"@radix-ui/react-tooltip": "*",
|
||||
"@react-router/fs-routes": "^7.4.0",
|
||||
"@supabase/postgrest-js": "catalog:",
|
||||
"@supabase/supa-mdx-lint": "0.2.6-alpha",
|
||||
"@supabase/vue-blocks": "workspace:*",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "*",
|
||||
"cmdk": "^1.0.0",
|
||||
"common": "workspace:*",
|
||||
"common-tags": "^1.8.2",
|
||||
"contentlayer2": "0.4.6",
|
||||
"eslint-config-supabase": "workspace:*",
|
||||
"framer-motion": "^11.0.3",
|
||||
"icons": "workspace:*",
|
||||
"jotai": "^2.8.0",
|
||||
"lucide-react": "*",
|
||||
"next": "catalog:",
|
||||
"next-contentlayer2": "0.4.6",
|
||||
"next-themes": "^0.3.0",
|
||||
"openai": "^5.9.0",
|
||||
"openapi-fetch": "0.12.4",
|
||||
"react": "catalog:",
|
||||
"react-docgen": "^7.0.3",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "^7.45.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-wrap-balancer": "^1.1.0",
|
||||
"recharts": "^2.8.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-pretty-code": "^0.9.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark": "^14.0.3",
|
||||
"remark-code-import": "^1.2.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sonner": "^1.5.0",
|
||||
"ui": "workspace:*",
|
||||
"ui-patterns": "workspace:*",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vaul": "^0.9.6",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.1.5",
|
||||
"@shikijs/compat": "^1.1.7",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@tanstack/react-router": "^1.114.27",
|
||||
"@tanstack/react-start": "^1.114.25",
|
||||
"@types/common-tags": "^1.8.4",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"config": "workspace:^",
|
||||
"dotenv": "^17.2.3",
|
||||
"lodash": "^4.17.21",
|
||||
"mdast-util-toc": "^6.1.1",
|
||||
"postcss": "^8.5.3",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-router": "^7.5.2",
|
||||
"rimraf": "^4.1.3",
|
||||
"shadcn": "^3.0.0",
|
||||
"shiki": "^1.1.7",
|
||||
"tailwindcss": "catalog:",
|
||||
"tsconfig": "workspace:*",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 624 B |
|
After Width: | Height: | Size: 800 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 840 B |
|
After Width: | Height: | Size: 913 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig><msapplication><tile><square70x70logo src="/favicon/ms-icon-70x70.png"/><square150x150logo src="/favicon/ms-icon-150x150.png"/><square310x310logo src="/favicon/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 568 B |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "Supabase",
|
||||
"short_name": "Supabase",
|
||||
"description": "The Postgres Development Platform.",
|
||||
"display": "standalone",
|
||||
"theme_color": "#1C1C1C",
|
||||
"background_color": "#1C1C1C",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 1022 B |
@@ -0,0 +1,100 @@
|
||||
<svg width="570" height="527" viewBox="0 0 570 527" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip1_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip2_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48ZM323.541 48L364 7.54133L404.459 48L364 88.4587L323.541 48Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip3_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip4_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 195L48 147L0 195L48 243L96 195Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip5_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 195L206 147L158 195L206 243L254 195ZM165.541 195L206 154.541L246.459 195L206 235.459L165.541 195Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip6_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M343.003 168.003L346.773 171.773L360.229 185.229L364 189L367.771 185.229L381.227 171.773L384.997 168.003L381.227 164.227L367.771 150.771L364 147L360.229 150.771L346.773 164.227L343.003 168.003ZM377.456 168.003L364 181.453L350.544 167.997L364 154.541L377.456 168.003ZM343.003 221.997L346.773 225.773L360.229 239.229L364 243L367.771 239.229L381.227 225.773L384.997 222.003L381.227 218.227L367.771 204.771L364 201L360.229 204.771L346.773 218.227L343.003 221.997ZM377.456 221.997L364 235.464L350.544 222.008L364 208.536L377.456 221.997ZM319.771 198.771L316 195L319.771 191.229L333.227 177.773L337.003 174.003L340.773 177.773L354.229 191.229L358 195L354.229 198.771L340.773 212.227L337.003 215.997L333.227 212.227L319.771 198.771ZM336.997 208.456L350.464 195L336.997 181.544L323.541 195L336.997 208.456ZM370 195L373.771 198.771L387.227 212.227L390.997 215.997L394.773 212.227L408.229 198.771L412 195L408.229 191.229L394.773 177.773L391.003 174.003L387.227 177.773L373.771 191.229L370 195ZM404.453 195L390.997 208.456L377.547 195L391.008 181.544L404.453 195Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip7_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 168.003L504.773 171.773L518.229 185.229L522 189L525.771 185.229L539.227 171.773L542.997 168.003L539.227 164.227L525.771 150.771L522 147L518.229 150.771L504.773 164.227L501.003 168.003ZM501.003 221.997L504.773 225.773L518.229 239.229L522 243L525.771 239.229L539.227 225.773L542.997 222.003L539.227 218.227L525.771 204.771L522 201L518.229 204.771L504.773 218.227L501.003 221.997ZM477.771 198.771L474 195L477.771 191.229L491.227 177.773L495.003 174.003L498.773 177.773L512.229 191.229L516 195L512.229 198.771L498.773 212.227L495.003 215.997L491.227 212.227L477.771 198.771ZM528 195L531.771 198.771L545.227 212.227L548.997 215.997L552.773 212.227L566.229 198.771L570 195L566.229 191.229L552.773 177.773L549.003 174.003L545.227 177.773L531.771 191.229L528 195Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip8_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 342L48 294L0 342L48 390L96 342ZM7.54133 342L48 301.541L88.4587 342L48 382.459L7.54133 342Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip9_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M185.003 315.003L188.773 318.773L202.229 332.229L206 336L209.771 332.229L223.227 318.773L226.997 315.003L223.227 311.227L209.771 297.771L206 294L202.229 297.771L188.773 311.227L185.003 315.003ZM185.003 368.997L188.773 372.773L202.229 386.229L206 390L209.771 386.229L223.227 372.773L226.997 369.003L223.227 365.227L209.771 351.771L206 348L202.229 351.771L188.773 365.227L185.003 368.997ZM161.771 345.771L158 342L161.771 338.229L175.227 324.773L179.003 321.003L182.773 324.773L196.229 338.229L200 342L196.229 345.771L182.773 359.227L179.003 362.997L175.227 359.227L161.771 345.771ZM212 342L215.771 345.771L229.227 359.227L232.997 362.997L236.773 359.227L250.229 345.771L254 342L250.229 338.229L236.773 324.773L233.003 321.003L229.227 324.773L215.771 338.229L212 342Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip10_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 342L364 294L316 342L364 390L412 342Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip11_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 315.003L504.773 318.773L518.229 332.229L522 336L525.771 332.229L539.227 318.773L542.997 315.003L539.227 311.227L525.771 297.771L522 294L518.229 297.771L504.773 311.227L501.003 315.003ZM535.456 315.003L522 328.453L508.544 314.997L522 301.541L535.456 315.003ZM501.003 368.997L504.773 372.773L518.229 386.229L522 390L525.771 386.229L539.227 372.773L542.997 369.003L539.227 365.227L525.771 351.771L522 348L518.229 351.771L504.773 365.227L501.003 368.997ZM535.456 368.997L522 382.464L508.544 369.008L522 355.536L535.456 368.997ZM477.771 345.771L474 342L477.771 338.229L491.227 324.773L495.003 321.003L498.773 324.773L512.229 338.229L516 342L512.229 345.771L498.773 359.227L495.003 362.997L491.227 359.227L477.771 345.771ZM494.997 355.456L508.464 342L494.997 328.544L481.541 342L494.997 355.456ZM528 342L531.771 345.771L545.227 359.227L548.997 362.997L552.773 359.227L566.229 345.771L570 342L566.229 338.229L552.773 324.773L549.003 321.003L545.227 324.773L531.771 338.229L528 342ZM562.453 342L548.997 355.456L535.547 342L549.008 328.544L562.453 342Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip12_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 479L48 431L0 479L48 527L96 479ZM7.54133 479L48 438.541L88.4587 479L48 519.459L7.54133 479Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip13_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M185.003 452.003L188.773 455.773L202.229 469.229L206 473L209.771 469.229L223.227 455.773L226.997 452.003L223.227 448.227L209.771 434.771L206 431L202.229 434.771L188.773 448.227L185.003 452.003ZM219.456 452.003L206 465.453L192.544 451.997L206 438.541L219.456 452.003ZM185.003 505.997L188.773 509.773L202.229 523.229L206 527L209.771 523.229L223.227 509.773L226.997 506.003L223.227 502.227L209.771 488.771L206 485L202.229 488.771L188.773 502.227L185.003 505.997ZM219.456 505.997L206 519.464L192.544 506.008L206 492.536L219.456 505.997ZM161.771 482.771L158 479L161.771 475.229L175.227 461.773L179.003 458.003L182.773 461.773L196.229 475.229L200 479L196.229 482.771L182.773 496.227L179.003 499.997L175.227 496.227L161.771 482.771ZM178.997 492.456L192.464 479L178.997 465.544L165.541 479L178.997 492.456ZM212 479L215.771 482.771L229.227 496.227L232.997 499.997L236.773 496.227L250.229 482.771L254 479L250.229 475.229L236.773 461.773L233.003 458.003L229.227 461.773L215.771 475.229L212 479ZM246.453 479L232.997 492.456L219.547 479L233.008 465.544L246.453 479Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip14_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 479L364 431L316 479L364 527L412 479Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip15_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 452.003L504.773 455.773L518.229 469.229L522 473L525.771 469.229L539.227 455.773L542.997 452.003L539.227 448.227L525.771 434.771L522 431L518.229 434.771L504.773 448.227L501.003 452.003ZM501.003 505.997L504.773 509.773L518.229 523.229L522 527L525.771 523.229L539.227 509.773L542.997 506.003L539.227 502.227L525.771 488.771L522 485L518.229 488.771L504.773 502.227L501.003 505.997ZM477.771 482.771L474 479L477.771 475.229L491.227 461.773L495.003 458.003L498.773 461.773L512.229 475.229L516 479L512.229 482.771L498.773 496.227L495.003 499.997L491.227 496.227L477.771 482.771ZM528 479L531.771 482.771L545.227 496.227L548.997 499.997L552.773 496.227L566.229 482.771L570 479L566.229 475.229L552.773 461.773L549.003 458.003L545.227 461.773L531.771 475.229L528 479Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1709_1536">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip3_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip4_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(0 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip5_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip6_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip7_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip8_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(0 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip9_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip10_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip11_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip12_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(0 431)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip13_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158 431)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip14_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316 431)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip15_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474 431)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,100 @@
|
||||
<svg width="570" height="527" viewBox="0 0 570 527" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip1_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip2_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48ZM323.541 48L364 7.54133L404.459 48L364 88.4587L323.541 48Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip3_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip4_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 195L48 147L0 195L48 243L96 195Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip5_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 195L206 147L158 195L206 243L254 195ZM165.541 195L206 154.541L246.459 195L206 235.459L165.541 195Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip6_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M343.003 168.003L346.773 171.773L360.229 185.229L364 189L367.771 185.229L381.227 171.773L384.997 168.003L381.227 164.227L367.771 150.771L364 147L360.229 150.771L346.773 164.227L343.003 168.003ZM377.456 168.003L364 181.453L350.544 167.997L364 154.541L377.456 168.003ZM343.003 221.997L346.773 225.773L360.229 239.229L364 243L367.771 239.229L381.227 225.773L384.997 222.003L381.227 218.227L367.771 204.771L364 201L360.229 204.771L346.773 218.227L343.003 221.997ZM377.456 221.997L364 235.464L350.544 222.008L364 208.536L377.456 221.997ZM319.771 198.771L316 195L319.771 191.229L333.227 177.773L337.003 174.003L340.773 177.773L354.229 191.229L358 195L354.229 198.771L340.773 212.227L337.003 215.997L333.227 212.227L319.771 198.771ZM336.997 208.456L350.464 195L336.997 181.544L323.541 195L336.997 208.456ZM370 195L373.771 198.771L387.227 212.227L390.997 215.997L394.773 212.227L408.229 198.771L412 195L408.229 191.229L394.773 177.773L391.003 174.003L387.227 177.773L373.771 191.229L370 195ZM404.453 195L390.997 208.456L377.547 195L391.008 181.544L404.453 195Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip7_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 168.003L504.773 171.773L518.229 185.229L522 189L525.771 185.229L539.227 171.773L542.997 168.003L539.227 164.227L525.771 150.771L522 147L518.229 150.771L504.773 164.227L501.003 168.003ZM501.003 221.997L504.773 225.773L518.229 239.229L522 243L525.771 239.229L539.227 225.773L542.997 222.003L539.227 218.227L525.771 204.771L522 201L518.229 204.771L504.773 218.227L501.003 221.997ZM477.771 198.771L474 195L477.771 191.229L491.227 177.773L495.003 174.003L498.773 177.773L512.229 191.229L516 195L512.229 198.771L498.773 212.227L495.003 215.997L491.227 212.227L477.771 198.771ZM528 195L531.771 198.771L545.227 212.227L548.997 215.997L552.773 212.227L566.229 198.771L570 195L566.229 191.229L552.773 177.773L549.003 174.003L545.227 177.773L531.771 191.229L528 195Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip8_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 342L48 294L0 342L48 390L96 342ZM7.54133 342L48 301.541L88.4587 342L48 382.459L7.54133 342Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip9_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M185.003 315.003L188.773 318.773L202.229 332.229L206 336L209.771 332.229L223.227 318.773L226.997 315.003L223.227 311.227L209.771 297.771L206 294L202.229 297.771L188.773 311.227L185.003 315.003ZM185.003 368.997L188.773 372.773L202.229 386.229L206 390L209.771 386.229L223.227 372.773L226.997 369.003L223.227 365.227L209.771 351.771L206 348L202.229 351.771L188.773 365.227L185.003 368.997ZM161.771 345.771L158 342L161.771 338.229L175.227 324.773L179.003 321.003L182.773 324.773L196.229 338.229L200 342L196.229 345.771L182.773 359.227L179.003 362.997L175.227 359.227L161.771 345.771ZM212 342L215.771 345.771L229.227 359.227L232.997 362.997L236.773 359.227L250.229 345.771L254 342L250.229 338.229L236.773 324.773L233.003 321.003L229.227 324.773L215.771 338.229L212 342Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip10_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 342L364 294L316 342L364 390L412 342Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip11_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 315.003L504.773 318.773L518.229 332.229L522 336L525.771 332.229L539.227 318.773L542.997 315.003L539.227 311.227L525.771 297.771L522 294L518.229 297.771L504.773 311.227L501.003 315.003ZM535.456 315.003L522 328.453L508.544 314.997L522 301.541L535.456 315.003ZM501.003 368.997L504.773 372.773L518.229 386.229L522 390L525.771 386.229L539.227 372.773L542.997 369.003L539.227 365.227L525.771 351.771L522 348L518.229 351.771L504.773 365.227L501.003 368.997ZM535.456 368.997L522 382.464L508.544 369.008L522 355.536L535.456 368.997ZM477.771 345.771L474 342L477.771 338.229L491.227 324.773L495.003 321.003L498.773 324.773L512.229 338.229L516 342L512.229 345.771L498.773 359.227L495.003 362.997L491.227 359.227L477.771 345.771ZM494.997 355.456L508.464 342L494.997 328.544L481.541 342L494.997 355.456ZM528 342L531.771 345.771L545.227 359.227L548.997 362.997L552.773 359.227L566.229 345.771L570 342L566.229 338.229L552.773 324.773L549.003 321.003L545.227 324.773L531.771 338.229L528 342ZM562.453 342L548.997 355.456L535.547 342L549.008 328.544L562.453 342Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip12_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 479L48 431L0 479L48 527L96 479ZM7.54133 479L48 438.541L88.4587 479L48 519.459L7.54133 479Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip13_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M185.003 452.003L188.773 455.773L202.229 469.229L206 473L209.771 469.229L223.227 455.773L226.997 452.003L223.227 448.227L209.771 434.771L206 431L202.229 434.771L188.773 448.227L185.003 452.003ZM219.456 452.003L206 465.453L192.544 451.997L206 438.541L219.456 452.003ZM185.003 505.997L188.773 509.773L202.229 523.229L206 527L209.771 523.229L223.227 509.773L226.997 506.003L223.227 502.227L209.771 488.771L206 485L202.229 488.771L188.773 502.227L185.003 505.997ZM219.456 505.997L206 519.464L192.544 506.008L206 492.536L219.456 505.997ZM161.771 482.771L158 479L161.771 475.229L175.227 461.773L179.003 458.003L182.773 461.773L196.229 475.229L200 479L196.229 482.771L182.773 496.227L179.003 499.997L175.227 496.227L161.771 482.771ZM178.997 492.456L192.464 479L178.997 465.544L165.541 479L178.997 492.456ZM212 479L215.771 482.771L229.227 496.227L232.997 499.997L236.773 496.227L250.229 482.771L254 479L250.229 475.229L236.773 461.773L233.003 458.003L229.227 461.773L215.771 475.229L212 479ZM246.453 479L232.997 492.456L219.547 479L233.008 465.544L246.453 479Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip14_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 479L364 431L316 479L364 527L412 479Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip15_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 452.003L504.773 455.773L518.229 469.229L522 473L525.771 469.229L539.227 455.773L542.997 452.003L539.227 448.227L525.771 434.771L522 431L518.229 434.771L504.773 448.227L501.003 452.003ZM501.003 505.997L504.773 509.773L518.229 523.229L522 527L525.771 523.229L539.227 509.773L542.997 506.003L539.227 502.227L525.771 488.771L522 485L518.229 488.771L504.773 502.227L501.003 505.997ZM477.771 482.771L474 479L477.771 475.229L491.227 461.773L495.003 458.003L498.773 461.773L512.229 475.229L516 479L512.229 482.771L498.773 496.227L495.003 499.997L491.227 496.227L477.771 482.771ZM528 479L531.771 482.771L545.227 496.227L548.997 499.997L552.773 496.227L566.229 482.771L570 479L566.229 475.229L552.773 461.773L549.003 458.003L545.227 461.773L531.771 475.229L528 479Z" fill="#B4B4B4"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1709_1536">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip3_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip4_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(0 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip5_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip6_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip7_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip8_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(0 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip9_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip10_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip11_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip12_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(0 431)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip13_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158 431)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip14_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316 431)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip15_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474 431)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,100 @@
|
||||
<svg width="570" height="527" viewBox="0 0 570 527" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip1_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip2_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48ZM323.541 48L364 7.54133L404.459 48L364 88.4587L323.541 48Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip3_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip4_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 195L48 147L0 195L48 243L96 195Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip5_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 195L206 147L158 195L206 243L254 195ZM165.541 195L206 154.541L246.459 195L206 235.459L165.541 195Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip6_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M343.003 168.003L346.773 171.773L360.229 185.229L364 189L367.771 185.229L381.227 171.773L384.997 168.003L381.227 164.227L367.771 150.771L364 147L360.229 150.771L346.773 164.227L343.003 168.003ZM377.456 168.003L364 181.453L350.544 167.997L364 154.541L377.456 168.003ZM343.003 221.997L346.773 225.773L360.229 239.229L364 243L367.771 239.229L381.227 225.773L384.997 222.003L381.227 218.227L367.771 204.771L364 201L360.229 204.771L346.773 218.227L343.003 221.997ZM377.456 221.997L364 235.464L350.544 222.008L364 208.536L377.456 221.997ZM319.771 198.771L316 195L319.771 191.229L333.227 177.773L337.003 174.003L340.773 177.773L354.229 191.229L358 195L354.229 198.771L340.773 212.227L337.003 215.997L333.227 212.227L319.771 198.771ZM336.997 208.456L350.464 195L336.997 181.544L323.541 195L336.997 208.456ZM370 195L373.771 198.771L387.227 212.227L390.997 215.997L394.773 212.227L408.229 198.771L412 195L408.229 191.229L394.773 177.773L391.003 174.003L387.227 177.773L373.771 191.229L370 195ZM404.453 195L390.997 208.456L377.547 195L391.008 181.544L404.453 195Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip7_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 168.003L504.773 171.773L518.229 185.229L522 189L525.771 185.229L539.227 171.773L542.997 168.003L539.227 164.227L525.771 150.771L522 147L518.229 150.771L504.773 164.227L501.003 168.003ZM501.003 221.997L504.773 225.773L518.229 239.229L522 243L525.771 239.229L539.227 225.773L542.997 222.003L539.227 218.227L525.771 204.771L522 201L518.229 204.771L504.773 218.227L501.003 221.997ZM477.771 198.771L474 195L477.771 191.229L491.227 177.773L495.003 174.003L498.773 177.773L512.229 191.229L516 195L512.229 198.771L498.773 212.227L495.003 215.997L491.227 212.227L477.771 198.771ZM528 195L531.771 198.771L545.227 212.227L548.997 215.997L552.773 212.227L566.229 198.771L570 195L566.229 191.229L552.773 177.773L549.003 174.003L545.227 177.773L531.771 191.229L528 195Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip8_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 342L48 294L0 342L48 390L96 342ZM7.54133 342L48 301.541L88.4587 342L48 382.459L7.54133 342Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip9_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M185.003 315.003L188.773 318.773L202.229 332.229L206 336L209.771 332.229L223.227 318.773L226.997 315.003L223.227 311.227L209.771 297.771L206 294L202.229 297.771L188.773 311.227L185.003 315.003ZM185.003 368.997L188.773 372.773L202.229 386.229L206 390L209.771 386.229L223.227 372.773L226.997 369.003L223.227 365.227L209.771 351.771L206 348L202.229 351.771L188.773 365.227L185.003 368.997ZM161.771 345.771L158 342L161.771 338.229L175.227 324.773L179.003 321.003L182.773 324.773L196.229 338.229L200 342L196.229 345.771L182.773 359.227L179.003 362.997L175.227 359.227L161.771 345.771ZM212 342L215.771 345.771L229.227 359.227L232.997 362.997L236.773 359.227L250.229 345.771L254 342L250.229 338.229L236.773 324.773L233.003 321.003L229.227 324.773L215.771 338.229L212 342Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip10_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 342L364 294L316 342L364 390L412 342Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip11_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 315.003L504.773 318.773L518.229 332.229L522 336L525.771 332.229L539.227 318.773L542.997 315.003L539.227 311.227L525.771 297.771L522 294L518.229 297.771L504.773 311.227L501.003 315.003ZM535.456 315.003L522 328.453L508.544 314.997L522 301.541L535.456 315.003ZM501.003 368.997L504.773 372.773L518.229 386.229L522 390L525.771 386.229L539.227 372.773L542.997 369.003L539.227 365.227L525.771 351.771L522 348L518.229 351.771L504.773 365.227L501.003 368.997ZM535.456 368.997L522 382.464L508.544 369.008L522 355.536L535.456 368.997ZM477.771 345.771L474 342L477.771 338.229L491.227 324.773L495.003 321.003L498.773 324.773L512.229 338.229L516 342L512.229 345.771L498.773 359.227L495.003 362.997L491.227 359.227L477.771 345.771ZM494.997 355.456L508.464 342L494.997 328.544L481.541 342L494.997 355.456ZM528 342L531.771 345.771L545.227 359.227L548.997 362.997L552.773 359.227L566.229 345.771L570 342L566.229 338.229L552.773 324.773L549.003 321.003L545.227 324.773L531.771 338.229L528 342ZM562.453 342L548.997 355.456L535.547 342L549.008 328.544L562.453 342Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip12_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 479L48 431L0 479L48 527L96 479ZM7.54133 479L48 438.541L88.4587 479L48 519.459L7.54133 479Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip13_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M185.003 452.003L188.773 455.773L202.229 469.229L206 473L209.771 469.229L223.227 455.773L226.997 452.003L223.227 448.227L209.771 434.771L206 431L202.229 434.771L188.773 448.227L185.003 452.003ZM219.456 452.003L206 465.453L192.544 451.997L206 438.541L219.456 452.003ZM185.003 505.997L188.773 509.773L202.229 523.229L206 527L209.771 523.229L223.227 509.773L226.997 506.003L223.227 502.227L209.771 488.771L206 485L202.229 488.771L188.773 502.227L185.003 505.997ZM219.456 505.997L206 519.464L192.544 506.008L206 492.536L219.456 505.997ZM161.771 482.771L158 479L161.771 475.229L175.227 461.773L179.003 458.003L182.773 461.773L196.229 475.229L200 479L196.229 482.771L182.773 496.227L179.003 499.997L175.227 496.227L161.771 482.771ZM178.997 492.456L192.464 479L178.997 465.544L165.541 479L178.997 492.456ZM212 479L215.771 482.771L229.227 496.227L232.997 499.997L236.773 496.227L250.229 482.771L254 479L250.229 475.229L236.773 461.773L233.003 458.003L229.227 461.773L215.771 475.229L212 479ZM246.453 479L232.997 492.456L219.547 479L233.008 465.544L246.453 479Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip14_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 479L364 431L316 479L364 527L412 479Z" fill="#525252"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip15_1709_1536)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 452.003L504.773 455.773L518.229 469.229L522 473L525.771 469.229L539.227 455.773L542.997 452.003L539.227 448.227L525.771 434.771L522 431L518.229 434.771L504.773 448.227L501.003 452.003ZM501.003 505.997L504.773 509.773L518.229 523.229L522 527L525.771 523.229L539.227 509.773L542.997 506.003L539.227 502.227L525.771 488.771L522 485L518.229 488.771L504.773 502.227L501.003 505.997ZM477.771 482.771L474 479L477.771 475.229L491.227 461.773L495.003 458.003L498.773 461.773L512.229 475.229L516 479L512.229 482.771L498.773 496.227L495.003 499.997L491.227 496.227L477.771 482.771ZM528 479L531.771 482.771L545.227 496.227L548.997 499.997L552.773 496.227L566.229 482.771L570 479L566.229 475.229L552.773 461.773L549.003 458.003L545.227 461.773L531.771 475.229L528 479Z" fill="#525252"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1709_1536">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip3_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip4_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(0 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip5_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip6_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip7_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474 147)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip8_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(0 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip9_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip10_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip11_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474 294)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip12_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(0 431)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip13_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158 431)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip14_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316 431)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip15_1709_1536">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474 431)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,61 @@
|
||||
<svg width="1360" height="96" viewBox="0 0 1360 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip1_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48ZM165.541 48L206 7.54133L246.459 48L206 88.4587L165.541 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip2_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip3_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip4_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M728 48L680 0L632 48L680 96L728 48ZM639.541 48L680 7.54133L720.459 48L680 88.4587L639.541 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip5_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M886 48L838 0L790 48L838 96L886 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1017 21L996 0L975 21L996 42L1017 21ZM978.299 21L996 3.29933L1013.7 21L996 38.7007L978.299 21Z" fill="white"/>
|
||||
<path d="M978.299 21L996 3.29933L1013.7 21L996 38.7007L978.299 21Z" fill="#3ECF8E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1017 75L996 54L975 75L996 96L1017 75ZM978.299 75L996 57.2993L1013.7 75L996 92.7007L978.299 75Z" fill="white"/>
|
||||
<path d="M978.299 75L996 57.2993L1013.7 75L996 92.7007L978.299 75Z" fill="#3ECF8E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M990 48L969 27L948 48L969 69L990 48ZM951.299 48L969 30.2993L986.701 48L969 65.7007L951.299 48Z" fill="white"/>
|
||||
<path d="M951.299 48L969 30.2993L986.701 48L969 65.7007L951.299 48Z" fill="#3ECF8E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1044 48L1023 27L1002 48L1023 69L1044 48ZM1005.3 48L1023 30.2993L1040.7 48L1023 65.7007L1005.3 48Z" fill="white"/>
|
||||
<path d="M1005.3 48L1023 30.2993L1040.7 48L1023 65.7007L1005.3 48Z" fill="#3ECF8E"/>
|
||||
<g clip-path="url(#clip6_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1202 48L1154 0L1106 48L1154 96L1202 48ZM1113.54 48L1154 7.54133L1194.46 48L1154 88.4587L1113.54 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip7_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1360 48L1312 0L1264 48L1312 96L1360 48ZM1271.54 48L1312 7.54133L1352.46 48L1312 88.4587L1271.54 48Z" fill="#15593B"/>
|
||||
<path d="M1271.54 48L1312 7.54133L1352.46 48L1312 88.4587L1271.54 48Z" fill="#3ECF8E"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1709_1278">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip3_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip4_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(632)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip5_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(790)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip6_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(1106)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip7_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(1264)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,61 @@
|
||||
<svg width="1360" height="96" viewBox="0 0 1360 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip1_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48ZM165.541 48L206 7.54133L246.459 48L206 88.4587L165.541 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip2_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip3_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip4_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M728 48L680 0L632 48L680 96L728 48ZM639.541 48L680 7.54133L720.459 48L680 88.4587L639.541 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip5_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M886 48L838 0L790 48L838 96L886 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1017 21L996 0L975 21L996 42L1017 21ZM978.299 21L996 3.29933L1013.7 21L996 38.7007L978.299 21Z" fill="white"/>
|
||||
<path d="M978.299 21L996 3.29933L1013.7 21L996 38.7007L978.299 21Z" fill="#3ECF8E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1017 75L996 54L975 75L996 96L1017 75ZM978.299 75L996 57.2993L1013.7 75L996 92.7007L978.299 75Z" fill="white"/>
|
||||
<path d="M978.299 75L996 57.2993L1013.7 75L996 92.7007L978.299 75Z" fill="#3ECF8E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M990 48L969 27L948 48L969 69L990 48ZM951.299 48L969 30.2993L986.701 48L969 65.7007L951.299 48Z" fill="white"/>
|
||||
<path d="M951.299 48L969 30.2993L986.701 48L969 65.7007L951.299 48Z" fill="#3ECF8E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1044 48L1023 27L1002 48L1023 69L1044 48ZM1005.3 48L1023 30.2993L1040.7 48L1023 65.7007L1005.3 48Z" fill="white"/>
|
||||
<path d="M1005.3 48L1023 30.2993L1040.7 48L1023 65.7007L1005.3 48Z" fill="#3ECF8E"/>
|
||||
<g clip-path="url(#clip6_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1202 48L1154 0L1106 48L1154 96L1202 48ZM1113.54 48L1154 7.54133L1194.46 48L1154 88.4587L1113.54 48Z" fill="#15593B"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip7_1709_1278)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1360 48L1312 0L1264 48L1312 96L1360 48ZM1271.54 48L1312 7.54133L1352.46 48L1312 88.4587L1271.54 48Z" fill="#15593B"/>
|
||||
<path d="M1271.54 48L1312 7.54133L1352.46 48L1312 88.4587L1271.54 48Z" fill="#3ECF8E"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1709_1278">
|
||||
<rect width="96" height="96" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(158)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip2_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(316)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip3_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(474)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip4_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(632)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip5_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(790)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip6_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(1106)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip7_1709_1278">
|
||||
<rect width="96" height="96" fill="white" transform="translate(1264)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |