Merge branch 'feat/docs2.0' of github.com:supabase/supabase into feat/docs2.0
No files matched your search
@@ -74,11 +74,14 @@ export function SearchProvider({ children }: any) {
|
||||
appId={APP_ID}
|
||||
// @ts-ignore
|
||||
navigator={{
|
||||
navigate({ suggestionUrl }) {
|
||||
navigate({ itemUrl }) {
|
||||
setIsOpen(false)
|
||||
router.push(suggestionUrl)
|
||||
router.push(itemUrl)
|
||||
},
|
||||
}}
|
||||
getMissingResultsUrl={({ query }) => {
|
||||
return `https://github.com/supabase/supabase/issues/new?title=Unable+to+search+docs+with+query:+${query}`
|
||||
}}
|
||||
hitComponent={Hit}
|
||||
transformItems={(items) => {
|
||||
return items.map((item, index) => {
|
||||
|
||||
@@ -226,8 +226,9 @@ export const menuItems: NavMenu = {
|
||||
label: 'Edge Functions',
|
||||
items: [
|
||||
{ name: 'Overview', url: '/guides/functions', items: [] },
|
||||
{ name: 'Quickstart', url: '/guides/functions/quickstart', items: [] },
|
||||
{ name: 'Auth', url: '/guides/functions/auth', items: [] },
|
||||
{ name: 'Examples', url: '/guides/functions/examples', items: [] },
|
||||
{ name: 'Best Practices', url: '/guides/functions/best-practices', items: [] },
|
||||
{ name: 'CI/CD Workflow', url: '/guides/functions/cicd-workflow', items: [] },
|
||||
],
|
||||
},
|
||||
@@ -313,6 +314,7 @@ export const menuItems: NavMenu = {
|
||||
{ name: 'Sequin', url: '/guides/integrations/sequin', items: [] },
|
||||
{ name: 'Snaplet', url: '/guides/integrations/snaplet', items: [] },
|
||||
{ name: 'Vercel', url: '/guides/integrations/vercel', items: [] },
|
||||
{ name: 'Zuplo', url: '/guides/integrations/zuplo', items: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -453,21 +453,21 @@ export const reference = {
|
||||
items: [
|
||||
{
|
||||
name: 'supabase-js',
|
||||
url: '/new/reference/javascript/start',
|
||||
url: '/reference/javascript/start',
|
||||
level: 'reference_javascript',
|
||||
items: [],
|
||||
icon: '/img/icons/javascript.svg',
|
||||
},
|
||||
{
|
||||
name: 'supabase-dart',
|
||||
url: '/new/reference/dart/start',
|
||||
url: '/reference/dart/start',
|
||||
level: 'reference_dart',
|
||||
items: [],
|
||||
icon: '/img/icons/javascript.svg',
|
||||
},
|
||||
{
|
||||
name: 'supbase-python',
|
||||
url: '/new/reference/python/start',
|
||||
url: '/reference/python/start',
|
||||
level: 'reference_python',
|
||||
items: [],
|
||||
icon: '/img/icons/javascript.svg',
|
||||
@@ -479,13 +479,13 @@ export const reference = {
|
||||
items: [
|
||||
{
|
||||
name: 'Supabase CLI',
|
||||
url: '/new/reference/cli/start',
|
||||
url: '/reference/cli/start',
|
||||
items: [],
|
||||
icon: '/img/icons/cli.svg',
|
||||
},
|
||||
{
|
||||
name: 'Management API',
|
||||
url: '/new/reference/javascript',
|
||||
url: '/reference/javascript',
|
||||
items: [],
|
||||
icon: '/img/icons/javascript.svg',
|
||||
},
|
||||
@@ -598,20 +598,20 @@ export const references = [
|
||||
versions: ['v2', 'v1'],
|
||||
description: 'something about the reference',
|
||||
icon: '/docs/img/icons/javascript-icon.svg',
|
||||
url: '/new/reference/javascript/start',
|
||||
url: '/reference/javascript/start',
|
||||
},
|
||||
{
|
||||
label: 'supabase-py',
|
||||
description: 'something about the reference',
|
||||
icon: '/docs/img/icons/python-icon.svg',
|
||||
url: '/new/reference/python/start',
|
||||
url: '/reference/python/start',
|
||||
},
|
||||
{
|
||||
label: 'supabase-dart',
|
||||
versions: ['v1', 'v0'],
|
||||
description: 'something about the reference',
|
||||
icon: '/docs/img/icons/dart-icon.svg',
|
||||
url: '/new/reference/dart/start',
|
||||
url: '/reference/dart/start',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -622,13 +622,13 @@ export const references = [
|
||||
label: 'CLI',
|
||||
description: 'something about the reference',
|
||||
icon: '/docs/img/icons/cli-icon.svg',
|
||||
url: '/new/reference/cli/start',
|
||||
url: '/reference/cli/start',
|
||||
},
|
||||
{
|
||||
label: 'Management API',
|
||||
description: 'something about the reference',
|
||||
icon: '/docs/img/icons/api-icon.svg',
|
||||
url: '/new/reference/management-api/start',
|
||||
url: '/reference/management-api/start',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { IconChevronLeft } from '~/../../packages/ui'
|
||||
import * as NavItems from './NavigationMenu.constants'
|
||||
import NavigationMenuGuideList from './NavigationMenuGuideList'
|
||||
import NavigationMenuRefList from './NavigationMenuRefList'
|
||||
import NavigationMenuCliList from './NavigationMenuCliList'
|
||||
import { useTheme } from 'common/Providers'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import NavigationMenuCliList from './NavigationMenuCliList'
|
||||
import NavigationMenuGuideList from './NavigationMenuGuideList'
|
||||
import NavigationMenuRefList from './NavigationMenuRefList'
|
||||
|
||||
const SideNav = () => {
|
||||
const router = useRouter()
|
||||
@@ -40,9 +38,6 @@ const SideNav = () => {
|
||||
case url.includes(`/docs/guides/functions`) && url:
|
||||
setLevel('functions')
|
||||
break
|
||||
case url.includes(`/docs/reference`) && url:
|
||||
setLevel('reference')
|
||||
break
|
||||
case url.includes(`/docs/guides/integrations`) && url:
|
||||
setLevel('integrations')
|
||||
break
|
||||
@@ -50,13 +45,13 @@ const SideNav = () => {
|
||||
(url.includes(`/docs/guides/hosting/platform`) && url):
|
||||
setLevel('platform')
|
||||
break
|
||||
case url.includes(`/docs/new/reference/javascript/`) && url:
|
||||
case url.includes(`/docs/reference/javascript`) && url:
|
||||
setLevel('reference_javascript')
|
||||
break
|
||||
case url.includes(`/docs/new/reference/dart/`) && url:
|
||||
case url.includes(`/docs/reference/dart`) && url:
|
||||
setLevel('reference_dart')
|
||||
break
|
||||
case url.includes(`/docs/new/reference/cli/`) && url:
|
||||
case url.includes(`/docs/reference/cli`) && url:
|
||||
setLevel('reference_cli')
|
||||
break
|
||||
|
||||
@@ -155,21 +150,21 @@ const SideNav = () => {
|
||||
label: 'JavaScript Client',
|
||||
icon: '/img/icons/javascript-icon',
|
||||
hasLightIcon: false,
|
||||
href: '/new/reference/javascript/start',
|
||||
level: 'reference_javascript',
|
||||
},
|
||||
{
|
||||
label: 'Python Client Library',
|
||||
icon: '/img/icons/python-icon',
|
||||
hasLightIcon: false,
|
||||
href: '/new/reference/javascript/start',
|
||||
href: '/reference/javascript/start',
|
||||
level: 'reference_javascript',
|
||||
},
|
||||
// {
|
||||
// label: 'Python Client Library',
|
||||
// icon: '/img/icons/python-icon',
|
||||
// hasLightIcon: false,
|
||||
// href: '/reference/javascript/start',
|
||||
// level: 'reference_javascript',
|
||||
// },
|
||||
{
|
||||
label: 'Dart Client Library',
|
||||
icon: '/img/icons/dart-icon',
|
||||
hasLightIcon: false,
|
||||
href: '/new/reference/dart/start',
|
||||
href: '/reference/dart/start',
|
||||
level: 'reference_dart',
|
||||
},
|
||||
{
|
||||
@@ -179,20 +174,20 @@ const SideNav = () => {
|
||||
label: 'Mangement API',
|
||||
icon: '/img/icons/api-icon',
|
||||
hasLightIcon: false,
|
||||
href: '/new/reference/javascript/start',
|
||||
href: '/reference/javascript/start',
|
||||
level: 'reference_javascript',
|
||||
},
|
||||
{
|
||||
label: 'CLI',
|
||||
icon: '/img/icons/cli-icon',
|
||||
hasLightIcon: false,
|
||||
href: '/new/reference/cli/start',
|
||||
href: '/reference/cli/start',
|
||||
level: 'reference_javascript',
|
||||
},
|
||||
{
|
||||
label: 'Self hosting server',
|
||||
icon: '/img/icons/menu/platform',
|
||||
href: '/new/reference/javascript/start',
|
||||
href: '/reference/javascript/start',
|
||||
level: 'reference_javascript',
|
||||
},
|
||||
],
|
||||
@@ -279,90 +274,6 @@ const SideNav = () => {
|
||||
<NavigationMenuRefList id={'reference_javascript'} currentLevel={level} setLevel={setLevel} />
|
||||
<NavigationMenuRefList id={'reference_dart'} currentLevel={level} setLevel={setLevel} />
|
||||
<NavigationMenuCliList id={'reference_cli'} currentLevel={level} setLevel={setLevel} />
|
||||
|
||||
{/* // ref menu */}
|
||||
{/* <div
|
||||
className={[
|
||||
'absolute transition-all ml-8 duration-200',
|
||||
level === 'ref' ? 'opacity-100 ml-0 visible' : 'opacity-0 invisible',
|
||||
].join(' ')}
|
||||
>
|
||||
<ul className={'relative w-full flex flex-col gap-2'}>
|
||||
{ref.map((link) => {
|
||||
return (
|
||||
<li
|
||||
onClick={() => {
|
||||
setLevel(link.level)
|
||||
router.push(tempBasePath + link.href)
|
||||
}}
|
||||
className={[
|
||||
'flex items-center gap-3',
|
||||
'text-base transition-all duration-200 text-scale-1200 hover:text-brand-900 hover:cursor-pointer ',
|
||||
].join(' ')}
|
||||
>
|
||||
<img src={`${router.basePath}/img/icons/menu/${link.icon}`} />
|
||||
{link.label}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div> */}
|
||||
|
||||
{/* // JS menu */}
|
||||
{/* <div
|
||||
className={[
|
||||
'absolute transition-all ml-8 duration-200',
|
||||
level === 'ref_js' ? 'opacity-100 ml-0 visible' : 'opacity-0 invisible',
|
||||
].join(' ')}
|
||||
>
|
||||
<ul className={'relative w-full flex flex-col gap-2'}>
|
||||
{ref_js.map((link) => {
|
||||
return (
|
||||
<li
|
||||
onClick={() => {
|
||||
setLevel(link.level)
|
||||
router.push(tempBasePath + link.href)
|
||||
}}
|
||||
className={[
|
||||
'flex items-center gap-3',
|
||||
'text-base transition-all duration-200 text-scale-1200 hover:text-brand-900 hover:cursor-pointer ',
|
||||
].join(' ')}
|
||||
>
|
||||
<img src={`${router.basePath}/img/icons/menu/${link.icon}`} />
|
||||
{link.label}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div> */}
|
||||
|
||||
{/* // Dart menu */}
|
||||
{/* <div
|
||||
className={[
|
||||
'absolute transition-all ml-8 duration-200',
|
||||
level === 'ref_dart' ? 'opacity-100 ml-0 visible' : 'opacity-0 invisible',
|
||||
].join(' ')}
|
||||
>
|
||||
<ul className={'relative w-full flex flex-col gap-2'}>
|
||||
{ref_js.map((link) => {
|
||||
return (
|
||||
<li
|
||||
onClick={() => {
|
||||
setLevel(link.level)
|
||||
router.push(tempBasePath + link.href)
|
||||
}}
|
||||
className={[
|
||||
'flex items-center gap-3',
|
||||
'text-base transition-all duration-200 text-scale-1200 hover:text-brand-900 hover:cursor-pointer ',
|
||||
].join(' ')}
|
||||
>
|
||||
<img src={`${router.basePath}/img/icons/menu/${link.icon}`} />
|
||||
{link.label}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const NavigationMenuRefList = ({ currentLevel, setLevel, id }) => {
|
||||
}) => {
|
||||
return (
|
||||
<li key={id}>
|
||||
<Link href={`/new/reference/${library}/${id}`} passHref>
|
||||
<Link href={`/reference/${library}/${id}`} passHref>
|
||||
<a className="cursor-pointer transition text-scale-1000 text-sm hover:text-brand-900 flex gap-3">
|
||||
{icon && <img className="w-3" src={`${router.basePath}${icon}`} />}
|
||||
{title}
|
||||
|
||||
@@ -92,7 +92,7 @@ const TopNavBarRef: FC = () => {
|
||||
<span className="font-mono text-sm font-medium text-brand-900">DOCS</span>
|
||||
</a>
|
||||
</Link>
|
||||
{/* {router.asPath.includes('/new/reference/') && <RefSwitcher />} */}
|
||||
{/* {router.asPath.includes('/reference/') && <RefSwitcher />} */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-12">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC, useState } from 'react'
|
||||
import { IconXCircle } from '~/../../packages/ui'
|
||||
import { AcceptedValue } from '~/pages/new/reference/cli/[...slug]'
|
||||
import { AcceptedValue } from '~/pages/reference/cli/[...slug]'
|
||||
|
||||
interface IOptions {
|
||||
name?: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC } from 'react'
|
||||
import { Flag } from '~/pages/new/reference/cli/[...slug]'
|
||||
import { Flag } from '~/pages/reference/cli/[...slug]'
|
||||
|
||||
type IParamProps = Flag | any
|
||||
|
||||
|
||||
@@ -118,6 +118,11 @@ const Nav = [
|
||||
url: '/reference/dart/storage-from-createsignedurl',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'from.createSignedUrls()',
|
||||
url: '/reference/dart/storage-from-createsignedurls',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'from.getPublicUrl()',
|
||||
url: '/reference/dart/storage-from-getpublicurl',
|
||||
|
||||
@@ -28,6 +28,33 @@ Once you initialize Supabase in your `main()` method, you can access the `Supaba
|
||||
final supabase = Supabase.instance.client;
|
||||
```
|
||||
|
||||
### Call `initialize()` with custom headers
|
||||
|
||||
You can pass `headers` to initialize your Supabase client with customer headers.
|
||||
Here is an example of passing a custom auth header to Supabase client.
|
||||
```dart title=main.dart
|
||||
Future<void> main() async {
|
||||
await Supabase.initialize(
|
||||
url: 'https://xyzcompany.supabase.co',
|
||||
anonKey: 'public-anon-key',
|
||||
headers: {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
);
|
||||
runApp(const MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
### Call `initialize()`multiple times
|
||||
|
||||
You can initialize Supabase multiple times in your app. When doing so, call `dispose()` each time before you initialize again.
|
||||
|
||||
```dart
|
||||
Supabase.instance.dispose();
|
||||
|
||||
await Supabase.initialize(url: 'https://xyzcompany.supabase.co', anonKey: 'public-anon-key');
|
||||
```
|
||||
|
||||
## Other Dart Projects
|
||||
|
||||
You can initialize a new Supabase client using the `SupabaseClient()` method.
|
||||
|
||||
@@ -117,7 +117,7 @@ const supabase = createClient('https://xyzcompany.supabase.co', 'public-anon-key
|
||||
```
|
||||
|
||||
By default the API server points to the `public` schema. You can enable other database schemas within the Dashboard.
|
||||
Go to `Settings > API > Schema` and add the schema which you want to expose to the API.
|
||||
Go to `Settings > API > Schema` and add the schema which you want to expose to the API. You also need to grant `USAGE` on your new schema with the grants you desire, such as `SELECT, INSERT, UPDATE, DELETE`.
|
||||
|
||||
Note: each client connection can only access a single schema, so the code above can access the `other_schema` schema but cannot access the `public` schema.
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ const SiteRefLayout = ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<main>
|
||||
<img
|
||||
{/* <img
|
||||
src={`${router.basePath}/img/gradient-bg.png`}
|
||||
className="absolute left-0 w-[520px] top-0 -z-1 pointer-events-none"
|
||||
/>
|
||||
/> */}
|
||||
|
||||
<div className="flex flex-row h-screen">
|
||||
<div
|
||||
@@ -73,7 +73,7 @@ const SiteRefLayout = ({ children }) => {
|
||||
<span className="font-mono text-sm font-medium text-brand-900">DOCS</span>
|
||||
</a>
|
||||
</Link>
|
||||
{/* {router.asPath.includes('/new/reference/') && <RefSwitcher />} */}
|
||||
{/* {router.asPath.includes('/reference/') && <RefSwitcher />} */}
|
||||
</div>
|
||||
<div className="h-4 bg-scale-200 w-full"></div>
|
||||
<div className="bg-gradient-to-b from-scale-200 to-transparent h-4 w-full"></div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useInView } from 'react-intersection-observer'
|
||||
import { FC } from 'react'
|
||||
|
||||
interface ISectionContainer extends FC {
|
||||
interface ISectionContainer {
|
||||
id: string
|
||||
title?: string
|
||||
monoFont?: boolean
|
||||
}
|
||||
|
||||
type RefSubLayoutSubComponents = {
|
||||
Section: ISectionContainer
|
||||
Details: ISectionDetails
|
||||
Examples: ISectionExamples
|
||||
Section: FC<ISectionContainer>
|
||||
Details: FC<ISectionDetails>
|
||||
Examples: FC<ISectionExamples>
|
||||
}
|
||||
|
||||
type StickyHeader = {
|
||||
id: string
|
||||
title: string
|
||||
monoFont: boolean
|
||||
title?: string
|
||||
monoFont?: boolean
|
||||
}
|
||||
|
||||
type RefSubLayoutType = {}
|
||||
@@ -31,16 +31,16 @@ const RefSubLayout: FC<RefSubLayoutType> & RefSubLayoutSubComponents = (props) =
|
||||
)
|
||||
}
|
||||
|
||||
const Section: FC<ISectionContainer> = ({ id, title, monoFont, children }) => {
|
||||
const Section: FC<ISectionContainer> = (props) => {
|
||||
return (
|
||||
<article key={id} className="scroll-mt-24">
|
||||
<StickyHeader id={id} title={title} monoFont={monoFont} />
|
||||
<div className="grid lg:grid-cols-2 ref-container gap-16">{children}</div>
|
||||
<article key={props.id} className="scroll-mt-24">
|
||||
<StickyHeader id={props.id} title={props.title} monoFont={props.monoFont} />
|
||||
<div className="grid lg:grid-cols-2 ref-container gap-16">{props.children}</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
const StickyHeader: FC<StickyHeader> = ({ id, title, monoFont }) => {
|
||||
const StickyHeader: FC<StickyHeader> = (props) => {
|
||||
const { ref } = useInView({
|
||||
threshold: 1,
|
||||
rootMargin: '30% 0% -35% 0px',
|
||||
@@ -52,21 +52,23 @@ const StickyHeader: FC<StickyHeader> = ({ id, title, monoFont }) => {
|
||||
return (
|
||||
<h2
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={['text-xl font-medium text-scale-1200 mb-8 ', monoFont && 'font-mono'].join(' ')}
|
||||
id={props.id}
|
||||
className={['text-xl font-medium text-scale-1200 mb-8 ', props.monoFont && 'font-mono'].join(
|
||||
' '
|
||||
)}
|
||||
>
|
||||
<span className="max-w-xl">{title}</span>
|
||||
{props.title && <span className="max-w-xl">{props.title}</span>}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
interface ISectionDetails extends FC {}
|
||||
interface ISectionDetails {}
|
||||
|
||||
const Details: FC<ISectionDetails> = (props: any) => {
|
||||
return <div className="">{props.children}</div>
|
||||
const Details: FC<ISectionDetails> = (props) => {
|
||||
return <div>{props.children}</div>
|
||||
}
|
||||
|
||||
interface ISectionExamples extends FC {}
|
||||
interface ISectionExamples {}
|
||||
|
||||
const Examples: FC<ISectionExamples> = (props) => {
|
||||
return (
|
||||
@@ -76,7 +78,6 @@ const Examples: FC<ISectionExamples> = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
// @ts-ignore // needs typing with FC type
|
||||
RefSubLayout.Section = Section
|
||||
RefSubLayout.Details = Details
|
||||
RefSubLayout.Examples = Examples
|
||||
|
||||
@@ -150,19 +150,20 @@ function extractParamTypeAsString(paramDefinition) {
|
||||
} else if (paramDefinition.type?.type === 'union') {
|
||||
// only do this for literal/primitive types - for complex objects we just return 'object'
|
||||
if (paramDefinition.type.types.every(({ type }) => ['literal', 'intrinsic'].includes(type))) {
|
||||
return paramDefinition.type.types.map((x) => {
|
||||
if (x.type === 'literal') {
|
||||
if (typeof x.value === 'string') {
|
||||
return `"${x.value}"`
|
||||
return paramDefinition.type.types
|
||||
.map((x) => {
|
||||
if (x.type === 'literal') {
|
||||
if (typeof x.value === 'string') {
|
||||
return `"${x.value}"`
|
||||
}
|
||||
return `${x.value}`
|
||||
} else if (x.type === 'intrinsic') {
|
||||
if (x.name === 'unknown') {
|
||||
return 'any'
|
||||
}
|
||||
return x.name
|
||||
}
|
||||
return `${x.value}`
|
||||
} else if (x.type === 'intrinsic') {
|
||||
if (x.name === 'unknown') {
|
||||
return 'any'
|
||||
}
|
||||
return x.name
|
||||
}
|
||||
})
|
||||
})
|
||||
.join(' | ')
|
||||
}
|
||||
} else if (paramDefinition.type?.type === 'array') {
|
||||
@@ -231,6 +232,7 @@ export function gen_v3(spec: OpenAPIV3.Document, dest: string, { apiUrl }: { api
|
||||
|
||||
responseList: toArrayWithKey(operation.responses!, 'responseCode') || [],
|
||||
}
|
||||
// @ts-expect-error // missing 'responses', see OpenAPIV3.OperationObject.responses
|
||||
operations.push(enriched)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@ const nextConfig = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
|
||||
@@ -37,10 +37,10 @@
|
||||
"@algolia/autocomplete-plugin-recent-searches": "^1.7.2",
|
||||
"@apollo/client": "^3.6.6",
|
||||
"@code-hike/mdx": "^0.7.4",
|
||||
"@docsearch/react": "1.0.0-alpha.27",
|
||||
"@mdx-js/loader": "^2.1.5",
|
||||
"@mdx-js/react": "^2.1.5",
|
||||
"@next/mdx": "^12.3.2",
|
||||
"@docsearch/react": "^3.3.0",
|
||||
"@mdx-js/loader": "^1.6.22",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"@next/mdx": "^12.0.4",
|
||||
"@radix-ui/react-accordion": "^1.0.1",
|
||||
"@tailwindcss/typography": "^0.4.1",
|
||||
"algoliasearch": "^4.14.2",
|
||||
|
||||
@@ -33,9 +33,9 @@ export default function Custom404() {
|
||||
}`}
|
||||
>
|
||||
<div className="text-scale-1200 flex w-[320px] flex-col items-center justify-center space-y-3">
|
||||
<h1 className="m-2 text-2xl">Looking for something? 🔍</h1>
|
||||
<h1 className="m-2 text-2xl">Looking for something?</h1>
|
||||
<p className="text-center text-sm">
|
||||
We couldn't find the page that you're looking for!
|
||||
We could not find the page that you are looking for!
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
@@ -11,5 +11,5 @@ export function middleware(request: NextRequest) {
|
||||
|
||||
// See "Matching Paths" below to learn more
|
||||
export const config = {
|
||||
matcher: '/about/:path*',
|
||||
//matcher: '/about/:path*',
|
||||
}
|
||||
@@ -57,31 +57,43 @@ You can enable third-party providers with the click of a button by navigating to
|
||||
|
||||
When using third-party providers, the [Supabase client library](/docs/reference/javascript/auth-signinwithoauth#sign-in-using-a-third-party-provider-with-redirect) redirects the user to the provider. When the third-party provider successfully authenticates the user, the provider redirects the user to the Supabase Auth callback URL where they are further redirected to the URL specified in the `redirectTo` parameter. This parameter defaults to the [`SITE_URL`](/docs/reference/auth/config#site_url). You can modify the `SITE_URL` or [add additional redirect URLs](https://app.supabase.com/project/_/auth/settings).
|
||||
|
||||
You can use wildcard match patterns to support preview URLs from providers like Netlify and Vercel. See the [full list of supported patterns](https://pkg.go.dev/github.com/gobwas/glob#Compile).
|
||||
You can use wildcard match patterns to support preview URLs from providers like Netlify and Vercel. See the [full list of supported patterns](https://pkg.go.dev/github.com/gobwas/glob#Compile). Use [this tool](https://www.digitalocean.com/community/tools/glob?comments=true&glob=http%3A%2F%2Flocalhost%3A3000%2F%2A%2A&matches=false&tests=http%3A%2F%2Flocalhost%3A3000&tests=http%3A%2F%2Flocalhost%3A3000%2F&tests=http%3A%2F%2Flocalhost%3A3000%2F%3Ftest%3Dtest&tests=http%3A%2F%2Flocalhost%3A3000%2Ftest-test%3Ftest%3Dtest&tests=http%3A%2F%2Flocalhost%3A3000%2Ftest%2Ftest%3Ftest%3Dtest) to test your patterns.
|
||||
|
||||
> ⚠️ WARNING: While the "globstar" (`**`) is useful for local development and preview URLs, we recommend setting the exact redirect URL path for your site URL in production.
|
||||
|
||||
#### Netlify preview URLs
|
||||
|
||||
For deployments with Netlify, set the `SITE_URL` to your official site URL. Add the following additional redirect URLs for local development and deployment previews:
|
||||
|
||||
- `http://localhost:3000/*/*`
|
||||
- `https://**--my_org.netlify.app/*`
|
||||
- `http://localhost:3000/**`
|
||||
- `https://**--my_org.netlify.app/**`
|
||||
|
||||
#### Vercel preview URLs
|
||||
|
||||
For deployments with Vercel, set the `SITE_URL` to your official site URL. Add the following additional redirect URLs for local development and deployment previews:
|
||||
|
||||
- `http://localhost:3000/*/*`
|
||||
- `https://**vercel.app/*/*`
|
||||
- `http://localhost:3000/**`
|
||||
- `https://*-username.vercel.app/**`
|
||||
|
||||
Vercel provides an environment variable for the URL of the deployment called `NEXT_PUBLIC_VERCEL_URL`. See the [Vercel docs](https://vercel.com/docs/concepts/projects/environment-variables#system-environment-variables) for more details. You can use this variable to dynamically redirect depending on the environment:
|
||||
|
||||
```js
|
||||
const getURL = () => {
|
||||
let url =
|
||||
process?.env?.NEXT_PUBLIC_SITE_URL ?? // Set this to your site URL in production env.
|
||||
process?.env?.NEXT_PUBLIC_VERCEL_URL ?? // Automatically set by Vercel.
|
||||
'http://localhost:3000/';
|
||||
// Make sure to include `https://` when not localhost.
|
||||
url = url.includes('http') ? url : `https://${url}`;
|
||||
// Make sure to including trailing `/`.
|
||||
url = url.charAt(url.length - 1) === '/' ? url : `${url}/`;
|
||||
return url;
|
||||
};
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'github'
|
||||
options: {
|
||||
redirectTo: process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: "http://localhost:3000"
|
||||
redirectTo: getURL()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -365,7 +365,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## Server-side data fetching to OAuth APIs using `provider token` {#oauth-provider-token}
|
||||
## Server-side data fetching to OAuth APIs using `provider token` {`#oauth-provider-token`}
|
||||
|
||||
When using third-party auth providers, sessions are initiated with an additional `provider_token` field which is persisted in the auth cookie and can be accessed within the session object. The `provider_token` can be used to make API requests to the OAuth provider's API endpoints on behalf of the logged-in user.
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import OldContent from '~/pages/guides/auth.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return OldContent()
|
||||
|
||||
@@ -87,7 +87,7 @@ Read about resetting your database password [here](/docs/guides/database/managin
|
||||
|
||||
## Next steps
|
||||
|
||||
- Read more about [Postgres](/docs/postgres/server/about)
|
||||
- Read more about [Postgres](https://www.postgresql.org/about/)
|
||||
- Sign in: [app.supabase.com](https://app.supabase.com)
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
|
||||
@@ -1,280 +1,31 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import {Button} from 'ui'
|
||||
|
||||
export const meta = {
|
||||
id: 'functions',
|
||||
title: 'Edge Functions',
|
||||
description: 'Globally distributed TypeScript functions.',
|
||||
sidebar_label: 'Overview',
|
||||
hide_table_of_contents: true,
|
||||
}
|
||||
|
||||
Edge Functions are server-side TypeScript functions, distributed globally at the edge—close to your users. They can be used for listening to webhooks or integrating your Supabase project with third-parties [like Stripe](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/stripe-webhooks).
|
||||
|
||||
Edge Functions are developed using [Deno](https://deno.com), which offers a few benefits to you as a developer:
|
||||
Edge Functions are server-side TypeScript functions, distributed globally at the edge—close to your users. They can be used for listening to webhooks or integrating your Supabase project with third-parties [like Stripe](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/stripe-webhooks). Edge Functions are developed using [Deno](https://deno.com), which offers a few benefits to you as a developer:
|
||||
|
||||
- It is open source.
|
||||
- It is portable. Supabase Edge Functions run locally, and on any other Deno-compatible platform (including self-hosted infrastructure).
|
||||
- It is TypeScript first and supports WASM.
|
||||
- Edge Functions are globally distributed for low-latency.
|
||||
|
||||
## Quickstart
|
||||
<Button as="a" href="/docs/guides/functions/quickstart" size="medium">Get started</Button>
|
||||
|
||||
Learn how to develop Edge Functions in less than 7 minutes:
|
||||
## Examples
|
||||
|
||||
<div className="video-container">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/rzglqRdZUQE"
|
||||
frameBorder="1"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
Check out the [Edge Function Examples](https://github.com/supabase/supabase/tree/master/examples/edge-functions) in our GitHub repository.
|
||||
|
||||
<div className="container" style={{ padding: 0 }}>
|
||||
<FunctionsExamples />
|
||||
</div>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Follow the steps to prepare your Supabase project on your local machine.
|
||||
|
||||
- Install the Supabase CLI. [Docs](/docs/guides/cli).
|
||||
- Login to the CLI using the command: `supabase login`. [Docs](/docs/reference/cli/usage#supabase-login).
|
||||
- Initialize Supabase inside your project using the command: `supabase init`. [Docs](/docs/guides/cli/local-development#getting-started).
|
||||
- Link to your Remote Project using the command `supabase link --project-ref your-project-ref`. [Docs](/docs/reference/cli/usage#supabase-link).
|
||||
- Optional: Setup your environment: Follow [this setup guide](https://deno.land/manual/getting_started/setup_your_environment) to integrate the Deno language server with your editor.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Let's build an Edge Function locally, then deploy it to the Supabase Platform.
|
||||
|
||||
### Creating a function
|
||||
|
||||
Let's create a new Edge Function called `hello-world` inside your project:
|
||||
|
||||
```bash
|
||||
supabase functions new hello-world
|
||||
```
|
||||
|
||||
This creates a function stub in your `supabase` folder at `./functions/hello-world/index.ts`.
|
||||
|
||||
### Deploy to production
|
||||
|
||||
```bash
|
||||
supabase functions deploy hello-world
|
||||
```
|
||||
|
||||
This command bundles your Edge Function from `./functions/hello-world/index.ts` and deploys it to the Supabase platform.
|
||||
The command outputs a URL to the Supabase Dashboard which you can open to find view more details. Let's open the link to find the execution command.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
By default, Edge Functions require a valid JWT in the authorization header. This header is automatically set when invoking your function via a Supabase client library.
|
||||
|
||||
If you want to use Edge Functions to handle webhooks (e.g. [Stripe payment webhooks](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/stripe-webhooks) etc.), you need to pass the `--no-verify-jwt` flag when deploying your function.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Executing Remote Functions
|
||||
|
||||
You can execute Edge Functions using curl. Copy the curl command from the Dashboard. It should look like this:
|
||||
|
||||
```bash
|
||||
curl --request POST 'https://<project_ref>.functions.supabase.co/hello-world' \
|
||||
--header 'Authorization: Bearer ANON_KEY' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{ "name":"Functions" }'
|
||||
```
|
||||
|
||||
If you receive an error `Invalid JWT`, find the `ANON_KEY` of your project in the Dashboard under `Settings > API`.
|
||||
|
||||
After invoking your Edge Function you should see the response `{ "message":"Hello Functions!" }`.
|
||||
|
||||
## Debugging your Functions
|
||||
|
||||
You can debug your deployed Edge Functions using the "Functions" section of the Dashboard. There are two types debugging tools available:
|
||||
|
||||
- Invocations: shows the Request and Response for each execution.
|
||||
- Logs: shows any platform events, including deployments and errors.
|
||||
|
||||

|
||||
|
||||
## Developing locally
|
||||
|
||||
You can run your Edge Function locally using [`supabase functions serve`](/docs/reference/cli/usage#supabase-functions-serve):
|
||||
|
||||
```bash
|
||||
supabase start # start the supabase stack
|
||||
supabase functions serve hello-world # start the Function watcher
|
||||
```
|
||||
|
||||
The `functions serve` command has hot-reloading capabilities. It will watch for any changes to your files and restart the Deno server.
|
||||
|
||||
### Invoking Functions locally
|
||||
|
||||
While serving your local Function, you can execute it using curl:
|
||||
|
||||
```bash
|
||||
curl --request POST 'http://localhost:54321/functions/v1/hello-world' \
|
||||
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{ "name":"Functions" }'
|
||||
```
|
||||
|
||||
You should see the response `{ "message":"Hello Functions!" }`.
|
||||
|
||||
<details>
|
||||
<summary>Implementation details</summary>
|
||||
|
||||
- All Edge Functions are `POST` requests.
|
||||
- The `Authorization` header is required. You can use either the `ANON` key, the `SERVICE_ROLE` key, or a logged-in user's JWT.
|
||||
- The Function is proxied through the local API (`http://localhost:54321`)
|
||||
|
||||
</details>
|
||||
<br />
|
||||
|
||||
If you execute Function with a different payload the response will change. <br />
|
||||
Modify the `--data '{"name":"Functions"}'` line to `--data '{"name":"World"}'` and try invoking the command again!
|
||||
|
||||
## Secrets and Environment Variables
|
||||
|
||||
It's common that you will need to use sensitive information or environment-specific variables inside your Edge Functions. You can access these using Deno's built-in handler
|
||||
|
||||
```js
|
||||
Deno.env.get(MY_SECRET_NAME)
|
||||
```
|
||||
|
||||
### Default secrets
|
||||
|
||||
By default, Edge Functions have access to these secrets:
|
||||
|
||||
- `SUPABASE_URL`: The API gateway for your Supabase project.
|
||||
- `SUPABASE_ANON_KEY`: The `anon` key for your Supabase API. This is safe to use in a browser when you have [Row Level Security](/docs/guides/auth/row-level-security) enabled.
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`: The `service_role` key for your Supabase API. This is safe to use in Edge Functions, but it should NEVER be used in a browser. This key will bypass [Row Level Security](/docs/guides/auth/row-level-security).
|
||||
- `SUPABASE_DB_URL`: The URL for your [PostgreSQL database](/docs/guides/database). You can use this to connect directly to your database.
|
||||
|
||||
### Local secrets
|
||||
|
||||
Let's create a local file for storing our secrets, and inside it we can store a secret `MY_NAME`:
|
||||
|
||||
```jsx
|
||||
echo "MY_NAME=Yoda" >> ./supabase/.env.local
|
||||
```
|
||||
|
||||
This creates a new file `./supabase/.env.local` for storing your local development secrets.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Never check your .env files into Git!
|
||||
|
||||
</Admonition>
|
||||
|
||||
Now let's access this environment variable `MY_NAME` inside our Function. Anywhere in your function, add this line:
|
||||
|
||||
```jsx
|
||||
console.log(Deno.env.get('MY_NAME'))
|
||||
```
|
||||
|
||||
Now we can invoke our function locally, by serving it with our new `.env.local` file:
|
||||
|
||||
```bash
|
||||
supabase functions serve hello-world --env-file ./supabase/.env.local
|
||||
```
|
||||
|
||||
When the function starts you should see the name “Yoda” output to the terminal.
|
||||
|
||||
### Production secrets
|
||||
|
||||
Let's create a `.env` for production. In this case we'll just use the same as our local secrets:
|
||||
|
||||
```bash
|
||||
cp ./supabase/.env.local ./supabase/.env
|
||||
```
|
||||
|
||||
This creates a new file `./supabase/.env` for storing your production secrets.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Never check your `.env` files into Git!
|
||||
|
||||
</Admonition>
|
||||
|
||||
Let's push all the secrets from the `.env` file to our remote project using [`supabase secrets set`](/docs/reference/cli/usage#supabase-secrets-set):
|
||||
|
||||
```bash
|
||||
supabase secrets set --env-file ./supabase/.env
|
||||
|
||||
# You can also set secrets individually using:
|
||||
supabase secrets set MY_NAME=Chewbacca
|
||||
```
|
||||
|
||||
You don't need to re-deploy after setting your secrets.
|
||||
|
||||
To see all the secrets which you have set remotely, use [`supabase secrets list`](/docs/reference/cli/usage#supabase-secrets-list):
|
||||
|
||||
```bash
|
||||
supabase secrets list
|
||||
```
|
||||
|
||||
## Suggestions
|
||||
|
||||
### Database Functions vs Edge Functions
|
||||
|
||||
For data-intensive operations we recommend using [Database Functions](/docs/guides/database/functions), which are executed within your database
|
||||
and can be called remotely using the [REST and GraphQL API](/docs/guides/api).
|
||||
|
||||
For use-cases which require low-latency we recommend [Edge Functions](/docs/guides/functions), which are globally-distributed and can be written in TypeScript.
|
||||
|
||||
### Organizing your Edge Functions
|
||||
|
||||
We recommend developing “fat functions”. This means that you should develop few large functions, rather than many small functions. One common pattern when developing Functions is that you need to share code between two or more Functions. To do this, you can store any shared code in a folder prefixed with an underscore (`_`). We recommend this folder structure:
|
||||
|
||||
```bash
|
||||
└── supabase
|
||||
├── functions
|
||||
│ ├── _shared
|
||||
│ | ├── supabaseAdmin.ts # Supabase client with SERVICE_ROLE key
|
||||
│ │ └── supabaseClient.ts # Supabase client with ANON key
|
||||
│ ├── function-one # use hyphens to name functions
|
||||
│ │ └── index.ts
|
||||
│ └── function-two
|
||||
│ └── index.ts
|
||||
├── migrations
|
||||
└── config.toml
|
||||
```
|
||||
|
||||
### Naming Edge Functions
|
||||
|
||||
We recommend using hyphens to name functions because hyphens are the most URL-friendly of all the naming conventions (snake_case, camelCase, PascalCase).
|
||||
|
||||
### CORS (Cross-Origin Resource Sharing)
|
||||
|
||||
We recommend adding a check to handle [CORS Preflight](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) requests in your edge function to be able to invoke the function from browsers.
|
||||
|
||||
See the [example on GitHub](https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/browser-with-cors/index.ts).
|
||||
|
||||
```ts
|
||||
export const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey',
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders })
|
||||
}
|
||||
...
|
||||
})
|
||||
```
|
||||
|
||||
### Using HTTP Methods
|
||||
|
||||
Edge Functions supports `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. A function can be designed to perform different actions based on a request's HTTP method. See the [example on building a RESTful service](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/restful-tasks) to learn how to handle different HTTP methods in your function.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Deno Deploy limitations
|
||||
- Deno does not support outgoing connections to ports `25`, `465`, and `587`.
|
||||
- Cannot write to File System
|
||||
- Edge Functions
|
||||
- Local development - only one function at a time
|
||||
- Serving of HTML content is not supported (`GET` requests that return `text/html` will be rewritten to `text/plain`).
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,64 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
|
||||
export const meta = {
|
||||
id: 'functions-best-practices',
|
||||
title: 'Edge Functions Best Practices',
|
||||
description: 'Globally distributed TypeScript functions.',
|
||||
}
|
||||
|
||||
## Database Functions vs Edge Functions
|
||||
|
||||
For data-intensive operations we recommend using [Database Functions](/docs/guides/database/functions), which are executed within your database
|
||||
and can be called remotely using the [REST and GraphQL API](/docs/guides/api).
|
||||
|
||||
For use-cases which require low-latency we recommend [Edge Functions](/docs/guides/functions), which are globally-distributed and can be written in TypeScript.
|
||||
|
||||
## Organizing your Edge Functions
|
||||
|
||||
We recommend developing “fat functions”. This means that you should develop few large functions, rather than many small functions. One common pattern when developing Functions is that you need to share code between two or more Functions. To do this, you can store any shared code in a folder prefixed with an underscore (`_`). We recommend this folder structure:
|
||||
|
||||
```bash
|
||||
└── supabase
|
||||
├── functions
|
||||
│ ├── _shared
|
||||
│ | ├── supabaseAdmin.ts # Supabase client with SERVICE_ROLE key
|
||||
│ │ └── supabaseClient.ts # Supabase client with ANON key
|
||||
│ ├── function-one # use hyphens to name functions
|
||||
│ │ └── index.ts
|
||||
│ └── function-two
|
||||
│ └── index.ts
|
||||
├── migrations
|
||||
└── config.toml
|
||||
```
|
||||
|
||||
## Naming Edge Functions
|
||||
|
||||
We recommend using hyphens to name functions because hyphens are the most URL-friendly of all the naming conventions (snake_case, camelCase, PascalCase).
|
||||
|
||||
## CORS (Cross-Origin Resource Sharing)
|
||||
|
||||
We recommend adding a check to handle [CORS Preflight](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request) requests in your edge function to be able to invoke the function from browsers.
|
||||
|
||||
See the [example on GitHub](https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/browser-with-cors/index.ts).
|
||||
|
||||
```ts
|
||||
export const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey',
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders })
|
||||
}
|
||||
...
|
||||
})
|
||||
```
|
||||
|
||||
## Using HTTP Methods
|
||||
|
||||
Edge Functions supports `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. A function can be designed to perform different actions based on a request's HTTP method. See the [example on building a RESTful service](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/restful-tasks) to learn how to handle different HTTP methods in your function.
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,210 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
|
||||
export const meta = {
|
||||
id: 'functions-quickstart',
|
||||
title: 'Edge Functions Quickstart',
|
||||
description: 'Globally distributed TypeScript functions.',
|
||||
sidebar_label: 'Quickstart',
|
||||
}
|
||||
|
||||
Learn how to build an Edge Function locally and deploy it to the Supabase Platform in less than 7 minutes.
|
||||
|
||||
<div className="video-container">
|
||||
<iframe
|
||||
src="https://www.youtube-nocookie.com/embed/rzglqRdZUQE"
|
||||
frameBorder="1"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Follow the steps to prepare your Supabase project on your local machine.
|
||||
|
||||
- Install the Supabase CLI. [Docs](/docs/guides/cli).
|
||||
- Login to the CLI using the command: `supabase login`. [Docs](/docs/reference/cli/usage#supabase-login).
|
||||
- Initialize Supabase inside your project using the command: `supabase init`. [Docs](/docs/guides/cli/local-development#getting-started).
|
||||
- Link to your Remote Project using the command `supabase link --project-ref your-project-ref`. [Docs](/docs/reference/cli/usage#supabase-link).
|
||||
- Optional: Setup your environment: Follow [this setup guide](https://deno.land/manual/getting_started/setup_your_environment) to integrate the Deno language server with your editor.
|
||||
|
||||
## Create a function
|
||||
|
||||
Let's create a new Edge Function called `hello-world` inside your project:
|
||||
|
||||
```bash
|
||||
supabase functions new hello-world
|
||||
```
|
||||
|
||||
This creates a function stub in your `supabase` folder at `./functions/hello-world/index.ts`.
|
||||
|
||||
## Deploy to production
|
||||
|
||||
```bash
|
||||
supabase functions deploy hello-world
|
||||
```
|
||||
|
||||
This command bundles your Edge Function from `./functions/hello-world/index.ts` and deploys it to the Supabase platform.
|
||||
The command outputs a URL to the Supabase Dashboard which you can open to find view more details. Let's open the link to find the execution command.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
By default, Edge Functions require a valid JWT in the authorization header. This header is automatically set when invoking your function via a Supabase client library.
|
||||
|
||||
If you want to use Edge Functions to handle webhooks (e.g. [Stripe payment webhooks](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/stripe-webhooks) etc.), you need to pass the `--no-verify-jwt` flag when deploying your function.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Execute remote functions
|
||||
|
||||
You can execute Edge Functions using curl. Copy the curl command from the Dashboard. It should look like this:
|
||||
|
||||
```bash
|
||||
curl --request POST 'https://<project_ref>.functions.supabase.co/hello-world' \
|
||||
--header 'Authorization: Bearer ANON_KEY' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{ "name":"Functions" }'
|
||||
```
|
||||
|
||||
If you receive an error `Invalid JWT`, find the `ANON_KEY` of your project in the Dashboard under `Settings > API`.
|
||||
|
||||
After invoking your Edge Function you should see the response `{ "message":"Hello Functions!" }`.
|
||||
|
||||
## Debug functions
|
||||
|
||||
You can debug your deployed Edge Functions using the "Functions" section of the Dashboard. There are two types debugging tools available:
|
||||
|
||||
- Invocations: shows the Request and Response for each execution.
|
||||
- Logs: shows any platform events, including deployments and errors.
|
||||
|
||||

|
||||
|
||||
## Develop locally
|
||||
|
||||
You can run your Edge Function locally using [`supabase functions serve`](/docs/reference/cli/usage#supabase-functions-serve):
|
||||
|
||||
```bash
|
||||
supabase start # start the supabase stack
|
||||
supabase functions serve hello-world # start the Function watcher
|
||||
```
|
||||
|
||||
The `functions serve` command has hot-reloading capabilities. It will watch for any changes to your files and restart the Deno server.
|
||||
|
||||
### Invoke functions locally
|
||||
|
||||
While serving your local Function, you can execute it using curl:
|
||||
|
||||
```bash
|
||||
curl --request POST 'http://localhost:54321/functions/v1/hello-world' \
|
||||
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{ "name":"Functions" }'
|
||||
```
|
||||
|
||||
You should see the response `{ "message":"Hello Functions!" }`.
|
||||
|
||||
<details>
|
||||
<summary>Implementation details</summary>
|
||||
|
||||
- All Edge Functions are `POST` requests.
|
||||
- The `Authorization` header is required. You can use either the `ANON` key, the `SERVICE_ROLE` key, or a logged-in user's JWT.
|
||||
- The Function is proxied through the local API (`http://localhost:54321`)
|
||||
|
||||
</details>
|
||||
<br />
|
||||
|
||||
If you execute Function with a different payload the response will change. <br />
|
||||
Modify the `--data '{"name":"Functions"}'` line to `--data '{"name":"World"}'` and try invoking the command again!
|
||||
|
||||
## Secrets and Environment Variables
|
||||
|
||||
It's common that you will need to use sensitive information or environment-specific variables inside your Edge Functions. You can access these using Deno's built-in handler
|
||||
|
||||
```js
|
||||
Deno.env.get(MY_SECRET_NAME)
|
||||
```
|
||||
|
||||
### Default secrets
|
||||
|
||||
By default, Edge Functions have access to these secrets:
|
||||
|
||||
- `SUPABASE_URL`: The API gateway for your Supabase project.
|
||||
- `SUPABASE_ANON_KEY`: The `anon` key for your Supabase API. This is safe to use in a browser when you have [Row Level Security](/docs/guides/auth/row-level-security) enabled.
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`: The `service_role` key for your Supabase API. This is safe to use in Edge Functions, but it should NEVER be used in a browser. This key will bypass [Row Level Security](/docs/guides/auth/row-level-security).
|
||||
- `SUPABASE_DB_URL`: The URL for your [PostgreSQL database](/docs/guides/database). You can use this to connect directly to your database.
|
||||
|
||||
### Local secrets
|
||||
|
||||
Let's create a local file for storing our secrets, and inside it we can store a secret `MY_NAME`:
|
||||
|
||||
```jsx
|
||||
echo "MY_NAME=Yoda" >> ./supabase/.env.local
|
||||
```
|
||||
|
||||
This creates a new file `./supabase/.env.local` for storing your local development secrets.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Never check your .env files into Git!
|
||||
|
||||
</Admonition>
|
||||
|
||||
Now let's access this environment variable `MY_NAME` inside our Function. Anywhere in your function, add this line:
|
||||
|
||||
```jsx
|
||||
console.log(Deno.env.get('MY_NAME'))
|
||||
```
|
||||
|
||||
Now we can invoke our function locally, by serving it with our new `.env.local` file:
|
||||
|
||||
```bash
|
||||
supabase functions serve hello-world --env-file ./supabase/.env.local
|
||||
```
|
||||
|
||||
When the function starts you should see the name “Yoda” output to the terminal.
|
||||
|
||||
### Production secrets
|
||||
|
||||
Let's create a `.env` for production. In this case we'll just use the same as our local secrets:
|
||||
|
||||
```bash
|
||||
cp ./supabase/.env.local ./supabase/.env
|
||||
```
|
||||
|
||||
This creates a new file `./supabase/.env` for storing your production secrets.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Never check your `.env` files into Git!
|
||||
|
||||
</Admonition>
|
||||
|
||||
Let's push all the secrets from the `.env` file to our remote project using [`supabase secrets set`](/docs/reference/cli/usage#supabase-secrets-set):
|
||||
|
||||
```bash
|
||||
supabase secrets set --env-file ./supabase/.env
|
||||
|
||||
# You can also set secrets individually using:
|
||||
supabase secrets set MY_NAME=Chewbacca
|
||||
```
|
||||
|
||||
You don't need to re-deploy after setting your secrets.
|
||||
|
||||
To see all the secrets which you have set remotely, use [`supabase secrets list`](/docs/reference/cli/usage#supabase-secrets-list):
|
||||
|
||||
```bash
|
||||
supabase secrets list
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Deno Deploy limitations
|
||||
- Deno does not support outgoing connections to ports `25`, `465`, and `587`.
|
||||
- Cannot write to File System
|
||||
- Edge Functions
|
||||
- Local development - only one function at a time
|
||||
- Serving of HTML content is not supported (`GET` requests that return `text/html` will be rewritten to `text/plain`).
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -39,7 +39,7 @@ Each system has a number of configuration options which can be found in the rele
|
||||
- [Realtime](https://github.com/supabase/realtime#server)
|
||||
- [GoTrue](https://github.com/supabase/gotrue)
|
||||
- [Storage](https://github.com/supabase/storage-api)
|
||||
- [Kong](https://docs.konghq.com/install/docker/)
|
||||
- [Kong](https://docs.konghq.com/gateway/latest/install/docker/)
|
||||
|
||||
## Managing your database
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const meta = {
|
||||
<h2>{item.name}</h2>
|
||||
<div className="grid grid-cols-12 gap-6 not-prose">
|
||||
{item.items.map((integration) => (
|
||||
<Link href={`/new/${integration.href}`} key={integration.title} passHref>
|
||||
<Link href={`/${integration.href}`} key={integration.title} passHref>
|
||||
<a className={integration.span ?? 'col-span-4'}>
|
||||
<GlassPanel
|
||||
title={integration.name}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
|
||||
export const meta = {
|
||||
id: 'zuplo',
|
||||
title: 'Zuplo',
|
||||
description: 'Building a public API backed by Supabase.',
|
||||
}
|
||||
|
||||
[Zuplo](https://zuplo.com) is a fully-managed API gateway that offers the easiest way to securely and safely share your API. In this guide we look at how you can combine Zuplo and Supabase to create a public API with rate-limiting, a self-serve developer portal, and API-key authentication. There is an [accompanying video for this article](https://www.youtube.com/watch?v=GJSkbxMnWxE).
|
||||
|
||||

|
||||
|
||||
In this example we're going to work with a simple table that allows people to read and write entries to a Supabase table that contains some reviews of skis. Because this is an API for developers, we have to assume that they may be calling it from another backend service and can't login as a user using the standard Supabase method. In this scenario, API keys are often a better choice - see [Wait, you're not using API keys?](https://zuplo.com/blog/2022/05/03/you-should-be-using-api-keys/).
|
||||
|
||||
We'll allow people, with a valid API key, to read data from the ski results table and to create new records. Hopefully it's obvious that there are many ways that you can extend this example to add more behavior like roles based access, with custom policies, custom handlers and more.
|
||||
|
||||
## Setting up Supabase
|
||||
|
||||
If you haven't already, create a new project in Supabase and create a table called ski-reviews with the following columns:
|
||||
|
||||
- id (int8)
|
||||
- created_at (timestamptz)
|
||||
- make (varchar)
|
||||
- model (varchar)
|
||||
- year (int8)
|
||||
- rating (int2)
|
||||
- author (varchar)
|
||||
|
||||
Manually enter a couple of rows of data, so that we have something to read from the DB.
|
||||
|
||||
## The `Get all` reviews route in Zuplo
|
||||
|
||||
Login to Zuplo at [portal.zuplo.com](https://portal.zuplo.com] and create a new project in Zuplo - I went with `supabase-ski-reviews`.
|
||||
|
||||
Select the **File** tab and choose **Routes**. Add your first route with the following settings:
|
||||
|
||||
- method: `GET`
|
||||
- path: `/reviews`
|
||||
- summary: `Get all reviews`
|
||||
- version: `v1`
|
||||
- CORS: `Anything goes`
|
||||
|
||||
And in the request handler section, paste the `READ ALL ROWS` URL of your Supabase backend (you can get to this in the **API docs** section of Supabase)
|
||||
|
||||
- URL Rewrite: `https://YOUR_SUPABASE_URL.supabase.co/rest/v1/ski-reviews?select=*`
|
||||
- Forward Search: `unchecked`
|
||||
|
||||
In order to call the Supabase backend I need to add some authentication headers to the outgoing request.
|
||||
|
||||
Expand the **Policies** section of your route. Click **Add policy** on the **Request** pipeline.
|
||||
|
||||
We don't want to forward any headers that the client sends us to Supabase, so find the **Clear Headers Policy** and add that to your inbound pipeline. Note, that we will allow the `content-type` header to flow through, so this should be your policy config.
|
||||
|
||||
```json
|
||||
{
|
||||
"export": "ClearHeadersInboundPolicy",
|
||||
"module": "$import(@zuplo/runtime)",
|
||||
"options": {
|
||||
"exclude": ["content-type"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Next, we need to add the credentials to the outgoing request. We'll need to get the JWT token from supabase - you'll find it in **Settings** > **API** as shown below:
|
||||
|
||||

|
||||
|
||||
Once you've got your service_role JWT, click **Add Policy** again on the **Request** pipeline and choose the **Add/Set Headers Policy** and configure it as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"export": "SetHeadersInboundPolicy",
|
||||
"module": "$import(@zuplo/runtime)",
|
||||
"options": {
|
||||
"headers": [
|
||||
{
|
||||
"name": "apikey",
|
||||
"value": "$env(SUPABASE_API_KEY)",
|
||||
"overwrite": true
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"value": "$env(SUPABASE_AUTHZ_HEADER)",
|
||||
"overwrite": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save your changes.
|
||||
|
||||
Next, create two secret [environment variables](https://zuplo.com/docs/deployments/environment-variables) as follows:
|
||||
|
||||
- SUPABASE_API_KEY: `"YOUR_SUPABASE_SECRET_ROLE_JWT"`
|
||||
- SUPABASE_AUTHZ_HEADER: `"Bearer YOUR_SUPABASE_SECRET_ROLE_JWT"`
|
||||
|
||||
Obviously, in both instances replace `YOUR_SUPABASE_SECRET_ROLE_JWT` with your service_role JWT from Supabase.
|
||||
|
||||
You are now ready to invoke your API gateway and see data flow through from your Supabase backend!
|
||||
|
||||
Click on the **open in browser** button shown below and you should see the JSON, flowing from Supabase in your browser 👏.
|
||||
|
||||

|
||||
|
||||
## Adding authentication
|
||||
|
||||
At this point, that route is wide open to the world so we need to secure it. We'll do this using API keys. You can follow this guide [Add API key Authentication](https://zuplo.com/docs/quickstarts/add-api-key-auth). Be sure to drag the API Key authentication policy to the very top of your **Request** pipeline. Come back here when you're done.
|
||||
|
||||
Welcome back! You've now learned how to secure your API with API-Keys.
|
||||
|
||||
## Adding a Create route
|
||||
|
||||
Next we'll add a route that allows somebody to create a review. Add another route with the following settings
|
||||
|
||||
- method: `POST`
|
||||
- path: `/reviews`
|
||||
- summary: `Create a new review`
|
||||
- version: `v1`
|
||||
- CORS: `Anything goes`
|
||||
|
||||
And the request handler as follows:
|
||||
|
||||
- URL Rewrite: `https://YOUR_SUPABASE_URL.supabase.co/rest/v1/ski-reviews`
|
||||
- Forward Search: `unchecked`
|
||||
|
||||
Expand the policies section and add the same policies (note you can reuse policies by picking from the existing policies at the top of the library)
|
||||
|
||||

|
||||
|
||||
- api-key-auth-inbound
|
||||
- clear-headers-inbound
|
||||
- set-headers-inbound
|
||||
|
||||
Now your **create** route is secured and will automatically set the right headers before calling Supabase. That was easy.
|
||||
|
||||
You can test this out by using the **API Test Console** to invoke your new endpoint. Go to the **API Test Console** and create a new test called `create-review.json`.
|
||||
|
||||
- Method: `POST`
|
||||
- Path: `/v1/reviews`
|
||||
- Headers:
|
||||
- `content-type`: `application/json`
|
||||
- `authorization`: `Bearer YOUR_ZUPLO_API_KEY`
|
||||
- Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"make": "Rossignol",
|
||||
"model": "Soul HD7",
|
||||
"rating": 5,
|
||||
"year": 2019
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
If you invoke your API by clicking `Test` you should see that you get a **201 Created** - congratulations!
|
||||
|
||||
## Add validation to your post
|
||||
|
||||
To make your API more usable and more secure it is good practice to validate incoming requests. In this case we will add a JSON Schema document and use it to validate the incoming body to our POST.
|
||||
|
||||
Create a new schema document called `new-review.json`.
|
||||
|
||||

|
||||
|
||||
This example fits the ski-reviews table we described above
|
||||
|
||||
```json
|
||||
{
|
||||
"$id": "http://example.com/example.json",
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Root Schema",
|
||||
"required": ["make", "model", "rating", "year"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"make": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"title": "The make Schema",
|
||||
"examples": ["DPS"]
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"title": "The model Schema",
|
||||
"examples": ["Pagoda"]
|
||||
},
|
||||
"rating": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"title": "The rating Schema",
|
||||
"examples": [5]
|
||||
},
|
||||
"year": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"title": "The year Schema",
|
||||
"examples": [2018]
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"make": "DPS",
|
||||
"model": "Pagoda",
|
||||
"rating": 5,
|
||||
"year": 2018,
|
||||
"author": "Josh"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Now add a new policy to **request** pipeline for your `Create new review` route. Choose the **JSON Body Validation** policy and configure it to use your newly created JSON schema document:
|
||||
|
||||
```json
|
||||
{
|
||||
"export": "ValidateJsonSchemaInbound",
|
||||
"module": "$import(@zuplo/runtime)",
|
||||
"options": {
|
||||
"validator": "$import(./schemas/new-review.json)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This policy can be dragged to the first position in your pipeline.
|
||||
|
||||
Now to test this is working, go back to your API test console and change the body of your `create-review.json` test to be invalid (add a new property for example). You should find that you get a `400 Bad Request` response.
|
||||
|
||||

|
||||
|
||||
Finally, lean back and marvel at your beautiful Developer Portal that took almost zero effort to get this far, wow! Hopefully you already found the link for this when adding API key support :)
|
||||
|
||||

|
||||
|
||||
Zuplo can also be used to handle Supabase JWT tokens for any API, learn more at [API Authentication with Supabase JWT Tokens](https://zuplo.com/blog/2022/11/15/api-authentication-with-supabase-jwt)
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -38,31 +38,6 @@ Supabase provides a logging interface specific to each product. You can use simp
|
||||
|
||||
[Postgres logs](https://app.supabase.com/project/_/logs/postgres-logs) show queries and activity for your [database](../../guides/database).
|
||||
|
||||
By default, query logs are disabled for all new Supabase projects, as they can reveal metadata about the contents of your database (e.g. table and column names). If you'd like to enable query logs, you can enable and configure the [pgAudit extension](https://www.pgaudit.org/):
|
||||
|
||||
- [Enable the pgAudit extension for your project](https://app.supabase.com/project/_/database/extensions)
|
||||
- [Reboot the DB](https://app.supabase.com/project/_/settings/general); a "Fast database reboot" is sufficient, and faster than a full project restart
|
||||
- Configure `pgaudit.log` to the [appropriate value](https://github.com/pgaudit/pgaudit/blob/master/README.md#pgauditlog)
|
||||
- You should be able to view query logs in your [log explorer](https://app.supabase.com/project/_/logs/explorer) now
|
||||
|
||||
As an example, if you wanted to enable logs for writes and DDL statements for a single session, you could execute within the session:
|
||||
|
||||
```sql
|
||||
set pgaudit.log = 'write, ddl';
|
||||
```
|
||||
|
||||
To _permanently_ enable logs for all statements that relate to roles and privileges, you could execute (followed by a db reboot):
|
||||
|
||||
```sql
|
||||
alter system set pgaudit.log to 'role';
|
||||
```
|
||||
|
||||
To remove a system-wide setting, you can use (followed by a db reboot):
|
||||
|
||||
```sql
|
||||
alter system reset pgaudit.log
|
||||
```
|
||||
|
||||

|
||||
|
||||
</TabPanel>
|
||||
@@ -106,6 +81,50 @@ The Logs tab displays logs emitted during function execution.
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
## Logging Postgres Queries
|
||||
|
||||
By default, query logs are disabled for new Supabase projects, as they can reveal metadata about the contents of your database (such as table and column names).
|
||||
|
||||
To enable query logs:
|
||||
|
||||
1. [Enable the pgAudit extension](https://app.supabase.com/project/_/database/extensions).
|
||||
2. [Restart your project](https://app.supabase.com/project/_/settings/general) using the **Fast database reboot** option.
|
||||
3. Configure `pgaudit.log` (see below). Perform a fast reboot if needed.
|
||||
4. View your query logs under [Logs > Postgres Logs](https://app.supabase.com/project/_/logs/postgres-logs).
|
||||
|
||||
### Configuring `pgaudit.log`
|
||||
|
||||
The stored value under `pgaudit.log` determines the classes of statements that are logged by [pgAudit extension](https://www.pgaudit.org/). Refer to the pgAudit documentation for the [full list of values](https://github.com/pgaudit/pgaudit/blob/master/README.md#pgauditlog).
|
||||
|
||||
To enable logging for function calls/do blocks, writes, and DDL statements for a single session, execute the following within the session:
|
||||
|
||||
```sql
|
||||
-- temporary single-session config update
|
||||
set pgaudit.log = 'function, write, ddl';
|
||||
```
|
||||
|
||||
To _permanently_ set a logging configuration (beyond a single session), execute the following, then perform a fast reboot:
|
||||
|
||||
```sql
|
||||
-- equivalent permanent config update.
|
||||
alter role postgres set pgaudit.log to 'function, write, ddl';
|
||||
```
|
||||
|
||||
To reset system-wide settings, execute the following, then perform a fast reboot:
|
||||
|
||||
```sql
|
||||
-- resets stored config.
|
||||
alter role postgres reset pgaudit.log
|
||||
```
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
If any permission errors are encountered when executing `alter role postgres ...`, it is likely that your project has yet to receive the patch to the latest version of [supautils](https://github.com/supabase/supautils), which is currently being rolled out.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Logs Explorer
|
||||
|
||||
The [Logs Explorer](https://app.supabase.com/project/_/logs-explorer) exposes logs from each part of the Supabase stack as a separate table that can be queried and joined using SQL.
|
||||
|
||||
@@ -10,7 +10,7 @@ export const meta = {
|
||||
Supabase provides a globally distributed cluster of [Realtime](https://github.com/supabase/realtime) servers that enable the following functionality:
|
||||
|
||||
- [Broadcast](#broadcast): Send ephemeral messages from client to clients with low latency.
|
||||
- [Presence](#presence): Track and synchrononize shared state between clients.
|
||||
- [Presence](#presence): Track and synchronize shared state between clients.
|
||||
- [Postgres CDC](#postgres-cdc): Listen to Postgres database changes and send them to authorized clients.
|
||||
|
||||
A [channel](https://hexdocs.pm/phoenix/channels.html) is the basic building block of Realtime and narrows the scope of data flow to subscribed clients. You can think of a channel as a chatroom where participants are able to see who's online and send and receive messages; similar to a Discord or Slack channel.
|
||||
|
||||
@@ -14,7 +14,7 @@ export const meta = {
|
||||
{topArticles.map((item) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/new/${item.href}`}
|
||||
href={`/${item.href}`}
|
||||
key={item.title}
|
||||
passHref
|
||||
>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-angular.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-expo.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-flutter.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-ionic-angular.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-ionic-react.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-ionic-vue.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import OldContent from '~/pages/guides/with-nextjs.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return OldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-nuxt-3.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-react.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-redwoodjs.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-solidjs.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-svelte.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-sveltekit.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import oldContent from '~/pages/guides/with-vue-3.mdx'
|
||||
|
||||
export const meta = {}
|
||||
|
||||
export const Page = ({ children }) => {
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'true') {
|
||||
return oldContent()
|
||||
|
||||
@@ -17,7 +17,7 @@ export const meta = {
|
||||
{topArticles.map((item) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/new/${item.href}`}
|
||||
href={`/${item.href}`}
|
||||
key={item.title}
|
||||
passHref
|
||||
>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import Layout from '~/layouts/DefaultLayout'
|
||||
|
||||
export const meta = {
|
||||
title: 'Ref',
|
||||
}
|
||||
|
||||
## REF REF
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -4,6 +4,14 @@ import CodeBlock from '~/components/CodeBlock/CodeBlock'
|
||||
import Options from '~/components/Options'
|
||||
import Param from '~/components/Params'
|
||||
import RefSubLayout from '~/layouts/ref/RefSubLayout'
|
||||
import { getAllDocs, getDocsBySlug } from '~/lib/docs'
|
||||
|
||||
import toc from 'markdown-toc'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
|
||||
import components from '~/components/index'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import OldLayout from '~/layouts/Default'
|
||||
|
||||
export type AcceptedValue = {
|
||||
id: string
|
||||
@@ -32,7 +40,19 @@ export type Command = {
|
||||
usage?: string
|
||||
}
|
||||
|
||||
export default function Config() {
|
||||
export default function Config(props) {
|
||||
/*
|
||||
* handle old ref pages
|
||||
*/
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'false') {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<OldLayout meta={props.meta} toc={props.toc}>
|
||||
<MDXRemote {...props.content} components={components} />
|
||||
</OldLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RefSubLayout>
|
||||
<div className="flex my-16">
|
||||
@@ -123,3 +143,38 @@ export default function Config() {
|
||||
</RefSubLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export async function getStaticProps({ params }: { params: { slug: string[] } }) {
|
||||
let slug
|
||||
if (params.slug.length > 1) {
|
||||
slug = `docs/${params.slug.join('/')}`
|
||||
} else {
|
||||
slug = `docs/${params.slug[0]}`
|
||||
}
|
||||
|
||||
let doc = getDocsBySlug(slug)
|
||||
const content = await serialize(doc.content || '')
|
||||
|
||||
return {
|
||||
props: {
|
||||
...doc,
|
||||
content,
|
||||
toc: toc(doc.content, { maxdepth: 1, firsth1: false }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getStaticPaths() {
|
||||
let docs = getAllDocs()
|
||||
|
||||
return {
|
||||
paths: docs.map(() => {
|
||||
return {
|
||||
params: {
|
||||
slug: docs.map((d) => d.slug),
|
||||
},
|
||||
}
|
||||
}),
|
||||
fallback: 'blocking',
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import fs from 'fs'
|
||||
import toc from 'markdown-toc'
|
||||
|
||||
import matter from 'gray-matter'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import components from '~/components/index'
|
||||
import { getAllDocs } from '~/lib/docs'
|
||||
import { getAllDocs, getDocsBySlug } from '~/lib/docs'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
@@ -27,6 +28,8 @@ import Param from '~/components/Params'
|
||||
import Options from '~/components/Options'
|
||||
import RefSubLayout from '~/layouts/ref/RefSubLayout'
|
||||
|
||||
import OldLayout from '~/layouts/Default'
|
||||
|
||||
export default function DartReference(props) {
|
||||
const router = useRouter()
|
||||
const slug = router.query.slug[0]
|
||||
@@ -38,6 +41,18 @@ export default function DartReference(props) {
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* handle old ref pages
|
||||
*/
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'false') {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<OldLayout meta={props.meta} toc={props.toc}>
|
||||
<MDXRemote {...props.content} components={components} />
|
||||
</OldLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RefSubLayout>
|
||||
{dartSpec.functions.map((item, itemIndex) => {
|
||||
@@ -251,10 +266,41 @@ export async function getStaticProps({ params }: { params: { slug: string[] } })
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
props: {
|
||||
docs: allMarkdownDocs,
|
||||
},
|
||||
/*
|
||||
* old content generation
|
||||
* this is for grabbing to old markdown files
|
||||
*/
|
||||
|
||||
let slug
|
||||
if (params.slug.length > 1) {
|
||||
slug = `docs/reference/dart/${params.slug.join('/')}`
|
||||
} else {
|
||||
slug = `docs/reference/dart/${params.slug[0]}`
|
||||
}
|
||||
|
||||
let doc = getDocsBySlug(slug)
|
||||
const content = await serialize(doc.content || '')
|
||||
|
||||
/*
|
||||
* handle old ref pages
|
||||
*/
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'false') {
|
||||
return {
|
||||
props: {
|
||||
/*
|
||||
* old reference docs are below
|
||||
*/
|
||||
...doc,
|
||||
content,
|
||||
toc: toc(doc.content, { maxdepth: 1, firsth1: false }),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
props: {
|
||||
docs: allMarkdownDocs,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import fs from 'fs'
|
||||
import toc from 'markdown-toc'
|
||||
|
||||
import matter from 'gray-matter'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import components from '~/components/index'
|
||||
import { getAllDocs } from '~/lib/docs'
|
||||
import { getAllDocs, getDocsBySlug } from '~/lib/docs'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
@@ -27,11 +28,14 @@ import Param from '~/components/Params'
|
||||
import Options from '~/components/Options'
|
||||
import RefSubLayout from '~/layouts/ref/RefSubLayout'
|
||||
|
||||
import OldLayout from '~/layouts/Default'
|
||||
|
||||
export default function JSReference(props) {
|
||||
const router = useRouter()
|
||||
|
||||
const slug = router.query.slug[0]
|
||||
|
||||
// When user lands on a url like http://supabase.com/docs/new/reference/javascript/sign-up
|
||||
// When user lands on a url like http://supabase.com/docs/reference/javascript/sign-up
|
||||
// find the #sign-up element and scroll to that
|
||||
useEffect(() => {
|
||||
if (document && slug !== 'start') {
|
||||
@@ -39,6 +43,18 @@ export default function JSReference(props) {
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* handle old ref pages
|
||||
*/
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'false') {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<OldLayout meta={props.meta} toc={props.toc}>
|
||||
<MDXRemote {...props.content} components={components} />
|
||||
</OldLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RefSubLayout>
|
||||
{jsSpec.functions.map((item, itemIndex) => {
|
||||
@@ -92,7 +108,7 @@ export default function JSReference(props) {
|
||||
{/* // parameters */}
|
||||
{parameters && (
|
||||
<div className="not-prose mt-12">
|
||||
<h5 className="mb-3 text-base">Parameters</h5>
|
||||
<h5 className="mb-3 text-base text-scale-1200">Parameters</h5>
|
||||
<ul className="">
|
||||
{parameters.map((param) => {
|
||||
// grab override params from yaml file
|
||||
@@ -248,8 +264,6 @@ export async function getStaticProps({ params }: { params: { slug: string[] } })
|
||||
|
||||
const markdownExists = checkFileExists(pathName)
|
||||
|
||||
console.log(x, 'markdownExists', markdownExists)
|
||||
|
||||
const fileContents = markdownExists ? fs.readFileSync(pathName, 'utf8') : ''
|
||||
const { data, content } = matter(fileContents)
|
||||
|
||||
@@ -263,10 +277,41 @@ export async function getStaticProps({ params }: { params: { slug: string[] } })
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
props: {
|
||||
docs: allMarkdownDocs,
|
||||
},
|
||||
/*
|
||||
* old content generation
|
||||
* this is for grabbing to old markdown files
|
||||
*/
|
||||
|
||||
let slug
|
||||
if (params.slug.length > 1) {
|
||||
slug = `docs/reference/javascript/${params.slug.join('/')}`
|
||||
} else {
|
||||
slug = `docs/reference/javascript/${params.slug[0]}`
|
||||
}
|
||||
|
||||
let doc = getDocsBySlug(slug)
|
||||
const content = await serialize(doc.content || '')
|
||||
|
||||
/*
|
||||
* handle old ref pages
|
||||
*/
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'false') {
|
||||
return {
|
||||
props: {
|
||||
/*
|
||||
* old reference docs are below
|
||||
*/
|
||||
...doc,
|
||||
content,
|
||||
toc: toc(doc.content, { maxdepth: 1, firsth1: false }),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
props: {
|
||||
docs: allMarkdownDocs,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
// pages/index.js
|
||||
|
||||
import fs from 'fs'
|
||||
import toc from 'markdown-toc'
|
||||
|
||||
import matter from 'gray-matter'
|
||||
import { MDXRemote } from 'next-mdx-remote'
|
||||
import { serialize } from 'next-mdx-remote/serialize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import components from '~/components/index'
|
||||
import { getAllDocs } from '~/lib/docs'
|
||||
import { getAllDocs, getDocsBySlug } from '~/lib/docs'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
@@ -29,6 +30,8 @@ import CodeBlock from '~/components/CodeBlock/CodeBlock'
|
||||
import { useRouter } from 'next/router'
|
||||
import { extractTsDocNode, generateParameters } from '~/lib/refGenerator/helpers'
|
||||
|
||||
import OldLayout from '~/layouts/Default'
|
||||
|
||||
const marginTop = 256
|
||||
export default function Ref(props) {
|
||||
// const myRef = useRef(null)
|
||||
@@ -126,6 +129,18 @@ export default function Ref(props) {
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
* handle old ref pages
|
||||
*/
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'false') {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<OldLayout meta={props.meta} toc={props.toc}>
|
||||
<MDXRemote {...props.content} components={components} />
|
||||
</OldLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex my-16">
|
||||
@@ -239,6 +254,8 @@ export default function Ref(props) {
|
||||
)}
|
||||
|
||||
{/* // parameters */}
|
||||
{/*
|
||||
@ts-expect-error */}
|
||||
{parameters && <div dangerouslySetInnerHTML={{ __html: parameters }}></div>}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
@@ -419,10 +436,41 @@ export async function getStaticProps({ params }: { params: { slug: string[] } })
|
||||
|
||||
// console.log('allMarkdownDocs', allMarkdownDocs)
|
||||
|
||||
return {
|
||||
props: {
|
||||
docs: allMarkdownDocs,
|
||||
},
|
||||
/*
|
||||
* old content generation
|
||||
* this is for grabbing to old markdown files
|
||||
*/
|
||||
|
||||
let slug
|
||||
if (params.slug.length > 1) {
|
||||
slug = `docs/reference/javascript/${params.slug.join('/')}`
|
||||
} else {
|
||||
slug = `docs/reference/javascript/${params.slug[0]}`
|
||||
}
|
||||
|
||||
let doc = getDocsBySlug(slug)
|
||||
const content = await serialize(doc.content || '')
|
||||
|
||||
/*
|
||||
* handle old ref pages
|
||||
*/
|
||||
if (process.env.NEXT_PUBLIC_NEW_DOCS === 'false') {
|
||||
return {
|
||||
props: {
|
||||
/*
|
||||
* old reference docs are below
|
||||
*/
|
||||
...doc,
|
||||
content,
|
||||
toc: toc(doc.content, { maxdepth: 1, firsth1: false }),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
props: {
|
||||
docs: allMarkdownDocs,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 494 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 165 KiB |
@@ -4,6 +4,7 @@ const path = require('path')
|
||||
const matter = require('gray-matter')
|
||||
const dotenv = require('dotenv')
|
||||
const algoliasearch = require('algoliasearch/lite')
|
||||
const { isEmpty } = require('lodash')
|
||||
|
||||
// [Joshen] We initially thought of building out our search using Algolia directly,
|
||||
// but eventually decided to just use DocSearch since it provides us a UI and some
|
||||
@@ -17,12 +18,20 @@ const algoliasearch = require('algoliasearch/lite')
|
||||
// A lot of them are not even linked to within the docs site, so just need to
|
||||
// double check if they can be removed or if we want them in the side bars.
|
||||
const ignoredFiles = [
|
||||
'docs/404.mdx',
|
||||
'docs/support.mdx',
|
||||
'docs/faqs.mdx',
|
||||
'docs/guides.mdx',
|
||||
'docs/guides/database/arrays.mdx',
|
||||
'docs/guides/database/json.mdx',
|
||||
'pages/404.mdx',
|
||||
'pages/faq.mdx',
|
||||
'pages/support.mdx',
|
||||
'pages/oss.tsx',
|
||||
'pages/_app.tsx',
|
||||
'pages/_document.tsx',
|
||||
'pages/[...slug].tsx',
|
||||
'pages/company/aup.md',
|
||||
'pages/company/privacy.md',
|
||||
'pages/company/sla.md',
|
||||
'pages/company/terms.md',
|
||||
'pages/handbook/contributing.mdx',
|
||||
'pages/handbook/introduction.mdx',
|
||||
'pages/handbook/supasquad.mdx',
|
||||
]
|
||||
|
||||
const nameMap = {
|
||||
@@ -69,16 +78,39 @@ async function walk(dir) {
|
||||
)
|
||||
const index = client.initIndex(indexName)
|
||||
|
||||
const slugs = (await walk('docs')).filter((slug) => !ignoredFiles.includes(slug))
|
||||
const referencePages = await walk('docs')
|
||||
const guidePages = (await walk('pages')).filter((slug) => !ignoredFiles.includes(slug))
|
||||
const allPages = guidePages.concat(referencePages)
|
||||
|
||||
const searchObjects = slugs
|
||||
const searchObjects = allPages
|
||||
.map((slug) => {
|
||||
let id, title, description
|
||||
const fileContents = fs.readFileSync(slug, 'utf8')
|
||||
const { data, content } = matter(fileContents)
|
||||
|
||||
const { id, title, description } = data
|
||||
if (isEmpty(data)) {
|
||||
// Guide pages do not have front-matter meta, unlike reference pages, have to manually extract
|
||||
const metaIndex = fileContents.indexOf('export const meta = {')
|
||||
if (metaIndex !== -1) {
|
||||
const metaString =
|
||||
fileContents
|
||||
.slice(metaIndex + 20, fileContents.indexOf('}') + 1)
|
||||
.replace(/\n/g, '')
|
||||
.slice(0, -2) + '}'
|
||||
const meta = eval(`(${metaString})`)
|
||||
id = meta.id
|
||||
title = meta.title
|
||||
description = meta.description
|
||||
}
|
||||
} else {
|
||||
id = data.id
|
||||
title = data.title
|
||||
description = data.description
|
||||
}
|
||||
|
||||
const url = (slug.includes('/generated') ? slug.replace('/generated', '') : slug)
|
||||
.replace('docs', '')
|
||||
.replace('pages', '')
|
||||
.replace(/\.mdx$/, '')
|
||||
const source = slug.includes('/reference') ? 'reference' : 'guide'
|
||||
const object = {
|
||||
|
||||
@@ -384,8 +384,25 @@
|
||||
@apply text-slate-1200 dark:text-scale-1200 font-normal;
|
||||
}
|
||||
|
||||
.DocSearch-StartScreen .DocSearch-Help {
|
||||
@apply py-16 px-6 text-center text-slate-400;
|
||||
.DocSearch-NoResults-Prefill-List {
|
||||
.DocSearch-Help {
|
||||
color: var(--colors-scale11) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.DocSearch-StartScreen {
|
||||
.DocSearch-Help {
|
||||
@apply text-center py-16;
|
||||
}
|
||||
}
|
||||
|
||||
.DocSearch-Help {
|
||||
@apply text-scale-1100;
|
||||
font-size: 0.875rem !important;
|
||||
a {
|
||||
color: var(--colors-brand9) !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.DocSearch-NoResults {
|
||||
|
||||
@@ -9,3 +9,4 @@ NEXT_PUBLIC_DOCS_URL="http://localhost:3005"
|
||||
NEXT_PUBLIC_STUDIO_URL="https://localhost:8082"
|
||||
NEXT_PUBLIC_LAUNCHWEEKSITE_URL="https://localhost:3008"
|
||||
NEXT_PUBLIC_REFERENCE_DOCS_URL="https://localhost:3010"
|
||||
|
||||
@@ -43,12 +43,12 @@ At Supabase, we're building some amazing tools that make Postgres as easy to use
|
||||
#### Simple interface
|
||||
|
||||
Why are database interfaces so hard to use? The Supabase team has built products for 70-year-olds, so we're confident we can make something easier for developers:
|
||||

|
||||

|
||||
|
||||
#### Connectors
|
||||
|
||||
Send realtime database changes to other systems, like queues or webhooks (Slack notifications!):
|
||||

|
||||

|
||||
|
||||
#### And more
|
||||
|
||||
|
||||
@@ -39,13 +39,13 @@ OK now for the important part - as promised, we will continue to release our upd
|
||||
|
||||
We've revamped the UI to fit in some more features that we're building.
|
||||
|
||||

|
||||

|
||||
|
||||
### Table View
|
||||
|
||||
This one is still very unstable, but we wanted to ship it anyway. It's a little hidden away so you'll have to hunt for it :). Give us a couple of months and we promise this will be as good as (better than?) Airtable.
|
||||
|
||||

|
||||

|
||||
|
||||
#### And more
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ $ sudo -su postgres envdir /etc/wal-g.d/env /usr/local/bin/wal-g backup-push /va
|
||||
|
||||
At this point, if you were to check the S3 path that you provided, the following two newly created and populated directories would be observed:
|
||||
|
||||

|
||||

|
||||
|
||||
From then on, subsequent physical backups would be found in the directory `basebackups_005` and any WAL archives would be sent to the directory `wal_005`.
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ Here's how to start a Supabase + Node.js repl:
|
||||
|
||||
Sign up for [replit.com](http://replit.com) and hit new repl in the top left
|
||||
|
||||

|
||||

|
||||
|
||||
Select node.js, give it a name, and click Create repl
|
||||
|
||||

|
||||

|
||||
|
||||
Import supabase's createClient method and hit run to install the required libs:
|
||||
|
||||
@@ -56,11 +56,11 @@ const supabase = createClient(
|
||||
)
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
Now that supabase is connected you'll want to add some data to your db, you can grab any SQL dataset on the web, or make your own, but the fasted way to test is to open the SQL tab in the Supabase dashboard and click the Countries sample database and click Run.
|
||||
|
||||

|
||||

|
||||
|
||||
From within your repl you can now query your countries table like:
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ type Mutation {
|
||||
): AccountDeleteResponse!
|
||||
```
|
||||
|
||||
For a complete example with relationships, check out the [reflection docs](https://supabase.github.io/pg_graphql/reflection/).
|
||||
For a complete example with relationships, check out the [API docs](https://supabase.github.io/pg_graphql/api/).
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ flutter_bloc: ^8.0.0
|
||||
|
||||
Since the app has evolved, we also need to update our table schema. In order to store rooms data, we will add a rooms table. We will also modify the messages table to add a foreign key constraint to the rooms table so that we can tell which message belongs to which room.
|
||||
|
||||
We will also intorudce a `create_new_room` function, which is a [database function](https://supabase.com/docs/guides/database/functions) that handles chat room creation. It knows to create a new room if a chat room with the two users does not exist yet, or to just return the room ID if it already exists.
|
||||
We will also introduce a `create_new_room` function, which is a [database function](https://supabase.com/docs/guides/database/functions) that handles chat room creation. It knows to create a new room if a chat room with the two users does not exist yet, or to just return the room ID if it already exists.
|
||||
|
||||
```sql
|
||||
-- *** Table definitions ***
|
||||
@@ -1154,7 +1154,7 @@ import 'package:my_chat_app/pages/rooms_page.dart';
|
||||
import 'package:my_chat_app/utils/constants.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
/// Page to redirect users to the appropreate page depending on the initial auth state
|
||||
/// Page to redirect users to the appropriate page depending on the initial auth state
|
||||
class SplashPage extends StatefulWidget {
|
||||
const SplashPage({Key? key}) : super(key: key);
|
||||
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
---
|
||||
title: SQL or NoSQL? Why not use both (with PostgreSQL)?
|
||||
description: How to turn Postgres into an easy-to-use NoSQL database that retains all the power of SQL
|
||||
author: burggraf
|
||||
image: 2022-11-24-sql-nosql-postgresql/sql-nosql-postgresql.jpg
|
||||
thumb: 2022-11-24-sql-nosql-postgresql/sql-nosql-postgresql.jpg
|
||||
tags:
|
||||
- postgres
|
||||
date: '2022-11-24'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
It's a tough decision for any developer starting a new project. Should you store your data in a standard, time-tested SQL database, or go with one of the newer NoSQL document-based databases? This seemingly simple decision can literally make or break your project down the line. Choose correctly and structure your data well, and you may sail smoothly into production and watch your app take off. Make the wrong choice and you could be headed for nightmares (and maybe even some major re-writes) before your app ever makes it out the door.
|
||||
|
||||
## Simplicity vs Power
|
||||
There are tradeoffs with both SQL and NoSQL solutions. Typically it's easier to get started with NoSQL data structures, especially when the data is complex or hierarchical. You can just take a JSON data object from your front-end code and throw it in the database and be done with it. But later when you need to access that data to answer some basic business questions, it's much more difficult. A SQL solution makes it easier to gather data and draw conclusions down the line. Let's look at an example:
|
||||
|
||||
Each day I track the food I eat, along with the number of calories in each item:
|
||||
|
||||
| Day | Food Item | Calories | Meal |
|
||||
|--------|-----------|----------|------|
|
||||
| 01 Jan | Apple | 72 | Breakfast |
|
||||
| 01 Jan | Oatmeal | 146 | Breakfast |
|
||||
| 01 Jan | Sandwich | 445 | Lunch |
|
||||
| 01 Jan | Chips | 280 | Lunch |
|
||||
| 01 Jan | Cookie | 108 | Lunch |
|
||||
| 01 Jan | Mixed Nuts | 175 | Snack |
|
||||
| 01 Jan | Pasta/Sauce | 380 | Dinner |
|
||||
| 01 Jan | Garlic Bread | 200 | Dinner |
|
||||
| 01 Jan | Broccoli | 32 | Dinner |
|
||||
|
||||
I also track the number of cups of water I drink and when I drink them:
|
||||
|
||||
| Day | Time | Cups |
|
||||
|--------|-------|------|
|
||||
| Jan 01 | 08:15 | 1 |
|
||||
| Jan 01 | 09:31 | 1 |
|
||||
| Jan 01 | 10:42 | 2 |
|
||||
| Jan 01 | 12:07 | 2 |
|
||||
| Jan 01 | 14:58 | 1 |
|
||||
| Jan 01 | 17:15 | 1 |
|
||||
| Jan 01 | 18:40 | 1 |
|
||||
| Jan 01 | 19:05 | 1 |
|
||||
|
||||
And finally, I track my exercise:
|
||||
|
||||
| Day | Time | Duration | Exercise |
|
||||
|--------|-------|----------|----------|
|
||||
| Jan 01 | 11:02 | 0.5 | Walking |
|
||||
| Jan 02 | 09:44 | 0.75 | Bicycling |
|
||||
| Jan 02 | 17:00 | 0.25 | Walking |
|
||||
|
||||
For each day I also track my current weight along with any notes for the day:
|
||||
|
||||
| Day | Weight | Notes |
|
||||
|--------|--------|-------|
|
||||
| Jan 01 | 172.6 | This new diet is awesome! |
|
||||
| Jan 14 | 170.2 | Not sure all this is worth it. |
|
||||
| Jan 22 | 169.8 | Jogged past a McDonald's today. It was hard. |
|
||||
| Feb 01 | 168.0 | I feel better, but sure miss all that greasy food. |
|
||||
|
||||
## Gathering All That Data
|
||||
|
||||
That's a lot of different data that needs to be gathered, stored, retrieved, and later analyzed. It's organized simply and easily, but the number of records varies from day to day. On any given day I may have zero or more entries for food, water, and exercise, and I may have zero or one entry for weight & notes.
|
||||
|
||||
In my app, I gather all the data for a single day on one page, to make it easier for my users. So, I get a JSON object for each day that looks like this:
|
||||
|
||||
```json
|
||||
{ "date": "2022-01-01",
|
||||
"weight": 172.6,
|
||||
"notes": "This new diet is awesome!",
|
||||
"food": [
|
||||
{ "title": "Apple", "calories": 72, "meal": "Breakfast"},
|
||||
{ "title": "Oatmeal", "calories": 146, "meal": "Breakfast"},
|
||||
{ "title": "Sandwich", "calories": 445, "meal": "Lunch"},
|
||||
{ "title": "Chips", "calories": 280, "meal": "Lunch"},
|
||||
{ "title": "Cookie", "calories": 108, "meal": "Lunch"},
|
||||
{ "title": "Mixed Nuts", "calories": 175, "meal": "Snack"},
|
||||
{ "title": "Pasta/Sauce", "calories": 380, "meal": "Dinner"},
|
||||
{ "title": "Garlic Bread", "calories": 200, "meal": "Dinner"},
|
||||
{ "title": "Broccoli", "calories": 32, "meal": "Dinner"}
|
||||
],
|
||||
"water": [
|
||||
{"time": "08:15", "qty": 1},
|
||||
{"time": "09:31", "qty": 1},
|
||||
{"time": "10:42", "qty": 2},
|
||||
{"time": "10:42", "qty": 2},
|
||||
{"time": "12:07", "qty": 1},
|
||||
{"time": "14:58", "qty": 1},
|
||||
{"time": "17:15", "qty": 1},
|
||||
{"time": "18:40", "qty": 1},
|
||||
{"time": "19:05", "qty": 1}
|
||||
],
|
||||
"exercise": [
|
||||
{"time": "11:02", "duration": 0.5, "type": "Walking"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Saving the Data
|
||||
|
||||
Once we've gathered all the data for a day, we need to store it in our database. In a NoSQL database, this can be a pretty easy process, as we can just create a record (document) for a specific user for a specific date and throw document into a collection and we're done. With SQL, we have some structure we have to work within, and in this case it looks like 4 separate tables: food, water, exercise, and notes. We'd want to do 4 separate inserts here, one for each table. If we don't have data for a specific table (say no exercise was recorded today) then we can skip that table.
|
||||
|
||||
If you're using SQL to store this data, you might want to save each table's data as it's entered in your data entry form (and not wait until all the data is entered.) Or you might want to create a database function that takes all the JSON data, parses it, and writes it to all the related tables in a single transaction. There's a lot of ways to handle this, but suffice it to say this: it's a bit more complicated than saving the data in a NoSQL database.
|
||||
|
||||
### Retrieving the Data
|
||||
If we want to display all the data for a single day, it's pretty much the same. With NoSQL you can grab the data for the user's day and then use it in your application. Nice! With SQL we need to query 4 tables to get all the data (or we could use a function to get it all in a single call.) Of course, when displaying the data, we'd need to first break up our JSON data into pieces that are needed by each section of our dashboard screen, and you could argue that it's simpler to map each SQL table with the dashboard section on the screen, but that's a trivial point.
|
||||
|
||||
### Analyzing the Data
|
||||
Now that we've saved the data and we can retrieve it and display it, let's use it for some analysis. *Let's display a graph of how many total calories I've eaten over the past month.* With SQL, this is a simple task:
|
||||
|
||||
```sql
|
||||
select
|
||||
date, sum(calories) as total_calories
|
||||
from food_log
|
||||
group by date
|
||||
where user_id = 'xyz'
|
||||
and day between '2022-01-01' and '2022-01-31'
|
||||
order by date;
|
||||
```
|
||||
|
||||
Bam! Done! Now we can send those results to our graphing library and make a nice pretty picture of my eating habits.
|
||||
|
||||
But if we've stored this data in NoSQL, it gets a little more complicated. We'll need to:
|
||||
|
||||
- grab all the data for the user for the month
|
||||
- parse each day's data to get the food log information
|
||||
- loop through each day and total the calories
|
||||
- send the aggregate data to our graphing module
|
||||
|
||||
If this is something we're going to do regularly, it makes sense to calculate the total calories for each day and store it in the day's document so we can get at that data faster. But that requires more work up front, and we still need to pull the data for each day and parse out that calorie total first. And if we update the data we still need to recalculate things and update that total. Eventually we'll want to do that with the water and exercise totals as well. The code will eventually start to get longer and more complex.
|
||||
|
||||
### SQL and NoSQL Together - FTW
|
||||
Let's see how we can use the power of SQL together with the ease-of-use of NoSQL in the same database to make this all a bit easier. We'll create a table for each day of data (for each user) and store the basic fields such as `weight` and `notes` first. Then we'll just throw the `food_log`, `water_log`, and `exercise_log` fields in a `JSONB` field.
|
||||
|
||||
```sql
|
||||
CREATE TABLE calendar (
|
||||
id uuid DEFAULT uuid_generate_v4() NOT NULL,
|
||||
date date,
|
||||
user_id uuid NOT NULL,
|
||||
weight numeric,
|
||||
notes text,
|
||||
food_log jsonb,
|
||||
water_log jsonb,
|
||||
exercise_log jsonb
|
||||
);
|
||||
-- (Optional) - create a foreign key relationship for the user_id field
|
||||
ALTER TABLE ONLY calendar
|
||||
ADD CONSTRAINT calendar_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id);
|
||||
```
|
||||
|
||||
Now let's insert some data into the table. PostgreSQL offers both JSON and JSONB fields, and since the latter are more optimized by the database and much faster for query processing, we’ll almost always want to use JSONB. We’ll use JSONB fields for `food_log`, `water_log`, and `exercise_log` and just dump the data we got from our app right into those fields as a string:
|
||||
|
||||
```sql
|
||||
insert into calendar (date, user_id, weight, notes, food_log, water_log, exercise_log)
|
||||
values (
|
||||
'2022-01-01',
|
||||
'xyz',
|
||||
172.6,
|
||||
'This new diet is awesome!',
|
||||
'[
|
||||
{ "title": "Apple", "calories": 72, "meal": "Breakfast"},
|
||||
{ "title": "Oatmeal", "calories": 146, "meal": "Breakfast"},
|
||||
{ "title": "Sandwich", "calories": 445, "meal": "Lunch"},
|
||||
{ "title": "Chips", "calories": 280, "meal": "Lunch"},
|
||||
{ "title": "Cookie", "calories": 108, "meal": "Lunch"},
|
||||
{ "title": "Mixed Nuts", "calories": 175, "meal": "Snack"},
|
||||
{ "title": "Pasta/Sauce", "calories": 380, "meal": "Dinner"},
|
||||
{ "title": "Garlic Bread", "calories": 200, "meal": "Dinner"},
|
||||
{ "title": "Broccoli", "calories": 32, "meal": "Dinner"}
|
||||
]',
|
||||
'[
|
||||
{"time": "08:15", "qty": 1},
|
||||
{"time": "09:31", "qty": 1},
|
||||
{"time": "10:42", "qty": 2},
|
||||
{"time": "10:42", "qty": 2},
|
||||
{"time": "12:07", "qty": 1},
|
||||
{"time": "14:58", "qty": 1},
|
||||
{"time": "17:15", "qty": 1},
|
||||
{"time": "18:40", "qty": 1},
|
||||
{"time": "19:05", "qty": 1}
|
||||
]',
|
||||
'[
|
||||
{"time": "11:02", "duration": 0.5, "type": "Walking"}
|
||||
]'
|
||||
);
|
||||
```
|
||||
|
||||
While that's a big insert statement, it sure beats doing inserts on 4 separate tables. With all those food entries and water log entries, we would have had to made 1 entry in the main table, then 9 food_log entries, 9 water_log entries, and one exercise_log entry for a total of 20 database records. We've wrapped that into a single record.
|
||||
|
||||
## But How Do We Query This Data?
|
||||
|
||||
Great, we're collecting the data now, and it's easy to insert the data into the database. Editing the data isn't too bad either because we're just downloading the data to the client, updating the JSON field(s) as needed, and throwing them back into the database. Not too hard. But how can I query this data? What about that task from before? *Let's display a graph of how many total calories I've eaten over the past month.*
|
||||
|
||||
In this case, that data is stored inside the `food_log` field inside the `calendar` table. If only PostgreSQL had a way of converting JSONB arrays into individual database records (recordsets). Well, it does! The `jsonb_array_elements` function will do this for us, allowing to create a simple table we can use to calculate our caloric intake.
|
||||
|
||||
Here's some SQL to turn that `food_log` array into individual output records:
|
||||
|
||||
```sql
|
||||
select
|
||||
user_id,
|
||||
date,
|
||||
jsonb_array_elements(food_log)->>'title' as title,
|
||||
jsonb_array_elements(food_log)->'calories' as calories,
|
||||
jsonb_array_elements(food_log)->'meal' as meal
|
||||
from calendar
|
||||
where user_id = 'xyz'
|
||||
and date between
|
||||
'2022-01-01' and '2022-01-31';
|
||||
```
|
||||
|
||||
This returns a table that looks like this:
|
||||
|
||||
| date | title | calories | meal |
|
||||
| ---------- | ------------ | -------- | --------- |
|
||||
| 2022-01-01 | Apple | 72 | Breakfast |
|
||||
| 2022-01-01 | Oatmeal | 146 | Breakfast |
|
||||
| 2022-01-01 | Sandwich | 445 | Lunch |
|
||||
| 2022-01-01 | Chips | 280 | Lunch |
|
||||
| 2022-01-01 | Cookie | 108 | Lunch |
|
||||
| 2022-01-01 | Mixed Nuts | 175 | Snack |
|
||||
| 2022-01-01 | Pasta/Sauce | 380 | Dinner |
|
||||
| 2022-01-01 | Garlic Bread | 200 | Dinner |
|
||||
| 2022-01-01 | Broccoli | 32 | Dinner |
|
||||
|
||||
A couple things to note:
|
||||
|
||||
- `jsonb_array_elements(food_log)->>'title' as title` this returns a text field, since the `->>` operator returns TEXT
|
||||
- `jsonb_array_elements(food_log)->'calories' as calories` this returns a JSON object, since the `->` operator return JSON
|
||||
|
||||
If we want to `sum` the calories to get some totals, we can't have a JSON object, so we need to cast that to something more useful, like an `INTEGER`:
|
||||
|
||||
- `(jsonb_array_elements(food_log)->'calories')::INTEGER as calories` this returns an INTEGER
|
||||
|
||||
Now we can't just throw the `sum` operator on this to get the total calories by day. If we try this:
|
||||
|
||||
```sql
|
||||
select
|
||||
date,
|
||||
sum((jsonb_array_elements(food_log)->'calories')::integer) as total_calories
|
||||
from calendar where user_id = 'xyz'
|
||||
and date between '2022-01-01' and '2022-01-31'
|
||||
group by date;
|
||||
```
|
||||
|
||||
we get an error back from PostgreSQL: **Failed to run sql query: aggregate function calls cannot contain set-returning function calls**.
|
||||
|
||||
Instead, we need to think of this as a set of building blocks, where our first SQL statement returns a table:
|
||||
|
||||
```sql
|
||||
select
|
||||
date,
|
||||
(jsonb_array_elements(food_log)->'calories')::integer as calories
|
||||
from calendar where user_id = 'xyz'
|
||||
and date between
|
||||
'2022-01-01' and '2022-01-31';
|
||||
```
|
||||
|
||||
Now we can take that "table" statement, throw some (parenthesis) around it, and query **it**:
|
||||
|
||||
```sql
|
||||
with data as
|
||||
(
|
||||
select
|
||||
date,
|
||||
(jsonb_array_elements(food_log)->'calories')::integer as calories
|
||||
from calendar
|
||||
where user_id = 'xyz'
|
||||
and date between
|
||||
'2022-01-01' and '2022-01-31'
|
||||
)
|
||||
select date, sum(calories) from
|
||||
data group by date;
|
||||
```
|
||||
|
||||
This gives us exactly what we want:
|
||||
|
||||
| date | sum |
|
||||
| ---------- | ---- |
|
||||
| 2022-01-01 | 1838 |
|
||||
|
||||
If we add more data for the rest of the days of the month, we'll have all the data we need for a beautiful graph.
|
||||
|
||||
### Searching the Data
|
||||
What if we want to answer the question: *How many calories were in the garlic bread I ate last month?* This data is stored inside the `food_log` field in the `calendar` table. We can use the same type of query we used before to "flatten" the `food_log` data so we can search it.
|
||||
|
||||
To get every item I ate during the month of January, we can use:
|
||||
|
||||
```sql
|
||||
select
|
||||
date,
|
||||
jsonb_array_elements(food_log)->>'title' as title,
|
||||
(jsonb_array_elements(food_log)->'calories')::integer as calories
|
||||
from calendar
|
||||
where user_id = 'xyz'
|
||||
and date between
|
||||
'2022-01-01' and '2022-01-31'
|
||||
```
|
||||
|
||||
Now to search for the **garlic bread** we can just put (parenthesis) around this to make a "table" and then search for the item we want:
|
||||
|
||||
```sql
|
||||
with my_food as
|
||||
(
|
||||
select
|
||||
date,
|
||||
jsonb_array_elements(food_log)->>'title' as title,
|
||||
(jsonb_array_elements(food_log)->'calories')::integer as calories
|
||||
from calendar
|
||||
where user_id = 'xyz'
|
||||
and date between
|
||||
'2022-01-01' and '2022-01-31'
|
||||
)
|
||||
select title, calories
|
||||
from my_food
|
||||
where title = 'Garlic Bread';
|
||||
```
|
||||
which gives us:
|
||||
|
||||
| title | calories |
|
||||
| ------------ | -------- |
|
||||
| Garlic Bread | 200 |
|
||||
|
||||
## Conclusion
|
||||
|
||||
If we take a little time to study the [JSON Functions and Operators](https://www.postgresql.org/docs/9.5/functions-json.html) that PostgreSQL offers, we can turn Postgres into an easy-to-use NoSQL database that still retains all the power of SQL. This gives us a super easy way to store our complex JSON data coming from our application code in our database. Then we can use powerful SQL capabilities to analyze and present that data in our application. It's the best of both worlds!
|
||||
|
||||
## More Postgres resources
|
||||
|
||||
- [Postgres Full Text Search vs the rest](https://supabase.com/blog/postgres-full-text-search-vs-the-rest)
|
||||
- [Postgres WASM by Snaplet and Supabase](https://supabase.com/blog/postgres-wasm)
|
||||
- [Choosing a Postgres Primary Key](https://supabase.com/blog/choosing-a-postgres-primary-key)
|
||||
- [Partial data dumps using Postgres Row Level Security](https://supabase.com/blog/partial-postgresql-data-dumps-with-rls)
|
||||
- [Postgres Views](https://supabase.com/blog/postgresql-views)
|
||||
- [Realtime Postgres RLS on Supabase](https://supabase.com/blog/realtime-row-level-security-in-postgresql)
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
---
|
||||
title: Transparent Column Encryption with Postgres
|
||||
description: Using pgsodium's Transparent Column Encryption to encrypt data and provide your users with row-level encryption.
|
||||
author: michel
|
||||
image: transparent-column-encryption-with-postgres.jpg
|
||||
thumb: transparent-column-encryption-with-postgres.jpg
|
||||
tags:
|
||||
- postgres
|
||||
date: '2022-12-01'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
One of the more common questions we receive at Supabase is “how do I encrypt sensitive data”?
|
||||
|
||||
pgsodium's Transparent Column Encryption (TCE) is one of the safest ways, and can be used to encrypt one or more text columns in any number of tables. Using TCE, you can encrypt data so that it doesn't leak into logs as well as providing your users with row-level encryption.
|
||||
|
||||
To understand how TCE works, let's first do a deep-dive into an important encryption topic: key derivation.
|
||||
|
||||
<div className="bg-gray-300 rounded-lg p-6 italic">
|
||||
|
||||
This post is a sneak peek of a big feature we are shipping on LW6. To not miss a thing, get your ticket for [Supabase Launch Week 6!](https://supabase.com/launch-week).
|
||||
|
||||
</div>
|
||||
|
||||
## How Key Derivation Works
|
||||
|
||||
The current state-of-the-art in encryption libraries is [libsodium](https://doc.libsodium.org/).
|
||||
|
||||
**libsodium** offers a range of APIs for authenticated secret and public key encryption, key derivation, encrypted streaming, [AEAD](https://en.wikipedia.org/wiki/Authenticated_encryption), various forms of hashing, and much more.
|
||||
|
||||
This powerful API is available to PostgreSQL using the [**pgsodium**](https://github.com/michelp/pgsodium) extension. **pgsodium** provides all the functionality of the full **libsodium** API, but previously it required developers to set up database encryption themselves, which remained a challenge even for those familiar with database administration.
|
||||
|
||||
To solve this problem, **pgsodium** now has a full key management API, primarily via the table `pgsodium.key` and the `pgsodium.create_key()` function. This key table contains no raw keys, but instead uses libsodium Key IDs to derive keys that are used internally for encryption. A [key derivation function](https://libsodium.gitbook.io/doc/key_derivation) is used with an internal root key that is unavailable to SQL and not stored in the database, but rather managed by you externally using flexible scripts, or by Supabase automatically as part of our service offering.
|
||||
|
||||
The simplest way to use **pgsodium** to encrypt and decrypt data is to first create a ******Key ID******. Valid Key IDs are stored in pgsodium in a special extension table, and they can be created using the `pgsodium.create_key()` function. This function takes a number of arguments depending on how it's used, but the simplest case is to create a new key with no arguments:
|
||||
|
||||
```sql
|
||||
select * from pgsodium.create_key();
|
||||
-[ RECORD 1 ]---+-------------------------------------
|
||||
id | eaa20d8c-c77c-4985-9f73-2a5f5d1f1e6d
|
||||
name |
|
||||
status | valid
|
||||
key_type | aead-det
|
||||
key_id | 2
|
||||
key_context | \x7067736f6469756d
|
||||
created | 2022-11-13 21:19:35.765823+00
|
||||
expires |
|
||||
associated_data |
|
||||
```
|
||||
|
||||
This key can now be used with pgsodium encryption functions by its UUID (`eaa20d8c-c77c-4985-9f73-2a5f5d1f1e6d`). For example:
|
||||
|
||||
```sql
|
||||
select * from pgsodium.crypto_aead_det_encrypt (
|
||||
'this is the message', -- a message to encrypt
|
||||
'this is associated data', -- some authenticated associated data
|
||||
'eaa20d8c-c77c-4985-9f73-2a5f5d1f1e6d'::uuid -- key ID
|
||||
);
|
||||
```
|
||||
|
||||
This produces the following encrypted “ciphertext” using the `aead-det` algorithm from the libsodium [XChaCha20-SIV](https://github.com/jedisct1/libsodium-xchacha20-siv) encryption function.
|
||||
|
||||
```
|
||||
-[ RECORD 1 ]-----------+---------------------------------------------------------------------------------------------------------
|
||||
crypto_aead_det_encrypt | \\x099baa820250d7375ed141f8f1936af384bc229f3de1010a6eff6ffdaf3998baffbae75b5cd83d1c469407ff2d3764a428b742
|
||||
```
|
||||
|
||||
Now to decrypt the ciphertext, pass it to the decryption function *with the same Key ID*:
|
||||
|
||||
```sql
|
||||
select *
|
||||
from convert_from(pgsodium.crypto_aead_det_decrypt (
|
||||
'\\x099baa820250d7375ed141f8f1936af384bc229f3de1010a6eff6ffdaf3998baffbae75b5cd83d1c469407ff2d3764a428b742',
|
||||
'this is associated data',
|
||||
'eaa20d8c-c77c-4985-9f73-2a5f5d1f1e6d'::uuid
|
||||
), 'utf8');
|
||||
```
|
||||
|
||||
Which recovers the original “plaintext” message:
|
||||
|
||||
```
|
||||
-[ RECORD 1 ]+--------------------
|
||||
convert_from | this is the message
|
||||
```
|
||||
|
||||
In the above example, there is *no raw key* like shown in the pgcrypto example above, only a Key ID which is used to derive the key used to encrypt the message and authenticate it with the associated data. In fact, it is impossible for a SQL user to derive the key used above, and if the Key ID is stored, then no decrypted information will leak into backups, disk storage, or the database WAL stream.
|
||||
|
||||
## Transparent Column Encryption
|
||||
|
||||
As of **pgsodium** 3.0.0 and up, the extension offers a simple and declarative Transparent Column Encryption feature (TCE). This feature is now shipped with all Supabase projects. TCE allows you to specify encrypted columns within a table and generates a new view that “wraps” that table to decrypt the contents.
|
||||
|
||||
TCE works using two dynamically generated objects for tables that contain encrypted columns:
|
||||
|
||||
- an `INSERT UPDATE` trigger that encrypts data when it is inserted or modified
|
||||
- a view that is created to wrap the table to decrypt the data when it is accessed
|
||||
|
||||
To “transparently” decrypt the table, access the dynamically generated view *instead of the table*. For every encrypted column in the table, the view will have an additional decrypted column that shows the decrypted result.
|
||||
|
||||
It's worth noting at this point that sometimes there is some confusion about handling encrypted data with TCE. The `T` stands for ***********Transparent*********** which means, you can always see decrypted data through the view, where the decrypted data can't be see is when stored on disk, or in pg_dumps, backups, WAL streams, etc. This is often called ******************Encryption At Rest****************** and is one layer in many that may be used to encrypt and protect your data.
|
||||
|
||||
Often Transparent encryption is understood to be “Transparent Disk Encryption” or “Full Disk Encryption”, this is where a drive is encrypted but reading and writing that drive is decrypted. TCE is similar to this, where data on disk is encrypted, but it is more fine grained, only particular columns are encrypted. The data is also encrypted in the sense that the table stored on disk contains encrypted data, without the view or the key, pg_dumps and backups still contain encrypted data, this is not possible with disk-only encryption.
|
||||
|
||||
For the moment TCE only works for columns of type `text` (or types castable to `text` like `json`). Soon we will also support `bytea` and possibly more as use cases and tests get better.
|
||||
|
||||
TCE uses one of PostgreSQL's lesser-known features: [`SECURITY LABEL`](https://www.postgresql.org/docs/current/sql-security-label.html). A security label can be thought of as a simple label which is attached to an object (a table, column, etc). Each label is scoped to an extension and that extension can provide security features depending on the label.
|
||||
|
||||
Let's see a simple example of using `SECURITY LABEL` to encrypt a column using **pgsodium**.
|
||||
|
||||
<div className="bg-gray-300 rounded-lg p-6 italic">
|
||||
|
||||
Note: We're using “credit cards” in our examples below, because they are very easy to understand. Please don't store credit cards in your Supabase database - we are not a certified PCI Service Provider.
|
||||
|
||||
</div>
|
||||
|
||||
### One Key ID for the Entire Column
|
||||
|
||||
For the simplest case, a column can be encrypted with one Key ID which must be of the type `aead-det` (as created above):
|
||||
|
||||
```sql
|
||||
CREATE TABLE credit_cards (
|
||||
id bigserial primary key,
|
||||
credit_card_number text
|
||||
);
|
||||
|
||||
SECURITY LABEL FOR pgsodium
|
||||
ON COLUMN credit_cards.credit_card_number
|
||||
IS 'ENCRYPT WITH KEY ID e348034b-3f07-4878-aad6-000511d12826';
|
||||
```
|
||||
|
||||
The advantage of this approach is simplicity - the user creates one key and labels a column with it. The cryptographic algorithm for this approach uses a *nonceless* encryption algorithm called `crypto_aead_det_xchacha20()`. This algorithm is written by the author of libsodium and can be found [here](https://github.com/jedisct1/libsodium-xchacha20-siv).
|
||||
|
||||
Using one key for an entire column means that whoever can decrypt one row can decrypt them all from a database dump. Also changing (rotating) the key means rewriting the whole table.
|
||||
|
||||
### One Key ID per Row
|
||||
|
||||
A more fine grained approach would be storing one Key ID per row:
|
||||
|
||||
```sql
|
||||
CREATE TABLE credit_cards (
|
||||
id bigserial primary key,
|
||||
credit_card_number text,
|
||||
key_id uuid not null DEFAULT 'e348034b-3f07-4878-aad6-000511d12826'::uuid
|
||||
);
|
||||
|
||||
SECURITY LABEL FOR pgsodium
|
||||
ON COLUMN credit_cards.credit_card_number
|
||||
IS 'ENCRYPT WITH KEY COLUMN key_id';
|
||||
```
|
||||
|
||||
This approach ensures that a key for one user doesn't necessarily decrypt any others. While rows can share key IDs, they don't necessarily have to (unlike the first example above where one key id was used for the entire column). It also acts as a natural partition that can work in conjunction with Row Level Security to share distinct keys between owners.
|
||||
|
||||
Notice also how there is a `DEFAULT` value for the `key_id`. In a way, this gives you the best of both approaches - encrypting the column when a per-row key ID is not provided. The downside to this approach is that you need to store one key ID per row, which takes up more disk space (but that's cheap!).
|
||||
|
||||
### One Key ID per Row with Nonce Support
|
||||
|
||||
The default cryptographic algorithm for the above approach uses a *nonceless* encryption algorithm called `[crypto_aead_det_xchacha20()](https://github.com/jedisct1/libsodium-xchacha20-siv)`. This algorithm has the advantage that it does not require nonce values, the disadvantage is that duplicate plaintexts will produce duplicate ciphertexts.
|
||||
|
||||
Nonces are some extra cryptographic context that is used in many cryptographic algorithms to produce different ciphertexts, even if the plaintexts are the same. The nonce does not have to be secret, but it *does* have to be unique. **pgsodium** comes with a useful function `pgsodium.crypto_aead_det_noncegen()` that will generate a cryptographically secure nonce for you, and in almost all cases it's best to use that function unless you know specifically what you are doing. In password hashing approaches, this is often similar to how a “salt” value is used to deduplicate password hashes.
|
||||
|
||||
Duplicate ciphertexts cannot be used to “attack the key”, it can only reveal the duplication. However, duplication is still information. In our examples so far, an attacker might be able to use this information to determine that two accounts share the same credit card number. While not technically breaking the encryption, this still leaks information to an attacker.
|
||||
|
||||
```sql
|
||||
CREATE TABLE credit_cards (
|
||||
id bigserial primary key,
|
||||
credit_card_number text,
|
||||
key_id uuid not null DEFAULT 'e348034b-3f07-4878-aad6-000511d12826'::uuid,
|
||||
nonce bytea default pgsodium.crypto_aead_det_noncegen()
|
||||
);
|
||||
|
||||
SECURITY LABEL FOR pgsodium
|
||||
ON COLUMN credit_cards.credit_card_number
|
||||
IS 'ENCRYPT WITH KEY COLUMN key_id NONCE nonce';
|
||||
```
|
||||
|
||||
This is the most secure form of TCE - there is a unique key ID and a unique nonce per row.
|
||||
|
||||
### One Key ID per Row with Associated Data
|
||||
|
||||
The encryption that is used for TCE is one of a family of functions provided by **libsodium** to do Authenticated Encryption with Associated Data or [AEAD Encryption](https://en.wikipedia.org/wiki/Authenticated_encryption). The “associated” data is plaintext (unencrypted) information that is mixed into the authentication signature of the encrypted data, such that when you authenticate the data, you also know that the associated data is authentic.
|
||||
|
||||
AEAD is helpful because often you have metadata associated with a secret, which isn't confidential but must not be forged.
|
||||
|
||||
In our credit card example, we might associate a "`credit_card_number`" with an "`account_id`". But what if a malicious actor wanted to use someone else's credit card on their own account? If someone could forge the `account_id` data column, swapping an `account_id` with their own `account_id`, then you could be tricked into using the wrong credit card. By “associating” the `account_id` with the `credit_card_number`, it cannot be forged without throwing an error.
|
||||
|
||||
Like above, this is done simply by extending the security label with the associated data column:
|
||||
|
||||
```sql
|
||||
CREATE TABLE credit_cards (
|
||||
id bigserial primary key,
|
||||
credit_card_number text,
|
||||
account_id integer,
|
||||
key_id uuid not null DEFAULT 'e348034b-3f07-4878-aad6-000511d12826'::uuid,
|
||||
nonce bytea default pgsodium.crypto_aead_det_noncegen()
|
||||
);
|
||||
|
||||
SECURITY LABEL FOR pgsodium
|
||||
ON COLUMN credit_cards.credit_card_number
|
||||
IS 'ENCRYPT WITH KEY COLUMN key_id ASSOCIATED (account_id) NONCE nonce';
|
||||
```
|
||||
|
||||
The new label indicates which column is to be associated with the secret, and that's it! Your `account_id` and credit card number are now protected under the same authentication signature as the secret itself.
|
||||
|
||||
## Using an Encrypted Table
|
||||
|
||||
Now that you have TCE setup for a table, it's easy to use by simply inserting data into the table, and querying that data by looking at its generated view. The view is named `decrypted_<table_name>` and by default is in the same schema as your table:
|
||||
|
||||
```sql
|
||||
INSERT INTO credit_cards (
|
||||
credit_card_number,
|
||||
account_id
|
||||
)
|
||||
VALUES
|
||||
('1234-5678-8765-4321', 123);
|
||||
```
|
||||
|
||||
Now that you have inserted data, look at the table and notice how the credit card number is encrypted. This is the data that is stored on disk, the encrypted card number, the key id, and the account id, ********************************but the key itself is not stored********************************. This means if someone gets a backup or dump of your database, they cannot decrypt the credit card number, they do not have the key, only the key ID:
|
||||
|
||||
```sql
|
||||
> select * from credit_cards where account_id = 123;
|
||||
-[ RECORD 1 ]------+---------------------------------------------------------------------
|
||||
id | 1
|
||||
credit_card_number | jf8KfImkKTr+j4gzyDZQtLDEFL9eSlFuKjNlNEJvDg+OIKUr2wjF/8NnYcLisb5F9xiN
|
||||
account_id | 123
|
||||
key_id | 7f753c4f-8c68-457a-8801-1798b2e9f44d
|
||||
nonce | \x300a14aa721184ff7cf0f6bf088da267
|
||||
```
|
||||
|
||||
For you, the developer, you need the unencrypted credit card number for you application. No problem, you can access that data using the dynamically generated decryption view `decrypted_credit_cards`:
|
||||
|
||||
```sql
|
||||
> select * from decrypted_credit_cards where account_id = 123;
|
||||
-[ RECORD 1 ]----------------+---------------------------------------------------------------------
|
||||
id | 1
|
||||
credit_card_number | jf8KfImkKTr+j4gzyDZQtLDEFL9eSlFuKjNlNEJvDg+OIKUr2wjF/8NnYcLisb5F9xiN
|
||||
decrypted_credit_card_number | 1234-5678-8765-4321
|
||||
account_id | 123
|
||||
key_id | 7f753c4f-8c68-457a-8801-1798b2e9f44d
|
||||
nonce | \x300a14aa721184ff7cf0f6bf088da267
|
||||
```
|
||||
|
||||
Notice how there is a new column called `decrypted_credit_card_number`. This column is not stored in database or on disk at all, it is generated “on-the-fly” as you select from the view. Database dumps do not contain this information, only the view itself, and most importantly, *************************************raw decryption keys are never stored*************************************.
|
||||
|
||||
## Future possibilities
|
||||
|
||||
We're always thinking about the future possibilities for features and tools that we can bring to the PostgreSQL community, and we'd love to hear from you about what kind of encryption features you'd like to see. Some things we've considered but not yet explored yet are:
|
||||
|
||||
- Built-in Key Management Server (KMS) with REST API ala AWS or GCP.
|
||||
- Seamless Integration with external KMS services for key management.
|
||||
- Signcryption-based Token formats for exchanging AEAD tokens.
|
||||
- End-to-End encryption for user data using libsodium's `crypto_secretstream()` API.
|
||||
- Group encryption using [signcryption](https://github.com/jedisct1/libsodium-signcryption)
|
||||
- Your idea here?
|
||||
|
||||
There's a lot of potential in the world of cryptography with Postgres and pgsodium, and we'd love to hear any ideas you may have as well. Join us in our [Discord #encryption channel](https://discord.com/channels/839993398554656828/1009906326480101417) if you want to chat more about it with us!
|
||||
|
||||
## More Postgres Resources
|
||||
|
||||
- [Postgres Full Text Search vs the rest](https://supabase.com/blog/postgres-full-text-search-vs-the-rest)
|
||||
- [Postgres WASM by Snaplet and Supabase](https://supabase.com/blog/postgres-wasm)
|
||||
- [Choosing a Postgres Primary Key](https://supabase.com/blog/choosing-a-postgres-primary-key)
|
||||
- [Implementing "seen by" functionality with Postgres](https://supabase.com/blog/seen-by-in-postgresql)
|
||||
- [Partial data dumps using Postgres Row Level Security](https://supabase.com/blog/partial-postgresql-data-dumps-with-rls)
|
||||
- [Realtime Postgres RLS on Supabase](https://supabase.com/blog/realtime-row-level-security-in-postgresql)
|
||||
@@ -0,0 +1,72 @@
|
||||
import createGlobe from 'cobe'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTheme } from './Providers/index'
|
||||
|
||||
const Globe = () => {
|
||||
const { isDarkMode } = useTheme()
|
||||
const canvasRef = useRef<any>()
|
||||
|
||||
useEffect(() => {
|
||||
let rotation: number = 0
|
||||
let width: number = 0
|
||||
const onResize = () => canvasRef.current && (width = canvasRef.current.offsetWidth)
|
||||
window.addEventListener('resize', onResize)
|
||||
onResize()
|
||||
const cobe = createGlobe(canvasRef.current, {
|
||||
devicePixelRatio: 2,
|
||||
width: width * 2,
|
||||
height: width * 2,
|
||||
phi: 0,
|
||||
theta: 0.3,
|
||||
dark: isDarkMode ? 1 : 0,
|
||||
diffuse: 3,
|
||||
scale: 1,
|
||||
opacity: 0.8,
|
||||
mapSamples: 20000,
|
||||
mapBrightness: 4,
|
||||
baseColor: [255 / 255, 255 / 255, 255 / 255],
|
||||
markerColor: [62 / 255, 207 / 255, 142 / 255],
|
||||
glowColor: [255 / 255, 255 / 255, 255 / 255],
|
||||
markers: [
|
||||
{ location: [53.4084, 2.9916], size: 0.06 },
|
||||
{ location: [1.3521, 103.8198], size: 0.06 },
|
||||
{ location: [-40.9006, 174.886], size: 0.06 },
|
||||
{ location: [14.0583, 108.2772], size: 0.06 },
|
||||
{ location: [37.7749, -122.4194], size: 0.06 },
|
||||
{ location: [41.3874, 2.1686], size: 0.06 },
|
||||
{ location: [49.2827, -123.1207], size: 0.06 },
|
||||
{ location: [-36.9848, 143.3906], size: 0.06 },
|
||||
{ location: [42.3601, -71.0589], size: 0.06 },
|
||||
{ location: [52.52, 13.405], size: 0.06 },
|
||||
{ location: [33.749, -84.388], size: 0.06 },
|
||||
{ location: [35.6762, 139.6503], size: 0.06 },
|
||||
{ location: [9.19, -75.0152], size: 0.06 },
|
||||
{ location: [-25.2521, -52.0215], size: 0.06 },
|
||||
],
|
||||
onRender: (state) => {
|
||||
state.phi = rotation
|
||||
rotation += 0.0025
|
||||
state.width = width * 2
|
||||
state.height = width * 2
|
||||
},
|
||||
})
|
||||
setTimeout(() => (canvasRef.current.style.opacity = '1'))
|
||||
return () => cobe.destroy()
|
||||
}, [isDarkMode])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
contain: 'layout paint size',
|
||||
opacity: 0,
|
||||
transition: 'opacity 1s ease',
|
||||
borderRadius: '100%',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Globe
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react'
|
||||
import { PageState, ConfDataContext, UserData } from './hooks/use-conf-data'
|
||||
import Ticket from './ticket'
|
||||
import ConfContainer from './conf-container'
|
||||
import Form from './form'
|
||||
import { SupabaseClient, Session } from '@supabase/supabase-js'
|
||||
|
||||
type Props = {
|
||||
supabase: SupabaseClient
|
||||
session: Session | null
|
||||
defaultUserData: UserData
|
||||
sharePage?: boolean
|
||||
defaultPageState?: PageState
|
||||
}
|
||||
|
||||
export default function Conf({
|
||||
supabase,
|
||||
session,
|
||||
defaultUserData,
|
||||
sharePage,
|
||||
defaultPageState = 'registration',
|
||||
}: Props) {
|
||||
const [userData, setUserData] = useState<UserData>(defaultUserData)
|
||||
const [pageState, setPageState] = useState<PageState>(defaultPageState)
|
||||
|
||||
return (
|
||||
<ConfDataContext.Provider
|
||||
value={{
|
||||
supabase,
|
||||
session,
|
||||
userData,
|
||||
setUserData,
|
||||
setPageState,
|
||||
}}
|
||||
>
|
||||
{pageState === 'registration' && !sharePage ? (
|
||||
<Form align={defaultPageState === 'registration' ? 'Center' : 'Left'} />
|
||||
) : (
|
||||
<Ticket
|
||||
username={userData.username}
|
||||
name={userData.name}
|
||||
ticketNumber={userData.ticketNumber}
|
||||
sharePage={sharePage}
|
||||
golden={userData.golden}
|
||||
/>
|
||||
)}
|
||||
</ConfDataContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* .container {
|
||||
margin: auto 0;
|
||||
padding: 0 var(--space-4x);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
margin: auto;
|
||||
padding: 0 var(--space-8x);
|
||||
}
|
||||
} */
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from './conf-container.module.css'
|
||||
|
||||
export default function ConfContainer({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* .form {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
margin-bottom: 12px;
|
||||
gap: 12px;
|
||||
} */
|
||||
|
||||
/* .formLeft {
|
||||
align-items: center;
|
||||
}
|
||||
.formCenter {
|
||||
align-items: center;
|
||||
} */
|
||||
|
||||
/* @media (min-width: 1200px) {
|
||||
.formLeft {
|
||||
align-items: start;
|
||||
}
|
||||
.formCenter {
|
||||
align-items: center;
|
||||
}
|
||||
} */
|
||||
/*
|
||||
.formInfo {
|
||||
max-width: 520px;
|
||||
} */
|
||||
|
||||
.formInfo h3 {
|
||||
font-size: 1.25em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.formInfo p {
|
||||
font-size: 1.1em;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 32px;
|
||||
color: var(--lw-secondary-color);
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
/* .form.share-page {
|
||||
justify-content: flex-start;
|
||||
} */
|
||||
}
|
||||
|
||||
/* .formInfoLeft {
|
||||
text-align: center;
|
||||
}
|
||||
.formInfoCenter {
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
} */
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.formInfoLeft {
|
||||
text-align: left;
|
||||
}
|
||||
.formInfoCenter {
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
border: none;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
height: 56px;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
font-size: var(--text-md);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.input {
|
||||
width: calc(100% - var(--space-32x));
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--lw-secondary-color);
|
||||
}
|
||||
|
||||
.input-label {
|
||||
background-color: var(--gray);
|
||||
border-radius: var(--space-2x);
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-label.focused {
|
||||
background-color: #33373c;
|
||||
}
|
||||
|
||||
.input-label.error {
|
||||
background: red;
|
||||
}
|
||||
|
||||
.input-label.success {
|
||||
background: #0070f3;
|
||||
}
|
||||
|
||||
.input-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
position: relative;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.form-row {
|
||||
max-width: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
margin-top: var(--space-4x);
|
||||
border-radius: var(--space-2x);
|
||||
border: 1px solid var(--brand);
|
||||
background: var(--brand);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-md);
|
||||
letter-spacing: -0.02em;
|
||||
outline: none;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.submit.generate-with-github {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.submit.register {
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
margin-top: 0;
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
right: var(--space-2x);
|
||||
top: var(--space-2x);
|
||||
}
|
||||
}
|
||||
|
||||
.submit.loading {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.submit.default:hover,
|
||||
.submit.default:focus {
|
||||
background: black;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.submit.default.generate-with-github:hover path,
|
||||
.submit.default.generate-with-github:focus path {
|
||||
fill: var(--brand);
|
||||
}
|
||||
|
||||
.submit.error:hover,
|
||||
.submit.error:focus {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.submit.default:disabled,
|
||||
.submit.default:disabled:hover,
|
||||
.submit.default:disabled:focus {
|
||||
cursor: default;
|
||||
background: var(--gray);
|
||||
border-color: var(--gray);
|
||||
color: #fff;
|
||||
justify-content: flex-start;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.submit.default.generate-with-github:disabled path,
|
||||
.submit.default.generate-with-github:disabled:hover path,
|
||||
.submit.default.generate-with-github:disabled:focus path {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.submit.default.generate-with-github.not-allowed:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.form-row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.submit.generate-with-github {
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-btn {
|
||||
background: #323332;
|
||||
border: 2px solid #34b27b;
|
||||
}
|
||||
|
||||
.github-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.or-divider {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: var(--lw-secondary-color);
|
||||
margin: var(--space-4x) 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.github-wrapper {
|
||||
flex-direction: row;
|
||||
}
|
||||
.or-divider {
|
||||
width: 240px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import cn from 'classnames'
|
||||
import useConfData from '~/components/LaunchWeek/Ticket/hooks/use-conf-data'
|
||||
import { useRouter } from 'next/router'
|
||||
import LoadingDots from './loading-dots'
|
||||
import styleUtils from './utils.module.css'
|
||||
import styles from './form.module.css'
|
||||
import useEmailQueryParam from '~/components/LaunchWeek/Ticket/hooks/use-email-query-param'
|
||||
import { IconLoader } from '~/../../packages/ui'
|
||||
// import Captcha, { useCaptcha } from './captcha'
|
||||
|
||||
type FormState = 'default' | 'loading' | 'error'
|
||||
|
||||
export type ConfUser = {
|
||||
id?: string
|
||||
email?: string
|
||||
ticketNumber?: number
|
||||
name?: string
|
||||
username?: string
|
||||
createdAt?: number
|
||||
golden?: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
sharePage?: boolean
|
||||
align?: 'Left' | 'Center'
|
||||
}
|
||||
|
||||
export default function Form({ sharePage, align = 'Center' }: Props) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [errorTryAgain, setErrorTryAgain] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formState, setFormState] = useState<FormState>('default')
|
||||
const { setPageState, setUserData, session, userData, supabase } = useConfData()
|
||||
const router = useRouter()
|
||||
const isCaptchaEnabled = false
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
document.body.classList.add('ticket-generated')
|
||||
const username = session.user.user_metadata.user_name
|
||||
const name = session.user.user_metadata.full_name
|
||||
const email = session.user.email
|
||||
supabase
|
||||
.from('lw6_tickets')
|
||||
.upsert({ email, name, username }, { onConflict: 'email', ignoreDuplicates: false })
|
||||
.eq('email', email)
|
||||
.select()
|
||||
.single()
|
||||
.then(async ({ error }) => {
|
||||
if (error) return supabase.auth.signOut()
|
||||
const { data } = await supabase
|
||||
.from('lw6_tickets_golden')
|
||||
.select('*')
|
||||
.eq('username', username)
|
||||
.single()
|
||||
setUserData(data)
|
||||
setFormState('default')
|
||||
|
||||
// Prefetch GitHub avatar
|
||||
new Image().src = `https://github.com/${username}.png`
|
||||
|
||||
// Prefetch the twitter share URL to eagerly generate the page
|
||||
fetch(`/launch-week/tickets/${username}`).catch((_) => {})
|
||||
// Prefetch ticket og image.
|
||||
fetch(
|
||||
`https://obuldanrptloktxcffvn.functions.supabase.co/launchweek-ticket-og?username=${encodeURIComponent(
|
||||
username ?? ''
|
||||
)}`
|
||||
).catch((_) => {})
|
||||
|
||||
setPageState('ticket')
|
||||
})
|
||||
}
|
||||
}, [session])
|
||||
|
||||
async function register(email: string, token?: string): Promise<ConfUser> {
|
||||
const { error } = await supabase!.from('lw6_tickets').insert({ email })
|
||||
if (error) {
|
||||
// console.log({ error })
|
||||
return {
|
||||
id: 'new',
|
||||
ticketNumber: 1234,
|
||||
name: '',
|
||||
username: '',
|
||||
golden: false,
|
||||
}
|
||||
}
|
||||
const { data } = await supabase!.from('lw6_tickets_golden').select('*').limit(1).single()
|
||||
return {
|
||||
id: data?.id ?? 'new',
|
||||
ticketNumber: data?.ticketNumber ?? 1234,
|
||||
name: data?.name ?? '',
|
||||
username: data?.username ?? '',
|
||||
golden: data?.golden ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = useCallback(
|
||||
(token?: string) => {
|
||||
register(email, token)
|
||||
.then(async (params) => {
|
||||
if (!params) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
if (sharePage) {
|
||||
const queryString = Object.keys(params)
|
||||
.map(
|
||||
(key) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(
|
||||
params[key as keyof typeof params] || ''
|
||||
)}`
|
||||
)
|
||||
.join('&')
|
||||
await router.replace(`/launch-week/tickets?${queryString}`, '/launch-week/tickets')
|
||||
} else {
|
||||
setUserData(params)
|
||||
setPageState('ticket')
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
let message = 'Error! Please try again.'
|
||||
|
||||
setErrorMsg(message)
|
||||
setFormState('error')
|
||||
})
|
||||
},
|
||||
[email, router, setPageState, setUserData, sharePage]
|
||||
)
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (formState === 'default') {
|
||||
setFormState('loading')
|
||||
|
||||
if (isCaptchaEnabled) {
|
||||
// return executeCaptcha()
|
||||
}
|
||||
|
||||
return handleRegister()
|
||||
} else {
|
||||
setFormState('default')
|
||||
}
|
||||
},
|
||||
[formState, isCaptchaEnabled, handleRegister]
|
||||
)
|
||||
|
||||
const onTryAgainClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
setFormState('default')
|
||||
setErrorTryAgain(true)
|
||||
// resetCaptcha()
|
||||
}, [])
|
||||
|
||||
useEmailQueryParam('email', setEmail)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div
|
||||
className={cn(
|
||||
styleUtils['appear-fifth'],
|
||||
'flex flex-col gap-2 items-center xl:items-start',
|
||||
align === 'Left' ? 'text-center xl:text-left' : 'text-center'
|
||||
)}
|
||||
>
|
||||
<p className="text-scale-1200 text-base w-full">Coming soon</p>
|
||||
<p className="text-scale-1000 text-base max-w-[420px]">
|
||||
Register to get your ticket and stay tuned all week for daily announcements
|
||||
</p>
|
||||
</div>
|
||||
{formState === 'error' ? (
|
||||
<div
|
||||
className={cn(styles.form, {
|
||||
[styles['share-page']]: sharePage,
|
||||
})}
|
||||
>
|
||||
<div className={styles['form-row']}>
|
||||
<div className={cn(styles['input-label'], styles.error)}>
|
||||
<div className={cn(styles.input, styles['input-text'])}>{errorMsg}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(styles.submit, styles.register, styles.error)}
|
||||
onClick={onTryAgainClick}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
className="relative mx-auto xl:mx-0 w-full md:w-auto md:min-w-[320px] md:max-w-[420px]"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<input
|
||||
className={`
|
||||
transition-all
|
||||
border border-scale-300 bg-scaleA-200 h-10
|
||||
focus:border-scale-500 focus:ring-scaleA-300
|
||||
text-scale-1200 text-base rounded-full w-full px-5
|
||||
`}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
id="email-input-field"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder="Enter email"
|
||||
aria-label="Your email address"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={[
|
||||
'transition-all',
|
||||
'absolute bg-scale-300 text-scale-1200 border border-scale-600 text-sm hover:bg-scale-400',
|
||||
'rounded-full px-4',
|
||||
'focus:invalid:border-scale-500 focus:invalid:ring-scaleA-300',
|
||||
'absolute right-1 my-auto h-8 top-0 bottom-0',
|
||||
].join(' ')}
|
||||
disabled={formState === 'loading'}
|
||||
>
|
||||
{formState === 'loading' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<IconLoader size={14} className="animate-spin" /> Registering
|
||||
</div>
|
||||
) : (
|
||||
'Register'
|
||||
)}
|
||||
</button>
|
||||
{/* <Captcha ref={captchaRef} onVerify={handleRegister} /> */}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Session, SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export type PageState = 'registration' | 'ticket'
|
||||
|
||||
export type UserData = {
|
||||
id?: string
|
||||
ticketNumber?: number
|
||||
username?: string
|
||||
name?: string
|
||||
golden?: boolean
|
||||
}
|
||||
|
||||
type ConfDataContextType = {
|
||||
supabase: SupabaseClient
|
||||
session: Session | null
|
||||
userData: UserData
|
||||
setUserData: React.Dispatch<React.SetStateAction<UserData>>
|
||||
setPageState: React.Dispatch<React.SetStateAction<PageState>>
|
||||
}
|
||||
|
||||
export const ConfDataContext = createContext<ConfDataContextType | null>(null)
|
||||
|
||||
export default function useConfData() {
|
||||
const result = useContext(ConfDataContext)
|
||||
if (!result) {
|
||||
throw new Error()
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
/**
|
||||
* If `paramName` exists in query string, then call `setEmail()` with the value
|
||||
* and delete it from the URL.
|
||||
*/
|
||||
export default function useEmailQueryParam(
|
||||
paramName: string,
|
||||
setEmail: (email: string) => unknown
|
||||
) {
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
if ('URLSearchParams' in window) {
|
||||
const { search, pathname } = window.location
|
||||
const params = new URLSearchParams(search)
|
||||
const email = params.get(paramName)
|
||||
if (email) {
|
||||
setEmail(email)
|
||||
params.delete(paramName)
|
||||
const newSearch = params.toString()
|
||||
const newAsPath = pathname + (newSearch ? `?${newSearch}` : '')
|
||||
const newPathname = router.pathname + (newSearch ? `?${newSearch}` : '')
|
||||
history.replaceState(
|
||||
{ url: newPathname, as: newAsPath, options: { shallow: true } },
|
||||
'',
|
||||
newAsPath
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [setEmail, router.pathname, paramName])
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
--loading-dots-height: auto;
|
||||
--loading-dots-size: 2px;
|
||||
height: var(--loading-dots-height);
|
||||
}
|
||||
|
||||
.loading .spacer {
|
||||
margin-right: var(--space-3x);
|
||||
}
|
||||
|
||||
.loading span {
|
||||
animation-name: blink;
|
||||
animation-duration: 1.4s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-fill-mode: both;
|
||||
width: var(--loading-dots-size);
|
||||
height: var(--loading-dots-size);
|
||||
border-radius: 50%;
|
||||
background-color: var(--accents-6);
|
||||
display: inline-block;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.loading.reverse span {
|
||||
background-color: var(--accents-2);
|
||||
}
|
||||
|
||||
.loading span:nth-of-type(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.loading span:nth-of-type(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { toPixels as px } from '~/lib/helpers'
|
||||
import cn from 'classnames'
|
||||
import styles from './loading-dots.module.css'
|
||||
|
||||
interface Props {
|
||||
size?: number
|
||||
height?: number | string
|
||||
reverse?: boolean
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function LoadingDots({ size = 2, height, children, reverse }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={cn(styles.loading, { [styles.reverse]: reverse })}
|
||||
style={{
|
||||
['--loading-dots-height' as string]: height ? px(height) : undefined,
|
||||
['--loading-dots-size' as string]: size !== 2 ? px(size) : undefined,
|
||||
}}
|
||||
>
|
||||
{children && <div className={styles.spacer}>{children}</div>}
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.button {
|
||||
border-radius: var(--space-2x);
|
||||
border: 1px solid #fff;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--text-md);
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
transition: background-color 0.2s ease;
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-4x);
|
||||
height: 55px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: var(--space-2x) auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.button {
|
||||
width: 200px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
.button:focus {
|
||||
background: black;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.first {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
.second {
|
||||
animation-delay: 2.2s;
|
||||
}
|
||||
.third {
|
||||
animation-delay: 2.4s;
|
||||
}
|
||||
|
||||
:global(.ticket-generated) .first {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
:global(.ticket-generated) .second {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
:global(.ticket-generated) .third {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
.linkedin-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.linkedin-button {
|
||||
display: grid !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { SITE_URL, TWEET_TEXT, TWEET_TEXT_GOLDEN } from '~/lib/constants'
|
||||
import { IconDownload, IconLinkedin, IconTwitter } from 'ui'
|
||||
import useConfData from '~/components/LaunchWeek/Ticket//hooks/use-conf-data'
|
||||
import LoadingDots from './loading-dots'
|
||||
|
||||
type Props = {
|
||||
username: string
|
||||
golden?: boolean
|
||||
}
|
||||
|
||||
export default function TicketActions({ username, golden = false }: Props) {
|
||||
const [imgReady, setImgReady] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const downloadLink = useRef<HTMLAnchorElement>()
|
||||
const permalink = encodeURIComponent(`${SITE_URL}/tickets/${username}?v=6`)
|
||||
const text = encodeURIComponent(golden ? TWEET_TEXT_GOLDEN : TWEET_TEXT)
|
||||
const { userData } = useConfData()
|
||||
const tweetUrl = `https://twitter.com/intent/tweet?url=${permalink}&via=supabase&text=${text}`
|
||||
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${permalink}`
|
||||
const downloadUrl = `https://obuldanrptloktxcffvn.functions.supabase.co/launchweek-ticket-og?username=${encodeURIComponent(
|
||||
userData.username ?? ''
|
||||
)}`
|
||||
|
||||
useEffect(() => {
|
||||
setImgReady(false)
|
||||
|
||||
const img = new Image()
|
||||
|
||||
img.src = downloadUrl
|
||||
img.onload = () => {
|
||||
setImgReady(true)
|
||||
setLoading(false)
|
||||
if (downloadLink.current) {
|
||||
downloadLink.current.click()
|
||||
downloadLink.current = undefined
|
||||
}
|
||||
}
|
||||
}, [downloadUrl])
|
||||
|
||||
const ActionStyle = ({ children }: any) => {
|
||||
return (
|
||||
<div className="rounded-full bg-white dark:bg-scale-400 dark:hover:bg-scale-500 py-1 px-3 border border-scale-500 dark:text-white text-sm mb-1 transition-all ease-out hover:bg-scale-500">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionStyle>
|
||||
<a
|
||||
href={tweetUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="text-scale-900">
|
||||
<IconTwitter size={12} strokeWidth={1.5} />
|
||||
</div>
|
||||
Tweet it
|
||||
</a>
|
||||
</ActionStyle>
|
||||
<ActionStyle>
|
||||
<a
|
||||
href={linkedInUrl}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="text-scale-900">
|
||||
<IconLinkedin size={12} strokeWidth={1.5} />
|
||||
</div>
|
||||
Share on Linkedin
|
||||
</a>
|
||||
</ActionStyle>
|
||||
<ActionStyle>
|
||||
<a
|
||||
href={loading ? undefined : downloadUrl}
|
||||
onClick={(e) => {
|
||||
if (imgReady) return
|
||||
|
||||
e.preventDefault()
|
||||
downloadLink.current = e.currentTarget
|
||||
// Wait for the image download to finish
|
||||
setLoading(true)
|
||||
}}
|
||||
download="ticket.png"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="text-scale-900">
|
||||
<IconDownload size={12} strokeWidth={1.5} />
|
||||
</div>
|
||||
Download
|
||||
{loading ? <LoadingDots size={4} /> : <>{/* <IconDownload width={24} /> Download */}</>}
|
||||
</a>
|
||||
</ActionStyle>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
.wrapper {
|
||||
color: var(--lw-secondary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation-delay: 2.6s;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:global(.ticket-generated) .wrapper {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-2x);
|
||||
}
|
||||
|
||||
.label-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.wrapper {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: auto;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.label-wrapper {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
border-radius: var(--space-2x);
|
||||
overflow: hidden;
|
||||
background: var(--gray);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.field {
|
||||
padding-right: 50px;
|
||||
}
|
||||
.field.desktop-copy-disabled {
|
||||
padding-right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-copy {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-copy-disabled.mobile-copy-disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-copy * {
|
||||
color: var(--lw-secondary-color);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-copy:hover * {
|
||||
color: #fff;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.field * {
|
||||
color: var(--lw-secondary-color);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.field:hover * {
|
||||
color: #fff;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.url {
|
||||
width: calc(100vw - 36px);
|
||||
user-select: all;
|
||||
overflow-x: scroll;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
|
||||
padding: var(--space-2x) 14px;
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
@media (min-width: 436px) {
|
||||
.url {
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.url {
|
||||
width: 400px;
|
||||
}
|
||||
.mobile-copy {
|
||||
margin-bottom: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.url::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* .fade {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, rgba(37, 39, 41, 0%) 0%, #252729 100%);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0px;
|
||||
width: 50px;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
border-radius: var(--space-2x);
|
||||
pointer-events: none;
|
||||
} */
|
||||
|
||||
.desktop-copy.desktop-copy-disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fade {
|
||||
right: 50px;
|
||||
}
|
||||
.fade.desktop-copy-disabled {
|
||||
right: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.copied {
|
||||
display: flex;
|
||||
z-index: 2;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease !important;
|
||||
}
|
||||
|
||||
.copied.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
border-radius: var(--space-2x);
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: -10px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.copied {
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
background: linear-gradient(90deg, rgba(37, 39, 41, 0%) 0px, #252729 40px);
|
||||
padding-left: 50px;
|
||||
}
|
||||
.copy-button {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
margin-right: 0px;
|
||||
width: 50px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import cn from 'classnames'
|
||||
import { SITE_URL } from '~/lib/constants'
|
||||
import styleUtils from './utils.module.css'
|
||||
// import IconCopy from '~/components/LaunchWeek/Ticket/icons/icon-copy'
|
||||
import styles from './ticket-copy.module.css'
|
||||
import { IconCopy, IconCheck } from 'ui'
|
||||
|
||||
type Props = {
|
||||
username: string
|
||||
}
|
||||
|
||||
export default function TicketCopy({ username }: Props) {
|
||||
const [fadeOpacity, setFadeOpacity] = useState(1)
|
||||
const [scrolling, setScrolling] = useState(false)
|
||||
const [copyEnabled, setCopyEnabled] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const scrollRef = useRef<HTMLParagraphElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const url = `${SITE_URL}/tickets/${username}`
|
||||
useEffect(() => {
|
||||
if (navigator.clipboard) {
|
||||
setCopyEnabled(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copiedText = <span className="text-xs text-scale-1200">Copied!</span>
|
||||
|
||||
// background: none;
|
||||
// outline: none;
|
||||
// border: none;
|
||||
// z-index: 2;
|
||||
// border-radius: var(--space-2x);
|
||||
// width: 40px;
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// justify-content: center;
|
||||
// margin-right: -10px;
|
||||
|
||||
const copyButton = (
|
||||
<button
|
||||
type="button"
|
||||
name="Copy"
|
||||
className="text-scale-900 hover:text-scale-1200 w-21 flex items-center cursor-pointer"
|
||||
ref={buttonRef}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<div className="text-brand-900">
|
||||
<IconCheck size={14} />
|
||||
</div>
|
||||
) : (
|
||||
<IconCopy size={14} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
styleUtils.appear,
|
||||
styleUtils['appear-third'],
|
||||
'bg-scaleA-200 h-8 rounded border border-scale-400 w-full'
|
||||
)}
|
||||
>
|
||||
<div className="px-3 h-full flex items-center gap-3 w-full truncate relative pr-20">
|
||||
<div className="text-scale-900 text-sm hidden lg:flex">Your ticket URL:</div>
|
||||
<div className="flex items-center truncate">
|
||||
<p
|
||||
className={['text-xs font-mono text-scale-1200 truncate'].join(' ')}
|
||||
ref={scrollRef}
|
||||
onScroll={() => {
|
||||
if (!scrolling) {
|
||||
setScrolling(true)
|
||||
const animationFrame = requestAnimationFrame(() => {
|
||||
const scrollableWidth =
|
||||
(scrollRef.current?.scrollWidth || 0) - (scrollRef.current?.clientWidth || 0)
|
||||
setFadeOpacity(
|
||||
(scrollableWidth - (scrollRef.current?.scrollLeft || 0)) /
|
||||
(scrollableWidth || 1)
|
||||
)
|
||||
cancelAnimationFrame(animationFrame)
|
||||
setScrolling(false)
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute right-3 with-auto height-auto flex items-center">
|
||||
{copied && copiedText}
|
||||
{copyButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.githubIcon {
|
||||
margin-left: 12px;
|
||||
margin-right: var(--space-4x);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
position: absolute;
|
||||
right: var(--space-4x);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.generateWithGithub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stageIcon {
|
||||
position: absolute;
|
||||
left: var(--space-4x);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--secondary-color);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
margin-left: var(--space-4x);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.form-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.form-row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.generateWithGithub {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.learn-more {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.learn-more:hover {
|
||||
text-decoration: underline;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, useRef } from 'react'
|
||||
// import { scrollTo } from '@lib/smooth-scroll';
|
||||
import cn from 'classnames'
|
||||
// import GithubIcon from '~/components/LaunchWeek/Ticket/icons/icon-github'
|
||||
// import CheckIcon from '~/components/LaunchWeek/Ticket/icons/icon-check'
|
||||
import { SITE_ORIGIN } from '~/lib/constants'
|
||||
import useConfData from '~/components/LaunchWeek/Ticket//hooks/use-conf-data'
|
||||
import LoadingDots from './loading-dots'
|
||||
import formStyles from './form.module.css'
|
||||
import ticketFormStyles from './ticket-form.module.css'
|
||||
|
||||
type FormState = 'default' | 'loading' | 'error'
|
||||
type TicketGenerationState = 'default' | 'loading'
|
||||
|
||||
type Props = {
|
||||
defaultUsername?: string
|
||||
setTicketGenerationState: React.Dispatch<React.SetStateAction<TicketGenerationState>>
|
||||
}
|
||||
|
||||
export default function Form({ defaultUsername = '', setTicketGenerationState }: Props) {
|
||||
const [username] = useState(defaultUsername)
|
||||
const [formState, setFormState] = useState<FormState>('default')
|
||||
const [errorMsg] = useState('')
|
||||
const { supabase } = useConfData()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
return formState === 'error' ? (
|
||||
<div>
|
||||
<div className={cn(formStyles['form-row'], ticketFormStyles['form-row'])}>
|
||||
<div className={cn(formStyles['input-label'], formStyles.error)}>
|
||||
<div className={cn(formStyles.input, formStyles['input-text'])}>{errorMsg}</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(formStyles.submit, formStyles.error)}
|
||||
onClick={() => {
|
||||
setFormState('default')
|
||||
setTicketGenerationState('default')
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (formState !== 'default') {
|
||||
setTicketGenerationState('default')
|
||||
setFormState('default')
|
||||
return
|
||||
}
|
||||
|
||||
setFormState('loading')
|
||||
setTicketGenerationState('loading')
|
||||
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: 'github',
|
||||
options: { redirectTo: `${SITE_ORIGIN}/launch-week/tickets` },
|
||||
})
|
||||
}}
|
||||
className="flex flex-col items-center xl:block"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-full bg-scale-400 py-1 px-3 border border-scale-500 dark:text-white text-sm mb-1 transition-all ease-out hover:bg-scale-500"
|
||||
disabled={formState === 'loading' || Boolean(username)}
|
||||
>
|
||||
<span className={`${username && 'text-scale-900'}`}>
|
||||
{username ? 'Done!' : 'Connect with GitHub'}
|
||||
</span>
|
||||
{username ? <span className={ticketFormStyles.checkIcon}></span> : null}
|
||||
</button>
|
||||
</div>
|
||||
{!username && <p className={'text-xs text-scale-900'}>Only public info will be used.</p>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.background {
|
||||
width: 2000px;
|
||||
height: 1000px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 1700px;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Noto Sans', 'Noto Sans JP',
|
||||
'Noto Sans KR', 'Noto Sans SC', 'Noto Sans TC', 'Noto Sans HK', sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import Head from 'next/head'
|
||||
import TicketVisual from './ticket-visual'
|
||||
import styles from './ticket-image.module.css'
|
||||
|
||||
export default function TicketImage() {
|
||||
const { query } = useRouter()
|
||||
if (query.ticketNumber) {
|
||||
return (
|
||||
<div className={styles.background}>
|
||||
<div className={styles.page}>
|
||||
<Head>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&Noto+Sans+HK:wght@700&family=Noto+Sans+JP:wght@700&family=Noto+Sans+KR:wght@700&family=Noto+Sans+SC:wght@700&family=Noto+Sans+TC:wght@700&family=Noto+Sans:wght@700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</Head>
|
||||
<TicketVisual
|
||||
size={1700 / 650}
|
||||
username={query.username ? query.username.toString() : undefined}
|
||||
ticketNumber={parseInt(query.ticketNumber.toString(), 10)}
|
||||
name={
|
||||
query.name
|
||||
? query.name?.toString()
|
||||
: query.username
|
||||
? query.username.toString()
|
||||
: undefined
|
||||
}
|
||||
golden={query.golden ? true : false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <></>
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.info {
|
||||
display: flex;
|
||||
gap: 0px;
|
||||
margin-bottom: 64px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.info {
|
||||
/* display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: calc(var(--space-4x) * var(--size));
|
||||
font-size: calc(1em * var(--size)); */
|
||||
padding: 0;
|
||||
margin-bottom: calc(32px * var(--size));
|
||||
margin-left: calc(160px * var(--size));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
/* margin-bottom: var(--space-6x); */
|
||||
/* max-width: 96px; */
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* @media (min-width: 768px) {
|
||||
.logo {
|
||||
margin-bottom: 0;
|
||||
font-size: calc(16px * var(--size));
|
||||
}
|
||||
} */
|
||||
|
||||
.date {
|
||||
/* text-transform: uppercase; */
|
||||
/* font-size: calc(18px * var(--size)); */
|
||||
|
||||
color: var(--lw-secondary-color);
|
||||
}
|
||||
|
||||
.date-golden {
|
||||
color: var(--gold-primary);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.date {
|
||||
margin-bottom: 0;
|
||||
line-height: 1.15;
|
||||
/* font-size: calc(20px * var(--size)); */
|
||||
}
|
||||
}
|
||||
|
||||
.created-by {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--accents-4);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.created-by {
|
||||
margin-right: var(--space-4x);
|
||||
}
|
||||
}
|
||||
|
||||
.created-by-text {
|
||||
white-space: nowrap;
|
||||
margin-right: var(--space);
|
||||
}
|
||||
|
||||
/* .created-by-logo {
|
||||
height: calc(16px * var(--size));
|
||||
display: inline-flex;
|
||||
} */
|
||||
|
||||
.url {
|
||||
color: var(--lw-secondary-color);
|
||||
}
|
||||
|
||||
.url-golden {
|
||||
color: var(--gold-primary);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import styles from './ticket-info.module.css'
|
||||
import cn from 'classnames'
|
||||
import { DATE, SITE_URL } from '~/lib/constants'
|
||||
|
||||
const siteUrl = new URL(SITE_URL)
|
||||
const siteUrlForTicket = `${siteUrl.host}${siteUrl.pathname}`.replace(/\/$/, '')
|
||||
|
||||
export default function TicketInfo({
|
||||
logoTextSecondaryColor = 'var(--accents-5)',
|
||||
golden = false,
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.info}>
|
||||
<div
|
||||
className={`${cn(styles.date, { [styles['date-golden']]: golden })} text-sm mr-4 ${
|
||||
golden && '!text-white'
|
||||
}`}
|
||||
>
|
||||
<div>{DATE}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`${cn(styles.date, { [styles['date-golden']]: golden })} text-sm ${
|
||||
golden && '!text-white'
|
||||
}`}
|
||||
>
|
||||
<div>supabase.com/launch-week</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// NOTE: When you export SVG from Figma, you must:
|
||||
// - Update width and height as 100%
|
||||
// - Change id to "mobile-..."
|
||||
// - Change url(#) to url(#mobile-)
|
||||
// This is because if a page has two same IDs it will fail.
|
||||
export default function TicketMonoMobile({ golden = false }: { golden?: boolean }) {
|
||||
const frameColor = golden ? '#F2C94C' : '#3fcf8e'
|
||||
|
||||
const perforationColor = golden ? 'var(--gold-accent)' : '#252729'
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 330 560"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* <path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.9193e-06 540C3.40212e-06 551.046 8.95431 560 20 560L138 560C138 545.088 150.088 533 165 533C179.912 533 192 545.088 192 560L310 560C321.046 560 330 551.046 330 540L330 20C330 8.95427 321.046 -1.40334e-05 310 -1.35505e-05L192 -8.39259e-06C192 14.9117 179.912 27 165 27C150.088 27 138 14.9117 138 -6.03217e-06L20 -8.74228e-07C8.95428 -3.91405e-07 -2.41646e-05 8.95428 -2.36041e-05 20L2.9193e-06 540Z"
|
||||
fill={frameColor}
|
||||
/> */}
|
||||
{/* <path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5 539C5 547.837 12.1634 555 21 555L133.388 555C135.789 539.702 149.028 528 165 528C180.972 528 194.211 539.702 196.612 555L309 555C317.837 555 325 547.837 325 539L325 21C325 12.1634 317.837 4.99999 309 4.99999L196.612 4.99999C194.211 20.2981 180.972 32 165 32C149.028 32 135.789 20.2982 133.388 4.99999L21 5C12.1634 5 4.99998 12.1635 4.99998 21L5 539Z"
|
||||
fill="black"
|
||||
/> */}
|
||||
<path d="M326 446H5" stroke={perforationColor} strokeDasharray="6 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||