mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 06:09:08 +03:00
Merge branch 'master' into feature/branching-gitless-merge
This commit is contained in:
commit
462fb3c947
122 files changed
+1414
-4089
No files matched your search
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
env:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
SUPABASE_SECRET_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Comment on external PRs with linter results
|
||||
name: docs_lint_comment_external
|
||||
|
||||
# This is a continuation of ./docs-lint-v2.yml, to write comments on external
|
||||
# PRs.
|
||||
@@ -11,7 +11,7 @@ name: Comment on external PRs with linter results
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['[Docs] Lint v2']
|
||||
workflows: [docs_lint]
|
||||
types:
|
||||
- completed
|
||||
|
||||
@@ -21,7 +21,7 @@ permissions:
|
||||
jobs:
|
||||
comment_on_pr:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'failure'
|
||||
steps:
|
||||
- id: download_artifact
|
||||
name: 'Download artifact'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: '[Docs] Lint v2'
|
||||
name: docs_lint
|
||||
|
||||
# Runs the docs linter on PRs that edit docs content.
|
||||
# There are two branches of this workflow for internal and external PRs, due
|
||||
@@ -102,3 +102,6 @@ jobs:
|
||||
with:
|
||||
name: lint_results
|
||||
path: __github_actions__pr/
|
||||
- name: fail if linter fails (external)
|
||||
if: steps.filter.outputs.docs == 'true' && github.event.pull_request.head.repo.full_name != github.repository && steps.external_lint.outputs.LINT_EXIT_CODE != 0
|
||||
run: exit 1
|
||||
@@ -17,9 +17,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SEARCH_GITHUB_APP_ID: ${{ secrets.SEARCH_GITHUB_APP_ID }}
|
||||
SEARCH_GITHUB_APP_INSTALLATION_ID: ${{ secrets.SEARCH_GITHUB_APP_INSTALLATION_ID }}
|
||||
SEARCH_GITHUB_APP_PRIVATE_KEY: ${{ secrets.SEARCH_GITHUB_APP_PRIVATE_KEY }}
|
||||
DOCS_GITHUB_APP_ID: ${{ secrets.SEARCH_GITHUB_APP_ID }}
|
||||
DOCS_GITHUB_APP_INSTALLATION_ID: ${{ secrets.SEARCH_GITHUB_APP_INSTALLATION_ID }}
|
||||
DOCS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.SEARCH_GITHUB_APP_PRIVATE_KEY }}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }}
|
||||
SUPABASE_SECRET_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
|
||||
|
||||
@@ -29,12 +29,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
DOCS_GITHUB_APP_ID: ${{ secrets.SEARCH_GITHUB_APP_ID }}
|
||||
DOCS_GITHUB_APP_INSTALLATION_ID: ${{ secrets.SEARCH_GITHUB_APP_INSTALLATION_ID }}
|
||||
DOCS_GITHUB_APP_PRIVATE_KEY: ${{ secrets.SEARCH_GITHUB_APP_PRIVATE_KEY }}
|
||||
NEXT_PUBLIC_MISC_URL: ${{ secrets.NEXT_PUBLIC_MISC_URL}}
|
||||
NEXT_PUBLIC_MISC_ANON_KEY: ${{ secrets.NEXT_PUBLIC_MISC_ANON_KEY }}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
SEARCH_GITHUB_APP_ID: ${{ secrets.SEARCH_GITHUB_APP_ID }}
|
||||
SEARCH_GITHUB_APP_INSTALLATION_ID: ${{ secrets.SEARCH_GITHUB_APP_INSTALLATION_ID }}
|
||||
SEARCH_GITHUB_APP_PRIVATE_KEY: ${{ secrets.SEARCH_GITHUB_APP_PRIVATE_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
SUPABASE_SECRET_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
@@ -58,10 +60,6 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: pnpm i
|
||||
|
||||
# Need the miscellaneous use API, which is available publicly (by design) in www
|
||||
- name: Copy environment variables
|
||||
run: cp apps/www/.env.local.example apps/docs/.env
|
||||
|
||||
- name: Update embeddings
|
||||
working-directory: ./apps/docs
|
||||
if: ${{ !inputs.refresh }}
|
||||
|
||||
@@ -2,5 +2,17 @@
|
||||
# Only publicly available environment variables should be listed here.
|
||||
# Do not add secrets here. Use .env.local instead.
|
||||
|
||||
NEXT_PUBLIC_SITE_URL="http://localhost:3001"
|
||||
NEXT_PUBLIC_BASE_PATH="/docs"
|
||||
|
||||
# Whether to enable features available only on Supabase's own hosted services
|
||||
# Setting this to true requires certain secret keys
|
||||
NEXT_PUBLIC_IS_PLATFORM="false"
|
||||
|
||||
# Supabase project containing integration information
|
||||
NEXT_PUBLIC_MISC_URL="https://obuldanrptloktxcffvn.supabase.co"
|
||||
NEXT_PUBLIC_MISC_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9idWxkYW5ycHRsb2t0eGNmZnZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTg2MTQ2ODUsImV4cCI6MjAzNDE5MDY4NX0.NFt49g6DFkc1X5khCzN5p01iAVo2TMxlx88cY1V0E2M"
|
||||
|
||||
# Supabase project containing docs content information
|
||||
NEXT_PUBLIC_SUPABASE_URL="https://xguihxuzqibwxjnimxev.supabase.co"
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhndWloeHV6cWlid3hqbmlteGV2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTgzNzc1MTgsImV4cCI6MjAzMzk1MzUxOH0.aIqjQ9V7djMxYit-DT1fYNV_VWMHSqldh_18XfX2_BE"
|
||||
@@ -1,14 +0,0 @@
|
||||
# PUBLIC
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# PRIVATE
|
||||
OPENAI_API_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
SEARCH_GITHUB_APP_ID=
|
||||
SEARCH_GITHUB_APP_INSTALLATION_ID=
|
||||
SEARCH_GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# Set IS_PLATFORM to true if you have the api keys and urls
|
||||
NEXT_PUBLIC_IS_PLATFORM=false
|
||||
NEXT_PUBLIC_LOCAL_SUPABASE=false
|
||||
File renamed without changes.
@@ -85,7 +85,6 @@ function htmlShell(
|
||||
`<meta name="og:image" content="https://supabase.com/docs/img/supabase-og-image.png">` +
|
||||
`<meta name="twitter:image" content="https://supabase.com/docs/img/supabase-og-image.png">` +
|
||||
`<link rel="canonical" href="https://supabase.com/docs/reference/${lib}` +
|
||||
(version ? '/' + version : '') +
|
||||
(slug ? '/' + slug : '') +
|
||||
`">` +
|
||||
'</head>' +
|
||||
|
||||
@@ -131,6 +131,9 @@ type RootQueryType {
|
||||
|
||||
"""Search the Supabase docs for content matching a query string"""
|
||||
searchDocs(query: String!, limit: Int): SearchResultCollection
|
||||
|
||||
"""Get the details of an error code returned from a Supabase service"""
|
||||
error(code: String!, service: Service!): Error
|
||||
}
|
||||
|
||||
"""A collection of search results containing content from Supabase docs"""
|
||||
@@ -149,5 +152,30 @@ type SearchResultCollection {
|
||||
type SearchResultEdge {
|
||||
"""The SearchResult at the end of the edge"""
|
||||
node: SearchResult!
|
||||
}
|
||||
|
||||
"""An error returned by a Supabase service"""
|
||||
type Error {
|
||||
"""
|
||||
The unique code identifying the error. The code is stable, and can be used for string matching during error handling.
|
||||
"""
|
||||
code: String!
|
||||
|
||||
"""The Supabase service that returns this error."""
|
||||
service: Service!
|
||||
|
||||
"""The HTTP status code returned with this error."""
|
||||
httpStatusCode: Int
|
||||
|
||||
"""
|
||||
A human-readable message describing the error. The message is not stable, and should not be used for string matching during error handling. Use the code instead.
|
||||
"""
|
||||
message: String
|
||||
}
|
||||
|
||||
enum Service {
|
||||
AUTH
|
||||
REALTIME
|
||||
STORAGE
|
||||
}"
|
||||
`;
|
||||
@@ -3,15 +3,11 @@ import { POST } from './route'
|
||||
|
||||
describe('/api/graphql basic error statuses', () => {
|
||||
beforeAll(() => {
|
||||
vi.mock('server-only', () => {
|
||||
return {}
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.doUnmock('server-only')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('/api/graphql error', () => {
|
||||
it('returns the error matching given error code and service', async () => {
|
||||
const errorQuery = `
|
||||
query {
|
||||
error(code: "test_code", service: AUTH) {
|
||||
code
|
||||
service
|
||||
httpStatusCode
|
||||
message
|
||||
}
|
||||
}
|
||||
`
|
||||
const request = new Request('http://localhost/api/graphql', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query: errorQuery }),
|
||||
})
|
||||
|
||||
const result = await POST(request)
|
||||
const {
|
||||
data: { error },
|
||||
errors,
|
||||
} = await result.json()
|
||||
|
||||
expect(errors).toBeUndefined()
|
||||
expect(error.code).toBe('test_code')
|
||||
expect(error.service).toBe('AUTH')
|
||||
expect(error.httpStatusCode).toBe(500)
|
||||
expect(error.message).toBe('This is a test error message')
|
||||
})
|
||||
|
||||
it('returns error if no matching error exists', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const errorQuery = `
|
||||
query {
|
||||
error(code: "nonexistent_code", service: AUTH) {
|
||||
code
|
||||
service
|
||||
httpStatusCode
|
||||
message
|
||||
}
|
||||
}
|
||||
`
|
||||
const request = new Request('http://localhost/api/graphql', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query: errorQuery }),
|
||||
})
|
||||
|
||||
const result = await POST(request)
|
||||
const {
|
||||
data: { error },
|
||||
errors,
|
||||
} = await result.json()
|
||||
|
||||
expect(error).toBe(null)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0].message).toMatch(/not found/i)
|
||||
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,24 @@ export class InvalidRequestError<Details extends ObjectOrNever = never> extends
|
||||
}
|
||||
}
|
||||
|
||||
export class NoDataError<Details extends ObjectOrNever = never> extends ApiError<Details> {
|
||||
constructor(message: string, source?: unknown, details?: Details) {
|
||||
super(`Data not found: ${message}`, source, details)
|
||||
}
|
||||
|
||||
isPrivate() {
|
||||
return false
|
||||
}
|
||||
|
||||
isUserError() {
|
||||
return true
|
||||
}
|
||||
|
||||
statusCode() {
|
||||
return 404
|
||||
}
|
||||
}
|
||||
|
||||
export function convertUnknownToApiError(error: unknown): ApiError {
|
||||
return new ApiError('Unknown error', error)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Octokit } from '@octokit/core'
|
||||
import { capitalize } from 'lodash'
|
||||
import { capitalize } from 'lodash-es'
|
||||
import { type SerializeOptions } from 'next-mdx-remote/dist/types'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
|
||||
import { Heading } from 'ui'
|
||||
|
||||
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
|
||||
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
|
||||
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
|
||||
import { MDXRemoteBase } from '~/features/docs/MdxBase'
|
||||
import { fetchRevalidatePerDay } from '~/features/helpers.fetch'
|
||||
import { Tabs, TabPanel } from '~/features/ui/Tabs'
|
||||
import { TabPanel, Tabs } from '~/features/ui/Tabs'
|
||||
import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform'
|
||||
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
|
||||
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pick } from 'lodash'
|
||||
import { pick } from 'lodash-es'
|
||||
|
||||
/**
|
||||
* Gets the Notion team to send feedback to based on the pathname.
|
||||
|
||||
@@ -119,7 +119,7 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
|
||||
level: 'platform',
|
||||
},
|
||||
{
|
||||
label: 'Security',
|
||||
label: 'Security & Compliance',
|
||||
icon: 'security',
|
||||
href: '/guides/security',
|
||||
level: 'security',
|
||||
@@ -917,10 +917,6 @@ export const database: NavMenuConstant = {
|
||||
name: 'Database configuration',
|
||||
url: '/guides/database/postgres/configuration',
|
||||
},
|
||||
{
|
||||
name: 'Managing database replication',
|
||||
url: '/guides/database/replication',
|
||||
},
|
||||
{
|
||||
name: 'Query optimization',
|
||||
url: '/guides/database/query-optimization',
|
||||
@@ -960,6 +956,22 @@ export const database: NavMenuConstant = {
|
||||
},
|
||||
ormQuickstarts,
|
||||
guiQuickstarts,
|
||||
{
|
||||
name: 'Database replication',
|
||||
url: undefined,
|
||||
items: [
|
||||
{ name: 'Overview', url: '/guides/database/replication' },
|
||||
{
|
||||
name: 'Setting up replication',
|
||||
url: '/guides/database/replication/setting-up-replication',
|
||||
},
|
||||
{
|
||||
name: 'Monitoring replication',
|
||||
url: '/guides/database/replication/monitoring-replication',
|
||||
},
|
||||
{ name: 'FAQ', url: '/guides/database/replication/faq' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Extensions',
|
||||
url: undefined,
|
||||
@@ -2080,6 +2092,19 @@ export const security: NavMenuConstant = {
|
||||
{ name: 'HIPAA', url: '/guides/security/hipaa-compliance' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Guides',
|
||||
url: undefined,
|
||||
items: [
|
||||
{ name: 'Production Checklist', url: '/guides/deployment/going-into-prod' },
|
||||
{
|
||||
name: 'Shared Responsibility Model',
|
||||
url: '/guides/deployment/shared-responsibility-model',
|
||||
},
|
||||
{ name: 'Row Level Security', url: '/guides/database/postgres/row-level-security' },
|
||||
{ name: 'Hardening the Data API', url: '/guides/database/hardening-data-api' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { noop } from 'lodash-es'
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { noop } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button_Shadcn_ as Button,
|
||||
Popover_Shadcn_ as Popover,
|
||||
PopoverTrigger_Shadcn_ as PopoverTrigger,
|
||||
PopoverContent_Shadcn_ as PopoverContent,
|
||||
Command_Shadcn_ as Command,
|
||||
CommandInput_Shadcn_ as CommandInput,
|
||||
CommandEmpty_Shadcn_ as CommandEmpty,
|
||||
CommandItem_Shadcn_ as CommandItem,
|
||||
CommandGroup_Shadcn_ as CommandGroup,
|
||||
CommandList_Shadcn_ as CommandList,
|
||||
cn,
|
||||
Command_Shadcn_ as Command,
|
||||
CommandEmpty_Shadcn_ as CommandEmpty,
|
||||
CommandGroup_Shadcn_ as CommandGroup,
|
||||
CommandInput_Shadcn_ as CommandInput,
|
||||
CommandItem_Shadcn_ as CommandItem,
|
||||
CommandList_Shadcn_ as CommandList,
|
||||
Popover_Shadcn_ as Popover,
|
||||
PopoverContent_Shadcn_ as PopoverContent,
|
||||
PopoverTrigger_Shadcn_ as PopoverTrigger,
|
||||
ScrollArea,
|
||||
} from 'ui'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { at } from 'lodash'
|
||||
import { at } from 'lodash-es'
|
||||
import { ReactNode } from 'react'
|
||||
import { config, logConstants } from 'shared-data'
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ Claims returned must conform to our specification. Supabase Auth will check for
|
||||
|
||||
These are the fields currently available on an access token:
|
||||
|
||||
Required Claims: `iss`, `aud`, `exp`, `iat`, `sub`, `role`, `aal`, `session_id`
|
||||
Optional Claims: `jti`, `nbf`, `app_metadata`, `user_metadata`, `amr`, `email`, `phone`
|
||||
Required Claims: `iss`, `aud`, `exp`, `iat`, `sub`, `role`, `aal`, `session_id`, `email`, `phone`, `is_anonymous`
|
||||
|
||||
Optional Claims: `jti`, `nbf`, `app_metadata`, `user_metadata`, `amr`,
|
||||
|
||||
**Inputs**
|
||||
|
||||
@@ -241,7 +242,10 @@ as $$
|
||||
'sub',
|
||||
'role',
|
||||
'aal',
|
||||
'session_id'
|
||||
'session_id',
|
||||
'email',
|
||||
'phone',
|
||||
'is_anonymous'
|
||||
] loop
|
||||
if original_claims ? claim then
|
||||
-- original_claims contains one of the listed claims, set it on new_claims
|
||||
|
||||
@@ -2,7 +2,24 @@
|
||||
title: 'Hardening the Data API'
|
||||
---
|
||||
|
||||
Your database's automatically generated Data API exposes the `public` schema by default. If your `public` schema is used by other tools as a default space, you might want to lock down this schema. This helps prevent accidental exposure of data that's automatically added to `public`.
|
||||
Your database's auto-generated Data API exposes the `public` schema by default. You can change this to any schema in your database, or even disable the Data API completely.
|
||||
|
||||
Any tables that are accessible through the Data API _must_ have [Row Level Security](/docs/guides/database/postgres/row-level-security) enabled. Row Level Security (RLS) is enabled by default when you create tables from the Supabase Dashboard. If you create a table using the SQL editor or your own SQL client or migration runner, you*must* enable RLS yourself.
|
||||
|
||||
## Shared responsibility
|
||||
|
||||
Your application's security is your responsibility as a developer. This includes RLS, falling under the [Shared Responsibility](/docs/guides/deployment/shared-responsibility-model) model. To help you:
|
||||
|
||||
- Supabase sends daily emails warning of any tables that are exposed to the Data API which do not have RLS enabled.
|
||||
- Supabase provides a Security Advisor and other tools in the Supabase Dashboard to fix any issues.
|
||||
|
||||
## Private schemas
|
||||
|
||||
We highly recommend creating a `private` schema for storing tables that you do not want to expose via the Data API. These tables can be accessed via Supabase Edge Functions or any other serverside tool. In this model, you should implement your security model in your serverside code. Although it's not required, we _still_ recommend enabling RLS for private tables and then connecting to your database using a Postgres role with `bypassrls` privileges.
|
||||
|
||||
## Managing the public schema
|
||||
|
||||
If your `public` schema is used by other tools as a default space, you might want to lock down this schema. This helps prevent accidental exposure of data that's automatically added to `public`.
|
||||
|
||||
There are two levels of security hardening for the Data API:
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ select pg_create_logical_replication_slot('example_slot', 'pgoutput');
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
This will need a direct connection to your database and you can find the connection info in the [Dashboard](https://supabase.com/dashboard/project/_/settings/database).
|
||||
This will need a **direct** connection (not a Connection Pooler) to your database and you can find the connection info in the [Dashboard](https://supabase.com/dashboard/project/_/settings/database).
|
||||
|
||||
You will also need to ensure that ipv6 is supported by your replication destination.
|
||||
You will also need to ensure that IPv6 is supported by your replication destination (or you can enable the [IPv4 add-on](/guides/platform/ipv4-address))
|
||||
|
||||
If you would prefer not to use the `postgres` user, then you can run `CREATE ROLE <user> WITH REPLICATION;` using the `postgres` user.
|
||||
|
||||
|
||||
@@ -1,93 +1,69 @@
|
||||
---
|
||||
id: 'replication'
|
||||
title: 'Replication'
|
||||
slug: 'replication'
|
||||
title: 'Replication and change data capture'
|
||||
description: 'An introduction to logical replication and change data capture'
|
||||
---
|
||||
|
||||
Replication is a technique for copying the data from one database to another. Supabase uses replication functionality to provide a real-time API. Replication is useful for:
|
||||
Replication is the process of copying changes from your database to another location. It's also referred to as change data capture (CDC): capturing all the changes that occur to your data.
|
||||
|
||||
- Spreading out the "load." For example, if your database has a lot of reads, you might want to split it between two databases.
|
||||
- Reducing latency. For example, you may want one database in London to serve your European customers, and one in New York to serve the US.
|
||||
## Use cases
|
||||
|
||||
Replication is done through _publications_, a method of choosing which changes to send to other systems (usually another Postgres database). Publications can be managed in the [Dashboard](https://supabase.com/dashboard) or with SQL.
|
||||
You might use replication for:
|
||||
|
||||
## Manage publications in the dashboard
|
||||
- **Analytics and Data Warehousing**: Replicate your operational database to analytics platforms like BigQuery for complex analysis without impacting your application's performance.
|
||||
- **Data Integration**: Keep your data synchronized across different systems and services in your tech stack.
|
||||
- **Backup and Disaster Recovery**: Maintain up-to-date copies of your data in different locations.
|
||||
- **Read Scaling**: Distribute read operations across multiple database instances to improve performance.
|
||||
|
||||
1. Go to the [Database](https://supabase.com/dashboard/project/_/database/tables) page in the Dashboard.
|
||||
2. Click on **Publications** in the sidebar.
|
||||
3. Control which database events are sent by toggling **Insert**, **Update**, and **Delete**.
|
||||
4. Control which tables broadcast changes by selecting **Source** and toggling each table.
|
||||
## Replication in Postgres
|
||||
|
||||
<video width="99%" muted playsInline controls={true}>
|
||||
<source
|
||||
src="https://xguihxuzqibwxjnimxev.supabase.co/storage/v1/object/public/videos/docs/api/api-realtime.mp4"
|
||||
type="video/mp4"
|
||||
/>
|
||||
</video>
|
||||
Postgres comes with built-in support for replication via publications and replication slots. Refer to the [Concepts and terms](#concepts-and-terms) section to learn how replication works.
|
||||
|
||||
## Create a publication
|
||||
## Setting up and monitoring replication in Supabase
|
||||
|
||||
This publication contains changes to all tables.
|
||||
- [Setting up replication](/docs/guides/database/replication/setting-up-replication)
|
||||
- [Monitoring replication](/docs/guides/database/replication/monitoring-replication)
|
||||
|
||||
```sql
|
||||
create publication publication_name
|
||||
for all tables;
|
||||
```
|
||||
<Admonition type="tip">
|
||||
|
||||
## Create a publication to listen to individual tables
|
||||
If you want to set up a read replica, see [Read Replicas](/docs/guides/platform/read-replicas) instead. If you want to sync your data in real time to a client such as a browser or mobile app, see [Realtime](/docs/guides/realtime) instead.
|
||||
|
||||
```sql
|
||||
create publication publication_name
|
||||
for table table_one, table_two;
|
||||
```
|
||||
</Admonition>
|
||||
|
||||
## Add tables to an existing publication
|
||||
## Concepts and terms
|
||||
|
||||
```sql
|
||||
alter publication publication_name
|
||||
add table table_name;
|
||||
```
|
||||
### Write-Ahead Log (WAL)
|
||||
|
||||
## Listen to `insert`
|
||||
Postgres uses a system called the Write-Ahead Log (WAL) to manage changes to the database. As you make changes, they are appended to the WAL (which is a series of files (also called "segments"), where the file size can be specified). Once one segment is full, Postgres will start appending to a new segment. After a period of time, a checkpoint occurs and Postgres synchronizes the WAL with your database. Once the checkpoint is complete, then the WAL files can be removed from disk and free up space.
|
||||
|
||||
```sql
|
||||
create publication publication_name
|
||||
for all tables
|
||||
with (publish = 'insert');
|
||||
```
|
||||
### Logical replication and WAL
|
||||
|
||||
## Listen to `update`
|
||||
Logical replication is a method of replication where Postgres uses the WAL files and transmit those changes to another Postgres database, or a system that supports reading WAL files.
|
||||
|
||||
```sql
|
||||
create publication publication_name
|
||||
for all tables
|
||||
with (publish = 'update');
|
||||
```
|
||||
### LSN
|
||||
|
||||
## Listen to `delete`
|
||||
LSN is a Log Sequence Number that is used to identify the position of a WAL file in the WAL directory. It is often used to determine the progress of replication in subscribers and calculate the lag of a replication slot.
|
||||
|
||||
```sql
|
||||
create publication publication_name
|
||||
for all tables
|
||||
with (publish = 'delete');
|
||||
```
|
||||
## Logical replication architecture
|
||||
|
||||
## Remove a publication
|
||||
When setting up logical replication, three key components are involved:
|
||||
|
||||
```sql
|
||||
drop publication if exists publication_name;
|
||||
```
|
||||
- `publication` - A set of tables on your primary database that will be `published`
|
||||
- `replication slot` - A slot used for replicating the data from a single publication. The slot, when created, will specify the output format of the changes
|
||||
- `subscription` - A subscription is created from an external system (i.e. another Postgres database) and must specify the name of the `publication`. If you do not specify a replication slot, one is automatically created
|
||||
|
||||
## Recreate a publication
|
||||
## Logical replication output format
|
||||
|
||||
If you're recreating a publication, it's best to do it in a transaction to ensure the operation succeeds.
|
||||
Logical replication is typically output in 2 forms, `pgoutput` and `wal2json`. The output method is how Postgres sends changes to any active replication slot.
|
||||
|
||||
```sql
|
||||
begin;
|
||||
-- remove the realtime publication
|
||||
drop publication if exists publication_name;
|
||||
## Logical replication configuration
|
||||
|
||||
-- re-create the publication but don't enable it for any tables
|
||||
create publication publication_name;
|
||||
commit;
|
||||
```
|
||||
When using logical replication, Postgres is then configured to keep WAL files around for longer than it needs them. If the files are removed too quickly, then your `replication slot` will become inactive and, if the database receives a large number of changes in a short time, then the `replication slot` can become lost as it was not able to keep up.
|
||||
|
||||
In order to mitigate this, Postgres has many options and settings that can be [tweaked](/guides/database/custom-postgres-config) to manage the WAL usage effectively. Not all of these settings are user configurable as they can impact the stability of your database. For those that are, these should be considered as advanced configuration and not changed without understanding that they can cause additional disk space and resources to be used, as well as incur additional costs.
|
||||
|
||||
| Setting | Description | User-facing | Default |
|
||||
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------- | ------- |
|
||||
| [`max_replication_slots`](https://postgresqlco.nf/doc/en/param/max_replication_slots/) | Max count of replication slots allowed | No | |
|
||||
| [`wal_keep_size`](https://postgresqlco.nf/doc/en/param/wal_keep_size/) | Minimum size of WAL files to keep for replication | No | |
|
||||
| [`max_slot_wal_keep_size`](https://postgresqlco.nf/doc/en/param/max_slot_wal_keep_size/) | Max WAL size that can be reserved by replication slots | No | |
|
||||
| [`checkpoint_timeout`](https://postgresqlco.nf/doc/en/param/checkpoint_timeout/) | Max time between WAL checkpoints | No | |
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: 'replication-faq'
|
||||
title: 'FAQs'
|
||||
description: 'Considerations and FAQs when setting up replication'
|
||||
sidebar_label: 'FAQs'
|
||||
---
|
||||
|
||||
# Which connection string should be used?
|
||||
|
||||
Always use the direct connection string for logical replication.
|
||||
|
||||
Connections through a pooler, such as Supavisor, will not work.
|
||||
|
||||
# The tool in use does not support IPv6
|
||||
|
||||
You can enable the [IPv4 add-on](/guides/platform/ipv4-address) for your project.
|
||||
|
||||
# What is XMIN and should it be used?
|
||||
|
||||
Xmin is a different form of replication from logical replication and should only be used if logical replication is not available for your database (i.e. older versions of Postgres).
|
||||
|
||||
Xmin performs replication by checking the [xmin system column](https://www.postgresql.org/docs/current/ddl-system-columns.html) and determining if that row has already been synchronized.
|
||||
|
||||
It does not capture deletion of data and is **not recommended**, particularly for larger databases.
|
||||
|
||||
# Can replication be configured in the Dashboard?
|
||||
|
||||
You can view [publications](https://supabase.com/dashboard/project/default/database/publications) in the Dashboard but all steps to configure replication must be done using the [SQL Editor](https://supabase.com/dashboard/project/default/sql/new) or a CLI tool of your choice.
|
||||
|
||||
# How to configure database settings for replication?
|
||||
|
||||
Yes. Using the Supabase CLI, you can [configure database settings](/guides/database/custom-postgres-config#cli-configurable-settings) to optimize them for your replication needs. These values can vary depending on the activity of your database size and activity.
|
||||
|
||||
# What are some important configuration options?
|
||||
|
||||
Some of the more important options to be aware of are:
|
||||
|
||||
- `max_wal_size`
|
||||
- `max_slot_wal_keep_size`
|
||||
- `wal_keep_size`
|
||||
- `max_wal_senders`
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: 'Monitoring replication'
|
||||
---
|
||||
|
||||
Monitoring replication lag is important and there are 3 ways to do this:
|
||||
|
||||
1. Dashboard - Under the [Reports](/guides/platform/reports) of the dashboard, you can view the replication lag of your project
|
||||
2. Database -
|
||||
- pg_stat_subscription (subscriber) - if PID is null, then the subscription is not active
|
||||
- pg_stat_subscription_stats - look here for error_count to see if there were issues applying or syncing (if yes, check the logs for why)
|
||||
- pg_replication_slots - use this to check if the slot is active and you can also calculate the lag from here
|
||||
3. [Metrics](/guides/telemetry/metrics) - Using the prometheus endpoint for your project
|
||||
- replication_slots_max_lag_bytes - this is the more important one
|
||||
- pg_stat_replication_replay_lag - lag to replay WAL files from the source DB on the target DB (throttled by disk or high activity)
|
||||
- pg_stat_replication_send_lag - lag in sending WAL files from the source DB (a high lag means that the publisher is not being asked to send new WAL files OR a network issues)
|
||||
|
||||
## Primary
|
||||
|
||||
### Replication status and lag
|
||||
|
||||
The `pg_stat_replication` table shows the status of any replicas connected to the primary database.
|
||||
|
||||
```sql
|
||||
select pid, application_name, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, sync_state
|
||||
from pg_stat_replication;
|
||||
```
|
||||
|
||||
### Replication slot status
|
||||
|
||||
A replication slot can be in one of three states:
|
||||
|
||||
- `active` - The slot is active and is receiving data
|
||||
- `inactive` - The slot is not active and is not receiving data
|
||||
- `lost` - The slot is lost and is not receiving data
|
||||
|
||||
The state can be checked using the `pg_replication_slots` table:
|
||||
|
||||
```sql
|
||||
select slot_name, active, state from pg_replication_slots;
|
||||
```
|
||||
|
||||
### WAL size
|
||||
|
||||
The WAL size can be checked using the `pg_ls_waldir()` function:
|
||||
|
||||
```sql
|
||||
select * from pg_ls_waldir();
|
||||
```
|
||||
|
||||
### Check LSN
|
||||
|
||||
```sql
|
||||
select pg_current_wal_lsn();
|
||||
```
|
||||
|
||||
## Subscriber
|
||||
|
||||
### Subscription status
|
||||
|
||||
The `pg_subscription` table shows the status of any subscriptions on a replica and the `pg_subscription_rel` table shows the status of each table within a subscription.
|
||||
|
||||
The `srsubstate` column in `pg_subscription_rel` can be one of the following:
|
||||
|
||||
- `i` - Initializing - The subscription is being initialized
|
||||
- `d` - Data Synchronizing - The subscription is synchronizing data for the first time (i.e. doing the initial copy)
|
||||
- `s` - Synchronized - The subscription is synchronized
|
||||
- `r` - Replicating - The subscription is replicating data
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
sub.subname AS subscription_name,
|
||||
relid::regclass AS table_name,
|
||||
srel.srsubstate AS replication_state,
|
||||
CASE srel.srsubstate
|
||||
WHEN 'i' THEN 'Initializing'
|
||||
WHEN 'd' THEN 'Data Synchronizing'
|
||||
WHEN 's' THEN 'Synchronized'
|
||||
WHEN 'r' THEN 'Replicating'
|
||||
ELSE 'Unknown'
|
||||
END AS state_description,
|
||||
srel.srsyncedlsn AS last_synced_lsn
|
||||
FROM
|
||||
pg_subscription sub
|
||||
JOIN
|
||||
pg_subscription_rel srel ON sub.oid = srel.srsubid
|
||||
ORDER BY
|
||||
table_name;
|
||||
```
|
||||
|
||||
### Check LSN
|
||||
|
||||
```sql
|
||||
select pg_last_wal_replay_lsn();
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
id: 'setting-up-replication'
|
||||
title: 'Setting up replication and CDC with Supabase'
|
||||
description: 'Performing Extract Transform Load (ETL) with Supabase'
|
||||
sidebar_label: 'Setting up replication and CDC'
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
To set up replication, the following is recommended:
|
||||
|
||||
- Instance size of XL or greater
|
||||
- [IPv4 add-on](/guides/platform/ipv4-address) enabled
|
||||
|
||||
To create a replication slot, you will need to use the `postgres` user and follow the instructions in our [guide](/guides/database/postgres/setup-replication-external).
|
||||
|
||||
<Admonition type="note">
|
||||
If you are running Postgres 17 or higher, you can create a new user and grant them replication
|
||||
permissions with the `postgres` user. For versions below 17, you will need to use the `postgres`
|
||||
user.
|
||||
</Admonition>
|
||||
|
||||
If you are replicating to an external system and using any of the tools below, check their documentation first and we have added additional information where the setup with Supabase can vary.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="estuary"
|
||||
queryGroup="tool"
|
||||
>
|
||||
|
||||
<TabPanel id="airbyte" label="Airbyte">
|
||||
|
||||
Airbyte has the following [documentation](https://docs.airbyte.com/integrations/sources/postgres/) for setting up Postgres as a source, either in their cloud offering or by self-hosting.
|
||||
|
||||
You can follow those steps with the following modifications:
|
||||
|
||||
1. Use the `postgres` user
|
||||
2. Select `logical replication` as the replication method (`xmin` is possible, but not recommended)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Airbyte has a known [issue](https://discuss.airbyte.io/t/postgres-source-replication-slot-safe-wal-size-only-reset-when-a-change-occurs/3263/7) where it does not clear WAL files on each successful sync. The recommended workaround is to have a `heartbeat` table that you write changes to once an hour.>
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="estuary" label="Estuary">
|
||||
Estuary has the following [documentation](https://docs.estuary.dev/reference/Connectors/capture-connectors/PostgreSQL/Supabase/) for setting up Postgres as a source.
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="fivetran" label="Fivetran">
|
||||
|
||||
Fivetran has the following [documentation](https://fivetran.com/docs/connectors/databases/postgresql/setup-guide) for setting up Postgres as a source.
|
||||
|
||||
You can follow those steps with the following modifications:
|
||||
|
||||
1. In Step 2, choose `logical replication` as the sync mechanism
|
||||
2. In Step 3, do not create a user and use the existing `postgres` user for replication
|
||||
3. In Step 5, no need to modify any WAL settings as we have done that for you
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="materialize" label="Materialize">
|
||||
|
||||
Materialize has the following [documentation](https://materialize.com/docs/sql/create-source/postgres/) on setting up Postgres as a source.
|
||||
|
||||
You can follow those steps with the following modifications:
|
||||
|
||||
1. Follow the steps in our [guide](/guides/database/postgres/setup-replication-external) to create a publication slot
|
||||
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="stitch" label="Stitch">
|
||||
|
||||
Stitch has the following [documentation](https://www.stitchdata.com/docs/integrations/databases/postgresql/v2#extract-data) on configuring Postgres as a source.
|
||||
|
||||
You can follow those steps with the following modifications:
|
||||
|
||||
1. Use the `postgres` user for replication
|
||||
2. Skip step 3
|
||||
|
||||
</TabPanel>
|
||||
|
||||
</Tabs>
|
||||
@@ -15,7 +15,7 @@ Data sent to the client when retrieving data stored in your database.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
There are various ways to interact with your database, such as through the PostgREST API using one of the client SDKs or via the Supavisor connection pooler. On the Supabase Dashboard, Egress from the PostgREST API is labeled as **Database Egress**, while Egress through Supavisor is labeled as **Supavisor Egress**.
|
||||
There are various ways to interact with your database, such as through the PostgREST API using one of the client SDKs or via the Supavisor connection pooler. On the Supabase Dashboard, Egress from the PostgREST API is labeled as **Database Egress**, while Egress through Supavisor is labeled as **Shared Pooler Egress**.
|
||||
|
||||
</Admonition>
|
||||
|
||||
@@ -43,6 +43,12 @@ Data pushed to clients via Supabase Realtime for subscribed events.
|
||||
|
||||
**Example:** When a user views a product page in an online shop, their client subscribes to real-time inventory updates. As stock levels change, Supabase Realtime pushes updates to all subscribed clients, contributing to Realtime Egress.
|
||||
|
||||
### Shared pooler Egress
|
||||
|
||||
Data sent to the client when using the shared connection pooler (Supavisor) to access your database. WHen using the shared connection pooler, we do not count database egress, as this would otherwise count double (Database -> Shared Pooler + Shared Pooler -> Client).
|
||||
|
||||
**Example:** You are using our [shared connection pooler](https://supabase.com/docs/guides/database/connecting-to-postgres#shared-pooler) and you query a list of invoices in your backend. The data returned from that query is contributing to Shared Pooler Egress.
|
||||
|
||||
### Log Drain Egress
|
||||
|
||||
Data pushed to the connected log drain.
|
||||
|
||||
@@ -76,11 +76,15 @@ Go to your Supabase project's [API Settings](https://supabase.com/dashboard/proj
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
```python
|
||||
from supabase import create_client
|
||||
import asyncio
|
||||
from supabase import acreate_client
|
||||
|
||||
URL = "https://<project>.supabase.co"
|
||||
KEY = "<your-anon-key>"
|
||||
supabase = create_client(URL, KEY)
|
||||
|
||||
async def create_supabase():
|
||||
supabase = await acreate_client(URL, KEY)
|
||||
return supabase
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -179,6 +183,11 @@ You can provide a callback for the `broadcast` channel to receive message. This
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
<Admonition type="note">
|
||||
|
||||
In the following Realtime examples, certain methods are awaited. These should be enclosed within an `async` function.
|
||||
|
||||
</Admonition>
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```python
|
||||
@@ -190,7 +199,7 @@ You can provide a callback for the `broadcast` channel to receive message. This
|
||||
print(f"Broadcast received: {payload}")
|
||||
|
||||
# Subscribe to the Channel
|
||||
my_channel
|
||||
await my_channel
|
||||
.on_broadcast('shout', message_received) # Listen for "shout". Can be "*" to listen to all events
|
||||
.subscribe()
|
||||
```
|
||||
@@ -323,6 +332,12 @@ You can use the Supabase client libraries to send Broadcast messages.
|
||||
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
When an asynchronous method needs to be used within a synchronous context, such as the callback for `.subscribe()`, utilize `asyncio.create_task()` to schedule the coroutine. This is why the [initialize the client](#initialize-the-client) example includes an import of `asyncio`.
|
||||
|
||||
</Admonition>
|
||||
|
||||
{/* prettier-ignore */}
|
||||
```python
|
||||
my_channel = supabase.channel('test-channel')
|
||||
@@ -332,12 +347,12 @@ You can use the Supabase client libraries to send Broadcast messages.
|
||||
if status != RealtimeSubscribeStates.SUBSCRIBED:
|
||||
return
|
||||
|
||||
my_channel.send_broadcast(
|
||||
asyncio.create_task(my_channel.send_broadcast(
|
||||
'shout',
|
||||
{ "message": 'hello, world' },
|
||||
)
|
||||
))
|
||||
|
||||
my_channel.subscribe(on_subscribe)
|
||||
await my_channel.subscribe(on_subscribe)
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -543,6 +558,12 @@ You can pass configuration options while initializing the Supabase Client.
|
||||
</TabPanel>
|
||||
<TabPanel id="python" label="Python">
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
When an asynchronous method needs to be used within a synchronous context, such as the callback for `.subscribe()`, utilize `asyncio.create_task()` to schedule the coroutine. This is why the [initialize the client](#initialize-the-client) example includes an import of `asyncio`.
|
||||
|
||||
</Admonition>
|
||||
|
||||
By default, broadcast messages are only sent to other clients. You can broadcast messages back to the sender by setting Broadcast's `self` parameter to `True`.
|
||||
|
||||
```python
|
||||
@@ -559,10 +580,10 @@ You can pass configuration options while initializing the Supabase Client.
|
||||
return
|
||||
|
||||
# Send a message once the client is subscribed
|
||||
channel_b.send_broadcast(
|
||||
asyncio.create_task(channel_b.send_broadcast(
|
||||
'test-my-messages',
|
||||
{ "message": 'talking to myself' },
|
||||
)
|
||||
))
|
||||
|
||||
my_channel.subscribe(on_subscribe)
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isPlainObject, keyBy } from 'lodash'
|
||||
import { isPlainObject, keyBy } from 'lodash-es'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as Collapsible from '@radix-ui/react-collapsible'
|
||||
|
||||
import { debounce } from 'lodash'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEqual } from 'lodash'
|
||||
import { isEqual } from 'lodash-es'
|
||||
import { ChevronRight, XCircle } from 'lucide-react'
|
||||
import type { HTMLAttributes, PropsWithChildren } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { toMarkdown } from 'mdast-util-to-markdown'
|
||||
import { mdxFromMarkdown, mdxToMarkdown } from 'mdast-util-mdx'
|
||||
import { toMarkdown } from 'mdast-util-to-markdown'
|
||||
import { mdxjs } from 'micromark-extension-mdxjs'
|
||||
import type { Metadata, ResolvingMetadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
@@ -131,7 +131,7 @@ export async function generateReferenceMetadata(
|
||||
const isApiReference = parsedPath.__type === 'api'
|
||||
const isSelfHostingReference = parsedPath.__type === 'self-hosting'
|
||||
if (isClientSdkReference) {
|
||||
const { sdkId, maybeVersion } = parsedPath
|
||||
const { sdkId, maybeVersion, path } = parsedPath
|
||||
const version = maybeVersion ?? REFERENCES[sdkId].versions[0]
|
||||
|
||||
const flattenedSections = await getFlattenedSections(sdkId, version)
|
||||
@@ -141,7 +141,7 @@ export async function generateReferenceMetadata(
|
||||
slug.length > 0
|
||||
? flattenedSections.find((section) => section.slug === slug[0])?.title
|
||||
: undefined
|
||||
const url = [BASE_PATH, 'reference', sdkId, maybeVersion, slug[0]].filter(Boolean).join('/')
|
||||
const url = [BASE_PATH, 'reference', sdkId, path[0]].filter(Boolean).join('/')
|
||||
|
||||
const images = generateOpenGraphImageMeta({
|
||||
type: 'API Reference',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Github } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import Breadcrumbs from '~/components/Breadcrumbs'
|
||||
import { Feedback } from '~/components/Feedback'
|
||||
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
|
||||
import { MDXRemoteBase } from './MdxBase'
|
||||
import { getTroubleshootingUpdatedDates, type ITroubleshootingEntry } from './Troubleshooting.utils'
|
||||
import Link from 'next/link'
|
||||
import { formatError, serializeTroubleshootingSearchParams } from './Troubleshooting.utils.shared'
|
||||
|
||||
export default async function TroubleshootingPage({ entry }: { entry: ITroubleshootingEntry }) {
|
||||
@@ -95,6 +95,19 @@ export default async function TroubleshootingPage({ entry }: { entry: ITroublesh
|
||||
</>
|
||||
)}
|
||||
<Feedback className="px-0 mb-6 lg:mb-8" />
|
||||
{entry.data.github_url && (
|
||||
<>
|
||||
<hr className="my-6" aria-hidden />
|
||||
<Link
|
||||
target="_blank"
|
||||
href={entry.data.github_url}
|
||||
className="flex items-center gap-2 text-sm text-foreground-lighter"
|
||||
>
|
||||
<Github size={14} />
|
||||
View discussion on GitHub
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* Discussions.
|
||||
*/
|
||||
|
||||
import _configureDotEnv from '../../scripts/utils/dotenv'
|
||||
|
||||
import { createAppAuth } from '@octokit/auth-app'
|
||||
import { Octokit } from '@octokit/core'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
@@ -20,13 +22,12 @@ import { gfm } from 'micromark-extension-gfm'
|
||||
import { mdxjs } from 'micromark-extension-mdxjs'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { parse, stringify } from 'smol-toml'
|
||||
|
||||
import {
|
||||
getAllTroubleshootingEntriesInternal as getAllTroubleshootingEntries,
|
||||
getArticleSlug,
|
||||
} from './Troubleshooting.utils.common.mjs'
|
||||
|
||||
import 'dotenv/config'
|
||||
const _ = _configureDotEnv
|
||||
|
||||
const REPOSITORY_ID = 'MDEwOlJlcG9zaXRvcnkyMTQ1ODcxOTM='
|
||||
const TROUBLESHOOTING_CATEGORY_ID = 'DIC_kwDODMpXOc4CUvEr'
|
||||
@@ -52,9 +53,9 @@ function octokit() {
|
||||
octokitInstance = new Octokit({
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
appId: process.env.SEARCH_GITHUB_APP_ID,
|
||||
installationId: process.env.SEARCH_GITHUB_APP_INSTALLATION_ID,
|
||||
privateKey: process.env.SEARCH_GITHUB_APP_PRIVATE_KEY,
|
||||
appId: process.env.DOCS_GITHUB_APP_ID,
|
||||
installationId: process.env.DOCS_GITHUB_APP_INSTALLATION_ID,
|
||||
privateKey: process.env.DOCS_GITHUB_APP_PRIVATE_KEY,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as fs from 'fs'
|
||||
import { mapValues, values } from 'lodash'
|
||||
import { mapValues, values } from 'lodash-es'
|
||||
|
||||
export const slugify = (text: string) => {
|
||||
if (!text) return ''
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
* tsx ReferenceGenerator.ts -o docs/client spec/supabase.yml
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { uniqBy } from 'lodash-es'
|
||||
import { flattenSections } from '../lib/helpers'
|
||||
import Example from './legacy/components/Example'
|
||||
import Page from './legacy/components/Page'
|
||||
import Tab from './legacy/components/Tab'
|
||||
import { OpenRef, TsDoc } from './legacy/definitions'
|
||||
import { slugify, tsDocCommentToMdComment, writeToDisk } from './legacy/lib/helpers'
|
||||
import { TsDoc, OpenRef } from './legacy/definitions'
|
||||
import { uniqBy } from 'lodash'
|
||||
import * as fs from 'fs'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { flattenSections } from '../lib/helpers'
|
||||
|
||||
const commonDocSpecJson = JSON.parse(
|
||||
fs.readFileSync('spec/common-client-libs-sections.json', 'utf8')
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { hasConsented, IS_PLATFORM } from 'common'
|
||||
import { IS_DEV } from './lib/constants'
|
||||
|
||||
if (!IS_DEV) {
|
||||
@@ -20,13 +21,12 @@ if (!IS_DEV) {
|
||||
],
|
||||
|
||||
beforeSend(event) {
|
||||
const frames = event.exception?.values?.[0].stacktrace?.frames || []
|
||||
if (!IS_PLATFORM || !hasConsented()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Drop errors not originating from docs app static files as unactionable
|
||||
if (
|
||||
frames &&
|
||||
!frames.some((frame) => frame.filename && frame.filename.startsWith('app:///_next'))
|
||||
) {
|
||||
const frames = event.exception?.values?.[0].stacktrace?.frames || []
|
||||
if (isThirdPartyError(frames)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -35,4 +35,24 @@ if (!IS_DEV) {
|
||||
})
|
||||
}
|
||||
|
||||
// We want to ignore errors not originating from docs app static files
|
||||
// (such as errors from browser extensions). Those errors come from files
|
||||
// not starting with 'app:///_next'.
|
||||
//
|
||||
// However, there is a complication because the Sentry code that sends
|
||||
// the error shows up in the stack trace, and that _does_ start with
|
||||
// 'app:///_next'. It is always the first frame in the stack trace,
|
||||
// and has a specific pre_context comment that we can use for filtering.
|
||||
function isThirdPartyError(frames: Sentry.StackFrame[] | undefined) {
|
||||
if (!frames) return false
|
||||
|
||||
function isSentryFrame(frame: Sentry.StackFrame, index: number) {
|
||||
return index === 0 && frame.pre_context?.[0]?.includes('sentry.javascript')
|
||||
}
|
||||
|
||||
return !frames.some((frame, index) => {
|
||||
frame.abs_path?.startsWith('app:///_next') && !isSentryFrame(frame, index)
|
||||
})
|
||||
}
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { TsDoc } from '../../generator/legacy/definitions'
|
||||
|
||||
import { values, mapValues } from 'lodash'
|
||||
import { mapValues, values } from 'lodash-es'
|
||||
import type { OpenAPIV3 } from 'openapi-types'
|
||||
import { flattenSections } from '../helpers'
|
||||
import type { ICommonItem } from '~/components/reference/Reference.types'
|
||||
import { flattenSections } from '../helpers'
|
||||
|
||||
export function extractTsDocNode(nodeToFind: string, definition: any) {
|
||||
const nodePath = nodeToFind.split('.')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = [
|
||||
export default [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'avatars.githubusercontent.com',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
import { type Database as DatabaseGenerated } from 'common'
|
||||
|
||||
type Database = {
|
||||
content: DatabaseGenerated['content']
|
||||
graphql_public: DatabaseGenerated['graphql_public']
|
||||
public: {
|
||||
Tables: DatabaseGenerated['public']['Tables']
|
||||
|
||||
@@ -34,13 +34,6 @@ const nextConfig = {
|
||||
// @ts-ignore
|
||||
remotePatterns,
|
||||
},
|
||||
// TODO: @next/mdx ^13.0.2 only supports experimental mdxRs flag. next ^13.0.2 will stop warning about this being unsupported.
|
||||
// mdxRs: true,
|
||||
modularizeImports: {
|
||||
lodash: {
|
||||
transform: 'lodash/{{member}}',
|
||||
},
|
||||
},
|
||||
webpack: (config) => {
|
||||
config.module.rules.push({
|
||||
test: /\.include$/,
|
||||
|
||||
+23
-21
@@ -2,37 +2,38 @@
|
||||
"name": "docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples",
|
||||
"dev": "concurrently --kill-others \"next dev --port 3001\" \"pnpm run dev:watch:troubleshooting\"",
|
||||
"dev:watch:troubleshooting": "node ./scripts/troubleshooting/watch.mjs",
|
||||
"dev:secrets:pull": "AWS_PROFILE=supabase-dev node ../../scripts/getSecrets.js -n local/docs",
|
||||
"prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples",
|
||||
"build": "next build",
|
||||
"build:analyze": "ANALYZE=true next build",
|
||||
"build:sitemap": "tsx ./internals/generate-sitemap.ts",
|
||||
"build:llms": "tsx ./scripts/llms.ts",
|
||||
"postbuild": "pnpm run build:sitemap && pnpm run build:llms && ./../../scripts/upload-static-assets.sh",
|
||||
"start": "next start",
|
||||
"build:sitemap": "tsx ./internals/generate-sitemap.ts",
|
||||
"clean": "rimraf .next .turbo node_modules features/docs/generated examples __generated__",
|
||||
"codegen:examples": "cp -r ../../examples ./examples",
|
||||
"codegen:graphql": "tsx --conditions=react-server ./scripts/graphqlSchema.ts && graphql-codegen --config codegen.ts",
|
||||
"codegen:references": "tsx features/docs/Reference.generated.script.ts",
|
||||
"codemod:frontmatter": "node ./scripts/codemod/mdx-meta.mjs && prettier --write \"content/**/*.mdx\"",
|
||||
"dev": "concurrently --kill-others \"next dev --port 3001\" \"pnpm run dev:watch:troubleshooting\"",
|
||||
"dev:secrets:pull": "AWS_PROFILE=supabase-dev node ../../scripts/getSecrets.js -n local/docs",
|
||||
"dev:watch:troubleshooting": "node ./scripts/troubleshooting/watch.mjs",
|
||||
"embeddings": "tsx scripts/search/generate-embeddings.ts",
|
||||
"embeddings:refresh": "pnpm run embeddings --refresh",
|
||||
"last-changed": "tsx scripts/last-changed.ts",
|
||||
"last-changed:reset": "pnpm run last-changed -- --reset",
|
||||
"lint": "next lint",
|
||||
"lint:mdx": "supa-mdx-lint content --config ../../supa-mdx-lint.config.toml",
|
||||
"pretypecheck": "pnpm run codegen:graphql",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"postbuild": "pnpm run build:sitemap && pnpm run build:llms && ./../../scripts/upload-static-assets.sh",
|
||||
"prebuild": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples",
|
||||
"predev": "pnpm run codegen:graphql && pnpm run codegen:references && pnpm run codegen:examples",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"pretest": "pnpm run codegen:examples",
|
||||
"pretypecheck": "pnpm run codegen:graphql",
|
||||
"start": "next start",
|
||||
"test": "pnpm supabase start && pnpm run test:local && pnpm supabase stop",
|
||||
"test:local": "vitest --exclude \"**/*.smoke.test.ts\"",
|
||||
"test:smoke": "pnpm run codegen:references && vitest -t \"prod smoke test\"",
|
||||
"embeddings": "tsx scripts/search/generate-embeddings.ts",
|
||||
"embeddings:refresh": "pnpm run embeddings --refresh",
|
||||
"troubleshooting:sync": "node features/docs/Troubleshooting.script.mjs",
|
||||
"last-changed": "tsx scripts/last-changed.ts",
|
||||
"last-changed:reset": "pnpm run last-changed -- --reset",
|
||||
"codegen:examples": "cp -r ../../examples ./examples",
|
||||
"codemod:frontmatter": "node ./scripts/codemod/mdx-meta.mjs && prettier --write \"content/**/*.mdx\"",
|
||||
"codegen:graphql": "tsx --conditions=react-server ./scripts/graphqlSchema.ts && graphql-codegen --config codegen.ts",
|
||||
"codegen:references": "tsx features/docs/Reference.generated.script.ts",
|
||||
"clean": "rimraf .next .turbo node_modules features/docs/generated examples __generated__"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@har-sdk/openapi-sampler": "^2.2.0",
|
||||
@@ -70,7 +71,7 @@
|
||||
"jsrsasign": "^11.0.0",
|
||||
"katex": "^0.16.21",
|
||||
"libpg-query": "15.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "*",
|
||||
"mdast": "^3.0.0",
|
||||
"mdast-util-from-markdown": "^1.2.0",
|
||||
@@ -125,6 +126,7 @@
|
||||
"@types/graphql-validation-complexity": "^0.4.4",
|
||||
"@types/hast": "^2.3.4",
|
||||
"@types/jsrsasign": "^10.5.12",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mdast": "^3.0.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,74 @@
|
||||
import { ApiErrorGeneric, convertPostgrestToApiError, NoDataError } from '~/app/api/utils'
|
||||
import { Result } from '~/features/helpers.fn'
|
||||
import { supabase } from '~/lib/supabase'
|
||||
|
||||
export const SERVICES = {
|
||||
AUTH: {
|
||||
value: 'AUTH',
|
||||
},
|
||||
REALTIME: {
|
||||
value: 'REALTIME',
|
||||
},
|
||||
STORAGE: {
|
||||
value: 'STORAGE',
|
||||
},
|
||||
} as const
|
||||
|
||||
type Service = keyof typeof SERVICES
|
||||
|
||||
export class ErrorModel {
|
||||
public code: string
|
||||
public service: Service
|
||||
public httpStatusCode?: number
|
||||
public message?: string
|
||||
|
||||
constructor({
|
||||
code,
|
||||
service,
|
||||
httpStatusCode: httpStatusCode,
|
||||
message,
|
||||
}: {
|
||||
code: string
|
||||
service: Service
|
||||
httpStatusCode?: number
|
||||
message?: string
|
||||
}) {
|
||||
this.code = code
|
||||
this.service = service
|
||||
this.httpStatusCode = httpStatusCode
|
||||
this.message = message
|
||||
}
|
||||
|
||||
static async loadSingleError({
|
||||
code,
|
||||
service,
|
||||
}: {
|
||||
code: string
|
||||
service: Service
|
||||
}): Promise<Result<ErrorModel, ApiErrorGeneric>> {
|
||||
return new Result(
|
||||
await supabase()
|
||||
.schema('content')
|
||||
.from('error')
|
||||
.select('code, ...service(service:name), httpStatusCode:http_status_code, message')
|
||||
.eq('code', code)
|
||||
.eq('service.name', service)
|
||||
.is('deleted_at', null)
|
||||
.single<{
|
||||
code: string
|
||||
service: Service
|
||||
httpStatusCode?: number
|
||||
message?: string
|
||||
}>()
|
||||
)
|
||||
.map((data) => {
|
||||
return new ErrorModel(data)
|
||||
})
|
||||
.mapError((error) => {
|
||||
if (error.code === 'PGRST116') {
|
||||
return new NoDataError('Error for given code and service does not exist', error)
|
||||
}
|
||||
return convertPostgrestToApiError(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { GraphQLError, GraphQLNonNull, GraphQLResolveInfo, GraphQLString } from 'graphql'
|
||||
import { type RootQueryTypeErrorArgs } from '~/__generated__/graphql'
|
||||
import { convertUnknownToApiError } from '~/app/api/utils'
|
||||
import { Result } from '~/features/helpers.fn'
|
||||
import { ErrorModel } from './errorModel'
|
||||
import {
|
||||
GRAPHQL_FIELD_ERROR_GLOBAL,
|
||||
GraphQLEnumTypeService,
|
||||
GraphQLObjectTypeError,
|
||||
} from './errorSchema'
|
||||
|
||||
async function resolveSingleError(
|
||||
_parent: unknown,
|
||||
args: RootQueryTypeErrorArgs,
|
||||
_context: unknown,
|
||||
_info: GraphQLResolveInfo
|
||||
): Promise<ErrorModel | GraphQLError> {
|
||||
return (
|
||||
await Result.tryCatchFlat(ErrorModel.loadSingleError, convertUnknownToApiError, args)
|
||||
).match(
|
||||
(data) => data,
|
||||
(error) => {
|
||||
console.error(`Error resolving ${GRAPHQL_FIELD_ERROR_GLOBAL}:`, error)
|
||||
return new GraphQLError(error.isPrivate() ? 'Internal Server Error' : error.message)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const errorRoot = {
|
||||
[GRAPHQL_FIELD_ERROR_GLOBAL]: {
|
||||
description: 'Get the details of an error code returned from a Supabase service',
|
||||
args: {
|
||||
code: {
|
||||
type: new GraphQLNonNull(GraphQLString),
|
||||
},
|
||||
service: {
|
||||
type: new GraphQLNonNull(GraphQLEnumTypeService),
|
||||
},
|
||||
},
|
||||
type: GraphQLObjectTypeError,
|
||||
resolve: resolveSingleError,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
GraphQLEnumType,
|
||||
GraphQLInt,
|
||||
GraphQLNonNull,
|
||||
GraphQLObjectType,
|
||||
GraphQLString,
|
||||
} from 'graphql'
|
||||
import { SERVICES } from './errorModel'
|
||||
|
||||
export const GRAPHQL_FIELD_ERROR_GLOBAL = 'error' as const
|
||||
|
||||
export const GraphQLEnumTypeService = new GraphQLEnumType({
|
||||
name: 'Service',
|
||||
values: SERVICES,
|
||||
})
|
||||
|
||||
export const GraphQLObjectTypeError = new GraphQLObjectType({
|
||||
name: 'Error',
|
||||
description: 'An error returned by a Supabase service',
|
||||
fields: {
|
||||
code: {
|
||||
type: new GraphQLNonNull(GraphQLString),
|
||||
description:
|
||||
'The unique code identifying the error. The code is stable, and can be used for string matching during error handling.',
|
||||
},
|
||||
service: {
|
||||
type: new GraphQLNonNull(GraphQLEnumTypeService),
|
||||
description: 'The Supabase service that returns this error.',
|
||||
},
|
||||
httpStatusCode: {
|
||||
type: GraphQLInt,
|
||||
description: 'The HTTP status code returned with this error.',
|
||||
},
|
||||
message: {
|
||||
type: GraphQLString,
|
||||
description:
|
||||
'A human-readable message describing the error. The message is not stable, and should not be used for string matching during error handling. Use the code instead.',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
printSchema,
|
||||
} from 'graphql'
|
||||
import { RootQueryTypeResolvers } from '~/__generated__/graphql'
|
||||
import { errorRoot } from './error/errorResolver'
|
||||
import { searchRoot } from './globalSearch/globalSearchResolver'
|
||||
import { GraphQLObjectTypeGuide } from './guide/guideSchema'
|
||||
import { GraphQLObjectTypeReferenceCLICommand } from './reference/referenceCLISchema'
|
||||
@@ -35,6 +36,7 @@ export const rootGraphQLSchema = new GraphQLSchema({
|
||||
fields: {
|
||||
...introspectRoot,
|
||||
...searchRoot,
|
||||
...errorRoot,
|
||||
},
|
||||
}),
|
||||
types: [
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { printSchema } from 'graphql'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { rootGraphQLSchema } from '../resources/rootSchema'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { rootGraphQLSchema } from '../resources/rootSchema.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
async function generateGraphQLSchema() {
|
||||
try {
|
||||
@@ -22,6 +25,6 @@ async function generateGraphQLSchema() {
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
generateGraphQLSchema()
|
||||
}
|
||||
@@ -11,18 +11,21 @@
|
||||
* the last Git commit date.
|
||||
*/
|
||||
|
||||
import 'dotenv/config'
|
||||
import _configureDotEnv from './utils/dotenv'
|
||||
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
import matter from 'gray-matter'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import path, { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { SimpleGit, simpleGit } from 'simple-git'
|
||||
import { parse } from 'smol-toml'
|
||||
import { type Section } from './helpers.mdx'
|
||||
import { Section } from './helpers.mdx.js'
|
||||
|
||||
const _ = _configureDotEnv
|
||||
|
||||
interface Options {
|
||||
reset: boolean
|
||||
@@ -46,9 +49,11 @@ type SectionWithChecksum = Omit<Section, 'heading'> &
|
||||
|
||||
const REQUIRED_ENV_VARS = {
|
||||
SUPABASE_URL: 'NEXT_PUBLIC_SUPABASE_URL',
|
||||
SERVICE_ROLE_KEY: 'SUPABASE_SERVICE_ROLE_KEY',
|
||||
SERVICE_ROLE_KEY: 'SUPABASE_SECRET_KEY',
|
||||
} as const
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
async function main() {
|
||||
console.log('Updating content timestamps....')
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import _configureDotEnv from './utils/dotenv'
|
||||
|
||||
import 'dotenv/config'
|
||||
import fs from 'node:fs/promises'
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
fetchCliLibReferenceSource,
|
||||
fetchCSharpLibReferenceSource,
|
||||
@@ -11,7 +13,9 @@ import {
|
||||
fetchPythonLibReferenceSource,
|
||||
fetchSwiftLibReferenceSource,
|
||||
type SearchSource,
|
||||
} from './search/sources'
|
||||
} from './search/sources/index.js'
|
||||
|
||||
const _ = _configureDotEnv
|
||||
|
||||
interface Source {
|
||||
title: string
|
||||
@@ -97,6 +101,6 @@ async function generateLlmsTxt() {
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
generateLlmsTxt()
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import _configureDotenv from '../utils/dotenv'
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import 'dotenv/config'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { OpenAI } from 'openai'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { Json, Section } from '../helpers.mdx'
|
||||
import { fetchAllSources } from './sources'
|
||||
import type { Json, Section } from '../helpers.mdx.js'
|
||||
import { fetchAllSources } from './sources/index.js'
|
||||
|
||||
const _ = _configureDotenv
|
||||
|
||||
const args = parseArgs({
|
||||
options: {
|
||||
@@ -18,19 +21,20 @@ async function generateEmbeddings() {
|
||||
const shouldRefresh = Boolean(args.values.refresh)
|
||||
|
||||
const requiredEnvVars = [
|
||||
'DOCS_GITHUB_APP_ID',
|
||||
'DOCS_GITHUB_APP_INSTALLATION_ID',
|
||||
'DOCS_GITHUB_APP_PRIVATE_KEY',
|
||||
'NEXT_PUBLIC_MISC_ANON_KEY',
|
||||
'NEXT_PUBLIC_MISC_URL',
|
||||
'NEXT_PUBLIC_SUPABASE_URL',
|
||||
'SUPABASE_SERVICE_ROLE_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'NEXT_PUBLIC_MISC_USE_URL',
|
||||
'NEXT_PUBLIC_MISC_USE_ANON_KEY',
|
||||
'SEARCH_GITHUB_APP_ID',
|
||||
'SEARCH_GITHUB_APP_INSTALLATION_ID',
|
||||
'SEARCH_GITHUB_APP_PRIVATE_KEY',
|
||||
'SUPABASE_SECRET_KEY',
|
||||
]
|
||||
|
||||
if (requiredEnvVars.some((name) => !process.env[name])) {
|
||||
const missingEnvVars = requiredEnvVars.filter((name) => !process.env[name])
|
||||
if (missingEnvVars.length > 0) {
|
||||
throw new Error(
|
||||
`Environment variables ${requiredEnvVars.join(
|
||||
`Environment variables ${missingEnvVars.join(
|
||||
', '
|
||||
)} are required: skipping embeddings generation`
|
||||
)
|
||||
@@ -38,8 +42,7 @@ async function generateEmbeddings() {
|
||||
|
||||
const supabaseClient = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
process.env.SUPABASE_SECRET_KEY,
|
||||
{
|
||||
auth: {
|
||||
persistSession: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Json, Section } from '../../helpers.mdx'
|
||||
import type { Json, Section } from '../../helpers.mdx.js'
|
||||
|
||||
export abstract class BaseLoader {
|
||||
type: string
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createAppAuth } from '@octokit/auth-app'
|
||||
import { Octokit } from '@octokit/core'
|
||||
import { paginateGraphql } from '@octokit/plugin-paginate-graphql'
|
||||
import crypto, { createHash } from 'node:crypto'
|
||||
import { BaseLoader, BaseSource } from './base'
|
||||
import { BaseLoader, BaseSource } from './base.js'
|
||||
|
||||
export const ExtendedOctokit = Octokit.plugin(paginateGraphql)
|
||||
export type ExtendedOctokit = InstanceType<typeof ExtendedOctokit>
|
||||
@@ -25,11 +25,9 @@ export type DiscussionsResponse = {
|
||||
}
|
||||
}
|
||||
|
||||
const appId = process.env.SEARCH_GITHUB_APP_ID ?? process.env.DOCS_GITHUB_APP_ID
|
||||
const installationId =
|
||||
process.env.SEARCH_GITHUB_APP_INSTALLATION_ID ?? process.env.DOCS_GITHUB_APP_INSTALLATION_ID
|
||||
const privateKey =
|
||||
process.env.SEARCH_GITHUB_APP_PRIVATE_KEY ?? process.env.DOCS_GITHUB_APP_PRIVATE_KEY
|
||||
const appId = process.env.DOCS_GITHUB_APP_ID
|
||||
const installationId = process.env.DOCS_GITHUB_APP_INSTALLATION_ID
|
||||
const privateKey = process.env.DOCS_GITHUB_APP_PRIVATE_KEY
|
||||
|
||||
/**
|
||||
* Fetches GitHub discussions for a repository + category
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
GitHubDiscussionLoader,
|
||||
type GitHubDiscussionSource,
|
||||
fetchDiscussions,
|
||||
} from './github-discussion'
|
||||
import { MarkdownLoader, type MarkdownSource } from './markdown'
|
||||
import { IntegrationLoader, type IntegrationSource, fetchPartners } from './partner-integrations'
|
||||
} from './github-discussion.js'
|
||||
import { MarkdownLoader, type MarkdownSource } from './markdown.js'
|
||||
import { IntegrationLoader, type IntegrationSource, fetchPartners } from './partner-integrations.js'
|
||||
import {
|
||||
CliReferenceLoader,
|
||||
type CliReferenceSource,
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
type ClientLibReferenceSource,
|
||||
OpenApiReferenceLoader,
|
||||
type OpenApiReferenceSource,
|
||||
} from './reference-doc'
|
||||
import { walk } from './util'
|
||||
} from './reference-doc.js'
|
||||
import { walk } from './util.js'
|
||||
|
||||
const ignoredFiles = ['pages/404.mdx']
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFile } from 'fs/promises'
|
||||
import { processMdx } from '../../helpers.mdx'
|
||||
import { BaseLoader, BaseSource } from './base'
|
||||
import { processMdx } from '../../helpers.mdx.js'
|
||||
import { BaseLoader, BaseSource } from './base.js'
|
||||
|
||||
export class MarkdownLoader extends BaseLoader {
|
||||
type = 'markdown' as const
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { type SupabaseClient, createClient } from '@supabase/supabase-js'
|
||||
import { upperFirst } from 'lodash'
|
||||
|
||||
import { BaseLoader, BaseSource } from './base'
|
||||
import { processMdx } from '../../helpers.mdx'
|
||||
import { upperFirst } from 'lodash-es'
|
||||
import { processMdx } from '../../helpers.mdx.js'
|
||||
import { BaseLoader, BaseSource } from './base.js'
|
||||
|
||||
type PartnerData = {
|
||||
slug: string // The partner slug corresponding to the last part of the URL
|
||||
overview: string // The Markdown content for indexing
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_MISC_USE_URL ?? process.env.NEXT_PUBLIC_MISC_URL
|
||||
const supabaseAnonKey =
|
||||
process.env.NEXT_PUBLIC_MISC_USE_ANON_KEY ?? process.env.NEXT_PUBLIC_MISC_ANON_KEY
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_MISC_URL
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_MISC_ANON_KEY
|
||||
|
||||
let supabaseClient: SupabaseClient
|
||||
function getSupabaseClient() {
|
||||
|
||||
@@ -7,12 +7,12 @@ import type {
|
||||
ICommonSection,
|
||||
IFunctionDefinition,
|
||||
ISpec,
|
||||
} from '../../../components/reference/Reference.types'
|
||||
import type { CliCommand, CliSpec } from '../../../generator/types/CliSpec'
|
||||
import { flattenSections } from '../../../lib/helpers'
|
||||
import { enrichedOperation, gen_v3 } from '../../../lib/refGenerator/helpers'
|
||||
import type { Json } from '../../helpers.mdx'
|
||||
import { BaseLoader, BaseSource } from './base'
|
||||
} from '../../../components/reference/Reference.types.js'
|
||||
import type { CliCommand, CliSpec } from '../../../generator/types/CliSpec.js'
|
||||
import { flattenSections } from '../../../lib/helpers.js'
|
||||
import { enrichedOperation, gen_v3 } from '../../../lib/refGenerator/helpers.js'
|
||||
import type { Json } from '../../helpers.mdx.js'
|
||||
import { BaseLoader, BaseSource } from './base.js'
|
||||
|
||||
export abstract class ReferenceLoader<SpecSection> extends BaseLoader {
|
||||
type = 'reference' as const
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// generator files need their own tsconfig.json because they don't like "module": "esnext" setting that nextjs requires in the main tsconfig
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"incremental": true,
|
||||
"noImplicitAny": false,
|
||||
"esModuleInterop": true,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
dotenv.config({ path: '.env.local' })
|
||||
|
||||
export default undefined
|
||||
@@ -791,6 +791,12 @@
|
||||
"type": "category",
|
||||
"title": "Realtime",
|
||||
"items": [
|
||||
{
|
||||
"id": "realtime-api",
|
||||
"title": "Overview",
|
||||
"slug": "realtime-api",
|
||||
"type": "function"
|
||||
},
|
||||
{
|
||||
"id": "stream",
|
||||
"title": "Listen to database changes",
|
||||
|
||||
@@ -97,7 +97,7 @@ functions:
|
||||
url: str = os.environ.get("SUPABASE_URL")
|
||||
key: str = os.environ.get("SUPABASE_KEY")
|
||||
supabase: Client = create_client(
|
||||
url,
|
||||
url,
|
||||
key,
|
||||
options=ClientOptions(
|
||||
postgrest_client_timeout=10,
|
||||
@@ -173,7 +173,7 @@ functions:
|
||||
```python
|
||||
response = supabase.auth.sign_up(
|
||||
{
|
||||
"email": "email@example.com",
|
||||
"email": "email@example.com",
|
||||
"password": "password",
|
||||
}
|
||||
)
|
||||
@@ -281,7 +281,7 @@ functions:
|
||||
```python
|
||||
response = supabase.auth.sign_up(
|
||||
{
|
||||
"phone": "123456789",
|
||||
"phone": "123456789",
|
||||
"password": "password",
|
||||
}
|
||||
)
|
||||
@@ -467,7 +467,7 @@ functions:
|
||||
```python
|
||||
response = supabase.auth.sign_in_with_password(
|
||||
{
|
||||
"email": "email@example.com",
|
||||
"email": "email@example.com",
|
||||
"password": "example-password",
|
||||
}
|
||||
)
|
||||
@@ -551,7 +551,7 @@ functions:
|
||||
```python
|
||||
response = supabase.auth.sign_in_with_password(
|
||||
{
|
||||
"phone": "+13334445555",
|
||||
"phone": "+13334445555",
|
||||
"password": "some-password",
|
||||
}
|
||||
)
|
||||
@@ -776,7 +776,7 @@ functions:
|
||||
```python
|
||||
response = supabase.auth.sign_in_with_id_token(
|
||||
{
|
||||
"provider": "google",
|
||||
"provider": "google",
|
||||
"token": "your-id-token",
|
||||
}
|
||||
)
|
||||
@@ -1000,8 +1000,8 @@ functions:
|
||||
```python
|
||||
response = supabase.auth.verify_otp(
|
||||
{
|
||||
"email": "email@example.com",
|
||||
"token": "123456",
|
||||
"email": "email@example.com",
|
||||
"token": "123456",
|
||||
"type": "email",
|
||||
}
|
||||
)
|
||||
@@ -1081,8 +1081,8 @@ functions:
|
||||
```python
|
||||
response = supabase.auth.verify_otp(
|
||||
{
|
||||
"phone": "+13334445555",
|
||||
"token": "123456",
|
||||
"phone": "+13334445555",
|
||||
"token": "123456",
|
||||
"type": "sms",
|
||||
}
|
||||
)
|
||||
@@ -1094,8 +1094,8 @@ functions:
|
||||
```python
|
||||
response = supabase.auth.verify_otp(
|
||||
{
|
||||
"email": "email@example.com",
|
||||
"token_hash": "<token-hash>",
|
||||
"email": "email@example.com",
|
||||
"token_hash": "<token-hash>",
|
||||
"type": "email",
|
||||
}
|
||||
)
|
||||
@@ -5531,8 +5531,8 @@ functions:
|
||||
supabase.table("texts")
|
||||
.select("content")
|
||||
.text_search(
|
||||
"content",
|
||||
"'eggs' & 'ham'",
|
||||
"content",
|
||||
"'eggs' & 'ham'",
|
||||
options={"config": "english"},
|
||||
)
|
||||
.execute()
|
||||
@@ -6650,7 +6650,7 @@ functions:
|
||||
code: |
|
||||
```python
|
||||
response = supabase.functions.invoke(
|
||||
"hello-world",
|
||||
"hello-world",
|
||||
invoke_options={
|
||||
"body": {"name": "Functions"},
|
||||
},
|
||||
@@ -6703,6 +6703,32 @@ functions:
|
||||
)
|
||||
```
|
||||
|
||||
- id: realtime-api
|
||||
title: 'Overview'
|
||||
description: |
|
||||
Realtime in Python only works with the asynchronous client.
|
||||
You can initialize a new Supabase client using the `acreate_client()` method.
|
||||
|
||||
- Some Realtime methods are asynchronous and must be awaited. Ensure these methods are called within an `async` function.
|
||||
- In the following Realtime examples, certain methods are awaited. These should be enclosed within an `async` function.
|
||||
- When an asynchronous method needs to be used within a synchronous context, such as the callback for `.subscribe()`, utilize `asyncio.create_task()` to schedule the coroutine. This is why the `acreate_client` example includes an import of `asyncio`.
|
||||
examples:
|
||||
- id: acreate-client
|
||||
name: acreate_client()
|
||||
code: |
|
||||
```python
|
||||
import os
|
||||
import asyncio
|
||||
from supabase import acreate_client, AsyncClient
|
||||
|
||||
url: str = os.environ.get("SUPABASE_URL")
|
||||
key: str = os.environ.get("SUPABASE_KEY")
|
||||
|
||||
async def create_supabase():
|
||||
supabase: AsyncClient = await acreate_client(url, key)
|
||||
return supabase
|
||||
```
|
||||
|
||||
- id: subscribe
|
||||
title: on().subscribe()
|
||||
notes: |
|
||||
@@ -6720,15 +6746,15 @@ functions:
|
||||
|
||||
def on_subscribe(status, err):
|
||||
if status == RealtimeSubscribeStates.SUBSCRIBED:
|
||||
channel.send_broadcast(
|
||||
"cursor-pos",
|
||||
asyncio.create_task(channel.send_broadcast(
|
||||
"cursor-pos",
|
||||
{"x": random.random(), "y": random.random()}
|
||||
)
|
||||
))
|
||||
|
||||
def handle_broadcast(payload):
|
||||
print("Cursor position received!", payload)
|
||||
|
||||
channel.on_broadcast(event="cursor-pos", callback=handle_broadcast).subscribe(on_subscribe)
|
||||
await channel.on_broadcast(event="cursor-pos", callback=handle_broadcast).subscribe(on_subscribe)
|
||||
```
|
||||
- id: listen-to-presence-sync
|
||||
name: Listen to presence sync
|
||||
@@ -6739,12 +6765,12 @@ functions:
|
||||
|
||||
def on_subscribe(status, err):
|
||||
if status == RealtimeSubscribeStates.SUBSCRIBED:
|
||||
channel.track({"online_at": datetime.datetime.now().isoformat()})
|
||||
asyncio.create_task(channel.track({"online_at": datetime.datetime.now().isoformat()}))
|
||||
|
||||
def handle_presence_sync():
|
||||
print("Synced presence state: ", channel.presence.state)
|
||||
|
||||
channel.on_presence_sync(callback=handle_presence_sync).subscribe(on_subscribe)
|
||||
await channel.on_presence_sync(callback=handle_presence_sync).subscribe(on_subscribe)
|
||||
```
|
||||
- id: listen-to-presence-join
|
||||
name: Listen to presence join
|
||||
@@ -6758,9 +6784,9 @@ functions:
|
||||
|
||||
def on_subscribe(status, err):
|
||||
if status == RealtimeSubscribeStates.SUBSCRIBED:
|
||||
channel.track({"online_at": datetime.datetime.now().isoformat()})
|
||||
asyncio.create_task(channel.track({"online_at": datetime.datetime.now().isoformat()}))
|
||||
|
||||
channel.on_presence_join(callback=handle_presence_join).subscribe(on_subscribe)
|
||||
await channel.on_presence_join(callback=handle_presence_join).subscribe(on_subscribe)
|
||||
```
|
||||
- id: listen-to-presence-leave
|
||||
name: Listen to presence leave
|
||||
@@ -6774,10 +6800,10 @@ functions:
|
||||
|
||||
def on_subscribe(status, err):
|
||||
if status == RealtimeSubscribeStates.SUBSCRIBED:
|
||||
channel.track({"online_at": datetime.datetime.now().isoformat()})
|
||||
channel.untrack()
|
||||
asyncio.create_task(channel.track({"online_at": datetime.datetime.now().isoformat()}))
|
||||
asyncio.create_task(channel.untrack())
|
||||
|
||||
channel.on_presence_leave(callback=handle_presence_leave).subscribe(on_subscribe)
|
||||
await channel.on_presence_leave(callback=handle_presence_leave).subscribe(on_subscribe)
|
||||
```
|
||||
- id: listen-to-all-database-changes
|
||||
name: Listen to all database changes
|
||||
@@ -6785,7 +6811,7 @@ functions:
|
||||
code: |
|
||||
```python
|
||||
response = (
|
||||
supabase.channel("room1")
|
||||
await supabase.channel("room1")
|
||||
.on_postgres_changes("*", schema="*", callback=handle_record_updated)
|
||||
.subscribe()
|
||||
)
|
||||
@@ -6795,7 +6821,7 @@ functions:
|
||||
code: |
|
||||
```python
|
||||
response = (
|
||||
supabase.channel("room1")
|
||||
await supabase.channel("room1")
|
||||
.on_postgres_changes("*", schema="public", table="countries", callback=handle_record_updated)
|
||||
.subscribe()
|
||||
)
|
||||
@@ -6805,7 +6831,7 @@ functions:
|
||||
code: |
|
||||
```python
|
||||
response = (
|
||||
supabase.channel("room1")
|
||||
await supabase.channel("room1")
|
||||
.on_postgres_changes("INSERT", schema="public", table="countries", callback=handle_record_inserted)
|
||||
.subscribe()
|
||||
)
|
||||
@@ -6822,7 +6848,7 @@ functions:
|
||||
code: |
|
||||
```python
|
||||
response = (
|
||||
supabase.channel("room1")
|
||||
await supabase.channel("room1")
|
||||
.on_postgres_changes("UPDATE", schema="public", table="countries", callback=handle_record_updated)
|
||||
.subscribe()
|
||||
)
|
||||
@@ -6839,7 +6865,7 @@ functions:
|
||||
code: |
|
||||
```python
|
||||
response = (
|
||||
supabase.channel("room1")
|
||||
await supabase.channel("room1")
|
||||
.on_postgres_changes("DELETE", schema="public", table="countries", callback=handle_record_deleted)
|
||||
.subscribe()
|
||||
)
|
||||
@@ -6850,7 +6876,7 @@ functions:
|
||||
code: |
|
||||
```python
|
||||
response = (
|
||||
supabase.channel("room1")
|
||||
await supabase.channel("room1")
|
||||
.on_postgres_changes("INSERT", schema="public", table="countries", callback=handle_record_inserted)
|
||||
.on_postgres_changes("DELETE", schema="public", table="countries", callback=handle_record_deleted)
|
||||
.subscribe()
|
||||
@@ -6864,7 +6890,7 @@ functions:
|
||||
code: |
|
||||
```python
|
||||
response = (
|
||||
supabase.channel("room1")
|
||||
await supabase.channel("room1")
|
||||
.on_postgres_changes("UPDATE", schema="public", table="countries", filter="id=eq.200", callback=handle_record_updated)
|
||||
.subscribe()
|
||||
)
|
||||
@@ -6883,9 +6909,9 @@ functions:
|
||||
|
||||
def on_subscribe(status, err):
|
||||
if status == RealtimeSubscribeStates.SUBSCRIBED:
|
||||
channel.send_broadcast('cursor-pos', {"x": random.random(), "y": random.random()})
|
||||
asyncio.create_task(channel.send_broadcast('cursor-pos', {"x": random.random(), "y": random.random()}))
|
||||
|
||||
channel.subscribe(on_subscribe)
|
||||
await channel.subscribe(on_subscribe)
|
||||
```
|
||||
response: |
|
||||
```
|
||||
@@ -6912,7 +6938,7 @@ functions:
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```python
|
||||
supabase.remove_channel(myChannel)
|
||||
await supabase.remove_channel(myChannel)
|
||||
```
|
||||
|
||||
- id: remove-all-channels
|
||||
@@ -6925,7 +6951,7 @@ functions:
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```python
|
||||
supabase.remove_all_channels()
|
||||
await supabase.remove_all_channels()
|
||||
```
|
||||
|
||||
- id: list-buckets
|
||||
@@ -7305,7 +7331,7 @@ functions:
|
||||
supabase.storage
|
||||
.from_("avatars")
|
||||
.move(
|
||||
"public/avatar1.png",
|
||||
"public/avatar1.png",
|
||||
"private/avatar2.png"
|
||||
)
|
||||
)
|
||||
@@ -7342,7 +7368,7 @@ functions:
|
||||
supabase.storage
|
||||
.from_("avatars")
|
||||
.copy(
|
||||
"public/avatar1.png",
|
||||
"public/avatar1.png",
|
||||
"private/avatar2.png"
|
||||
)
|
||||
)
|
||||
@@ -7391,7 +7417,7 @@ functions:
|
||||
supabase.storage
|
||||
.from_("avatars")
|
||||
.create_signed_urls(
|
||||
["folder/avatar1.png", "folder/avatar2.png"],
|
||||
["folder/avatar1.png", "folder/avatar2.png"],
|
||||
60
|
||||
)
|
||||
)
|
||||
@@ -7475,7 +7501,7 @@ functions:
|
||||
supabase.storage
|
||||
.from_("avatars")
|
||||
.create_signed_url(
|
||||
"folder/avatar1.png",
|
||||
"folder/avatar1.png",
|
||||
60
|
||||
)
|
||||
)
|
||||
@@ -7495,8 +7521,8 @@ functions:
|
||||
supabase.storage
|
||||
.from_("avatars")
|
||||
.create_signed_url(
|
||||
"folder/avatar1.png",
|
||||
60,
|
||||
"folder/avatar1.png",
|
||||
60,
|
||||
{"transform": {"width": 100, "height": 100}}
|
||||
)
|
||||
)
|
||||
@@ -7510,8 +7536,8 @@ functions:
|
||||
supabase.storage
|
||||
.from_("avatars")
|
||||
.create_signed_url(
|
||||
"folder/avatar1.png",
|
||||
60,
|
||||
"folder/avatar1.png",
|
||||
60,
|
||||
{"download": True}
|
||||
)
|
||||
)
|
||||
@@ -7691,7 +7717,7 @@ functions:
|
||||
supabase.storage
|
||||
.from_("avatars")
|
||||
.get_public_url(
|
||||
"folder/avatar1.jpg",
|
||||
"folder/avatar1.jpg",
|
||||
{"transform": {"width": 100, "height": 100}}
|
||||
)
|
||||
)
|
||||
@@ -7705,7 +7731,7 @@ functions:
|
||||
supabase.storage
|
||||
.from_("avatars")
|
||||
.get_public_url(
|
||||
"folder/avatar1.jpg",
|
||||
"folder/avatar1.jpg",
|
||||
{"download": True}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
"NEXT_PUBLIC_MISC_URL",
|
||||
"NEXT_PUBLIC_MISC_USE_URL",
|
||||
"NEXT_PUBLIC_MISC_ANON_KEY",
|
||||
"NEXT_PUBLIC_MISC_USE_ANON_KEY",
|
||||
"NODE_ENV",
|
||||
"NEXT_PUBLIC_API_URL",
|
||||
"NEXT_PUBLIC_BASE_PATH",
|
||||
@@ -48,9 +46,6 @@
|
||||
"DOCS_REVALIDATION_OVERRIDE_KEYS",
|
||||
"FORCE_ASSET_CDN",
|
||||
"OPENAI_API_KEY",
|
||||
"SEARCH_GITHUB_APP_ID",
|
||||
"SEARCH_GITHUB_APP_INSTALLATION_ID",
|
||||
"SEARCH_GITHUB_APP_PRIVATE_KEY",
|
||||
"SITE_NAME",
|
||||
"SUPABASE_SECRET_KEY"
|
||||
],
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: ['examples/**/*', '**/node_modules/**'],
|
||||
setupFiles: ['vitest.setup.ts'],
|
||||
},
|
||||
plugins: [tsconfigPaths({ root: import.meta.dirname })],
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { afterAll, beforeAll, vi } from 'vitest'
|
||||
|
||||
let oldEnv: NodeJS.ProcessEnv
|
||||
|
||||
beforeAll(() => {
|
||||
// Use local Supabase to run e2e tests
|
||||
oldEnv = { ...process.env }
|
||||
process.env = {
|
||||
...process.env,
|
||||
NEXT_PUBLIC_SUPABASE_URL: 'http://localhost:54321',
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0',
|
||||
}
|
||||
|
||||
// Prevent errors about importing server-only modules from Client Components
|
||||
vi.mock('server-only', () => {
|
||||
return {}
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
process.env = oldEnv
|
||||
vi.doUnmock('server-only')
|
||||
})
|
||||
@@ -21,7 +21,7 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => {
|
||||
<SimpleCodeBlock className="bash" parentClassName="min-h-72">
|
||||
{`
|
||||
SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'}
|
||||
SUPABASE_KEY=${projectKeys.anonKey ?? 'your-anon-key'}
|
||||
SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'}
|
||||
`}
|
||||
</SimpleCodeBlock>
|
||||
</ConnectTabContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PostgresSchema } from '@supabase/postgres-meta'
|
||||
import { toPng, toSvg } from 'html-to-image'
|
||||
import { Download, Loader2 } from 'lucide-react'
|
||||
import { Check, Download, Loader2, Clipboard, Info } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import ReactFlow, { Background, BackgroundVariant, MiniMap, useReactFlow } from 'reactflow'
|
||||
@@ -21,7 +21,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { SchemaGraphLegend } from './SchemaGraphLegend'
|
||||
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
|
||||
import { TableNode } from './SchemaTableNode'
|
||||
|
||||
import { copyToClipboard } from 'ui'
|
||||
// [Joshen] Persisting logic: Only save positions to local storage WHEN a node is moved OR when explicitly clicked to reset layout
|
||||
|
||||
export const SchemaGraph = () => {
|
||||
@@ -30,6 +30,13 @@ export const SchemaGraph = () => {
|
||||
const { project } = useProjectContext()
|
||||
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}, [copied])
|
||||
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
const miniMapNodeColor = '#111318'
|
||||
@@ -160,6 +167,59 @@ export const SchemaGraph = () => {
|
||||
}
|
||||
}
|
||||
|
||||
function tablesToSQL(t: typeof tables) {
|
||||
if (!Array.isArray(t)) return ''
|
||||
const warning =
|
||||
'-- WARNING: This schema is for context only and is not meant to be run.\n-- Table order and constraints may not be valid for execution.\n\n'
|
||||
const sql = t
|
||||
.map((table) => {
|
||||
if (!table || !Array.isArray((table as any).columns)) return ''
|
||||
|
||||
const columns = (table as { columns?: any[] }).columns ?? []
|
||||
const columnLines = columns.map((c) => {
|
||||
let line = ` ${c.name} ${c.data_type}`
|
||||
if (c.is_identity) {
|
||||
line += ' GENERATED ALWAYS AS IDENTITY'
|
||||
}
|
||||
if (c.is_nullable === false) {
|
||||
line += ' NOT NULL'
|
||||
}
|
||||
if (c.default_value !== null && c.default_value !== undefined) {
|
||||
line += ` DEFAULT ${c.default_value}`
|
||||
}
|
||||
if (c.is_unique) {
|
||||
line += ' UNIQUE'
|
||||
}
|
||||
if (c.check) {
|
||||
line += ` CHECK (${c.check})`
|
||||
}
|
||||
return line
|
||||
})
|
||||
|
||||
const constraints: string[] = []
|
||||
|
||||
if (Array.isArray(table.primary_keys) && table.primary_keys.length > 0) {
|
||||
const pkCols = table.primary_keys.map((pk) => pk.name).join(', ')
|
||||
constraints.push(` CONSTRAINT ${table.name}_pkey PRIMARY KEY (${pkCols})`)
|
||||
}
|
||||
|
||||
if (Array.isArray(table.relationships)) {
|
||||
table.relationships.forEach((rel) => {
|
||||
if (rel && rel.source_table_name === table.name) {
|
||||
constraints.push(
|
||||
` CONSTRAINT ${rel.constraint_name} FOREIGN KEY (${rel.source_column_name}) REFERENCES ${rel.target_table_schema}.${rel.target_table_name}(${rel.target_column_name})`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const allLines = [...columnLines, ...constraints]
|
||||
return `CREATE TABLE ${table.schema}.${table.name} (\n${allLines.join(',\n')}\n);`
|
||||
})
|
||||
.join('\n')
|
||||
return warning + sql
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccessTables && isSuccessSchemas && tables.length > 0) {
|
||||
const schema = schemas.find((s) => s.name === selectedSchema) as PostgresSchema
|
||||
@@ -192,6 +252,40 @@ export const SchemaGraph = () => {
|
||||
onSelectSchema={setSelectedSchema}
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ButtonTooltip
|
||||
type="outline"
|
||||
icon={copied ? <Check /> : <Clipboard />}
|
||||
onClick={() => {
|
||||
if (tables) {
|
||||
copyToClipboard(tablesToSQL(tables))
|
||||
setCopied(true)
|
||||
}
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: (
|
||||
<div className="max-w-[180px] space-y-2 text-foreground-light">
|
||||
<p className="text-foreground">Note</p>
|
||||
<p>
|
||||
This schema is for context or debugging only. Table order and constraints
|
||||
may be invalid. Not meant to be run as-is.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
Copy as SQL
|
||||
</ButtonTooltip>
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
loading={isDownloading}
|
||||
className="px-1.5"
|
||||
icon={<Download />}
|
||||
onClick={() => downloadImage('png')}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Download current view as PNG' } }}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonTooltip
|
||||
|
||||
@@ -87,7 +87,7 @@ export const PostgresVersionSelector = ({
|
||||
type === 'create'
|
||||
? createVersions?.available_versions ?? []
|
||||
: unpauseVersions?.available_versions ?? []
|
||||
const availableVersions = versions.sort((a, b) => a.version.localeCompare(b.version))
|
||||
const availableVersions = versions.sort((a, b) => a.version.localeCompare(b.version)).reverse()
|
||||
const { postgresVersionSelection } = form.watch()
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -146,6 +146,9 @@ const SignInMfaForm = () => {
|
||||
}?`}</a>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<Link href="/logout">Force sign out and clear cookies</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
target="_blank"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'components/interfaces/TableGridEditor/TableEntity.utils'
|
||||
import { EntityTypeIcon } from 'components/ui/EntityTypeIcon'
|
||||
import type { ItemRenderer } from 'components/ui/InfiniteList'
|
||||
import { getTableDefinition } from 'data/database/table-definition-query'
|
||||
import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants'
|
||||
import { Entity } from 'data/entity-types/entity-types-infinite-query'
|
||||
import { useProjectLintsQuery } from 'data/lint/lint-query'
|
||||
@@ -25,6 +26,7 @@ import { getTableEditor } from 'data/table-editor/table-editor-query'
|
||||
import { isTableLike } from 'data/table-editor/table-editor-types'
|
||||
import { fetchAllTableRows } from 'data/table-rows/table-rows-query'
|
||||
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
|
||||
import { formatSql } from 'lib/formatSql'
|
||||
import { copyToClipboard } from 'lib/helpers'
|
||||
import { useTableEditorStateSnapshot } from 'state/table-editor'
|
||||
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
|
||||
@@ -44,8 +46,6 @@ import {
|
||||
TreeViewItemVariant,
|
||||
} from 'ui'
|
||||
import { useProjectContext } from '../ProjectLayout/ProjectContext'
|
||||
import { useTableDefinitionQuery } from 'data/database/table-definition-query'
|
||||
import { formatSql } from 'lib/formatSql'
|
||||
|
||||
export interface EntityListItemProps {
|
||||
id: number | string
|
||||
@@ -70,15 +70,6 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const { selectedSchema } = useQuerySchemaState()
|
||||
|
||||
const { data: tableDefinition, isLoading: isTableDefinitionLoading } = useTableDefinitionQuery(
|
||||
{
|
||||
id: entity.id,
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
{ enabled: isTableLikeEntityListItem(entity) }
|
||||
)
|
||||
|
||||
// For tabs preview flag logic
|
||||
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
|
||||
const tabId = createTabId(entity.type, { id: entity.id })
|
||||
@@ -307,17 +298,26 @@ const EntityListItem: ItemRenderer<Entity, EntityListItemProps> = ({
|
||||
<DropdownMenuItem
|
||||
key="copy-schema"
|
||||
className="space-x-2"
|
||||
disabled={isTableDefinitionLoading || !tableDefinition}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
if (!tableDefinition) return
|
||||
const toastId = toast.loading('Getting table schema...')
|
||||
|
||||
const tableDefinition = await getTableDefinition({
|
||||
id: entity.id,
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
if (!tableDefinition) {
|
||||
return toast.error('Failed to get table schema', { id: toastId })
|
||||
}
|
||||
|
||||
try {
|
||||
const formatted = formatSql(tableDefinition)
|
||||
await copyToClipboard(formatted)
|
||||
toast.success('Table schema copied to clipboard', { id: 'copy-schema' })
|
||||
toast.success('Table schema copied to clipboard', { id: toastId })
|
||||
} catch (err: any) {
|
||||
toast.error('Failed to copy schema: ' + (err.message || err), {
|
||||
id: 'copy-schema',
|
||||
id: toastId,
|
||||
})
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -379,7 +379,9 @@ const FileExplorerHeader = ({
|
||||
<DropdownMenuItem key={option.key} onClick={() => snap.setView(option.key)}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{snap.view === option.key && <Check className="text-brand" strokeWidth={2} />}
|
||||
{snap.view === option.key && (
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -391,7 +393,9 @@ const FileExplorerHeader = ({
|
||||
<DropdownMenuItem key={option.key} onClick={() => setSortBy(option.key)}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{sortBy === option.key && <Check className="text-brand" strokeWidth={2} />}
|
||||
{sortBy === option.key && (
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
@@ -405,7 +409,7 @@ const FileExplorerHeader = ({
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p>{option.name}</p>
|
||||
{sortByOrder === option.key && (
|
||||
<Check className="text-brand" strokeWidth={2} />
|
||||
<Check size={16} className="text-brand" strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events'
|
||||
import { useParams } from 'common'
|
||||
@@ -8,6 +9,8 @@ import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Input } from 'ui'
|
||||
import { toast } from 'sonner'
|
||||
import { useLastUsedAPIKeysLogQuery, getLastUsedAPIKeys } from './DisplayApiSettings.utils'
|
||||
|
||||
const DisplayApiSettings = ({
|
||||
legacy,
|
||||
@@ -42,6 +45,24 @@ const DisplayApiSettings = ({
|
||||
// api keys should not be empty. However it can be populated with a delay on project creation
|
||||
const isApiKeysEmpty = apiKeys.length === 0
|
||||
|
||||
const { isLoading: isLoadingLastUsed, logData: lastUsedLogData } = useLastUsedAPIKeysLogQuery(
|
||||
projectRef!
|
||||
)
|
||||
|
||||
const lastUsedAPIKeys = useMemo(() => {
|
||||
if (apiKeys.length < 1 || !lastUsedLogData || lastUsedLogData.length < 1) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
return getLastUsedAPIKeys(apiKeys, lastUsedLogData)
|
||||
} catch (e: any) {
|
||||
toast.error('Failed to identify when the anon and service_role keys were last used')
|
||||
console.error(e)
|
||||
return {}
|
||||
}
|
||||
}, [lastUsedLogData, apiKeys])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
@@ -143,6 +164,15 @@ const DisplayApiSettings = ({
|
||||
(legacy ? 'Prefer using Secret API keys instead.' : '')
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="pt-2 text-foreground-lighter w-full text-sm data-[invisible=true]:invisible"
|
||||
data-invisible={isLoadingLastUsed}
|
||||
>
|
||||
{lastUsedAPIKeys[x.api_key]
|
||||
? `Last request was ${lastUsedAPIKeys[x.api_key]} ago.`
|
||||
: 'No requests in the past 24 hours.'}
|
||||
</div>
|
||||
</Panel.Content>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
import useLogsQuery from 'hooks/analytics/useLogsQuery'
|
||||
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
export function useLastUsedAPIKeysLogQuery(projectRef: string, now: Date = new Date()) {
|
||||
return useLogsQuery(projectRef, {
|
||||
iso_timestamp_start: new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
iso_timestamp_end: now.toISOString(),
|
||||
sql: "-- last-used-anon--service_role-api-keys\nSELECT unix_millis(max(timestamp)) as timestamp, payload.role, payload.signature_prefix FROM edge_logs cross join unnest(metadata) as m cross join unnest(m.request) as request cross join unnest(request.sb) as sb cross join unnest(sb.jwt) as jwt cross join unnest(jwt.apikey) as apikey cross join unnest(apikey.payload) as payload WHERE apikey.invalid is null and payload.issuer = 'supabase' and payload.algorithm = 'HS256' and payload.role in ('anon', 'service_role') GROUP BY payload.role, payload.signature_prefix",
|
||||
})
|
||||
}
|
||||
|
||||
export function getLastUsedAPIKeys(
|
||||
apiKeys: {
|
||||
tags: string
|
||||
api_key: string
|
||||
}[],
|
||||
logData:
|
||||
| {
|
||||
timestamp: number
|
||||
role?: 'anon' | 'service_role' | string
|
||||
signature_prefix?: string
|
||||
}[]
|
||||
| null
|
||||
) {
|
||||
if (apiKeys.length < 1 || !logData || logData.length < 1) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const now = dayjs()
|
||||
|
||||
return apiKeys.reduce(
|
||||
(a, i) => {
|
||||
const entry = logData?.find(
|
||||
({ role, signature_prefix }) =>
|
||||
role &&
|
||||
signature_prefix &&
|
||||
i.tags.indexOf(role) >= 0 &&
|
||||
i.api_key.split('.')[2]?.startsWith(signature_prefix)
|
||||
)?.timestamp
|
||||
|
||||
if (entry) {
|
||||
a[i.api_key] = dayjs.duration(now.diff(dayjs(entry))).humanize(false)
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
{} as { [apikey: string]: string }
|
||||
)
|
||||
}
|
||||
@@ -51,9 +51,11 @@ export const useProjectSettingsV2Query = <TData = ProjectSettingsData>(
|
||||
({ signal }) => getProjectSettings({ projectRef }, signal),
|
||||
{
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
refetchInterval(data) {
|
||||
const apiKeys = (data as ProjectSettings)?.service_api_keys ?? []
|
||||
const interval = canReadAPIKeys && apiKeys.length === 0 ? 2000 : 0
|
||||
refetchInterval(_data) {
|
||||
const data = _data as ProjectSettings | undefined
|
||||
const apiKeys = data?.service_api_keys ?? []
|
||||
const interval =
|
||||
canReadAPIKeys && data?.status !== 'INACTIVE' && apiKeys.length === 0 ? 2000 : 0
|
||||
return interval
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -39,7 +39,11 @@ export async function executeSql<T = any>(
|
||||
| 'isRoleImpersonationEnabled'
|
||||
>,
|
||||
signal?: AbortSignal,
|
||||
headersInit?: HeadersInit
|
||||
headersInit?: HeadersInit,
|
||||
fetcherOverride?: (
|
||||
sql: string,
|
||||
headers?: HeadersInit
|
||||
) => Promise<{ data: T } | { error: ResponseError }>
|
||||
): Promise<{ result: T }> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
|
||||
@@ -52,21 +56,37 @@ export async function executeSql<T = any>(
|
||||
let headers = new Headers(headersInit)
|
||||
if (connectionString) headers.set('x-connection-encrypted', connectionString)
|
||||
|
||||
let { data, error } = await post('/platform/pg-meta/{ref}/query', {
|
||||
signal,
|
||||
params: {
|
||||
header: { 'x-connection-encrypted': connectionString ?? '' },
|
||||
path: { ref: projectRef },
|
||||
// @ts-expect-error: This is just a client side thing to identify queries better
|
||||
query: {
|
||||
key:
|
||||
queryKey?.filter((seg) => typeof seg === 'string' || typeof seg === 'number').join('-') ??
|
||||
'',
|
||||
let data
|
||||
let error
|
||||
|
||||
if (fetcherOverride) {
|
||||
const result = await fetcherOverride(sql, headers)
|
||||
if ('data' in result) {
|
||||
data = result.data
|
||||
} else {
|
||||
error = result.error
|
||||
}
|
||||
} else {
|
||||
const result = await post('/platform/pg-meta/{ref}/query', {
|
||||
signal,
|
||||
params: {
|
||||
header: { 'x-connection-encrypted': connectionString ?? '' },
|
||||
path: { ref: projectRef },
|
||||
// @ts-expect-error: This is just a client side thing to identify queries better
|
||||
query: {
|
||||
key:
|
||||
queryKey
|
||||
?.filter((seg) => typeof seg === 'string' || typeof seg === 'number')
|
||||
.join('-') ?? '',
|
||||
},
|
||||
},
|
||||
},
|
||||
body: { query: sql },
|
||||
headers,
|
||||
})
|
||||
body: { query: sql },
|
||||
headers,
|
||||
})
|
||||
|
||||
data = result.data
|
||||
error = result.error
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function createBucket({
|
||||
|
||||
const payload: Partial<CreateStorageBucketBody> = { id, public: isPublic }
|
||||
if (file_size_limit) payload.file_size_limit = file_size_limit
|
||||
if (allowed_mime_types) payload.allowed_mime_types
|
||||
if (allowed_mime_types) payload.allowed_mime_types = allowed_mime_types
|
||||
|
||||
const { data, error } = await post('/platform/storage/{ref}/buckets', {
|
||||
params: { path: { ref: projectRef } },
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { constructHeaders } from 'lib/api/apiHelpers'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { PG_META_URL } from 'lib/constants'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
export async function queryPgMetaSelfHosted(sql: string, headersInit?: { [prop: string]: any }) {
|
||||
const headers = constructHeaders(headersInit ?? {})
|
||||
const response = await post(`${PG_META_URL}/query`, { query: sql }, { headers })
|
||||
|
||||
if (response.error) {
|
||||
return { error: response.error as ResponseError }
|
||||
} else {
|
||||
return { data: response.data }
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||
@@ -1,8 +1,10 @@
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import pgMeta from '@supabase/pg-meta'
|
||||
import { streamText } from 'ai'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import { queryPgMetaSelfHosted } from 'lib/self-hosted'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getTools } from '../sql/tools'
|
||||
|
||||
@@ -65,7 +67,8 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
...(authorization && { Authorization: authorization }),
|
||||
}
|
||||
},
|
||||
IS_PLATFORM ? undefined : queryPgMetaSelfHosted
|
||||
)
|
||||
: { result: [] }
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import pgMeta from '@supabase/pg-meta'
|
||||
import { streamText } from 'ai'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import { queryPgMetaSelfHosted } from 'lib/self-hosted'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getTools } from '../sql/tools'
|
||||
|
||||
@@ -56,7 +58,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
...(authorization && { Authorization: authorization }),
|
||||
}
|
||||
},
|
||||
IS_PLATFORM ? undefined : queryPgMetaSelfHosted
|
||||
)
|
||||
: { result: [] }
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import pgMeta from '@supabase/pg-meta'
|
||||
import { streamText } from 'ai'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import { queryPgMetaSelfHosted } from 'lib/self-hosted'
|
||||
import { getTools } from './tools'
|
||||
|
||||
export const maxDuration = 30
|
||||
@@ -59,7 +61,8 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookie && { cookie }),
|
||||
...(authorization && { Authorization: authorization }),
|
||||
}
|
||||
},
|
||||
IS_PLATFORM ? undefined : queryPgMetaSelfHosted
|
||||
)
|
||||
: { result: [] }
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ import { stripIndent } from 'common-tags'
|
||||
import { z } from 'zod'
|
||||
|
||||
// import { processSql, renderSupabaseJs } from '@supabase/sql-to-rest'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { getDatabaseFunctions } from 'data/database-functions/database-functions-query'
|
||||
import { getDatabasePolicies } from 'data/database-policies/database-policies-query'
|
||||
import { getEntityDefinitionsSql } from 'data/database/entity-definitions-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { queryPgMetaSelfHosted } from 'lib/self-hosted'
|
||||
|
||||
export const getTools = ({
|
||||
projectRef,
|
||||
@@ -43,7 +45,8 @@ export const getTools = ({
|
||||
sql: getEntityDefinitionsSql({ schemas }),
|
||||
},
|
||||
undefined,
|
||||
headers
|
||||
headers,
|
||||
IS_PLATFORM ? undefined : queryPgMetaSelfHosted
|
||||
)
|
||||
: { result: [] }
|
||||
|
||||
|
||||
@@ -177,17 +177,19 @@ function createStorageExplorerState({
|
||||
},
|
||||
|
||||
sortBy,
|
||||
setSortBy: (value: STORAGE_SORT_BY) => {
|
||||
setSortBy: async (value: STORAGE_SORT_BY) => {
|
||||
state.sortBy = value
|
||||
state.updateExplorerPreference()
|
||||
state.setSelectedFilePreview(undefined)
|
||||
await state.refetchAllOpenedFolders()
|
||||
},
|
||||
|
||||
sortByOrder,
|
||||
setSortByOrder: (value: STORAGE_SORT_BY_ORDER) => {
|
||||
setSortByOrder: async (value: STORAGE_SORT_BY_ORDER) => {
|
||||
state.sortByOrder = value
|
||||
state.updateExplorerPreference()
|
||||
state.setSelectedFilePreview(undefined)
|
||||
await state.refetchAllOpenedFolders()
|
||||
},
|
||||
|
||||
isSearching: false,
|
||||
|
||||
@@ -4,8 +4,8 @@ description: Using Postgres Hooks to allow regular users to create event trigger
|
||||
author: steve_chavez
|
||||
author_url: https://github.com/steve-chavez
|
||||
author_image_url: https://github.com/steve-chavez.png
|
||||
image: 2025-05-08-event-triggers-wo-superuser/cover-event-triggers.png
|
||||
thumb: 2025-05-08-event-triggers-wo-superuser/cover-event-triggers.png
|
||||
image: 2025-05-08-event-triggers-wo-superuser/cover-event-triggers-dark.png
|
||||
thumb: 2025-05-08-event-triggers-wo-superuser/cover-event-triggers-dark.png
|
||||
categories:
|
||||
- postgres
|
||||
tags:
|
||||
@@ -24,7 +24,14 @@ In this blog post, we’ll explain how we do this in the `supautils` extension,
|
||||
|
||||
## Privileged Role
|
||||
|
||||

|
||||
<Img
|
||||
wide
|
||||
alt="Utility Hook"
|
||||
src={{
|
||||
dark: '/images/blog/2025-05-08-event-triggers-wo-superuser/utility-hook-dark.png',
|
||||
light: '/images/blog/2025-05-08-event-triggers-wo-superuser/utility-hook-light.png',
|
||||
}}
|
||||
/>
|
||||
|
||||
The core of `supautils` is the “privileged role”, which is a role that serves as proxy to superuser. It provides a safe subset of superuser capabilities and it’s accessible to regular users.
|
||||
|
||||
@@ -61,7 +68,14 @@ And once a superuser trips on the event trigger, it will fire with its privilege
|
||||
|
||||
## Skipping Event Triggers
|
||||
|
||||

|
||||
<Img
|
||||
wide
|
||||
alt="FMGR Hook"
|
||||
src={{
|
||||
dark: '/images/blog/2025-05-08-event-triggers-wo-superuser/fmgr-hook-dark.png',
|
||||
light: '/images/blog/2025-05-08-event-triggers-wo-superuser/fmgr-hook-light.png',
|
||||
}}
|
||||
/>
|
||||
|
||||
A solution would be skipping user Event Triggers for superusers.
|
||||
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
import React from 'react'
|
||||
import { ImageResponse } from '@vercel/og'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { themes } from '~/components/LaunchWeek/14/utils/ticketThemes'
|
||||
|
||||
export const runtime = 'edge' // 'nodejs' is the default
|
||||
export const dynamic = 'force-dynamic' // defaults to auto
|
||||
export const fetchCache = 'force-no-store'
|
||||
export const revalidate = 0
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
|
||||
const STORAGE_URL = `${SUPABASE_URL}/storage/v1/object/public/images/launch-week/lw14`
|
||||
|
||||
// Load custom font
|
||||
// const FONT_URL = `${STORAGE_URL}/assets/font/Nippo-Regular.otf`
|
||||
// const MONO_FONT_URL = `${STORAGE_URL}/assets/font/DepartureMono-Regular.otf`
|
||||
|
||||
const FONT_URL = '/fonts/launchweek/14/Nippo-Regular.otf'
|
||||
const MONO_FONT_URL = '/fonts/launchweek/14/DepartureMono-Regular.otf'
|
||||
|
||||
const LW_TABLE = 'tickets'
|
||||
const LW_MATERIALIZED_VIEW = 'tickets_view'
|
||||
|
||||
const usernameToLines = (username: string): string[] => {
|
||||
const maxLineLength = 14
|
||||
|
||||
const nonBreakingReplacements = [
|
||||
{ regexp: new RegExp(' ', 'g'), replacement: '\u00A0' }, // Space → Non-breaking space
|
||||
{ regexp: new RegExp('-', 'g'), replacement: '\u2011' }, // Hyphen → Non-breaking hyphen
|
||||
{ regexp: new RegExp('/', 'g'), replacement: '\u2060\u002F\u2060' }, // Slash with word joiners
|
||||
{ regexp: new RegExp('\\.', 'g'), replacement: '\u2024' }, // One dot leader (alternative to period)
|
||||
]
|
||||
const allowList = [...nonBreakingReplacements.map((x) => x.regexp), new RegExp('\\w', 'g')]
|
||||
.map((x) => x.source.replace('-', '\\-'))
|
||||
.join('|')
|
||||
const allowRegexp = new RegExp(`[^${allowList}]`, 'g')
|
||||
const allowdUsername = username.replace(allowRegexp, '')
|
||||
// Split only by spaces, keeping hyphenated words together
|
||||
const words = allowdUsername.split(' ')
|
||||
const lines: string[] = []
|
||||
let currentLine = ''
|
||||
|
||||
// First try to break at word boundaries (spaces only)
|
||||
for (const word of words) {
|
||||
// If adding this word would exceed the line length and we already have content
|
||||
if (
|
||||
currentLine.length + (currentLine ? 1 : 0) + word.length > maxLineLength &&
|
||||
currentLine.length > 0
|
||||
) {
|
||||
// Add current line to lines array and start a new line
|
||||
lines.push(currentLine)
|
||||
currentLine = word
|
||||
} else {
|
||||
// Add word to current line with a space if needed
|
||||
currentLine = currentLine ? `${currentLine} ${word}` : word
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last line if it has content
|
||||
if (currentLine) {
|
||||
lines.push(currentLine)
|
||||
}
|
||||
|
||||
// If we still have too few lines but some are too long, split them by character
|
||||
// but try to avoid splitting at hyphens
|
||||
let finalLines: string[] = []
|
||||
for (const line of lines) {
|
||||
if (line.length > maxLineLength) {
|
||||
// For long words, try to find good break points
|
||||
let remainingText = line
|
||||
while (remainingText.length > 0) {
|
||||
if (remainingText.length <= maxLineLength) {
|
||||
finalLines.push(remainingText)
|
||||
break
|
||||
}
|
||||
|
||||
// Try to find a good break point that's not a hyphen
|
||||
let breakPoint = maxLineLength
|
||||
|
||||
// Look for a hyphen in the potential break area (a few chars before maxLineLength)
|
||||
const searchArea = remainingText.substring(Math.max(0, breakPoint - 5), breakPoint + 1)
|
||||
const hyphenPos = searchArea.indexOf('-')
|
||||
|
||||
// If we found a hyphen, adjust the break point to keep the hyphenated word together
|
||||
if (hyphenPos >= 0) {
|
||||
// Calculate the actual position in the original string
|
||||
const actualHyphenPos = Math.max(0, breakPoint - 5) + hyphenPos
|
||||
|
||||
// If hyphen is near the beginning, include the whole hyphenated word on next line
|
||||
if (actualHyphenPos < maxLineLength / 2) {
|
||||
breakPoint = actualHyphenPos
|
||||
}
|
||||
// If hyphen is near the end, include the whole hyphenated word on this line
|
||||
else {
|
||||
// Find the end of the hyphenated word
|
||||
const spaceAfterHyphen = remainingText.indexOf(' ', actualHyphenPos)
|
||||
if (spaceAfterHyphen > 0 && spaceAfterHyphen - actualHyphenPos < 10) {
|
||||
// If the rest of the hyphenated word is reasonably short, keep it together
|
||||
breakPoint = spaceAfterHyphen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalLines.push(remainingText.substring(0, breakPoint))
|
||||
remainingText = remainingText.substring(breakPoint).trim()
|
||||
}
|
||||
} else {
|
||||
finalLines.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to 3 lines maximum
|
||||
if (finalLines.length > 3) {
|
||||
finalLines = finalLines.slice(0, 2)
|
||||
// Truncate the last line if needed and add ellipsis
|
||||
let lastLine = finalLines[2] || ''
|
||||
if (lastLine.length > 8) {
|
||||
lastLine = lastLine.slice(0, 8) + '...'
|
||||
}
|
||||
finalLines.push(lastLine)
|
||||
}
|
||||
|
||||
finalLines = finalLines.map((line, index) => {
|
||||
let result = line
|
||||
nonBreakingReplacements.forEach(({ regexp, replacement }) => {
|
||||
result = result.replace(regexp, replacement)
|
||||
})
|
||||
|
||||
if (index < finalLines.length - 1) {
|
||||
result = result.padEnd(maxLineLength, '\u00A0')
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Ensure we don't have more than 3 lines
|
||||
return finalLines.slice(0, 3)
|
||||
}
|
||||
|
||||
export async function GET(req: Request, res: Response) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// Just here to silence snyk false positives
|
||||
// Verify that req.url is from an allowed domain
|
||||
const username = url.searchParams.get('username') ?? url.searchParams.get('amp;username')
|
||||
const userAgent = req.headers.get('user-agent')
|
||||
|
||||
const font = fetch(new URL(FONT_URL, url)).then((res) => res.arrayBuffer())
|
||||
const mono_font = fetch(new URL(MONO_FONT_URL, url)).then((res) => res.arrayBuffer())
|
||||
|
||||
try {
|
||||
if (!username) throw new Error('missing username param')
|
||||
|
||||
const supabaseAdminClient = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL as string,
|
||||
process.env.LIVE_SUPABASE_COM_SERVICE_ROLE_KEY as string
|
||||
)
|
||||
|
||||
// Track social shares
|
||||
if (userAgent?.toLocaleLowerCase().includes('twitter')) {
|
||||
await supabaseAdminClient
|
||||
.from(LW_TABLE)
|
||||
.update({ shared_on_twitter: 'now' })
|
||||
.eq('launch_week', 'lw14')
|
||||
.eq('username', username)
|
||||
.is('shared_on_twitter', null)
|
||||
} else if (userAgent?.toLocaleLowerCase().includes('linkedin')) {
|
||||
await supabaseAdminClient
|
||||
.from(LW_TABLE)
|
||||
.update({ shared_on_linkedin: 'now' })
|
||||
.eq('launch_week', 'lw14')
|
||||
.eq('username', username)
|
||||
.is('shared_on_linkedin', null)
|
||||
}
|
||||
|
||||
// Get ticket data
|
||||
const { data: user, error } = await supabaseAdminClient
|
||||
.from(LW_MATERIALIZED_VIEW)
|
||||
.select(
|
||||
'id, name, metadata, shared_on_twitter, shared_on_linkedin, platinum, secret, role, company, location, ticket_number'
|
||||
)
|
||||
.eq('launch_week', 'lw14')
|
||||
.eq('username', username)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) console.log('Failed to fetch user. Inner error:', error.message)
|
||||
if (!user) throw new Error(error?.message ?? 'user not found')
|
||||
|
||||
const {
|
||||
name,
|
||||
secret,
|
||||
platinum: isPlatinum,
|
||||
shared_on_twitter: sharedOnTwitter,
|
||||
shared_on_linkedin: sharedOnLinkedIn,
|
||||
ticket_number,
|
||||
} = user
|
||||
|
||||
const platinum = isPlatinum ?? (!!sharedOnTwitter && !!sharedOnLinkedIn) ?? false
|
||||
const platinumSecret = platinum && secret
|
||||
|
||||
const seatCode = (466561 + (ticket_number || 0)).toString(36).toUpperCase()
|
||||
|
||||
// Generate image and upload to storage.
|
||||
const ticketType = secret
|
||||
? platinum
|
||||
? 'platinumSecret'
|
||||
: 'secret'
|
||||
: platinum
|
||||
? 'platinum'
|
||||
: 'regular'
|
||||
|
||||
const STYLING_CONFIG = () => ({
|
||||
TICKET_FOREGROUND: themes()[ticketType].TICKET_FOREGROUND,
|
||||
})
|
||||
|
||||
const TICKET_THEME = {
|
||||
regular: {
|
||||
color: 'rgb(239, 239, 239)',
|
||||
background: 'transparent',
|
||||
},
|
||||
secret: {
|
||||
color: 'rgba(44, 244, 148)',
|
||||
background: 'rgba(44, 244, 148, 0.2)',
|
||||
},
|
||||
platinum: {
|
||||
color: 'rgba(255, 199, 58)',
|
||||
background: 'rgba(255, 199, 58, 0.2)',
|
||||
},
|
||||
platinumSecret: {
|
||||
color: 'rgba(255, 199, 58)',
|
||||
background: 'rgba(255, 199, 58, 0.2)',
|
||||
},
|
||||
}
|
||||
|
||||
const fontData = await font
|
||||
const monoFontData = await mono_font
|
||||
|
||||
const OG_WIDTH = 1200
|
||||
const OG_HEIGHT = 628
|
||||
const USERNAME_BOTTOM = 435
|
||||
|
||||
const BACKGROUND = () => ({
|
||||
regular: {
|
||||
BACKGROUND_IMG: new URL(`/images/launchweek/14/og-14-regular.png`, url).href,
|
||||
},
|
||||
platinum: {
|
||||
BACKGROUND_IMG: new URL(`/images/launchweek/14/og-14-platinum.png`, url).href,
|
||||
},
|
||||
platinumSecret: {
|
||||
BACKGROUND_IMG: new URL(`/images/launchweek/14/og-14-platinum-secret.png`, url).href,
|
||||
},
|
||||
secret: {
|
||||
BACKGROUND_IMG: new URL(`/images/launchweek/14/og-14-secret.png`, url).href,
|
||||
},
|
||||
})
|
||||
|
||||
const NOISE = new URL('/images/launchweek/14/noise-pattern.png', url).href
|
||||
|
||||
const computeBackgroundWidth = (letters: number) => {
|
||||
return 100 + (letters * 40 + (letters - 1) * 12)
|
||||
}
|
||||
const lines = usernameToLines(name ?? username)
|
||||
|
||||
const secretStyles = {
|
||||
background: 'linear-gradient(0deg, rgb(18 18 18 / 0.5) 50%, transparent 50%)',
|
||||
backgroundSize: '100% 6px, 0px 100%',
|
||||
}
|
||||
|
||||
const testStyles = {
|
||||
background: 'red',
|
||||
backgroundSize: '100% 6px, 0px 100%',
|
||||
}
|
||||
|
||||
const secretTextStyles = {
|
||||
textShadow: '0 0 15px rgba(52,211,153,0.8)',
|
||||
}
|
||||
|
||||
const platinumSecretTextStyles = {
|
||||
textShadow: '0 0 15px rgba(255,199,58,0.8)',
|
||||
}
|
||||
|
||||
const generatedTicketImage = new ImageResponse(
|
||||
(
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: '1200px',
|
||||
height: '628px',
|
||||
position: 'relative',
|
||||
fontFamily: '"Nippo-Regular"',
|
||||
overflow: 'hidden',
|
||||
color: STYLING_CONFIG().TICKET_FOREGROUND,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{/* Background */}
|
||||
<img
|
||||
width="1204"
|
||||
height="634"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-2px',
|
||||
left: '-2px',
|
||||
bottom: '-2px',
|
||||
right: '-2px',
|
||||
backgroundSize: 'cover',
|
||||
backgroundColor: STYLING_CONFIG().TICKET_FOREGROUND,
|
||||
}}
|
||||
src={BACKGROUND()[ticketType].BACKGROUND_IMG}
|
||||
/>
|
||||
{/* Seat number */}
|
||||
<div
|
||||
style={{
|
||||
transform: 'rotate(-90deg)',
|
||||
fontFamily: '"Nippo-Regular"',
|
||||
fontSize: '82px',
|
||||
position: 'absolute',
|
||||
color: TICKET_THEME[ticketType].color,
|
||||
top: 70,
|
||||
right: 135,
|
||||
...(secret ? secretTextStyles : {}),
|
||||
...(platinumSecret ? platinumSecretTextStyles : {}),
|
||||
}}
|
||||
>
|
||||
{seatCode}
|
||||
</div>
|
||||
|
||||
{/* Render each username line */}
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
position: 'absolute',
|
||||
bottom: USERNAME_BOTTOM - index * 80,
|
||||
paddingLeft: '93px',
|
||||
paddingRight: 0,
|
||||
left: 27,
|
||||
height: '61px',
|
||||
width: `${computeBackgroundWidth(line.length)}px`,
|
||||
backgroundColor: TICKET_THEME[ticketType].background,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: '"DepartureMono-Regular"',
|
||||
margin: '0',
|
||||
color: TICKET_THEME[ticketType].color,
|
||||
padding: '0',
|
||||
fontSize: '82px',
|
||||
lineHeight: '56px',
|
||||
display: 'flex',
|
||||
...(secret ? secretTextStyles : {}),
|
||||
...(platinumSecret ? platinumSecretTextStyles : {}),
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{secret && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
right: '0px',
|
||||
bottom: '0px',
|
||||
...secretStyles,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{secret && (
|
||||
<img
|
||||
src={NOISE}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
opacity: '0.1',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
{
|
||||
width: OG_WIDTH,
|
||||
height: OG_HEIGHT,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Nippo-Regular',
|
||||
data: fontData,
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
name: 'DepartureMono-Regular',
|
||||
data: monoFontData,
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
headers: {
|
||||
'content-type': 'image/png',
|
||||
'cache-control': 'public, max-age=31536000, s-maxage=31536000, no-transform, immutable',
|
||||
'cdn-cache-control': 'max-age=31536000',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// [Note] Uncomment only for local testing to return the image directly and skip storage upload.
|
||||
// return generatedTicketImage
|
||||
|
||||
// Upload image to storage.
|
||||
const { error: storageError } = await supabaseAdminClient.storage
|
||||
.from('images')
|
||||
.upload(`launch-week/lw14/og/${ticketType}/${username}.png`, generatedTicketImage.body!, {
|
||||
contentType: 'image/png',
|
||||
// cacheControl: `${60 * 60 * 24 * 7}`,
|
||||
cacheControl: `0`,
|
||||
// Update cached og image, people might need to update info
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (storageError) throw new Error(`storageError: ${storageError.message}`)
|
||||
|
||||
const NEW_TIMESTAMP = new Date()
|
||||
|
||||
return await fetch(`${STORAGE_URL}/og/${ticketType}/${username}.png?t=${NEW_TIMESTAMP}`)
|
||||
} catch (error: any) {
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import Image from 'next/image'
|
||||
import { ActionButton } from '~/components/LaunchWeek/14/ActionButton'
|
||||
import {
|
||||
TicketHeader,
|
||||
TicketHeaderClaim,
|
||||
TicketHeaderDate,
|
||||
TicketHeaderRemainingTime,
|
||||
} from '~/components/LaunchWeek/14/Header'
|
||||
import TicketCanvas from '~/components/LaunchWeek/14/TicketCanvas'
|
||||
import {
|
||||
TicketClaim,
|
||||
TicketClaimButtons,
|
||||
TicketClaimContent,
|
||||
TicketClaimMessage,
|
||||
} from '~/components/LaunchWeek/14/TicketClaim'
|
||||
import TicketCopy from '~/components/LaunchWeek/14/TicketCopy'
|
||||
import { TicketLayout, TicketLayoutCanvas } from '~/components/LaunchWeek/14/TicketLayout'
|
||||
import TicketShare from '~/components/LaunchWeek/14/TicketShare'
|
||||
import { Tunnel } from '~/components/LaunchWeek/14/Tunnel'
|
||||
import { TicketShareLayout } from '~/components/LaunchWeek/14/TicketShareLayout'
|
||||
import { useRegistration } from '~/components/LaunchWeek/14/hooks/use-registration'
|
||||
import useConfData from './hooks/use-conf-data'
|
||||
import { cn } from 'ui'
|
||||
|
||||
const dates = [new Date('2025-03-31T07:00:00.000-08:00')]
|
||||
|
||||
export const LwView = () => {
|
||||
const [state] = useConfData()
|
||||
const register = useRegistration()
|
||||
|
||||
return (
|
||||
<TicketLayout>
|
||||
<TicketHeader hidden={true}>
|
||||
<TicketHeaderRemainingTime targetDate={dates[0]} />
|
||||
<TicketHeaderClaim />
|
||||
<TicketHeaderDate />
|
||||
</TicketHeader>
|
||||
<TicketLayoutCanvas narrow={true}>
|
||||
<TicketCanvas narrow={true} onUpgradeToSecret={register.upgradeTicket} />
|
||||
{state.claimFormState === 'visible' && (
|
||||
<TicketClaim>
|
||||
<div className="flex flex-col md:flex-row gap-12 lg:gap-2 grow w-full min-w-full max-w-full pt-16 md:pt-32 md:px-6 lg:px-0 lg:py-0 items-center">
|
||||
<div className='flex flex-col gap-2 w-full grow justify-center font-["Departure_Mono"]'>
|
||||
<h1 className="text-4xl uppercase tracking-wide pointer-events-none">
|
||||
<span className="flex gap-1 items-center">
|
||||
<Image
|
||||
src="/images/launchweek/14/logo-pixel-small-dark.png"
|
||||
width="18"
|
||||
height="20"
|
||||
className="w-auto h-5 invert dark:invert-0"
|
||||
alt=""
|
||||
/>
|
||||
Supabase
|
||||
</span>
|
||||
LaunchWeek 14
|
||||
<span className="block mt-2 text-foreground-lighter">MAR 31 — APR 04</span>
|
||||
</h1>
|
||||
<span className="block mt-2 text-foreground-lighter">7 AM PT</span>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 z-10 w-full grow justify-start lg:justify-end">
|
||||
<TicketClaimContent>
|
||||
<TicketClaimButtons>
|
||||
<ActionButton variant="primary" icon="T" onClick={() => register.signIn()}>
|
||||
CLAIM YOUR TICKET
|
||||
</ActionButton>
|
||||
</TicketClaimButtons>
|
||||
<TicketClaimMessage />
|
||||
</TicketClaimContent>
|
||||
</div>
|
||||
</div>
|
||||
</TicketClaim>
|
||||
)}
|
||||
{state.ticketVisibility && (
|
||||
<>
|
||||
<TicketShareLayout narrow>
|
||||
<TicketCopy />
|
||||
<TicketShare />
|
||||
</TicketShareLayout>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'w-full absolute bottom-[50%] md:bottom-[25%] lg:-bottom-8 xl:-bottom-16 left-0 right-0 -z-20 overflow-hidden flex justify-center',
|
||||
{ ['hidden']: state.claimFormState === 'visible' }
|
||||
)}
|
||||
>
|
||||
<Tunnel />
|
||||
</div>
|
||||
</TicketLayoutCanvas>
|
||||
</TicketLayout>
|
||||
)
|
||||
}
|
||||
@@ -150,14 +150,6 @@ const LWXStickyNav: FC = () => {
|
||||
<ArrowUpRight className="w-4 h-5 text-foreground-muted hidden sm:inline-block" />
|
||||
</Link>
|
||||
</li>
|
||||
<li className="hidden sm:block">
|
||||
<Link
|
||||
href="#ticket"
|
||||
className="p-1 transition-colors hover:text-foreground pointer-events-auto"
|
||||
>
|
||||
Ticket
|
||||
</Link>
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { cn } from 'ui'
|
||||
import { useThreeJS } from './helpers'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import SceneRenderer from './utils/SceneRenderer'
|
||||
import TicketScene from './scenes/TicketScene'
|
||||
import useConfData from './hooks/use-conf-data'
|
||||
|
||||
interface TicketCanvasProps {
|
||||
className?: string
|
||||
onUpgradeToSecret?: () => void
|
||||
narrow: boolean
|
||||
}
|
||||
|
||||
const TicketCanvas = ({ className, onUpgradeToSecret, narrow }: TicketCanvasProps) => {
|
||||
const sceneRef = useRef<TicketScene | null>(null)
|
||||
const initQueue = useRef<{ init: Promise<void>; renderer: SceneRenderer }[]>([])
|
||||
const onUpgradeToSecretRef = useRef(onUpgradeToSecret)
|
||||
const [state, dispatch] = useConfData()
|
||||
const userData = state.userTicketData
|
||||
const initialSceneDataRef = useRef({
|
||||
visible: state.ticketVisibility,
|
||||
secret: state.userTicketData.secret,
|
||||
platinum: state.userTicketData.platinum,
|
||||
narrow: narrow,
|
||||
user: {
|
||||
id: state.userTicketData.id,
|
||||
name: state.userTicketData.name ?? state.userTicketData.username,
|
||||
ticketNumber: state.userTicketData.ticket_number,
|
||||
},
|
||||
})
|
||||
|
||||
const setup = useCallback(
|
||||
(container: HTMLElement) => {
|
||||
const uuid = Math.random().toString(36).substring(7)
|
||||
|
||||
const sceneRenderer = new SceneRenderer(container, initQueue.current, uuid)
|
||||
|
||||
const initPromise = sceneRenderer.init(async () => {
|
||||
dispatch({ type: 'TICKET_LOADING_START' })
|
||||
const scene = new TicketScene({
|
||||
defaultVisible: initialSceneDataRef.current.visible,
|
||||
defaultSecret: initialSceneDataRef.current.secret,
|
||||
defaultPlatinum: initialSceneDataRef.current.platinum,
|
||||
narrow: initialSceneDataRef.current.narrow,
|
||||
user: initialSceneDataRef.current.user,
|
||||
onSeatChartButtonClicked: () => {
|
||||
scene.showBackSide()
|
||||
scene.upgradeToSecret()
|
||||
onUpgradeToSecretRef.current?.()
|
||||
},
|
||||
onGoBackButtonClicked: () => {
|
||||
scene.showFrontSide()
|
||||
},
|
||||
})
|
||||
|
||||
await sceneRenderer.activateScene(scene, true)
|
||||
|
||||
sceneRef.current = scene
|
||||
|
||||
dispatch({ type: 'TICKET_LOADING_SUCCESS' })
|
||||
})
|
||||
|
||||
initQueue.current.push({ init: initPromise, renderer: sceneRenderer })
|
||||
|
||||
return sceneRenderer
|
||||
},
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
async function updateTicket() {
|
||||
if (sceneRef.current) {
|
||||
sceneRef.current.setVisible(state.ticketVisibility)
|
||||
sceneRef.current.setTicketNumber(state.userTicketData.ticket_number ?? 0)
|
||||
sceneRef.current.setUserName(
|
||||
state.userTicketData.name ?? state.userTicketData.username ?? ''
|
||||
)
|
||||
|
||||
if (state.userTicketData.secret) await sceneRef.current.upgradeToSecret()
|
||||
if (state.userTicketData.platinum) await sceneRef.current.upgradeToPlatinum()
|
||||
|
||||
sceneRef.current.reloadTextures()
|
||||
}
|
||||
}
|
||||
|
||||
if (sceneRef.current) {
|
||||
void updateTicket()
|
||||
}
|
||||
}, [
|
||||
narrow,
|
||||
state.ticketVisibility,
|
||||
state.userTicketData.name,
|
||||
state.userTicketData.platinum,
|
||||
state.userTicketData.secret,
|
||||
state.userTicketData.ticket_number,
|
||||
state.userTicketData.username,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
onUpgradeToSecretRef.current = onUpgradeToSecret
|
||||
}, [onUpgradeToSecret])
|
||||
|
||||
const { containerRef } = useThreeJS(setup)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute sm:inset-0 flex justify-center items-center overflow-hidden w-full h-full z-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full"
|
||||
onClick={(e) => {
|
||||
sceneRef.current?.click(e.nativeEvent)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketCanvas
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import useConfData from './hooks/use-conf-data'
|
||||
|
||||
export const TicketClaim = ({ children, narrow }: { children: ReactNode; narrow?: boolean }) => {
|
||||
return <div className={'flex px-6'}>{children}</div>
|
||||
}
|
||||
|
||||
export const TicketClaimContent = ({ children }: { children?: ReactNode }) => {
|
||||
return <div className="grid gap-6 content-center">{children}</div>
|
||||
}
|
||||
|
||||
export const TicketClaimMessage = () => {
|
||||
const [state] = useConfData()
|
||||
return (
|
||||
<div className="grid justify-center gap-3 md:px-16">
|
||||
<div className='md:text-center md:justify-center text-foreground-lighter md:text-sm font-["Departure_Mono"] leading-normal [text-shadow:_0px_0px_4px_rgb(255_255_255_/_0.25)] text-sm text-balance max-w-[310px] md:max-w-[400px] uppercase'>
|
||||
Share your ticket for a chance to win swag
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TicketClaimButtons = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
<div className="inline-flex md:justify-center md:items-center gap-2.5 flex-wrap">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { LW14_URL } from '~/lib/constants'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import useConfData from './hooks/use-conf-data'
|
||||
import { cn } from 'ui'
|
||||
|
||||
export default function TicketCopy({ className }: { className?: string }) {
|
||||
const [state] = useConfData()
|
||||
const userData = state.userTicketData
|
||||
const { username } = userData
|
||||
const [copied, setCopied] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const displayUrl = `.../launch-week/tickets/${username}`
|
||||
const link = `${LW14_URL}/tickets/${username}`
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
name="Copy"
|
||||
ref={buttonRef}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
'font-mono w-full px-2 lg:px-3.5 !pr-1 py-1 rounded-md bg-alternative-200 border flex gap-2 relative text-foreground-light hover:text-foreground text-xs pointer-events-auto justify-between items-center hover:border-stronger transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{displayUrl}</span>
|
||||
<div className="w-6 min-w-6 h-6 flex items-center justify-center flex-shrink-0 border border-strong rounded bg-muted hover:bg-selection hover:border-stronger">
|
||||
{copied ? <Check size={14} strokeWidth={3} /> : <Copy size={14} strokeWidth={1.5} />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn'
|
||||
|
||||
export const TicketLayout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<SectionContainerWithCn id="ticket" className="lw-nav-anchor font-mono z-20 relative px-0">
|
||||
{children}
|
||||
</SectionContainerWithCn>
|
||||
)
|
||||
}
|
||||
|
||||
export const TicketLayoutCanvas = ({ children, narrow }: { children: ReactNode; narrow: true }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn('relative w-full h-[650px] lg:h-auto lg:aspect-[1.5841584158]', {
|
||||
['h-[530px] lg:aspect-[2.3] xl:aspect-[2.9473684211]']: narrow,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { useBreakpoint } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { Check } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import useConfData from './hooks/use-conf-data'
|
||||
import {
|
||||
LW14_TWEET_TEXT,
|
||||
LW14_TWEET_TEXT_PLATINUM,
|
||||
LW14_TWEET_TEXT_SECRET,
|
||||
LW14_URL,
|
||||
} from '~/lib/constants'
|
||||
import supabase from './supabase'
|
||||
|
||||
export default function TicketShare() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [state] = useConfData()
|
||||
const userData = state.userTicketData
|
||||
const { platinum, username, metadata, secret: hasSecretTicket } = userData
|
||||
const [_imgReady, setImgReady] = useState(false)
|
||||
const [_loading, setLoading] = useState(false)
|
||||
const isLessThanMd = useBreakpoint()
|
||||
const downloadLink = useRef<HTMLAnchorElement>()
|
||||
const link = `${LW14_URL}/tickets/${username}?t=${dayjs(new Date()).format('DHHmmss')}`
|
||||
const permalink = encodeURIComponent(link)
|
||||
const text = hasSecretTicket
|
||||
? LW14_TWEET_TEXT_SECRET
|
||||
: platinum
|
||||
? LW14_TWEET_TEXT_PLATINUM
|
||||
: LW14_TWEET_TEXT
|
||||
const encodedText = encodeURIComponent(text)
|
||||
const tweetUrl = `https://twitter.com/intent/tweet?url=${permalink}&text=${encodedText}`
|
||||
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${permalink}&text=${encodedText}`
|
||||
const downloadUrl = `/api-v2/ticket-og?username=${encodeURIComponent(username ?? '')}`
|
||||
const TICKETS_TABLE = 'tickets'
|
||||
|
||||
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 handleShare = async (social: 'twitter' | 'linkedin') => {
|
||||
setTimeout(async () => {
|
||||
if (social === 'twitter') {
|
||||
await supabase
|
||||
.from(TICKETS_TABLE)
|
||||
.update({
|
||||
shared_on_twitter: 'now',
|
||||
metadata: { ...metadata, theme: resolvedTheme, hasSharedSecret: hasSecretTicket },
|
||||
})
|
||||
.eq('launch_week', 'lw14')
|
||||
.eq('username', username)
|
||||
} else if (social === 'linkedin') {
|
||||
await supabase
|
||||
.from(TICKETS_TABLE)
|
||||
.update({
|
||||
shared_on_linkedin: 'now',
|
||||
metadata: { ...metadata, theme: resolvedTheme, hasSharedSecret: hasSecretTicket },
|
||||
})
|
||||
.eq('launch_week', 'lw14')
|
||||
.eq('username', username)
|
||||
}
|
||||
|
||||
if (userData.shared_on_linkedin && userData.shared_on_twitter) {
|
||||
await fetch(`/api-v2/ticket-og?username=${username}&platinum=true`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-row flex-wrap justify-stretch w-full gap-2 pointer-events-auto font-["Departure_Mono"]'>
|
||||
<Button
|
||||
onClick={() => handleShare('twitter')}
|
||||
type={userData.shared_on_twitter ? 'secondary' : 'default'}
|
||||
icon={userData.shared_on_twitter && <Check strokeWidth={2} />}
|
||||
size={isLessThanMd ? 'tiny' : 'small'}
|
||||
className="px-2 lg:px-3.5 h-[28px] lg:h-[34px] uppercase flex-1 w-full"
|
||||
asChild
|
||||
>
|
||||
<Link href={tweetUrl} target="_blank">
|
||||
{userData.shared_on_twitter ? 'Shared on Twitter' : 'Share on Twitter'}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleShare('linkedin')}
|
||||
type={userData.shared_on_linkedin ? 'secondary' : 'default'}
|
||||
icon={userData.shared_on_linkedin && <Check strokeWidth={2} />}
|
||||
size={isLessThanMd ? 'tiny' : 'small'}
|
||||
className="px-2 lg:px-3.5 h-[28px] lg:h-[34px] uppercase flex-1 w-full"
|
||||
asChild
|
||||
>
|
||||
<Link href={linkedInUrl} target="_blank">
|
||||
{userData.shared_on_linkedin ? 'Shared on Linkedin' : 'Share on Linkedin'}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import useLw14ConfData from './hooks/use-conf-data'
|
||||
|
||||
export const TicketShareLayout = ({
|
||||
children,
|
||||
narrow,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
narrow?: boolean
|
||||
}) => {
|
||||
const [{ userTicketData }] = useLw14ConfData()
|
||||
|
||||
const userName = userTicketData.name ?? userTicketData.username
|
||||
let userWelcomeText = userName ? <span>, {userName}!</span> : '!'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute lg:relative lg:flex lg:flex-col lg:max-w-[450px] xl:max-w-[500px] lg:h-full left-0 right-0 grid justify-center gap-2 md:px-5 lg:px-0 px-6',
|
||||
narrow
|
||||
? 'top-[280px] md:top-[360px] lg:top-0 lg:left-0 lg:right-auto lg:justify-center'
|
||||
: 'top-[250px] xs:top-[290px] md:top-auto md:bottom-10 xl:bottom-16 2xl:bottom-20'
|
||||
)}
|
||||
>
|
||||
{narrow && (
|
||||
<>
|
||||
{/* <Image
|
||||
src={logo}
|
||||
alt="LW logo"
|
||||
className="size-12 hidden lg:block"
|
||||
width="48"
|
||||
height="48"
|
||||
/> */}
|
||||
<div className='uppercase text-3xl lg:text-4xl py-4 text-left font-["Departure_Mono"]'>
|
||||
Good to see you{userWelcomeText}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs pb-2 font-["Departure_Mono"] uppercase text-foreground-lighter',
|
||||
narrow ? 'text-left' : 'text-center'
|
||||
)}
|
||||
>
|
||||
Share your ticket for a chance to win swag
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
export const CRTShader = {
|
||||
uniforms: {
|
||||
tDiffuse: { value: null },
|
||||
time: { value: 0 },
|
||||
scanlineIntensity: { value: 0.6 },
|
||||
scanlineCount: { value: 360 },
|
||||
vignetteIntensity: { value: 0.5 },
|
||||
noiseIntensity: { value: 0.01 },
|
||||
flickerIntensity: { value: 0.01 },
|
||||
rgbShiftAmount: { value: 0.0 },
|
||||
// Add intensity control
|
||||
intensity: { value: 1.0 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D tDiffuse;
|
||||
uniform float time;
|
||||
uniform float scanlineIntensity;
|
||||
uniform float scanlineCount;
|
||||
uniform float vignetteIntensity;
|
||||
uniform float noiseIntensity;
|
||||
uniform float flickerIntensity;
|
||||
uniform float rgbShiftAmount;
|
||||
uniform float intensity;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
// Random function
|
||||
// https://stackoverflow.com/a/10625698
|
||||
float random(vec2 st) {
|
||||
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Get original color
|
||||
vec4 originalColor = texture2D(tDiffuse, vUv);
|
||||
|
||||
// Skip effects if intensity is zero
|
||||
if (intensity <= 0.0) {
|
||||
gl_FragColor = originalColor;
|
||||
return;
|
||||
}
|
||||
|
||||
// Scale effect parameters by intensity
|
||||
float activeRGBShift = rgbShiftAmount * intensity;
|
||||
float activeScanlineIntensity = scanlineIntensity * intensity;
|
||||
float activeVignetteIntensity = vignetteIntensity * intensity;
|
||||
float activeNoiseIntensity = noiseIntensity * intensity;
|
||||
float activeFlickerIntensity = flickerIntensity * intensity;
|
||||
|
||||
// RGB shift effect - more subtle to preserve detail
|
||||
vec2 shiftR = vec2(activeRGBShift, 0.0);
|
||||
vec2 shiftG = vec2(0.0, activeRGBShift);
|
||||
|
||||
float r = texture2D(tDiffuse, vUv + shiftR).r;
|
||||
float g = texture2D(tDiffuse, vUv + shiftG).g;
|
||||
float b = texture2D(tDiffuse, vUv).b;
|
||||
|
||||
vec4 shiftedColor = vec4(r, g, b, originalColor.a);
|
||||
|
||||
// Scanline effect - thicker and more pronounced
|
||||
float scanlineFreq = scanlineCount * 0.7; // Reduce frequency for thicker lines
|
||||
float scanline = sin(vUv.y * scanlineFreq * 3.14159) * 0.5 + 0.5;
|
||||
scanline = pow(scanline, 0.7) * activeScanlineIntensity * 1.5; // Increase intensity and make lines thicker with lower power
|
||||
|
||||
// Apply scanline as a multiplicative overlay with stronger effect
|
||||
vec4 scanlineColor = mix(shiftedColor, shiftedColor * (1.0 - scanline), activeScanlineIntensity * 1.2);
|
||||
|
||||
// Vignette effect
|
||||
vec2 center = vec2(0.5, 0.5);
|
||||
float dist = distance(vUv, center);
|
||||
float vignette = smoothstep(0.3, 0.85, dist) * activeVignetteIntensity;
|
||||
|
||||
// Apply vignette as the final overlay effect
|
||||
vec4 finalColor = scanlineColor * (1.0 - vignette * 0.7);
|
||||
|
||||
// Blend between original and effect based on intensity
|
||||
vec4 final = mix(originalColor, finalColor, intensity);
|
||||
final.a = originalColor.a;
|
||||
gl_FragColor = final;
|
||||
}
|
||||
`,
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
DataTexture,
|
||||
FloatType,
|
||||
MathUtils,
|
||||
RedFormat,
|
||||
ShaderMaterial,
|
||||
UniformsUtils,
|
||||
WebGLRenderer,
|
||||
WebGLRenderTarget,
|
||||
} from 'three'
|
||||
import { Pass, FullScreenQuad } from 'three/examples/jsm/postprocessing/Pass'
|
||||
import { DigitalGlitch } from 'three/examples/jsm/shaders/DigitalGlitch'
|
||||
|
||||
class GlitchPass extends Pass {
|
||||
uniforms: typeof DigitalGlitch.uniforms
|
||||
heightMap: DataTexture
|
||||
material: ShaderMaterial
|
||||
fsQuad: FullScreenQuad
|
||||
goWild: boolean
|
||||
curF: number
|
||||
randX: number
|
||||
intensity: number
|
||||
|
||||
constructor(dt_size = 64) {
|
||||
super()
|
||||
|
||||
const shader = DigitalGlitch
|
||||
|
||||
this.uniforms = UniformsUtils.clone(shader.uniforms)
|
||||
|
||||
this.heightMap = this.generateHeightmap(dt_size)
|
||||
|
||||
this.uniforms['tDisp'].value = this.heightMap
|
||||
|
||||
this.material = new ShaderMaterial({
|
||||
uniforms: this.uniforms,
|
||||
vertexShader: shader.vertexShader,
|
||||
fragmentShader: shader.fragmentShader,
|
||||
})
|
||||
|
||||
this.fsQuad = new FullScreenQuad(this.material)
|
||||
|
||||
this.goWild = false
|
||||
this.curF = 0
|
||||
this.randX = 0
|
||||
this.generateTrigger()
|
||||
this.intensity = 0
|
||||
}
|
||||
|
||||
render(renderer: WebGLRenderer, writeBuffer: WebGLRenderTarget, readBuffer: WebGLRenderTarget) {
|
||||
this.uniforms['tDiffuse'].value = readBuffer.texture
|
||||
this.uniforms['seed'].value = Math.random() //default seeding
|
||||
this.uniforms['byp'].value = 0
|
||||
|
||||
if (this.curF % this.randX < this.randX / 10) {
|
||||
this.uniforms['amount'].value = 0 * this.intensity
|
||||
this.uniforms['angle'].value = Math.PI
|
||||
this.uniforms['distortion_x'].value = MathUtils.randFloat(0, 0.05) * this.intensity
|
||||
this.uniforms['distortion_y'].value = MathUtils.randFloat(0, 0.05) * this.intensity
|
||||
this.uniforms['seed_x'].value = MathUtils.randFloat(-0.05, 0.05) * this.intensity
|
||||
this.uniforms['seed_y'].value = MathUtils.randFloat(-0.05, 0.05) * this.intensity
|
||||
this.curF = 0
|
||||
}
|
||||
|
||||
this.curF++
|
||||
|
||||
if (this.renderToScreen) {
|
||||
renderer.setRenderTarget(null)
|
||||
this.fsQuad.render(renderer)
|
||||
} else {
|
||||
renderer.setRenderTarget(writeBuffer)
|
||||
if (this.clear) renderer.clear()
|
||||
this.fsQuad.render(renderer)
|
||||
}
|
||||
}
|
||||
|
||||
generateTrigger() {
|
||||
this.randX = MathUtils.randInt(200, 240)
|
||||
}
|
||||
|
||||
generateHeightmap(dt_size: number) {
|
||||
const data_arr = new Float32Array(dt_size * dt_size)
|
||||
const length = dt_size * dt_size
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const val = MathUtils.randFloat(0, 1)
|
||||
data_arr[i] = val
|
||||
}
|
||||
|
||||
const texture = new DataTexture(data_arr, dt_size, dt_size, RedFormat, FloatType)
|
||||
texture.needsUpdate = true
|
||||
return texture
|
||||
}
|
||||
|
||||
setIntensity(amount: number) {
|
||||
this.intensity = amount < 0.01 ? 0 : amount / 4
|
||||
}
|
||||
|
||||
enable(value: boolean) {
|
||||
this.uniforms['byp'].value = value ? 0 : 1
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.material.dispose()
|
||||
|
||||
this.heightMap.dispose()
|
||||
|
||||
this.fsQuad.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export { GlitchPass }
|
||||
@@ -1,238 +0,0 @@
|
||||
export const shared = `
|
||||
vec3 mod289(vec3 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec4 mod289(vec4 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec4 permute(vec4 x)
|
||||
{
|
||||
return mod289(((x*34.0)+1.0)*x);
|
||||
}
|
||||
|
||||
vec4 taylorInvSqrt(vec4 r)
|
||||
{
|
||||
return 1.79284291400159 - 0.85373472095314 * r;
|
||||
}
|
||||
|
||||
`
|
||||
|
||||
export const simplexNoise3d = `
|
||||
//
|
||||
// Description : Array and textureless GLSL 2D/3D/4D simplex
|
||||
// noise functions.
|
||||
// Author : Ian McEwan, Ashima Arts.
|
||||
// Maintainer : ijm
|
||||
// Lastmod : 20110822 (ijm)
|
||||
// License : Copyright (C) 2011 Ashima Arts. All rights reserved.
|
||||
// Distributed under the MIT License. See LICENSE file.
|
||||
// https://github.com/ashima/webgl-noise
|
||||
//
|
||||
|
||||
float snoise3D(vec3 v)
|
||||
{
|
||||
const vec2 C = vec2(1.0/6.0, 1.0/3.0) ;
|
||||
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
|
||||
|
||||
// First corner
|
||||
vec3 i = floor(v + dot(v, C.yyy) );
|
||||
vec3 x0 = v - i + dot(i, C.xxx) ;
|
||||
|
||||
// Other corners
|
||||
vec3 g = step(x0.yzx, x0.xyz);
|
||||
vec3 l = 1.0 - g;
|
||||
vec3 i1 = min( g.xyz, l.zxy );
|
||||
vec3 i2 = max( g.xyz, l.zxy );
|
||||
|
||||
// x0 = x0 - 0.0 + 0.0 * C.xxx;
|
||||
// x1 = x0 - i1 + 1.0 * C.xxx;
|
||||
// x2 = x0 - i2 + 2.0 * C.xxx;
|
||||
// x3 = x0 - 1.0 + 3.0 * C.xxx;
|
||||
vec3 x1 = x0 - i1 + C.xxx;
|
||||
vec3 x2 = x0 - i2 + C.yyy; // 2.0*C.x = 1/3 = C.y
|
||||
vec3 x3 = x0 - D.yyy; // -1.0+3.0*C.x = -0.5 = -D.y
|
||||
|
||||
// Permutations
|
||||
i = mod289(i);
|
||||
vec4 p = permute( permute( permute(
|
||||
i.z + vec4(0.0, i1.z, i2.z, 1.0 ))
|
||||
+ i.y + vec4(0.0, i1.y, i2.y, 1.0 ))
|
||||
+ i.x + vec4(0.0, i1.x, i2.x, 1.0 ));
|
||||
|
||||
// Gradients: 7x7 points over a square, mapped onto an octahedron.
|
||||
// The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294)
|
||||
float n_ = 0.142857142857; // 1.0/7.0
|
||||
vec3 ns = n_ * D.wyz - D.xzx;
|
||||
|
||||
vec4 j = p - 49.0 * floor(p * ns.z * ns.z); // mod(p,7*7)
|
||||
|
||||
vec4 x_ = floor(j * ns.z);
|
||||
vec4 y_ = floor(j - 7.0 * x_ ); // mod(j,N)
|
||||
|
||||
vec4 x = x_ *ns.x + ns.yyyy;
|
||||
vec4 y = y_ *ns.x + ns.yyyy;
|
||||
vec4 h = 1.0 - abs(x) - abs(y);
|
||||
|
||||
vec4 b0 = vec4( x.xy, y.xy );
|
||||
vec4 b1 = vec4( x.zw, y.zw );
|
||||
|
||||
//vec4 s0 = vec4(lessThan(b0,0.0))*2.0 - 1.0;
|
||||
//vec4 s1 = vec4(lessThan(b1,0.0))*2.0 - 1.0;
|
||||
vec4 s0 = floor(b0)*2.0 + 1.0;
|
||||
vec4 s1 = floor(b1)*2.0 + 1.0;
|
||||
vec4 sh = -step(h, vec4(0.0));
|
||||
|
||||
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;
|
||||
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;
|
||||
|
||||
vec3 p0 = vec3(a0.xy,h.x);
|
||||
vec3 p1 = vec3(a0.zw,h.y);
|
||||
vec3 p2 = vec3(a1.xy,h.z);
|
||||
vec3 p3 = vec3(a1.zw,h.w);
|
||||
|
||||
//Normalise gradients
|
||||
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
|
||||
p0 *= norm.x;
|
||||
p1 *= norm.y;
|
||||
p2 *= norm.z;
|
||||
p3 *= norm.w;
|
||||
|
||||
// Mix final noise value
|
||||
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
|
||||
m = m * m;
|
||||
return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1),
|
||||
dot(p2,x2), dot(p3,x3) ) );
|
||||
}
|
||||
|
||||
#pragma glslify: export(snoise)
|
||||
`
|
||||
export const periodicNoise3d = `
|
||||
//
|
||||
// GLSL textureless classic 3D noise "cnoise",
|
||||
// with an RSL-style periodic variant "pnoise".
|
||||
// Author: Stefan Gustavson (stefan.gustavson@liu.se)
|
||||
// Version: 2011-10-11
|
||||
//
|
||||
// Many thanks to Ian McEwan of Ashima Arts for the
|
||||
// ideas for permutation and gradient selection.
|
||||
//
|
||||
// Copyright (c) 2011 Stefan Gustavson. All rights reserved.
|
||||
// Distributed under the MIT license. See LICENSE file.
|
||||
// https://github.com/ashima/webgl-noise
|
||||
//
|
||||
|
||||
|
||||
vec3 fade(vec3 t) {
|
||||
return t*t*t*(t*(t*6.0-15.0)+10.0);
|
||||
}
|
||||
|
||||
// Classic Perlin noise, periodic variant
|
||||
float pnoise3D(vec3 P, vec3 rep)
|
||||
{
|
||||
vec3 Pi0 = mod(floor(P), rep); // Integer part, modulo period
|
||||
vec3 Pi1 = mod(Pi0 + vec3(1.0), rep); // Integer part + 1, mod period
|
||||
Pi0 = mod289(Pi0);
|
||||
Pi1 = mod289(Pi1);
|
||||
vec3 Pf0 = fract(P); // Fractional part for interpolation
|
||||
vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
|
||||
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
|
||||
vec4 iy = vec4(Pi0.yy, Pi1.yy);
|
||||
vec4 iz0 = Pi0.zzzz;
|
||||
vec4 iz1 = Pi1.zzzz;
|
||||
|
||||
vec4 ixy = permute(permute(ix) + iy);
|
||||
vec4 ixy0 = permute(ixy + iz0);
|
||||
vec4 ixy1 = permute(ixy + iz1);
|
||||
|
||||
vec4 gx0 = ixy0 * (1.0 / 7.0);
|
||||
vec4 gy0 = fract(floor(gx0) * (1.0 / 7.0)) - 0.5;
|
||||
gx0 = fract(gx0);
|
||||
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
|
||||
vec4 sz0 = step(gz0, vec4(0.0));
|
||||
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
|
||||
gy0 -= sz0 * (step(0.0, gy0) - 0.5);
|
||||
|
||||
vec4 gx1 = ixy1 * (1.0 / 7.0);
|
||||
vec4 gy1 = fract(floor(gx1) * (1.0 / 7.0)) - 0.5;
|
||||
gx1 = fract(gx1);
|
||||
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
|
||||
vec4 sz1 = step(gz1, vec4(0.0));
|
||||
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
|
||||
gy1 -= sz1 * (step(0.0, gy1) - 0.5);
|
||||
|
||||
vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
|
||||
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
|
||||
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
|
||||
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
|
||||
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
|
||||
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
|
||||
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
|
||||
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);
|
||||
|
||||
vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
|
||||
g000 *= norm0.x;
|
||||
g010 *= norm0.y;
|
||||
g100 *= norm0.z;
|
||||
g110 *= norm0.w;
|
||||
vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
|
||||
g001 *= norm1.x;
|
||||
g011 *= norm1.y;
|
||||
g101 *= norm1.z;
|
||||
g111 *= norm1.w;
|
||||
|
||||
float n000 = dot(g000, Pf0);
|
||||
float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
|
||||
float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
|
||||
float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
|
||||
float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
|
||||
float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
|
||||
float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
|
||||
float n111 = dot(g111, Pf1);
|
||||
|
||||
vec3 fade_xyz = fade(Pf0);
|
||||
vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
|
||||
vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
|
||||
float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
|
||||
return 2.2 * n_xyz;
|
||||
}
|
||||
|
||||
#pragma glslify: export(pnoise)
|
||||
`
|
||||
|
||||
export const blendSoftLight = `
|
||||
// The MIT License (MIT) Copyright (c) 2015 Matt DesLauriers
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
vec3 blendSoftLight(vec3 base, vec3 blend) {
|
||||
return mix(
|
||||
sqrt(base) * (2.0 * blend - 1.0) + 2.0 * base * (1.0 - blend),
|
||||
2.0 * base * blend + base * base * (1.0 - 2.0 * blend),
|
||||
step(base, vec3(0.5))
|
||||
);
|
||||
}
|
||||
`
|
||||
|
||||
export const luma = `
|
||||
// This software is released under the MIT license:
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
float luma(vec3 color) {
|
||||
return dot(color, vec3(0.299, 0.587, 0.114));
|
||||
}
|
||||
|
||||
float luma(vec4 color) {
|
||||
return dot(color.rgb, vec3(0.299, 0.587, 0.114));
|
||||
}
|
||||
`
|
||||
@@ -1,132 +0,0 @@
|
||||
import { blendSoftLight, luma, periodicNoise3d, shared, simplexNoise3d } from './helpers'
|
||||
|
||||
export const HUDShader = {
|
||||
vertexShader: `
|
||||
precision mediump float;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D tDiffuse;
|
||||
uniform vec2 resolution;
|
||||
uniform float vignetteRadius;
|
||||
uniform float vignetteSmoothness;
|
||||
uniform float time;
|
||||
|
||||
varying vec2 vUv;
|
||||
varying vec2 screenPosition;
|
||||
|
||||
${shared}
|
||||
${periodicNoise3d}
|
||||
${simplexNoise3d}
|
||||
${blendSoftLight}
|
||||
${luma}
|
||||
|
||||
// grain function
|
||||
// The MIT License (MIT) Copyright (c) 2015 Matt DesLauriers
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
float grain(vec2 texCoord, vec2 resolution, float frame, float multiplier) {
|
||||
vec2 mult = texCoord * resolution;
|
||||
float offset = snoise3D(vec3(mult / multiplier, frame));
|
||||
float n1 = pnoise3D(vec3(mult, offset), vec3(1.0/texCoord * resolution, 1.0));
|
||||
return n1 / 2.0 + 0.5;
|
||||
}
|
||||
|
||||
float grain(vec2 texCoord, vec2 resolution, float frame) {
|
||||
return grain(texCoord, resolution, frame, 2.5);
|
||||
}
|
||||
|
||||
float grain(vec2 texCoord, vec2 resolution) {
|
||||
return grain(texCoord, resolution, 0.0);
|
||||
}
|
||||
|
||||
// Vignette function
|
||||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2017 Tyler Lindberg
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
float vignette(vec2 uv, float radius, float smoothness) {
|
||||
float diff = radius - distance(uv, vec2(0.5, 0.5));
|
||||
return smoothstep(-smoothness, smoothness, diff);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Sample the diffuse texture to get base color with alpha
|
||||
|
||||
vec4 blackBase = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
|
||||
float v = vignette(vUv, vignetteRadius, vignetteSmoothness);
|
||||
float invertedVignette = 1.0 - v;
|
||||
|
||||
float minAlpha = 0.3;
|
||||
blackBase.a *= mix(minAlpha, 1.0, invertedVignette);
|
||||
|
||||
vec4 textureColor = texture2D(tDiffuse, vUv);
|
||||
|
||||
vec4 stackedColor = vec4(
|
||||
mix(blackBase.rgb, textureColor.rgb, textureColor.a),
|
||||
mix(blackBase.a, max(blackBase.a, textureColor.a), 0.8)
|
||||
);
|
||||
|
||||
float grainSize = 3.0 + 4.0 * sin(time);
|
||||
vec3 g = vec3(grain(vUv, resolution / grainSize, time));
|
||||
|
||||
vec3 noiseColor = blendSoftLight(vec3(0.2,0.2,0.2), g);
|
||||
|
||||
float grainStrength = 0.02;
|
||||
stackedColor = vec4(
|
||||
mix(stackedColor.rgb, noiseColor, grainStrength),
|
||||
stackedColor.a
|
||||
);
|
||||
|
||||
gl_FragColor = stackedColor;
|
||||
}
|
||||
`,
|
||||
uniforms: {
|
||||
tDiffuse: { value: null as any },
|
||||
time: { value: 0.0 },
|
||||
resolution: { value: [70.0, 100.0] }, // Adjusted for Lygia noise scale
|
||||
vignetteSmoothness: { value: 0 },
|
||||
vignetteRadius: { value: 1 },
|
||||
},
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
/**
|
||||
* ISC License
|
||||
*
|
||||
* Copyright 2021 mbalex99
|
||||
*
|
||||
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted,
|
||||
* provided that the above copyright notice and this permission notice appear in all copies.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE
|
||||
* INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
|
||||
* FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
||||
* ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
import {
|
||||
AdditiveBlending,
|
||||
Color,
|
||||
LinearFilter,
|
||||
MeshBasicMaterial,
|
||||
RGBAFormat,
|
||||
ShaderMaterial,
|
||||
Texture,
|
||||
UniformsUtils,
|
||||
Vector2,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
WebGLRenderTarget,
|
||||
} from 'three'
|
||||
|
||||
import { Pass } from 'three/examples/jsm/postprocessing/Pass'
|
||||
|
||||
// typescript definitions doesn't have FullScreenQuad
|
||||
//@ts-ignore
|
||||
import { FullScreenQuad } from 'three/examples/jsm/postprocessing/Pass'
|
||||
|
||||
import { CopyShader } from 'three/examples/jsm/shaders/CopyShader.js'
|
||||
import { LuminosityHighPassShader } from 'three/examples/jsm/shaders/LuminosityHighPassShader.js'
|
||||
|
||||
/**
|
||||
* Thanks to https://github.com/mrdoob/three.js/issues/14104#issuecomment-429664412 for this fragmentShaderfix
|
||||
*
|
||||
* UnrealBloomPass is inspired by the bloom pass of Unreal Engine. It creates a
|
||||
* mip map chain of bloom textures and blurs them with different radii. Because
|
||||
* of the weighted combination of mips, and because larger blurs are done on
|
||||
* higher mips, this effect provides good quality and performance.
|
||||
*
|
||||
* Reference:
|
||||
* - https://docs.unrealengine.com/latest/INT/Engine/Rendering/PostProcessEffects/Bloom/
|
||||
*/
|
||||
class TransparentBloomPass extends Pass {
|
||||
strength: number
|
||||
radius: number
|
||||
threshold: number
|
||||
resolution: Vector2
|
||||
clearColor: Color
|
||||
renderTargetsHorizontal: any[]
|
||||
renderTargetsVertical: any[]
|
||||
nMips: number
|
||||
renderTargetBright: WebGLRenderTarget
|
||||
highPassUniforms: any
|
||||
materialHighPassFilter: ShaderMaterial
|
||||
separableBlurMaterials: any[]
|
||||
compositeMaterial: ShaderMaterial
|
||||
bloomTintColors: Vector3[]
|
||||
copyUniforms: any
|
||||
materialCopy: ShaderMaterial
|
||||
_oldClearColor: Color
|
||||
oldClearAlpha: number
|
||||
basic: MeshBasicMaterial
|
||||
fsQuad: FullScreenQuad
|
||||
static BlurDirectionX: any
|
||||
static BlurDirectionY: any
|
||||
constructor(resolution: Vector2, strength: number, radius: number, threshold: number) {
|
||||
super()
|
||||
|
||||
this.strength = strength !== undefined ? strength : 1
|
||||
this.radius = radius
|
||||
this.threshold = threshold
|
||||
this.resolution =
|
||||
resolution !== undefined ? new Vector2(resolution.x, resolution.y) : new Vector2(256, 256)
|
||||
|
||||
// create color only once here, reuse it later inside the render function
|
||||
this.clearColor = new Color(0, 0, 0)
|
||||
|
||||
// render targets
|
||||
const pars = {
|
||||
minFilter: LinearFilter,
|
||||
magFilter: LinearFilter,
|
||||
format: RGBAFormat,
|
||||
}
|
||||
this.renderTargetsHorizontal = []
|
||||
this.renderTargetsVertical = []
|
||||
this.nMips = 5
|
||||
let resx = Math.round(this.resolution.x / 2)
|
||||
let resy = Math.round(this.resolution.y / 2)
|
||||
|
||||
this.renderTargetBright = new WebGLRenderTarget(resx, resy, pars)
|
||||
this.renderTargetBright.texture.name = 'UnrealBloomPass.bright'
|
||||
this.renderTargetBright.texture.generateMipmaps = false
|
||||
|
||||
for (let i = 0; i < this.nMips; i++) {
|
||||
const renderTargetHorizonal = new WebGLRenderTarget(resx, resy, pars)
|
||||
|
||||
renderTargetHorizonal.texture.name = 'UnrealBloomPass.h' + i
|
||||
renderTargetHorizonal.texture.generateMipmaps = false
|
||||
|
||||
this.renderTargetsHorizontal.push(renderTargetHorizonal)
|
||||
|
||||
const renderTargetVertical = new WebGLRenderTarget(resx, resy, pars)
|
||||
|
||||
renderTargetVertical.texture.name = 'UnrealBloomPass.v' + i
|
||||
renderTargetVertical.texture.generateMipmaps = false
|
||||
|
||||
this.renderTargetsVertical.push(renderTargetVertical)
|
||||
|
||||
resx = Math.round(resx / 2)
|
||||
|
||||
resy = Math.round(resy / 2)
|
||||
}
|
||||
|
||||
// luminosity high pass material
|
||||
|
||||
if (LuminosityHighPassShader === undefined)
|
||||
console.error('THREE.UnrealBloomPass relies on LuminosityHighPassShader')
|
||||
|
||||
const highPassShader = LuminosityHighPassShader
|
||||
this.highPassUniforms = UniformsUtils.clone(highPassShader.uniforms)
|
||||
|
||||
this.highPassUniforms['luminosityThreshold'].value = threshold
|
||||
this.highPassUniforms['smoothWidth'].value = 0.01
|
||||
|
||||
this.materialHighPassFilter = new ShaderMaterial({
|
||||
uniforms: this.highPassUniforms,
|
||||
vertexShader: highPassShader.vertexShader,
|
||||
fragmentShader: highPassShader.fragmentShader,
|
||||
defines: {},
|
||||
})
|
||||
|
||||
// Gaussian Blur Materials
|
||||
this.separableBlurMaterials = []
|
||||
const kernelSizeArray = [3, 5, 7, 9, 11]
|
||||
resx = Math.round(this.resolution.x / 2)
|
||||
resy = Math.round(this.resolution.y / 2)
|
||||
|
||||
for (let i = 0; i < this.nMips; i++) {
|
||||
this.separableBlurMaterials.push(this.getSeperableBlurMaterial(kernelSizeArray[i]))
|
||||
|
||||
this.separableBlurMaterials[i].uniforms['texSize'].value = new Vector2(resx, resy)
|
||||
|
||||
resx = Math.round(resx / 2)
|
||||
|
||||
resy = Math.round(resy / 2)
|
||||
}
|
||||
|
||||
// Composite material
|
||||
this.compositeMaterial = this.getCompositeMaterial(this.nMips)
|
||||
this.compositeMaterial.uniforms['blurTexture1'].value = this.renderTargetsVertical[0].texture
|
||||
this.compositeMaterial.uniforms['blurTexture2'].value = this.renderTargetsVertical[1].texture
|
||||
this.compositeMaterial.uniforms['blurTexture3'].value = this.renderTargetsVertical[2].texture
|
||||
this.compositeMaterial.uniforms['blurTexture4'].value = this.renderTargetsVertical[3].texture
|
||||
this.compositeMaterial.uniforms['blurTexture5'].value = this.renderTargetsVertical[4].texture
|
||||
this.compositeMaterial.uniforms['bloomStrength'].value = strength
|
||||
this.compositeMaterial.uniforms['bloomRadius'].value = 0.1
|
||||
this.compositeMaterial.needsUpdate = true
|
||||
|
||||
const bloomFactors = [1.0, 0.8, 0.6, 0.4, 0.2]
|
||||
this.compositeMaterial.uniforms['bloomFactors'].value = bloomFactors
|
||||
this.bloomTintColors = [
|
||||
new Vector3(1, 1, 1),
|
||||
new Vector3(1, 1, 1),
|
||||
new Vector3(1, 1, 1),
|
||||
new Vector3(1, 1, 1),
|
||||
new Vector3(1, 1, 1),
|
||||
]
|
||||
this.compositeMaterial.uniforms['bloomTintColors'].value = this.bloomTintColors
|
||||
|
||||
// copy material
|
||||
if (CopyShader === undefined) {
|
||||
console.error('THREE.UnrealBloomPass relies on CopyShader')
|
||||
}
|
||||
|
||||
const copyShader = CopyShader
|
||||
|
||||
this.copyUniforms = UniformsUtils.clone(copyShader.uniforms)
|
||||
this.copyUniforms['opacity'].value = 1.0
|
||||
|
||||
this.materialCopy = new ShaderMaterial({
|
||||
uniforms: this.copyUniforms,
|
||||
vertexShader: copyShader.vertexShader,
|
||||
fragmentShader: copyShader.fragmentShader,
|
||||
blending: AdditiveBlending,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
transparent: true,
|
||||
})
|
||||
|
||||
this.enabled = true
|
||||
this.needsSwap = false
|
||||
|
||||
this._oldClearColor = new Color()
|
||||
this.oldClearAlpha = 1
|
||||
|
||||
this.basic = new MeshBasicMaterial()
|
||||
|
||||
this.fsQuad = new FullScreenQuad(undefined)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (let i = 0; i < this.renderTargetsHorizontal.length; i++) {
|
||||
this.renderTargetsHorizontal[i].dispose()
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.renderTargetsVertical.length; i++) {
|
||||
this.renderTargetsVertical[i].dispose()
|
||||
}
|
||||
|
||||
this.renderTargetBright.dispose()
|
||||
}
|
||||
|
||||
setSize(width: number, height: number) {
|
||||
let resx = Math.round(width / 2)
|
||||
let resy = Math.round(height / 2)
|
||||
|
||||
this.renderTargetBright.setSize(resx, resy)
|
||||
|
||||
for (let i = 0; i < this.nMips; i++) {
|
||||
this.renderTargetsHorizontal[i].setSize(resx, resy)
|
||||
this.renderTargetsVertical[i].setSize(resx, resy)
|
||||
|
||||
this.separableBlurMaterials[i].uniforms['texSize'].value = new Vector2(resx, resy)
|
||||
|
||||
resx = Math.round(resx / 2)
|
||||
resy = Math.round(resy / 2)
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
renderer: WebGLRenderer,
|
||||
writeBuffer: any,
|
||||
readBuffer: { texture: Texture },
|
||||
deltaTime: any,
|
||||
maskActive: any
|
||||
) {
|
||||
renderer.getClearColor(this._oldClearColor)
|
||||
this.oldClearAlpha = renderer.getClearAlpha()
|
||||
const oldAutoClear = renderer.autoClear
|
||||
renderer.autoClear = false
|
||||
|
||||
renderer.setClearColor(this.clearColor, 0)
|
||||
|
||||
if (maskActive) renderer.state.buffers.stencil.setTest(false)
|
||||
|
||||
// Render input to screen
|
||||
|
||||
if (this.renderToScreen) {
|
||||
this.fsQuad.material = this.basic
|
||||
this.basic.map = readBuffer.texture
|
||||
|
||||
renderer.setRenderTarget(null)
|
||||
renderer.clear()
|
||||
this.fsQuad.render(renderer)
|
||||
}
|
||||
|
||||
// 1. Extract Bright Areas
|
||||
|
||||
this.highPassUniforms['tDiffuse'].value = readBuffer.texture
|
||||
this.highPassUniforms['luminosityThreshold'].value = this.threshold
|
||||
this.fsQuad.material = this.materialHighPassFilter
|
||||
|
||||
renderer.setRenderTarget(this.renderTargetBright)
|
||||
renderer.clear()
|
||||
this.fsQuad.render(renderer)
|
||||
|
||||
// 2. Blur All the mips progressively
|
||||
|
||||
let inputRenderTarget = this.renderTargetBright
|
||||
|
||||
for (let i = 0; i < this.nMips; i++) {
|
||||
this.fsQuad.material = this.separableBlurMaterials[i]
|
||||
|
||||
this.separableBlurMaterials[i].uniforms['colorTexture'].value = inputRenderTarget.texture
|
||||
this.separableBlurMaterials[i].uniforms['direction'].value =
|
||||
TransparentBloomPass.BlurDirectionX
|
||||
renderer.setRenderTarget(this.renderTargetsHorizontal[i])
|
||||
renderer.clear()
|
||||
this.fsQuad.render(renderer)
|
||||
|
||||
this.separableBlurMaterials[i].uniforms['colorTexture'].value =
|
||||
this.renderTargetsHorizontal[i].texture
|
||||
this.separableBlurMaterials[i].uniforms['direction'].value =
|
||||
TransparentBloomPass.BlurDirectionY
|
||||
renderer.setRenderTarget(this.renderTargetsVertical[i])
|
||||
renderer.clear()
|
||||
this.fsQuad.render(renderer)
|
||||
|
||||
inputRenderTarget = this.renderTargetsVertical[i]
|
||||
}
|
||||
|
||||
// Composite All the mips
|
||||
|
||||
this.fsQuad.material = this.compositeMaterial
|
||||
this.compositeMaterial.uniforms['bloomStrength'].value = this.strength
|
||||
this.compositeMaterial.uniforms['bloomRadius'].value = this.radius
|
||||
this.compositeMaterial.uniforms['bloomTintColors'].value = this.bloomTintColors
|
||||
|
||||
renderer.setRenderTarget(this.renderTargetsHorizontal[0])
|
||||
renderer.clear()
|
||||
this.fsQuad.render(renderer)
|
||||
|
||||
// Blend it additively over the input texture
|
||||
|
||||
this.fsQuad.material = this.materialCopy
|
||||
this.copyUniforms['tDiffuse'].value = this.renderTargetsHorizontal[0].texture
|
||||
|
||||
if (maskActive) renderer.state.buffers.stencil.setTest(true)
|
||||
|
||||
if (this.renderToScreen) {
|
||||
renderer.setRenderTarget(null)
|
||||
this.fsQuad.render(renderer)
|
||||
} else {
|
||||
renderer.setRenderTarget(readBuffer as any)
|
||||
this.fsQuad.render(renderer)
|
||||
}
|
||||
|
||||
// Restore renderer settings
|
||||
|
||||
renderer.setClearColor(this._oldClearColor, this.oldClearAlpha)
|
||||
renderer.autoClear = oldAutoClear
|
||||
}
|
||||
|
||||
getSeperableBlurMaterial(kernelRadius: number) {
|
||||
return new ShaderMaterial({
|
||||
defines: {
|
||||
KERNEL_RADIUS: kernelRadius,
|
||||
SIGMA: kernelRadius,
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
colorTexture: { value: null },
|
||||
texSize: { value: new Vector2(0.5, 0.5) },
|
||||
direction: { value: new Vector2(0.5, 0.5) },
|
||||
},
|
||||
|
||||
vertexShader: `varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
}`,
|
||||
|
||||
fragmentShader: `#include <common>
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D colorTexture;
|
||||
uniform vec2 texSize;
|
||||
uniform vec2 direction;
|
||||
|
||||
float gaussianPdf(in float x, in float sigma) {
|
||||
return 0.39894 * exp( -0.5 * x * x/( sigma * sigma))/sigma;
|
||||
}
|
||||
void main() {\n\
|
||||
vec2 invSize = 1.0 / texSize;\
|
||||
float fSigma = float(SIGMA);\
|
||||
float weightSum = gaussianPdf(0.0, fSigma);\
|
||||
float alphaSum = 0.0;\
|
||||
vec3 diffuseSum = texture2D( colorTexture, vUv).rgb * weightSum;\
|
||||
for( int i = 1; i < KERNEL_RADIUS; i ++ ) {\
|
||||
float x = float(i);\
|
||||
float w = gaussianPdf(x, fSigma);\
|
||||
vec2 uvOffset = direction * invSize * x;\
|
||||
vec4 sample1 = texture2D( colorTexture, vUv + uvOffset);\
|
||||
vec4 sample2 = texture2D( colorTexture, vUv - uvOffset);\
|
||||
diffuseSum += (sample1.rgb + sample2.rgb) * w;\
|
||||
alphaSum += (sample1.a + sample2.a) * w;\
|
||||
weightSum += 2.0 * w;\
|
||||
}\
|
||||
gl_FragColor = vec4(diffuseSum/weightSum, alphaSum/weightSum);\n\
|
||||
}`,
|
||||
})
|
||||
}
|
||||
|
||||
getCompositeMaterial(nMips: number) {
|
||||
return new ShaderMaterial({
|
||||
defines: {
|
||||
NUM_MIPS: nMips,
|
||||
},
|
||||
|
||||
uniforms: {
|
||||
blurTexture1: { value: null },
|
||||
blurTexture2: { value: null },
|
||||
blurTexture3: { value: null },
|
||||
blurTexture4: { value: null },
|
||||
blurTexture5: { value: null },
|
||||
dirtTexture: { value: null },
|
||||
bloomStrength: { value: 1.0 },
|
||||
bloomFactors: { value: null },
|
||||
bloomTintColors: { value: null },
|
||||
bloomRadius: { value: 0.0 },
|
||||
},
|
||||
|
||||
vertexShader: `varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
}`,
|
||||
|
||||
fragmentShader: `varying vec2 vUv;
|
||||
uniform sampler2D blurTexture1;
|
||||
uniform sampler2D blurTexture2;
|
||||
uniform sampler2D blurTexture3;
|
||||
uniform sampler2D blurTexture4;
|
||||
uniform sampler2D blurTexture5;
|
||||
uniform sampler2D dirtTexture;
|
||||
uniform float bloomStrength;
|
||||
uniform float bloomRadius;
|
||||
uniform float bloomFactors[NUM_MIPS];
|
||||
uniform vec3 bloomTintColors[NUM_MIPS];
|
||||
|
||||
float lerpBloomFactor(const in float factor) {
|
||||
float mirrorFactor = 1.2 - factor;
|
||||
return mix(factor, mirrorFactor, bloomRadius);
|
||||
}
|
||||
|
||||
void main() {
|
||||
gl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) +
|
||||
lerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) +
|
||||
lerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) +
|
||||
lerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) +
|
||||
lerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) );
|
||||
}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
TransparentBloomPass.BlurDirectionX = new Vector2(1.0, 0.0)
|
||||
TransparentBloomPass.BlurDirectionY = new Vector2(0.0, 1.0)
|
||||
|
||||
export { TransparentBloomPass as UnrealBloomPass, TransparentBloomPass }
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
|
||||
/**
|
||||
* Helper to simplifies controlling requestAnimationFrame
|
||||
* @param callback Animation callback function
|
||||
* @returns Object with start and stop functions
|
||||
*/
|
||||
export const createThreeAnimation = (callback: (time?: number) => void) => {
|
||||
const requestRef = { current: undefined } as { current: number | undefined }
|
||||
const previousTimeRef = { current: undefined } as { current: number | undefined }
|
||||
|
||||
const animate = (time: number) => {
|
||||
if (previousTimeRef.current !== undefined) {
|
||||
callback(time)
|
||||
}
|
||||
previousTimeRef.current = time
|
||||
requestRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
requestRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (requestRef.current) {
|
||||
cancelAnimationFrame(requestRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
return { start, stop }
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to load a GLTF model
|
||||
* @param url URL of the GLTF model to load
|
||||
* @returns Promise that resolves with the loaded model
|
||||
*/
|
||||
export const loadGLTFModel = (url: string) => {
|
||||
return new Promise<GLTF>((resolve, reject) => {
|
||||
const loader = new GLTFLoader()
|
||||
loader.load(
|
||||
url,
|
||||
(gltf) => {
|
||||
resolve(gltf)
|
||||
},
|
||||
undefined,
|
||||
(error) => reject(error)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for Three.js setup and animation
|
||||
* @param setupCallback Callback function for setting up the scene
|
||||
* @returns Object with containerRef
|
||||
*/
|
||||
export const useThreeJS = (
|
||||
setupCallback: (container: HTMLElement) => {
|
||||
cleanup: () => void
|
||||
animate: (time?: number) => void
|
||||
}
|
||||
) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
const renderer = setupCallback(container)
|
||||
|
||||
// Set up animation loop
|
||||
const { start, stop } = createThreeAnimation(renderer.animate.bind(renderer))
|
||||
start()
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
stop()
|
||||
renderer.cleanup()
|
||||
}
|
||||
}, [setupCallback])
|
||||
|
||||
return { containerRef }
|
||||
}
|
||||
|
||||
export function colorObjToRgb(color: { rgb: number; alpha: number }) {
|
||||
return `rgb(${(color.rgb >> 16) & 255} ${(color.rgb >> 8) & 255} ${(color.rgb >> 0) & 255} / ${color.alpha})`
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,295 +0,0 @@
|
||||
import {
|
||||
ColorManagement,
|
||||
HalfFloatType,
|
||||
PerspectiveCamera,
|
||||
Scene,
|
||||
SRGBColorSpace,
|
||||
WebGLRenderer,
|
||||
WebGLRenderTarget,
|
||||
} from 'three'
|
||||
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
|
||||
|
||||
export interface MousePositionState {
|
||||
clientX: number
|
||||
clientY: number
|
||||
isWithinContainer: boolean
|
||||
containerX: number
|
||||
containerY: number
|
||||
mouseIntensity: number
|
||||
}
|
||||
|
||||
export interface BaseScene {
|
||||
getId(): string
|
||||
/**
|
||||
* Initialize the scene with the given context
|
||||
*/
|
||||
setup(context: SceneRenderer): Promise<Scene | void>
|
||||
|
||||
/**
|
||||
* Update the scene for each animation frame
|
||||
*/
|
||||
update(context: SceneRenderer, dt?: number): void
|
||||
|
||||
/**
|
||||
* Clean up resources when the scene is destroyed
|
||||
*/
|
||||
cleanup(): void
|
||||
|
||||
resize(ev: UIEvent): void
|
||||
|
||||
/**
|
||||
* Handle device pixel ratio changes
|
||||
*/
|
||||
devicePixelRatioChanged?(newPixelRatio: number, oldPixelRatio: number): void
|
||||
}
|
||||
|
||||
class SceneRenderer {
|
||||
renderer: WebGLRenderer
|
||||
composer: EffectComposer
|
||||
camera: PerspectiveCamera
|
||||
mainThreeJsScene: Scene | null = null
|
||||
activeScenes: BaseScene[] = []
|
||||
cachedContainerBBox: DOMRect
|
||||
mousePositionState: MousePositionState
|
||||
mouseIntensityDecay = 0.98
|
||||
mouseIntensityGainRate = 0.003
|
||||
currentPixelRatio: number
|
||||
|
||||
private _resizeHandler: ((ev: UIEvent) => void) | null = null
|
||||
private _mouseMoveHandler: ((ev: MouseEvent) => void) | null = null
|
||||
private _mediaQueryListHandler: ((ev: MediaQueryListEvent) => void) | null = null
|
||||
private _isDisposed = false
|
||||
private _isInitialized = false
|
||||
|
||||
constructor(
|
||||
public container: HTMLElement,
|
||||
private waitFor?: { init: Promise<void>; renderer: SceneRenderer }[],
|
||||
private uuid?: string
|
||||
) {
|
||||
this.renderer = new WebGLRenderer({ antialias: true, alpha: true })
|
||||
this.composer = new EffectComposer(this.renderer)
|
||||
|
||||
this.camera = new PerspectiveCamera(
|
||||
75,
|
||||
this.container.clientWidth / this.container.clientHeight,
|
||||
0.1,
|
||||
1000
|
||||
)
|
||||
|
||||
this.mousePositionState = {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
isWithinContainer: false,
|
||||
containerX: 0,
|
||||
containerY: 0,
|
||||
mouseIntensity: 0,
|
||||
}
|
||||
|
||||
this.cachedContainerBBox = this.container.getBoundingClientRect()
|
||||
this.currentPixelRatio = window.devicePixelRatio
|
||||
}
|
||||
|
||||
async init(sceneInitializer: () => Promise<void>) {
|
||||
// console.log('SCENE RENDERER: Init call', this.waitFor?.length, this.uuid)
|
||||
await Promise.allSettled(
|
||||
this.waitFor?.filter((t) => t.renderer !== this).map((t) => t.init) || []
|
||||
)
|
||||
|
||||
if (this._isDisposed) {
|
||||
// console.log('SCENE RENDERER: Already disposed before sceneInitializer', this.uuid)
|
||||
return
|
||||
}
|
||||
|
||||
// console.log('SCENE RENDERER: Waited for all pending inits', this.waitFor?.length, this.uuid)
|
||||
|
||||
await sceneInitializer()
|
||||
|
||||
if (this._isDisposed) {
|
||||
// console.log('SCENE RENDERER: Already disposed after sceneInitializer', this.uuid)
|
||||
return
|
||||
}
|
||||
|
||||
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio)
|
||||
this.renderer.setClearColor(0x000000, 0) // Set transparent background
|
||||
this.renderer.autoClear = false // Prevent automatic clearing
|
||||
this.renderer.outputColorSpace = SRGBColorSpace
|
||||
ColorManagement.enabled = true
|
||||
|
||||
// Initialize composer with correct size and pixel ratio
|
||||
this.composer.setSize(this.container.clientWidth, this.container.clientHeight)
|
||||
this.composer.setPixelRatio(window.devicePixelRatio)
|
||||
|
||||
this.container.appendChild(this.renderer.domElement)
|
||||
|
||||
this._resizeHandler = this._resize.bind(this)
|
||||
window.addEventListener('resize', this._resizeHandler)
|
||||
|
||||
this._mouseMoveHandler = this._updateMousePosition.bind(this)
|
||||
window.addEventListener('mousemove', this._mouseMoveHandler)
|
||||
|
||||
// Disable it since it brakes Chrome browser
|
||||
// // Add device pixel ratio change listener
|
||||
// this._mediaQueryListHandler = this._handleDevicePixelRatioChange.bind(this)
|
||||
// window
|
||||
// .matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
|
||||
// .addEventListener('change', this._mediaQueryListHandler)
|
||||
}
|
||||
|
||||
async activateScene(scene: BaseScene, main?: boolean) {
|
||||
if (this._isDisposed) {
|
||||
// console.log('SCENE RENDERER: Already disposed before activateScene', this.uuid)
|
||||
return
|
||||
}
|
||||
|
||||
const threeScene = await scene.setup(this)
|
||||
|
||||
if (this._isDisposed) {
|
||||
// console.log('SCENE RENDERER: Already disposed after activateScene', this.uuid)
|
||||
return
|
||||
}
|
||||
|
||||
if (main && threeScene) {
|
||||
this.mainThreeJsScene = threeScene
|
||||
}
|
||||
|
||||
const newId = scene.getId()
|
||||
const existingSceneId = this.activeScenes.findIndex((s) => s.getId() === newId)
|
||||
if (existingSceneId > -1) {
|
||||
const sceneToRemove = this.activeScenes[existingSceneId]
|
||||
this.activeScenes[existingSceneId] = scene
|
||||
sceneToRemove.cleanup()
|
||||
} else {
|
||||
this.activeScenes.push(scene)
|
||||
}
|
||||
}
|
||||
|
||||
animate(time?: number) {
|
||||
// Clear the renderer before rendering
|
||||
this.renderer.clear()
|
||||
|
||||
this._decayMouseIntensity()
|
||||
|
||||
for (const activeScene of this.activeScenes) {
|
||||
activeScene.update(this, time)
|
||||
}
|
||||
this.composer.render(time)
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// console.log('SCENE RENDERER: Cleanup', this.uuid)
|
||||
|
||||
this._isDisposed = true
|
||||
this.composer.dispose()
|
||||
this.renderer.dispose()
|
||||
|
||||
try {
|
||||
this.container.removeChild(this.renderer.domElement)
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
|
||||
if (this._resizeHandler) {
|
||||
window.removeEventListener('resize', this._resizeHandler)
|
||||
this._resizeHandler = null
|
||||
}
|
||||
|
||||
if (this._mouseMoveHandler) {
|
||||
window.removeEventListener('mousemove', this._mouseMoveHandler)
|
||||
this._mouseMoveHandler = null
|
||||
}
|
||||
|
||||
if (this._mediaQueryListHandler) {
|
||||
window
|
||||
.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
|
||||
.removeEventListener('change', this._mediaQueryListHandler)
|
||||
this._mediaQueryListHandler = null
|
||||
}
|
||||
|
||||
if (this.waitFor) {
|
||||
// Mutate array in place instead of replacing reference
|
||||
this.waitFor.splice(
|
||||
0,
|
||||
this.waitFor.length,
|
||||
...this.waitFor.filter((t) => t.renderer === this)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private _resize(ev: UIEvent) {
|
||||
this.camera.aspect = this.container.clientWidth / this.container.clientHeight
|
||||
this.camera.updateProjectionMatrix()
|
||||
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
|
||||
this.composer.setSize(this.container.clientWidth, this.container.clientHeight)
|
||||
this.cachedContainerBBox = this.container.getBoundingClientRect()
|
||||
|
||||
for (const activeScene of this.activeScenes) {
|
||||
activeScene.resize(ev)
|
||||
}
|
||||
}
|
||||
|
||||
private _handleDevicePixelRatioChange(ev: MediaQueryListEvent) {
|
||||
const oldPixelRatio = this.currentPixelRatio
|
||||
const newPixelRatio = window.devicePixelRatio
|
||||
|
||||
// Update renderer and composer pixel ratio
|
||||
this.renderer.setPixelRatio(newPixelRatio)
|
||||
this.composer.setPixelRatio(newPixelRatio)
|
||||
|
||||
// Store the new pixel ratio
|
||||
this.currentPixelRatio = newPixelRatio
|
||||
|
||||
// Notify all active scenes about the pixel ratio change
|
||||
for (const activeScene of this.activeScenes) {
|
||||
if (typeof activeScene.devicePixelRatioChanged === 'function') {
|
||||
activeScene.devicePixelRatioChanged(newPixelRatio, oldPixelRatio)
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(
|
||||
// `SCENE RENDERER: Device pixel ratio changed from ${oldPixelRatio} to ${newPixelRatio}`
|
||||
// )
|
||||
}
|
||||
|
||||
private _decayMouseIntensity() {
|
||||
if (this.mousePositionState.mouseIntensity > 0) {
|
||||
this.mousePositionState.mouseIntensity *= this.mouseIntensityDecay
|
||||
if (this.mousePositionState.mouseIntensity < 0.0000001) {
|
||||
this.mousePositionState.mouseIntensity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _updateMousePosition(ev: MouseEvent) {
|
||||
this.cachedContainerBBox = this.container.getBoundingClientRect()
|
||||
const dx = ev.clientX - this.mousePositionState.clientX
|
||||
const dy = ev.clientY - this.mousePositionState.clientY
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
// Update intensity based on movement (clamped between 0 and 1)
|
||||
this.mousePositionState.mouseIntensity = Math.min(
|
||||
1.0,
|
||||
Math.max(0, this.mousePositionState.mouseIntensity + distance * this.mouseIntensityGainRate)
|
||||
)
|
||||
|
||||
// Update last position
|
||||
this.mousePositionState.clientX = ev.clientX
|
||||
this.mousePositionState.clientY = ev.clientY
|
||||
|
||||
const rect = this.cachedContainerBBox
|
||||
if (!rect) {
|
||||
return
|
||||
}
|
||||
const isWithinContainer =
|
||||
ev.clientX >= rect.left &&
|
||||
ev.clientX <= rect.right &&
|
||||
ev.clientY >= rect.top &&
|
||||
ev.clientY <= rect.bottom
|
||||
|
||||
this.mousePositionState.isWithinContainer = isWithinContainer
|
||||
this.mousePositionState.containerX = ((ev.clientX - rect.left) / rect.width) * 2 - 1
|
||||
this.mousePositionState.containerY = -((ev.clientY - rect.top) / rect.height) * 2 + 1
|
||||
}
|
||||
}
|
||||
|
||||
export default SceneRenderer
|
||||
@@ -1,14 +0,0 @@
|
||||
export const themes = () => ({
|
||||
regular: {
|
||||
TICKET_FOREGROUND: '#1F1F1F',
|
||||
},
|
||||
platinum: {
|
||||
TICKET_FOREGROUND: '#1F1F1F',
|
||||
},
|
||||
secret: {
|
||||
TICKET_FOREGROUND: '#1F1F1F',
|
||||
},
|
||||
platinumSecret: {
|
||||
TICKET_FOREGROUND: '#1F1F1F',
|
||||
},
|
||||
})
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user