Merge branch 'master' into feat/homepage-integrations-section

This commit is contained in:
Francesco Sansalvadore authored and GitHub committed 2024-06-19 11:38:04 +02:00
commit 2f9a1dec80
7571 files changed
+542142 -358518

No files matched your search

+3 -4
View File
@@ -3,18 +3,17 @@
.git
.github
about
apps
docker
examples
i18n
**/node_modules
spec
apps/docs/spec
supabase
tests
**/.next
.vercel
.env.*
**/.env.*
.dockerignore
studio/Dockerfile
apps/studio/Dockerfile
+2 -1
View File
@@ -1,6 +1,7 @@
/packages/shared-data/pricing.ts @roryw10 @kevcodez
/packages/shared-data/plans.ts @roryw10 @kevcodez
/studio/ @supabase/Dashboard
/apps/studio/ @supabase/Dashboard
/apps/www/ @supabase/Website
/apps/www/_blog
/docker/ @supabase/cli
+19
View File
@@ -0,0 +1,19 @@
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file.
YES/NO
## What kind of change does this PR introduce?
Bug fix, feature, docs update, ...
## What is the current behavior?
Please link any relevant issues here.
## What is the new behavior?
Feel free to include screenshots if it includes visual changes.
## Additional context
Add any other context or screenshots.
+46
View File
@@ -0,0 +1,46 @@
name: AI Unit Tests & Type Check
on:
push:
branches: [master]
paths:
- 'packages/ai-commands/**'
pull_request:
branches: [master]
paths:
- 'packages/ai-commands/**'
schedule:
- cron: '15 0 * * 1' # Every Monday @ 12:15am UTC (off the hour to avoid heavy load times)
# Cancel old builds on new commit for same workflow + branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
defaults:
run:
working-directory: ./packages/ai-commands
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
packages
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install deps
run: npm ci
- name: Type check
run: npm run typecheck
- name: Run tests
run: npm run test
+65
View File
@@ -0,0 +1,65 @@
name: Auto Label Issues
on:
issues:
types: [opened, reopened]
jobs:
check-external:
runs-on: ubuntu-latest
outputs:
result: ${{ steps.is-member.outputs.result }}
steps:
- name: Get deps
run: |
sudo apt-get update && sudo apt-get install -y jq
- name: Get org members
id: is-member
env:
GH_ORG: supabase
GH_USER: ${{ github.event.issue.user.login }}
GH_TOKEN: ${{ secrets.SB_ORG_MEMBERSHIP_PAT }}
run: |
gh api /orgs/supabase/members/$GH_USER
- if: failure()
name: is-member
run: |
echo "User is not a member of Supabase org"
echo "result=false" >> "$GITHUB_OUTPUT"
- if: success()
run: |
echo "User is a member of Supabase org"
echo "result=true" >> "$GITHUB_OUTPUT"
- name: Label contributor
id: label-contributor
if: ${{ steps.is-member.outputs.result == 'false' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
USERNAME: ${{ github.event.issue.user.login }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.issue.number }}
LABELS: external-issue
run: |
echo "applying external label"
gh issue edit $NUMBER --add-label $LABELS
- name: Label internal
id: label-internal
if: ${{ steps.is-member.outputs.result == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
USERNAME: ${{ github.event.issue.user.login }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.issue.number }}
LABELS: internal-issue
run: |
echo "applying internal label"
gh issue edit $NUMBER --add-label $LABELS
triage-new:
runs-on: ubuntu-latest
steps:
- name: Label triage
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NUMBER: ${{ github.event.issue.number }}
LABELS: to-triage
run: |
echo "applying triage label"
gh issue edit $NUMBER --add-label $LABELS
+58
View File
@@ -0,0 +1,58 @@
name: Autofix Linting Checks
on:
pull_request:
types:
- labeled
# Cancel old builds on new commit for same workflow + branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
autofix:
runs-on: ubuntu-latest
permissions:
contents: write
if: ${{ github.event_name == 'pull_request' && (github.event.label.name == 'autofix') }}
steps:
- name: Calculate number of commits
run: echo "PR_FETCH_DEPTH=$(( ${{ github.event.pull_request.commits }} + 1 ))" >> "${GITHUB_ENV}"
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
sparse-checkout: apps
fetch-depth: ${{ env.PR_FETCH_DEPTH }}
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
# Installing all dependencies takes up to three minutes, hacking around to only installing prettier+deps
- name: Install required dependencies
run: |
rm package.json
rm package-lock.json
npm i prettier@4.0.0-alpha.8 prettier-plugin-sql-cst
- name: Run Prettier in fix mode
run: |
npx prettier --write 'apps/**/*.{js,jsx,ts,tsx,css,md,mdx,json}'
- name: Commit changes and push to existing branch
run: |
git checkout -- package.json
git checkout -- package-lock.json
git config --global user.name 'github-tidy-bot'
git config --global user.email 'github-tidy-bot@supabase.com'
if [[ `git status --porcelain` ]]; then
echo "[bot] Changes detected, committing."
echo "[bot] Running in non-squash mode."
git commit -am "ci: Autofix updates from GitHub workflow"
git push
else
echo "[bot] No changes detected, nothing to commit."
fi
+63
View File
@@ -0,0 +1,63 @@
name: Config (Preview)
on:
pull_request:
types:
- opened
- reopened
- synchronize
branches:
- master
paths:
- "supabase/**"
workflow_dispatch:
jobs:
wait:
runs-on: ubuntu-latest
outputs:
status: ${{ steps.check.outputs.conclusion }}
steps:
- uses: fountainhead/action-wait-for-check@v1.2.0
id: check
with:
checkName: Supabase
ref: ${{ github.event.pull_request.head.sha }}
token: ${{ secrets.GITHUB_TOKEN }}
apply:
needs:
- wait
if: ${{ needs.wait.outputs.status == 'success' }}
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
TF_VAR_linked_project: xguihxuzqibwxjnimxev
TF_VAR_git_branch: ${{ github.head_ref }}
TF_CLI_ARGS_apply: -target=supabase_settings.preview
defaults:
run:
working-directory: supabase/remotes
outputs:
db_user: ${{ steps.branch.outputs.user }}
db_pass: ${{ steps.branch.outputs.password }}
db_host: ${{ steps.branch.outputs.host }}
db_port: ${{ steps.branch.outputs.port }}
jwt_secret: ${{ steps.branch.outputs.jwt_secret }}
ref: ${{ steps.branch.outputs.id }}
status: ${{ steps.branch.outputs.status }}
version: ${{ steps.branch.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- run: terraform init
- run: terraform apply -auto-approve -no-color
- id: branch
run: |
terraform output -json branch_database \
| jq -r "to_entries|map(\"\(.key)=\(.value|tostring)\")|.[]" \
>> "$GITHUB_OUTPUT"
+28
View File
@@ -0,0 +1,28 @@
name: Config (Production)
on:
push:
branches:
- master
paths:
- "supabase/remotes/**"
workflow_dispatch:
jobs:
apply:
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
TF_VAR_linked_project: xguihxuzqibwxjnimxev
defaults:
run:
working-directory: supabase/remotes
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_wrapper: false
- run: terraform init
- run: terraform apply -auto-approve -no-color
+45
View File
@@ -0,0 +1,45 @@
name: '[Docs] Update last-changed dates'
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
reset:
description: 'Reset last-updated dates using Git commit dates'
required: false
type: boolean
jobs:
deploy:
runs-on: ubuntu-latest
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SEARCH_SUPABASE_SERVICE_ROLE_KEY }}
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
fetch-depth: 0
sparse-checkout: |
apps/docs
- name: Setup node
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- name: Download dependencies
run: npm ci
- name: Update last-changed dates
working-directory: ./apps/docs
if: ${{ !inputs.reset }}
run: npm run last-changed
- name: Reset last-changed dates
working-directory: ./apps/docs
if: ${{ inputs.reset }}
run: npm run last-changed:reset
@@ -0,0 +1,41 @@
name: Update Mgmt Api Docs
on:
schedule:
# Run at 00:00 UTC every Monday
- cron: '0 0 * * 1'
workflow_dispatch:
jobs:
update-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: master
sparse-checkout: |
apps/docs
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install deps
run: npm ci
- name: Change to apps/docs/spec directory and run make command
working-directory: apps/docs/spec
run: make download.api.v1 dereference.api.v1 generate.sections.api.v1 format
- name: Create pull request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'feat: update mgmt api docs'
title: 'feat: update mgmt api docs'
body: 'This PR updates mgmt api docs automatically.'
branch: 'gha/auto-update-mgmt-api-docs'
base: 'master'
+3 -7
View File
@@ -15,10 +15,6 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- uses: actions/checkout@v4
with:
@@ -26,10 +22,10 @@ jobs:
apps/docs
packages
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install deps
+25
View File
@@ -0,0 +1,25 @@
name: reviewdog
on:
pull_request:
paths:
- '.vale.ini'
- 'vale/**'
- 'apps/docs/content/**'
jobs:
vale:
name: runner / vale
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
sparse-checkout: |
.vale.ini
vale
apps/docs/content
- uses: errata-ai/vale-action@reviewdog
with:
files: apps/docs/content
# [Charis] Temporary because we still have to go through giant backlog of existing errors
# Don't want people to worry about this (just want them looking at the specific PR comments)
# fail_on_error: true
+45
View File
@@ -0,0 +1,45 @@
name: Playwright Tests
on:
push:
branches: [master]
paths:
- 'apps/studio/**'
- 'package-lock.json'
pull_request:
branches: [master]
paths:
- 'apps/studio/**'
- 'package-lock.json'
# Cancel old builds on new commit for same workflow + branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- uses: supabase/setup-cli@v1
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run test:playwright
# mark the action as succeeded even if the tests failed. This is temporarily until we make the tests more stable.
# continue-on-error: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-tests/playwright-report/
retention-days: 7
+11 -15
View File
@@ -17,23 +17,18 @@ jobs:
- name: Check out repo
uses: actions/checkout@v4
with:
sparse-checkout: |
apps
studio
sparse-checkout: apps
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version-file: '.nvmrc'
# Installing all dependencies takes up to three minutes, hacking around to only installing prettier+deps
- name: Download dependencies
run: |
rm package.json
rm package-lock.json
npm i prettier@2 prettier-plugin-sql-cst
npm ci
- name: Run prettier
run: |-
npx prettier -c '{apps,studio}/**/*.{js,jsx,ts,tsx,css,md,mdx,json}'
npm run test:prettier
# i18n is not a node package, so we handle that one separately
format-i18n:
@@ -45,15 +40,15 @@ jobs:
sparse-checkout: |
i18n
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version-file: '.nvmrc'
# Installing all dependencies takes up to three minutes, hacking around to only installing prettier+deps
- name: Download dependencies
run: |
rm package.json
rm package-lock.json
npm i prettier@2 prettier-plugin-sql-cst
npm i prettier@4.0.0-alpha.8 prettier-plugin-sql-cst
- name: Run prettier
run: |-
npx prettier -c 'i18n/**/*.{js,jsx,ts,tsx,css,md,mdx,json}'
@@ -66,13 +61,14 @@ jobs:
with:
sparse-checkout: |
apps/docs/pages
apps/docs/content
# Installing all dependencies takes up to three minutes, hacking around to only installing prettier+deps
- name: Download dependencies
run: |
rm package.json
rm package-lock.json
npm i prettier@2 prettier-plugin-sql-cst
npm i prettier@4.0.0-alpha.8 prettier-plugin-sql-cst
- name: Run prettier
run: |-
# Check mdx files which contain sql code blocks
grep -lr '```sql' apps/docs/pages/**/*.mdx | xargs npx prettier -c
grep -lr '```sql' apps/docs/{pages,content}/**/*.mdx | xargs npx prettier -c
+8 -16
View File
@@ -1,17 +1,10 @@
name: Publish to Image Registry
on:
push:
branches:
- 'studio'
tags:
- '*'
# run this action every Monday at 04:00 UTC (Singapore noon)
schedule:
- cron: '0 4 * * 1'
workflow_dispatch:
inputs:
version:
description: 'Image tag'
required: true
type: string
jobs:
settings:
@@ -25,11 +18,10 @@ jobs:
images: |
supabase/studio
flavor: |
latest=false
latest=true
tags: |
type=ref,event=tag
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ github.event_name == 'push' && github.ref_type == 'branch' }}
type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ github.event_name == 'schedule' }}
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ github.event_name == 'workflow_dispatch' }}
release_x86:
needs: settings
@@ -61,7 +53,7 @@ jobs:
with:
push: true
context: '{{defaultContext}}'
file: studio/Dockerfile
file: apps/studio/Dockerfile
target: production
platforms: linux/${{ env.arch }}
tags: ${{ steps.meta.outputs.tags }}
@@ -104,7 +96,7 @@ jobs:
with:
push: true
context: .
file: studio/Dockerfile
file: apps/studio/Dockerfile
target: production
platforms: linux/${{ env.arch }}
tags: ${{ steps.meta.outputs.tags }}
+8 -4
View File
@@ -8,7 +8,6 @@ on:
- '.github/workflows/search.yml'
- 'supabase/migrations/**'
- 'apps/docs/**'
- 'spec/**'
workflow_dispatch:
inputs:
refresh:
@@ -23,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
env:
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
PROJECT_ID: ${{ secrets.SEARCH_SUPABASE_PROJECT_ID }}
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.SEARCH_SUPABASE_URL }}
@@ -39,12 +38,13 @@ jobs:
with:
sparse-checkout: |
apps/docs
apps/www/.env.local.example
supabase
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version-file: '.nvmrc'
- name: Download dependencies
run: npm ci
@@ -55,6 +55,10 @@ jobs:
- name: Run migrations
run: npx supabase db push
# 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 }}
+18
View File
@@ -0,0 +1,18 @@
name: Automatically label stale issues
on:
schedule:
- cron: '30 1 * * *'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Label Stale Issues
uses: actions/stale@v9.0.0
with:
days-before-issue-stale: 30
stale-issue-message: 'After 30 days of inactivity, this issue has been marked as stale. Commenting (or other activity) will remove the stale label.'
stale-issue-label: stale
any-of-labels: contributor
exempt-issue-labels: dnc, needs-analysis
exempt-all-assignees: true
+8 -15
View File
@@ -7,12 +7,12 @@ on:
push:
branches: [master, studio]
paths:
- 'studio/**'
- 'apps/studio/**'
- 'package-lock.json'
pull_request:
branches: [master, studio]
paths:
- 'studio/**'
- 'apps/studio/**'
- 'package-lock.json'
# Cancel old builds on new commit for same workflow + branch/PR
@@ -25,30 +25,23 @@ jobs:
# Uses larger hosted runner as it significantly decreases build times
runs-on: [larger-runner-4cpu]
strategy:
matrix:
node-version: [18.x]
cmd:
- npm run test:studio
- npm run build:studio
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
studio
apps/studio
packages
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install deps
run: npm ci
working-directory: ./
- name: Run ${{ matrix.cmd }}
- name: Run Tests
env:
# Default is 2 GB, increase to have less frequent OOM errors
NODE_OPTIONS: '--max_old_space_size=3072'
run: ${{ matrix.cmd }}
run: npm run test:studio
working-directory: ./
+5 -8
View File
@@ -12,20 +12,17 @@ concurrency:
jobs:
typecheck:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
# Uses larger hosted runner as it significantly decreases build times
runs-on: [larger-runner-4cpu]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install deps
+34
View File
@@ -0,0 +1,34 @@
name: UI Patterns Tests
on:
pull_request:
branches: [master]
paths:
- 'packages/ui-patterns/**'
# Cancel old builds on new commit for same workflow + branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
packages
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install deps
run: npm ci
- name: Run tests
run: npm run test:ui-patterns
+3 -7
View File
@@ -15,20 +15,16 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
packages
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install deps
+3 -4
View File
@@ -4,7 +4,7 @@ node_modules
out
.docz
tmp
.swp
*.swp
coverage
allure-results
@@ -82,8 +82,10 @@ typings/
.env
# excempt the apps/www .env
!apps/www/.env
.env.tmp
.env.test
.env.local
.env.staging
.env.production
# parcel-bundler cache (https://parceljs.org/)
@@ -125,9 +127,6 @@ apps/new-docs/*
# UI tokens
packages/ui/tokens/**/*.json
# UI Storybook build
packages/ui/storybook-static
# For self-hosted logs: https://github.com/supabase/supabase/blob/86e3ab20abfdb9c3e666334d3d2f8efeef9ccf2c/docker/docker-compose-logging.yml#L101
gcloud.json
+2 -1
View File
@@ -1 +1,2 @@
^./i18n
^./i18n
^./packages/api-types
+1 -1
View File
@@ -1 +1 @@
18
20
+5 -8
View File
@@ -4,6 +4,7 @@ node_modules
package-lock.json
docker*
apps/**/out
apps/docs/spec/**
# prettier-plugin-sql-cst only supports sqlite syntax
**/supabase/migrations/*.sql
apps/www/schema.sql
@@ -12,11 +13,7 @@ examples/slack-clone/nextjs-slack-clone/full-schema.sql
# ignore files with custom js formatting
apps/docs/pages/guides/auth/*.mdx
apps/docs/pages/guides/integrations/*.mdx
studio/public
studio/__mocks__
studio/.next
studio/.storybook
studio/.swc
studio/.turbo
studio/node
studio/data
apps/studio/public
apps/**/.turbo
apps/docs/CONTRIBUTING.md
apps/design-system/__registry__
+7 -1
View File
@@ -7,5 +7,11 @@
"endOfLine": "lf",
"sqlKeywordCase": "lower",
"pluginSearchDirs": false,
"plugins": ["prettier-plugin-sql-cst"]
"plugins": ["prettier-plugin-sql-cst"],
"overrides": [
{
"files": "**/*.json",
"options": { "parser": "json" }
}
]
}
+14
View File
@@ -0,0 +1,14 @@
StylesPath = './vale/styles'
MinAlertLevel = 'suggestion'
Vocab = Supabase
[formats]
mdx = md
[*]
BasedOnStyles = Vale, Custom
BlockIgnores = (?s) *(<StepHikeCompact>.*?</StepHikeCompact>), \
(?s) *(export\s+const\s+\S+\s*=\s*\[.*?\]), \
(?s) *(<Image.*?/>)
TokenIgnores = (href\s*([=:])\s*['"].*?\1), \
+35
View File
@@ -0,0 +1,35 @@
# CONTRIBUTING.md
Thank you for contributing to Supabase! We’re a big, exciting open source project and we’d love to have you contribute! Here’s some resources and guidance to help you get started:
[1. Getting Started](#getting-started)
[2. Issues](#issues)
[3. Pull Requests](#pull-requests)
## Getting Started
To ensure a positive and inclusive environment, please read our [code of conduct](https://github.com/supabase/.github/blob/main/CODE_OF_CONDUCT.md) before contributing. For help setting up the code in this repo, please follow our [DEVELOPERS.md](https://github.com/supabase/supabase/blob/master/DEVELOPERS.md) file. For the [docs](https://supabase.com/docs) site, follow this [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md) guide.
## Issues
If you find a bug, please create an Issue and we’ll triage it.
- Please search [existing Issues](https://github.com/supabase/supabase/issues) before creating a new one.
- Please include a clear description of the problem along with steps to reproduce it. Exact steps with screenshots and urls really help here.
## Pull Requests
We actively welcome your Pull Requests! A couple of things to keep in mind before you submit:
- If you’re fixing an Issue, make sure someone else hasn’t already created a PR fixing the same issue. Likewise, make sure to link your PR to the related Issue(s).
- We will always try to accept the first viable PR that resolves the Issue.
- If you're new, we encourage you to take a look at issues tagged with [good first issue](https://github.com/supabase/supabase/labels/good%20first%20issue).
- If you’re submitting a new feature, make sure you have opened a [Discussion](https://github.com/orgs/supabase/discussions/new/choose) to discuss the new feature before opening a PR. We’d love to accept your hard work, but unfortunately if a feature hasn’t gone through a proper design process, your PR will be closed.
- Please use the PR message template and provide detailed context for quicker review. PRs without clear problem statements will be closed.
Prior to submitting your PR, please conduct the following pre-flight checks:
- Run `npm run build` locally to ensure that your code builds successfully without having to wait on us to approve Vercel Preview deploys.
- Ensure that the Prettier tests run successfully on your PR.
Running these before you create the PR will help reduce back and forth with the team.
+69 -49
View File
@@ -1,15 +1,26 @@
# Developing Supabase
1. [Getting started](#getting-started)
- [Install dependencies](#install-dependencies)
2. [Local development](#local-development)
- [Fork the repository](#fork-the-repository)
- [Clone the repo](#clone-the-repo)
- [Running turborepo](#running-turborepo)
- [Shared components](#shared-components)
- [Installing packages](#installing-packages)
- [New Supabase docs](#new-supabase-docs)
3. [Create a pull request](#create-a-pull-request)
- [Developing Supabase](#developing-supabase)
- [Getting started](#getting-started)
- [Install dependencies](#install-dependencies)
- [Local development](#local-development)
- [Fork the repo](#fork-the-repo)
- [Clone the repo](#clone-the-repo)
- [Install dependencies](#install-dependencies-1)
- [Running sites individually](#running-sites-individually)
- [Shared components](#shared-components)
- [Installing packages](#installing-packages)
- [Running Docker for Supabase Studio](#running-docker-for-supabase-studio)
- [Prerequsites](#prerequsites)
- [Get Started](#get-started)
- [Create a pull request](#create-a-pull-request)
- [Issue assignment](#issue-assignment)
- [Common tasks](#common-tasks)
- [Add a redirect](#add-a-redirect)
- [Federated docs](#federated-docs)
- [Community channels](#community-channels)
- [Contributors](#contributors)
- [Common tasks](#common-tasks)
- [Add a redirect](#add-a-redirect)
@@ -17,31 +28,32 @@
## Getting started
Thank you for expressing your interest in [Supabase](https://supabase.com) and your willingness to contribute!
Thank you for your interest in [Supabase](https://supabase.com) and your willingness to contribute!
To ensure a positive and inclusive environment, we kindly request you to read our [code of conduct](https://github.com/supabase/.github/blob/main/CODE_OF_CONDUCT.md). Additionally, we encourage you to explore the existing [issues](https://github.com/supabase/supabase/issues) to see how you can make a meaningful impact. This document will guide you through the process of setting up your development environment, enabling you to successfully build and test [Supabase](https://supabase.com).
To ensure a positive and inclusive environment, please read our [code of conduct](https://github.com/supabase/.github/blob/main/CODE_OF_CONDUCT.md). We encourage you to explore the existing [issues](https://github.com/supabase/supabase/issues) to see how you can make a meaningful impact. This document will help you setup your development environment.
### Install dependencies
You will need to install and configure the following dependencies on your machine to build [Supabase](https://supabase.com):
- [Git](http://git-scm.com/)
- [Node.js v18.x (LTS)](http://nodejs.org)
- [npm](https://www.npmjs.com/) version 9.x.x
- [Node.js v20.x (LTS)](http://nodejs.org)
- [npm](https://www.npmjs.com/) version 9.x.x or higher
- [Docker](https://docs.docker.com/get-docker/) (to run studio locally)
## Local development
We are in the process of migrating this repository to monorepo, using [Turborepo](https://turborepo.org/docs).
This repo uses [Turborepo](https://turborepo.org/docs).
Eventually, all the apps will be run using [Turborepo](https://turborepo.org/docs), which will significantly improve the developer workflow.
All of our apps are in this [Turborepo](https://turborepo.org/docs), which make it easy to share packages and config between projects.
### Fork the repository
### Fork the repo
To contribute code to [Supabase](https://supabase.com), you must fork the [Supabase Repository](https://github.com/supabase/supabase).
To contribute code to [Supabase](https://supabase.com), you must fork the [Supabase repo](https://github.com/supabase/supabase).
### Clone the repo
1. Clone your GitHub forked repository:
1. Clone your GitHub forked repo:
```sh
git clone https://github.com/<github_username>/supabase.git
@@ -52,9 +64,7 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab
cd supabase
```
### Running turborepo
[Supabase](https://supabase.com) uses [Turborepo](https://turborepo.org/docs) to manage and run this monorepo.
### Install dependencies
1. Install the dependencies in the root of the repo.
@@ -62,18 +72,24 @@ To contribute code to [Supabase](https://supabase.com), you must fork the [Supab
npm install # install dependencies
```
2. After that you can run the apps simultaneously with the following.
2. Copy the example `.env.local.example` to `.env.local`
```sh
cp apps/www/.env.local.example apps/www/.env.local
```
3. After that you can run the apps simultaneously with the following.
```sh
npm run dev # start all the applications
```
Then visit, and edit, any of the following sites:
| Site | Directory | Scope name | Description | Local development server |
| -------------------------------------------------------- | ------------ | ---------- | ------------------------------------ | -------------------------- |
| [supabase.com](https://supabase.com) | `/apps/www` | www | The main website | http://localhost:3000 |
| [supabase.com/dashboard](https://supabase.com/dashboard) | `/studio` | studio | Studio dashboard | http://localhost:8082 |
| [supabase.com/docs](https://supabase.com/docs) | `/apps/docs` | docs | Guides and Reference (Next.js based) | http://localhost:3001/docs |
| Site | Directory | Scope name | Description | Local development server |
| -------------------------------------------------------- | -------------- | ---------- | --------------------------------------------- | -------------------------- |
| [supabase.com](https://supabase.com) | `/apps/www` | www | The main website | http://localhost:3000 |
| [supabase.com/dashboard](https://supabase.com/dashboard) | `/apps/studio` | studio | Studio dashboard (requires Docker, see below) | http://localhost:8082 |
| [supabase.com/docs](https://supabase.com/docs) | `/apps/docs` | docs | Guides and Reference (Next.js based) | http://localhost:3001/docs |
#### Running sites individually
@@ -83,14 +99,18 @@ You can run any of the sites individually by using the scope name. For example:
npm run dev:www
```
Note: Particularly for `www` make sure you have copied `apps/www/.env.local.example` to `apps/www/.env.local`
#### Shared components
The monorepo has a set of shared components under `/packages`:
- `/packages/common`: Common React code, shared between all sites.
- `/packages/ai-commands`: Helpers/Commands for AI related functions
- `/packages/common`: Common React components, shared between all sites
- `/packages/config`: All shared config
- `/packages/spec`: Generates documentation using spec files.
- `/packages/shared-data`: Shared data that can be used across all apps
- `/packages/tsconfig`: Shared Typescript settings
- `/packages/ui`: Common UI components
#### Installing packages
@@ -102,29 +122,21 @@ For example:
- `npm install react -w common`: installs into `./packages/common`
- `npm install react -w www`: installs into `./apps/www`
- `npm install react -w studio`: installs into `./studio`
- `npm install react -w studio`: installs into `./apps/studio`
You do not need to install `devDependencies` in each workspace. These can all be installed in the root package.
#### New Supabase docs
Following the changes to the [Supabase docs](https://supabase.com/blog/new-supabase-docs-built-with-nextjs) the following is needed to run the new docs locally:
- Inside of `apps/docs` create a `.env.local` file with the following: `NEXT_PUBLIC_NEW_DOCS=true`
Now when you run a local development docs server you will see the new docs site.
---
## Running Docker
## Running Docker for Supabase Studio
To test your changes, you need to run Supabase using Docker. Docker lets you use features of your locally modified Supabase version.
To run Studio locally, you'll need to setup Docker in addition to your NextJS frontend.
#### Prerequsites
First, make sure you have the Docker application installed on your device. You can download and install it from [here](https://docs.docker.com/get-docker/).
First, make sure you have the Docker installed on your device. You can download and install it from [here](https://docs.docker.com/get-docker/).
#### Getting Started
#### Get Started
1. Navigate to the `docker` directory in your forked repo
@@ -141,20 +153,28 @@ First, make sure you have the Docker application installed on your device. You c
3. Run docker
```sh
docker-compose up
docker compose up
```
This command initializes the containers specified in the docker-compose.yml file. It might take a few moments to complete, depending on your computer and internet connection.
This command initializes the containers specified in the `docker-compose.yml` file. It might take a few moments to complete, depending on your computer and internet connection.
Once the `docker-compose up` process completes, you should have your local version of Supabase up and running within Docker containers. You can access and test the features you've modified or added.
Once the `docker compose up` process completes, you should have your local version of Supabase up and running within Docker containers. You can access it at `http://localhost:8082`.
Remember to keep the Docker application open as long as you're working with your local Supabase instance.
## Create a pull request
After making your changes, open a pull request. Once you submit your pull request, the Supabase team will review it with you.
After making any changes, open a pull request. Once you submit your pull request, the Supabase team will review it with you.
Once your PR has been merged, you will be proudly listed as a contributor in the [contributor chart](https://github.com/supabase/supabase/graphs/contributors).
Once your PR has been merged, you will be proudly listed as a contributor in the [contributor chart](https://github.com/supabase/supabase/graphs/contributors)!
## Issue assignment
We don't have a process for assigning issues to contributors. Please feel free to jump into any issues in this repo that you are able to help with. Our intention is to encourage anyone to help without feeling burdened by an assigned task. Life can sometimes get in the way, and we don't want to leave contributors feeling obligated to complete issues when they may have limited time or unexpected commitments.
We also recognize that not having a process can sometimes lead to competing or duplicate PRs. There's no perfect solution here. We encourage you to communicate early and often on an Issue to indicate that you're actively working on it. If you see that an Issue already has a PR, try working with that author instead of drafting your own.
We review PRs in the order of their submission. We try to accept the earliest one that is closest to being ready to merge.
---
@@ -188,7 +208,7 @@ Some things to consider:
## Community channels
If you are stuck somewhere or have any questions, join our [Discord Community Server](https://discord.supabase.com/) or the [Github Discussions](https://github.com/supabase/supabase/discussions). We are here to help!
If you get stuck somewhere or have any questions, join our [Discord Community Server](https://discord.supabase.com/) or the [Github Discussions](https://github.com/supabase/supabase/discussions). We are here to help!
## Contributors
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2023 Supabase
Copyright 2024 Supabase
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+22 -33
View File
@@ -3,8 +3,6 @@
<img src="https://user-images.githubusercontent.com/8291514/213727225-56186826-bee8-43b5-9b15-86e839d89393.png#gh-dark-mode-only">
</p>
---
# Supabase
[Supabase](https://supabase.com) is an open source Firebase alternative. We're building the features of Firebase using enterprise-grade open source tools.
@@ -12,9 +10,9 @@
- [x] Hosted Postgres Database. [Docs](https://supabase.com/docs/guides/database)
- [x] Authentication and Authorization. [Docs](https://supabase.com/docs/guides/auth)
- [x] Auto-generated APIs.
- [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api-overview)
- [x] GraphQL. [Docs](https://supabase.com/docs/guides/api#graphql-api-overview)
- [x] Realtime subscriptions. [Docs](https://supabase.com/docs/guides/api#realtime-api-overview)
- [x] REST. [Docs](https://supabase.com/docs/guides/api)
- [x] GraphQL. [Docs](https://supabase.com/docs/guides/graphql)
- [x] Realtime subscriptions. [Docs](https://supabase.com/docs/guides/realtime)
- [x] Functions.
- [x] Database Functions. [Docs](https://supabase.com/docs/guides/database/functions)
- [x] Edge Functions [Docs](https://supabase.com/docs/guides/functions)
@@ -24,6 +22,10 @@
![Supabase Dashboard](https://raw.githubusercontent.com/supabase/supabase/master/apps/www/public/images/github/supabase-dashboard.png)
Watch "releases" of this repo to get notified of major updates.
<kbd><img src="https://raw.githubusercontent.com/supabase/supabase/d5f7f413ab356dc1a92075cb3cee4e40a957d5b1/web/static/watch-repo.gif" alt="Watch this repo"/></kbd>
## Documentation
For full documentation, visit [supabase.com/docs](https://supabase.com/docs)
@@ -37,19 +39,6 @@ To see how to Contribute, visit [Getting Started](./DEVELOPERS.md)
- [Email Support](https://supabase.com/docs/support#business-support). Best for: problems with your database or infrastructure.
- [Discord](https://discord.supabase.com). Best for: sharing your applications and hanging out with the community.
## Status
- [x] Alpha: We are testing Supabase with a closed set of customers
- [x] Public Alpha: Anyone can sign up over at [supabase.com/dashboard](https://supabase.com/dashboard). But go easy on us, there are a few kinks
- [x] Public Beta: Stable enough for most non-enterprise use-cases
- [ ] Public: General Availability [[status](https://supabase.com/docs/guides/getting-started/features#feature-status)]
We are currently in Public Beta. Watch "releases" of this repo to get notified of major updates.
<kbd><img src="https://raw.githubusercontent.com/supabase/supabase/d5f7f413ab356dc1a92075cb3cee4e40a957d5b1/web/static/watch-repo.gif" alt="Watch this repo"/></kbd>
---
## How it works
Supabase is a combination of open source tools. We’re building the features of Firebase using enterprise-grade, open source products. If the tools and communities exist, with an MIT, Apache 2, or equivalent open license, we will use and support that tool. If the tool doesn't exist, we build and open source it ourselves. Supabase is not a 1-to-1 mapping of Firebase. Our aim is to give developers a Firebase-like developer experience using open source tools.
@@ -59,15 +48,15 @@ Supabase is a combination of open source tools. We’re building the features of
Supabase is a [hosted platform](https://supabase.com/dashboard). You can sign up and start using Supabase without installing anything.
You can also [self-host](https://supabase.com/docs/guides/hosting/overview) and [develop locally](https://supabase.com/docs/guides/local-development).
![Architecture](https://github.com/supabase/supabase/blob/master/apps/docs/public/img/supabase-architecture.png)
![Architecture](apps/docs/public/img/supabase-architecture.svg)
- [PostgreSQL](https://www.postgresql.org/) is an object-relational database system with over 30 years of active development that has earned it a strong reputation for reliability, feature robustness, and performance.
- [Postgres](https://www.postgresql.org/) is an object-relational database system with over 30 years of active development that has earned it a strong reputation for reliability, feature robustness, and performance.
- [Realtime](https://github.com/supabase/realtime) is an Elixir server that allows you to listen to PostgreSQL inserts, updates, and deletes using websockets. Realtime polls Postgres' built-in replication functionality for database changes, converts changes to JSON, then broadcasts the JSON over websockets to authorized clients.
- [PostgREST](http://postgrest.org/) is a web server that turns your PostgreSQL database directly into a RESTful API
- [pg_graphql](http://github.com/supabase/pg_graphql/) a PostgreSQL extension that exposes a GraphQL API
- [Storage](https://github.com/supabase/storage-api) provides a RESTful interface for managing Files stored in S3, using Postgres to manage permissions.
- [postgres-meta](https://github.com/supabase/postgres-meta) is a RESTful API for managing your Postgres, allowing you to fetch tables, add roles, and run queries, etc.
- [GoTrue](https://github.com/supabase/gotrue) is a JWT based API for managing users and issuing JWT tokens.
- [Storage](https://github.com/supabase/storage-api) provides a RESTful interface for managing Files stored in S3, using Postgres to manage permissions.
- [pg_graphql](http://github.com/supabase/pg_graphql/) a PostgreSQL extension that exposes a GraphQL API
- [postgres-meta](https://github.com/supabase/postgres-meta) is a RESTful API for managing your Postgres, allowing you to fetch tables, add roles, and run queries, etc.
- [Kong](https://github.com/Kong/kong) is a cloud-native API gateway.
#### Client libraries
@@ -122,6 +111,15 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
<td><a href="https://github.com/supabase/storage-dart" target="_blank" rel="noopener noreferrer">storage-dart</a></td>
<td><a href="https://github.com/supabase/functions-dart" target="_blank" rel="noopener noreferrer">functions-dart</a></td>
</tr>
<tr>
<td>Swift</td>
<td><a href="https://github.com/supabase/supabase-swift" target="_blank" rel="noopener noreferrer">supabase-swift</a></td>
<td><a href="https://github.com/supabase/supabase-swift/tree/main/Sources/PostgREST" target="_blank" rel="noopener noreferrer">postgrest-swift</a></td>
<td><a href="https://github.com/supabase/supabase-swift/tree/main/Sources/Auth" target="_blank" rel="noopener noreferrer">auth-swift</a></td>
<td><a href="https://github.com/supabase/supabase-swift/tree/main/Sources/Realtime" target="_blank" rel="noopener noreferrer">realtime-swift</a></td>
<td><a href="https://github.com/supabase/supabase-swift/tree/main/Sources/Storage" target="_blank" rel="noopener noreferrer">storage-swift</a></td>
<td><a href="https://github.com/supabase/supabase-swift/tree/main/Sources/Functions" target="_blank" rel="noopener noreferrer">functions-swift</a></td>
</tr>
<!-- /notranslate -->
<th colspan="7">💚 Community 💚</th>
<!-- notranslate -->
@@ -188,15 +186,6 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>Swift</td>
<td><a href="https://github.com/supabase-community/supabase-swift" target="_blank" rel="noopener noreferrer">supabase-swift</a></td>
<td><a href="https://github.com/supabase-community/postgrest-swift" target="_blank" rel="noopener noreferrer">postgrest-swift</a></td>
<td><a href="https://github.com/supabase-community/gotrue-swift" target="_blank" rel="noopener noreferrer">gotrue-swift</a></td>
<td><a href="https://github.com/supabase-community/realtime-swift" target="_blank" rel="noopener noreferrer">realtime-swift</a></td>
<td><a href="https://github.com/supabase-community/storage-swift" target="_blank" rel="noopener noreferrer">storage-swift</a></td>
<td><a href="https://github.com/supabase-community/functions-swift" target="_blank" rel="noopener noreferrer">functions-swift</a></td>
</tr>
<tr>
<td>Godot Engine (GDScript)</td>
<td><a href="https://github.com/supabase-community/godot-engine.supabase" target="_blank" rel="noopener noreferrer">supabase-gdscript</a></td>
@@ -291,7 +280,7 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
- [Simplified Chinese / 简体中文](/i18n/README.zh-cn.md)
- [Swedish / Svenska](/i18n/README.sv.md)
- [Thai / ไทย](/i18n/README.th.md)
- [Traditional Chinese / 繁体中文](/i18n/README.zh-tw.md)
- [Traditional Chinese / 繁體中文](/i18n/README.zh-tw.md)
- [Turkish / Türkçe](/i18n/README.tr.md)
- [Ukrainian / Українська](/i18n/README.uk.md)
- [Vietnamese / Tiếng Việt](/i18n/README.vi-vn.md)
+5
View File
@@ -0,0 +1,5 @@
# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
OPENAI_API_KEY=your-openai-api-key
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ['eslint-config-supabase/next'],
}
+35
View File
@@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
# database.design
## Overview
This site will host a tool for designing SQL schemas.
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { useAppStateSnapshot } from '@/lib/state'
import { usePathname, useParams } from 'next/navigation'
import { LoadingLine as LoadingLineComponent } from 'ui'
function LoadingLine() {
const snap = useAppStateSnapshot()
const params = useParams()
const pathName = usePathname()
const runId = params.runId as string
const loading =
runId && snap.runsLoading.includes(runId) && pathName.includes(runId) ? true : false
return <LoadingLineComponent loading={loading} />
}
export { LoadingLine }
+19
View File
@@ -0,0 +1,19 @@
'use client'
import { useAppStateSnapshot } from '@/lib/state'
import { DialogContent, Dialog } from 'ui'
const LoginDialog = ({ children }: { children: React.ReactNode }) => {
const snap = useAppStateSnapshot()
return (
<Dialog
open={snap.loginDialogOpen}
onOpenChange={() => snap.setLoginDialogOpen(!snap.loginDialogOpen)}
>
<DialogContent className="max-w-md">{children}</DialogContent>
</Dialog>
)
}
export { LoginDialog }
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { AssistantChatForm } from '@/components/AssistantChatForm'
import { useAppStateSnapshot } from '@/lib/state'
import { createClient } from '@/lib/supabase/client'
const NewThreadInput = () => {
const supabase = createClient()
const snap = useAppStateSnapshot()
return (
<>
<div className="relative w-10/12 xl:w-11/12 max-w-xl">
<AssistantChatForm
key={'new-thread-form'}
chatContext={'new'}
canSubmit={async () => {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
snap.setLoginDialogOpen(true)
return false
}
return true
}}
placeholder="e.g Create a Telegram-like chat application"
/>
</div>
</>
)
}
export default NewThreadInput
@@ -0,0 +1,19 @@
'use client'
import { createRef, useEffect } from 'react'
const BottomMarker = () => {
const ref = createRef<HTMLDivElement>()
useEffect(() => {
if (ref.current) {
setTimeout(() => {
if (ref.current) ref.current.scrollIntoView({ behavior: 'smooth' })
}, 700)
}
}, [ref])
return <div ref={ref} />
}
export { BottomMarker }
@@ -0,0 +1,23 @@
import { format } from 'sql-formatter'
import { MonacoEditor } from '@/components/MonacoEditor'
import { CodeEditorContainer } from './CodeEditorContainer'
export async function CodeEditor({ promisedMessage }: { promisedMessage: Promise<string> }) {
const code = await promisedMessage
// strip the ```sql ``` from the code
// not sure why we need this & can't get openai to stop returning it
const strippedCode = code.replace('```sql\n', '').replace('\n```', '')
const formattedCode = format(strippedCode, { language: 'postgresql' })
/**
* - CodeEditorContainer is a client component, which uses valtio state
* - MonacoEditor is a server component injected into it
*/
return (
<CodeEditorContainer>
<MonacoEditor id="sql-editor" language="pgsql" value={formattedCode} />
</CodeEditorContainer>
)
}
@@ -0,0 +1,24 @@
'use client'
import { cn } from 'ui'
import { useAppStateSnapshot } from '@/lib/state'
import { ReactNode } from 'react'
function CodeEditorContainer({ children }: { children: ReactNode }) {
const snap = useAppStateSnapshot()
return (
<div
className={cn(
snap.hideCode ? 'max-w-0' : 'max-w-lg 2xl:max-w-xl',
'w-full xl:border-l',
'grow flex flex-col h-full'
)}
>
{children}
</div>
)
}
export { CodeEditorContainer }
@@ -0,0 +1,116 @@
'use client'
import type { Message as MessageItem } from 'ai/react'
import Link from 'next/link'
import { useParams, usePathname } from 'next/navigation'
import { cn } from 'ui'
interface MessageItemProps {
message: MessageItem
isLatest: boolean
times: {
hoursFromNow: number
formattedTimeFromNow: string
formattedCreatedAt: string
}
}
const MessageItem = ({ message, isLatest, times }: MessageItemProps) => {
const { thread_id } = useParams()
// using the local state for run loading
const isLoading = false
const { hoursFromNow, formattedTimeFromNow, formattedCreatedAt } = times
// chat shown as selected when url matches
const isSelected = usePathname().includes(message.id)
return (
<Link
className={cn(
'group',
'transition flex w-full gap-x-5 px-4 xl:px-8 hover:bg-surface-200/50 cursor-pointer border-r',
isSelected && 'bg-surface-200',
isSelected ? 'border-r-foreground' : 'border-r border-r-transparent'
)}
href={`/${thread_id}/${message.id}`}
>
<div className="flex flex-col justify-between items-center relative top-3">
<div
className={cn(
'transition w-2.5 h-2.5 mt-[1px] ml-[1px] rounded-full border',
isSelected
? 'bg-dbnew border-dbnew'
: 'bg-transparent border-foreground-muted group-hover:border-foreground'
)}
/>
{isLoading && (
<span
className={cn(
'absolute w-4 h-4 -top-0.5',
'after:content-spinner after:t-0 after:block after:absolute after:h-4 after:w-4 after:border-r after:border-r-dbnew after:rounded-[50%] after:rotate-45 z-10',
'animate-spin'
)}
>
<div className="absolute border w-4 h-4 rounded-full z-0" />
</span>
)}
{/* Node line*/}
{!isLatest && <div className="border-l border-strong flex-grow" />}
</div>
<div className="flex w-full flex-col gap-y-2 py-4">
<div className="group relative">
<span className="z-10 absolute top-0 -left-[8px]">
<svg viewBox="0 0 8 13" height="13" width="8">
<path
className={
// 'transition fill-background-surface-100 border'
isSelected
? 'transition stroke-border fill-background-surface-100 stroke-border-default'
: 'transition stroke-border fill-background-surface-100 stroke-border-default'
}
d="M1.533,2.568L8,11.193V0L2.812,0C1.042,0,0.474,1.156,1.533,2.568z"
/>
</svg>
</span>
<div
title={message.content}
className={cn(
'cursor-pointer transition relative overflow-hidden',
'w-full rounded-lg rounded-tl-none',
'bg-alternative',
'border',
isSelected ? 'bg-surface-100' : 'bg-surface-100 group-hover:bg-surface-200'
)}
>
<p
className={cn(
'transition p-4 text-xs',
isSelected ? 'text-foreground' : 'text-light group-hover:text-foreground'
)}
>
{message.content}
</p>
{isLoading && <div className="chat-shimmering-loader w-full h-0.5 absolute bottom-0" />}
</div>
</div>
{isSelected && (
<p
className={cn(
'transition-all',
isSelected ? 'h-inherit opacity-100' : 'h-0 opacity-0',
'font-mono text-xs text-foreground-lighter'
)}
>
Sent {hoursFromNow > 6 ? `on ${formattedCreatedAt}` : formattedTimeFromNow}
</p>
)}
</div>
</Link>
)
}
export default MessageItem
@@ -0,0 +1,62 @@
import { ScrollArea } from 'ui'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { BottomMarker } from './BottomMarker'
import { getMessages } from './getMessages'
import MessageItem from './MessageItem'
dayjs.extend(relativeTime)
export const Messages = async ({ threadId }: { threadId: string }) => {
const { data: messages, error } = await getMessages(threadId)
if (error) {
return <>Error happened</>
}
const initialMessages = messages.flatMap((m) => [
{
id: m.message_id,
content: m.message_input,
role: 'user' as const,
createdAt: new Date(m.created_at),
},
{
id: m.message_id,
content: m.message_content,
role: 'assistant' as const,
createdAt: new Date(m.created_at),
},
])
const userMessages = initialMessages.filter((m) => m.role === 'user')
return (
<ScrollArea className="grow h-px">
<div className="flex flex-col mb-6 xl:mb-12">
{userMessages.map((message, idx) => {
const createdAtTimestamp = dayjs(message.createdAt)
const isLatest = Array.isArray(messages) && idx === messages.length - 1
const hoursFromNow = dayjs().diff(createdAtTimestamp, 'hour')
const formattedTimeFromNow = dayjs(createdAtTimestamp).fromNow()
const formattedCreatedAt = dayjs(createdAtTimestamp).format('DD MMM YYYY, HH:mm')
const times = {
hoursFromNow,
formattedTimeFromNow,
formattedCreatedAt,
}
return (
<MessageItem key={message.id} message={message} isLatest={isLatest} times={times} />
)
})}
</div>
<BottomMarker />
</ScrollArea>
)
}
@@ -0,0 +1,8 @@
import SchemaFlowHandler from '@/components/SchemaFlowHandler'
export async function SchemaFlow({ promisedMessage }: { promisedMessage: Promise<string> }) {
const code = await promisedMessage
const strippedCode = code.replace('```sql\n', '').replace('\n```', '')
return <SchemaFlowHandler content={strippedCode} />
}
@@ -0,0 +1,134 @@
import { stripIndent } from 'common-tags'
import { cookies } from 'next/headers'
import OpenAI from 'openai'
// import is weird, what's up with this?
import { createClient } from '@/lib/supabase/server'
import { ContextLengthError } from '../../../../../packages/ai-commands/src/errors'
import { getMessages } from './getMessages'
// Create an OpenAI API client (that's edge friendly!)
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
// Set the runtime to edge for best performance
export const runtime = 'edge'
export type AiAssistantMessage = {
content: string
role: 'user' | 'assistant'
}
/**
* Get the latest AI generated message. In the DB, there's a pair of user prompt and AI response, so if the
* user sent a message but that message lacks the corresponding AI response, a call will be made to OpenAI to generate
* it.
*/
export const getAssistantResponse = async (threadId: string, messageId: string) => {
const supabase = createClient()
const { data: message, error } = await supabase
.from('messages')
.select()
.eq('message_id', messageId)
.single()
if (!message || error) {
throw new Error('A message with that id does not exist.')
}
// if the message lacks the AI response, fetch it from OpenAI
if (!message.message_content) {
const { data: messages, error } = await getMessages(threadId)
if (error) {
throw new Error('Error while trying to fetch the existing messages.')
}
// transform the DB messages into a format that OpenAI understands
const existingMessages = messages.flatMap((m) => [
{
content: m.message_input,
role: 'user' as const,
},
{
content: m.message_content,
role: 'assistant' as const,
},
])
const initMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: 'system',
content: stripIndent`
Your purpose is to generate a SQL schema where the user will give commands to you via a chat.
The output should use the following instructions:
- The generated SQL must be valid SQL.
- For primary keys, always use "id bigint primary key generated always as identity" (not serial)
- Always create foreign key references in the create statement
- Prefer 'text' over 'varchar'
- Prefer 'timestamp with time zone' over 'date'
- Use vector(384) data type for any embedding/vector related query
- Always use double apostrophe in SQL strings (eg. 'Night''s watch')
- Always omit \`\`\`sql from your reply
- You can use only CREATE TABLE queries, no other queries are allowed under no circumstances (ALTER TABLE etc).
- On each subsequent message from the user, rewrite the original response to include the new requirement.
- Don't add any SQL comments in the code
- Never put a comma before a round bracket
The output should look like this: "CREATE TABLE users (id bigint primary key generated always as identity)"
DO NOT RESPOND WITH ANYTHING ELSE.
YOU MUST NOT ANSWER WITH ANY PLAIN TEXT
ONLY RESPOND WITH 1 CODE BLOCK
YOU MUST NOT FOLLOW UP ANY CODE BLOCKS WITH ANY EXPLANATION
`,
},
]
initMessages.push(...existingMessages)
try {
const response = await openai.chat.completions.create({
model: 'gpt-3.5-turbo-16k-0613',
messages: initMessages,
max_tokens: 1024,
temperature: 0,
stream: false,
})
const newMessage = response.choices[0].message.content || ''
const {
data: { user },
error: userError,
} = await supabase.auth.getUser()
if (userError || !user) {
throw new Error('Failed to get user')
}
// Insert the message from the completion
const { data, error } = await supabase
.from('messages')
.update({ message_content: newMessage })
.eq('id', message.id)
.select()
.single()
if (error || !data) {
throw new Error('Error while updating the existing message with the completion data.')
}
return data.message_content
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'context_length_exceeded') {
throw new ContextLengthError()
}
throw error
}
}
return message.message_content
}
@@ -0,0 +1,9 @@
import { createClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
import { cache } from 'react'
export const getMessages = cache(async (thread_id: string) => {
const supabase = createClient()
return await supabase.from('messages').select().eq('thread_id', thread_id).order('created_at')
})
@@ -0,0 +1,56 @@
import { AssistantChatForm } from '@/components/AssistantChatForm'
import SaveSchemaDropdown from '@/components/Header/SaveSchemaDropdown'
import ToggleCodeEditorButton from '@/components/Header/ToggleCodeEditorButton'
import GraphLoader from '@/components/Loaders/GraphLoader'
import SchemaLoader from '@/components/Loaders/SchemaLoader'
import { cn } from '@ui/lib/utils/cn'
import { Suspense } from 'react'
import { CodeEditor } from './CodeEditor'
import { Messages } from './Messages'
import { SchemaFlow } from './SchemaFlow'
import { getAssistantResponse } from './getAssistantMessage'
export interface ThreadPageProps {
params: {
thread_id: string
message_id: string
}
}
export default async function ThreadPage({ params }: ThreadPageProps) {
const promise = getAssistantResponse(params.thread_id, params.message_id)
return (
<div className="flex flex-col-reverse items-between xl:flex-row xl:items-center xl:justify-between bg-alternative h-full">
<div
className={cn(
'bg',
'h-full',
'border-t xl:border-t-0 xl:border-r relative',
'flex flex-col h-full border-r',
'w-full xl:w-[400px] 2xl:w-[500px]'
)}
>
<div className="flex flex-col grow items-between">
<Messages threadId={params.thread_id} />
<div className="px-4 pb-4">
<AssistantChatForm chatContext={'edit'} placeholder={'Any changes to make?'} />
</div>
</div>
</div>
<div className="xl:hidden flex items-center gap-x-2 justify-end border-t py-2 px-2 bg-background">
<ToggleCodeEditorButton />
<SaveSchemaDropdown />
</div>
<div className="grow max-h-screen flex flex-row items-center justify-between bg-alternative h-full">
<Suspense fallback={<SchemaLoader />}>
<SchemaFlow promisedMessage={promise} />
</Suspense>
<Suspense fallback={<GraphLoader />}>
<CodeEditor promisedMessage={promise} />
</Suspense>
</div>
</div>
)
}
+234
View File
@@ -0,0 +1,234 @@
'use server'
import { createClient } from '@/lib/supabase/server'
import { Message } from 'ai'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { z } from 'zod'
export async function logout() {
const supabase = createClient()
const { error } = await supabase.auth.signOut()
if (error) {
console.log('Error logging out:', error.message)
return
}
revalidatePath('/', 'layout')
redirect('/')
}
//export async function createThread(prevState: any, formData: FormData) {
export async function createThread(input: string, message: Message, currentThread?: string) {
const supabase = createClient()
let thread_id = currentThread ?? ''
let message_id = ''
try {
const {
data: { user },
error: userError,
} = await supabase.auth.getUser()
if (userError || !user) {
return {
success: false,
message: 'Failed to get user',
data: undefined,
}
}
// create a new thread
if (!thread_id) {
try {
const { data, error } = await supabase
.from('threads')
.insert({ user_id: user.id, thread_title: input })
.select()
if (error) throw error
if (data) {
console.log({ data })
thread_id = data[0].id
// message_id = data[0].message_id
}
} catch (error) {
console.error(error)
}
}
//insert the message from the completion
try {
const { data, error } = await supabase
.from('messages')
.insert({
thread_id,
message_content: message.content,
message_input: input,
message_role: 'assistant',
user_id: user.id,
})
.select()
if (error) throw error
if (data) {
message_id = data[0].message_id
}
} catch (error) {
console.error(error)
}
} catch (error: any) {
console.error(error)
return {
success: false,
message: 'Failed to update title to update title',
data: undefined,
}
}
redirect(`/${thread_id}/${message_id}`)
}
export async function upsertThreadMessage(input: string, currentThread?: string) {
const supabase = createClient()
let thread_id = currentThread ?? ''
let message_id = ''
try {
const {
data: { user },
error: userError,
} = await supabase.auth.getUser()
if (userError || !user) {
return {
success: false,
message: 'Failed to get user',
data: undefined,
}
}
// create a new thread
if (!thread_id) {
try {
const { data, error } = await supabase
.from('threads')
.insert({ user_id: user.id, thread_title: input })
.select()
if (error) throw error
if (data) {
console.log({ data })
thread_id = data[0].id
// message_id = data[0].message_id
}
} catch (error) {
console.error(error)
}
}
//insert the message from the completion
try {
const { data, error } = await supabase
.from('messages')
.insert({
thread_id,
message_content: '',
message_input: input,
message_role: 'assistant',
user_id: user.id,
})
.select()
if (error) throw error
if (data) {
message_id = data[0].message_id
}
} catch (error) {
console.error(error)
}
} catch (error: any) {
console.error(error)
return {
success: false,
message: 'Failed to update title to update title',
data: undefined,
}
}
redirect(`/${thread_id}/${message_id}`)
}
export async function deleteThread(prevState: any, formData: FormData) {
const supabase = createClient()
try {
const schema = z.object({
thread_id: z.string(),
})
const data = schema.parse({
thread_id: formData.get('thread_id'),
})
await supabase.from('threads').delete().eq('id', data.thread_id)
revalidatePath('/profile')
return {
success: true,
data,
}
} catch (error: any) {
console.error(error)
return {
success: false,
message: 'Failed to delete the thread',
data: undefined,
}
}
}
export async function updateThreadName(prevState: any, formData: FormData) {
const supabase = createClient()
try {
const schema = z.object({
thread_title: z.string(),
thread_id: z.string(),
})
const data = schema.parse({
thread_title: formData.get('thread_title'),
thread_id: formData.get('thread_id'),
})
const { error } = await supabase
.from('threads')
.update({ thread_title: data.thread_title })
.eq('id', data.thread_id)
if (error) {
throw error
}
revalidatePath('/profile')
return {
message: 'Thread name updated to ' + data.thread_title,
success: true,
data,
}
} catch (error: any) {
console.error(error)
return {
success: false,
message: 'Failed to update title to update title',
data: undefined,
}
}
}
@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { createClient } from '@/lib/supabase/server'
export async function GET(request: Request) {
// The `/auth/callback` route is required for the server-side auth flow implemented
// by the Auth Helpers package. It exchanges an auth code for the user's session.
// https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-sign-in-with-code-exchange
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get('code')
if (code) {
const supabase = createClient()
await supabase.auth.exchangeCodeForSession(code)
}
// URL to redirect to after sign in process completes
return NextResponse.redirect(requestUrl.origin)
}
+39
View File
@@ -0,0 +1,39 @@
import { FAQS, faq } from '@/data/faqs'
import { slugify } from '@/lib/utils'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'database.design | Faq',
}
const FAQ = async () => {
return (
<div className="bg-background">
<div className="py-16 bg-white ">
<div className="max-w-screen-md px-4 mx-auto sm:px-6 lg:px-8">
<h1 className="text-center font-display text-3xl font-bold text-black sm:text-5xl">
Frequently Asked Questions
</h1>
<p className="mt-5 text-lg text-center text-foreground-light">
Everything you have ever wondered about database.design
</p>
</div>
</div>
<div className="flex flex-col items-center max-w-screen-md px-4 py-10 mx-auto sm:pt-20 sm:px-6 lg:px-8">
{FAQS.map((faq: faq, index) => (
<article
key={index}
className="prose prose-headings:scroll-mt-20 prose-headings:font-display prose-headings:font-semibold w-full"
>
<a href={`#${slugify(faq.question)}`} className="no-underline hover:underline">
<h2 id={slugify(faq.question)}>{faq.question}</h2>
</a>
<p>{faq.answer}</p>
</article>
))}
</div>
</div>
)
}
export default FAQ
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+199
View File
@@ -0,0 +1,199 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import '~ui/build/css/source/global.css';
@import '~ui/build/css/themes/dark.css';
@import '~ui/build/css/themes/light.css';
@font-face {
font-family: 'circular';
src:
url(/fonts/custom/CustomFont-Book.woff2) format('woff2'),
url(/fonts/custom/CustomFont-Book.woff) format('woff');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'circular';
src:
url(/fonts/custom/CustomFont-BookItalic.woff2) format('woff2'),
url(/fonts/custom/CustomFont-BookItalic.woff) format('woff');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'circular';
src:
url(/fonts/custom/CustomFont-Medium.woff2) format('woff2'),
url(/fonts/custom/CustomFont-Medium.woff) format('woff');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'circular';
src:
url(/fonts/custom/CustomFont-MediumItalic.woff2) format('woff2'),
url(/fonts/custom/CustomFont-MediumItalic.woff) format('woff');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: 'circular';
src:
url(/fonts/custom/CustomFont-Bold.woff2) format('woff2'),
url(/fonts/custom/CustomFont-Bold.woff) format('woff');
font-weight: 700;
font-style: 600;
}
@font-face {
font-family: 'circular';
src:
url(/fonts/custom/CustomFont-BoldItalic.woff2) format('woff2'),
url(/fonts/custom/CustomFont-BoldItalic.woff) format('woff');
font-style: 600;
font-style: italic;
}
@font-face {
font-family: 'circular';
src:
url(/fonts/custom/CustomFont-Black.woff2) format('woff2'),
url(/fonts/custom/CustomFont-Black.woff) format('woff');
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: 'circular';
src:
url(/fonts/custom/CustomFont-BlackItalic.woff2) format('woff2'),
url(/fonts/custom/CustomFont-BlackItalic.woff) format('woff');
font-weight: 800;
font-style: italic;
}
@font-face {
font-family: 'source code pro';
src: url('/fonts/source-code-pro/SourceCodePro-Regular.eot');
src:
url('/fonts/source-code-pro/SourceCodePro-Regular.woff2') format('woff2'),
url('/fonts/source-code-pro/SourceCodePro-Regular.woff') format('woff'),
url('/fonts/source-code-pro/SourceCodePro-Regular.ttf') format('truetype'),
url('/fonts/source-code-pro/SourceCodePro-Regular.svg#SourceCodePro-Regular') format('svg');
font-weight: normal;
font-style: normal;
font-display: swap;
}
html,
body,
main {
/* @apply bg-background;
@apply text-foreground;
height: 100%;
width: 100%; */
padding: 0;
margin: 0;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
}
.chat-shimmering-loader {
animation: shimmer 1.5s infinite linear;
background: linear-gradient(
to right,
hsl(var(--background-default)) 0%,
hsl(var(--brand-default)) 25%,
hsl(var(--brand-300)) 35%,
hsl(var(--background-default)) 45%,
hsl(var(--background-surface-100)) 75%
);
background-size: 3000px 100%;
}
.shimmering-loader {
animation: shimmer 2s infinite linear;
background: linear-gradient(
to right,
hsl(var(--border-default)) 4%,
hsl(var(--background-surface-200)) 25%,
hsl(var(--border-default)) 36%
);
background-size: 1000px 100%;
}
.dark .shimmering-loader {
animation: shimmer 2s infinite linear;
background: linear-gradient(
to right,
hsl(var(--border-default)) 4%,
hsl(var(--border-control)) 25%,
hsl(var(--border-default)) 36%
);
background-size: 1000px 100%;
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.monaco-editor,
.monaco-diff-editor {
--vscode-editor-background: hsl(var(--background-alternative)) !important;
--vscode-editorGutter-background: hsl(var(--background-alternative)) !important;
padding-top: 12px;
}
/* .monaco-editor .overflow-guard */
.shimmering-loader {
animation: shimmer 2s infinite linear;
background: linear-gradient(
to right,
hsl(var(--border-default)) 4%,
hsl(var(--background-surface-100)) 25%,
hsl(var(--border-default)) 36%
);
background-size: 1000px 100%;
}
.dark .shimmering-loader {
animation: shimmer 2s infinite linear;
background: linear-gradient(
to right,
hsl(var(--background-surface-100)) 4%,
hsl(var(--border-default)) 25%,
hsl(var(--background-surface-100)) 36%
);
background-size: 1000px 100%;
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.fade-in {
opacity: 0;
animation-name: fadeIn;
animation-duration: 500ms;
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
+37
View File
@@ -0,0 +1,37 @@
import '@ui/layout/ai-icon-animation/ai-icon-animation-style.module.css'
import './globals.css'
import Footer from '@/components/Footer'
import Header from '@/components/Header/Header'
import { ThemeProvider } from '@/components/providers'
import type { Metadata } from 'next'
import { LoadingLine } from './LoadingLine'
const defaultUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: 'http://localhost:3000'
export const metadata: Metadata = {
metadataBase: new URL(defaultUrl),
title: 'database.design',
description: 'Generate schemas from your ideas',
}
// suppressHydrationWarning:
// https://github.com/pacocoursey/next-themes#with-app
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark h-full bg-background" suppressHydrationWarning>
<body className="flex flex-col h-full">
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
<Header />
<LoadingLine />
<main role="main" className="h-full w-full flex flex-col grow">
{children}
</main>
<Footer />
</ThemeProvider>
</body>
</html>
)
}
+5
View File
@@ -0,0 +1,5 @@
import LoginForm from '@/components/Auth/LoginForm'
export default function Login({ searchParams }: { searchParams: { message: string } }) {
return <LoginForm searchParams={searchParams} />
}
+28
View File
@@ -0,0 +1,28 @@
// import LoginForm from '@/components/Auth/LoginForm'
import type { Metadata } from 'next'
import { LoginDialog } from './LoginDialog'
import NewThreadInput from './NewThreadInput'
import LoginForm from '@/components/Auth/LoginForm'
export const metadata: Metadata = {
title: 'database.design | Create',
}
const NewThread = async ({
searchParams,
}: {
searchParams: { prompt: string; message: string }
}) => {
return (
<>
<div className="h-full flex items-center justify-center w-full flex-col gap-y-4">
<NewThreadInput />
</div>
<LoginDialog>
<LoginForm searchParams={searchParams} />
</LoginDialog>
</>
)
}
export default NewThread
@@ -0,0 +1,80 @@
'use client'
import { deleteThread } from '@/app/actions'
import { createRef, useEffect } from 'react'
import { useFormState, useFormStatus } from 'react-dom'
import { Button, Input_Shadcn_, Modal } from 'ui'
import { ThreadType } from './Threads'
const ConfirmDeleteThreadModal = ({
thread,
onClose,
visible,
}: {
thread: ThreadType
onClose: () => void
visible: boolean
}) => {
const formRef = createRef<HTMLFormElement>()
const initialState = {
message: '',
success: false,
data: {
thread_id: thread.thread_id,
},
}
const [state, formAction] = useFormState(deleteThread, initialState)
useEffect(() => {
if (state?.success === true) {
onClose()
formRef.current?.reset()
state.success = false
}
}, [state, onClose, formRef])
useEffect(() => {
if (state?.success === true) {
onClose()
formRef.current?.reset()
state.success = false
}
}, [state, onClose, formRef])
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="warning" htmlType="submit" aria-disabled={pending} loading={pending}>
Delete thread
</Button>
)
}
return (
<Modal
size="small"
visible={visible}
onCancel={onClose}
hideFooter
header="Confirm to delete thread?"
className="pb-2"
>
<form action={formAction} key={`${thread.thread_id}-delete-thread-form`}>
<Modal.Content className="py-4">
<p className="text-sm">Once the thread is deleted, it cannot be recovered.</p>
</Modal.Content>
<Modal.Separator />
<Input_Shadcn_ name="thread_id" value={state.data.thread_id} type="hidden" />
<Modal.Content className="flex flex-row gap-3 justify-end">
<Button type="default">Cancel</Button>
<SubmitButton />
</Modal.Content>
</form>
</Modal>
)
}
export default ConfirmDeleteThreadModal
@@ -0,0 +1,83 @@
'use client'
import { updateThreadName } from '@/app/actions'
import { createRef, useEffect } from 'react'
import { useFormState, useFormStatus } from 'react-dom'
import { Button, Input_Shadcn_, Label_Shadcn_, Modal } from 'ui'
import { ThreadType } from './Threads'
const EditThreadModal = ({
thread,
onClose,
visible,
}: {
thread: ThreadType
onClose: () => void
visible: boolean
}) => {
const formRef = createRef<HTMLFormElement>()
const initialState = {
message: undefined,
success: undefined,
data: {
thread_id: thread.thread_id,
thread_title: thread.thread_title,
},
}
const [state, formAction] = useFormState(updateThreadName, initialState)
useEffect(() => {
if (state?.success === true) {
onClose()
formRef.current?.reset()
state.success = undefined
}
}, [state, onClose, formRef])
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="secondary" htmlType="submit" aria-disabled={pending} loading={pending}>
Update thread name
</Button>
)
}
return (
<Modal
alignFooter="right"
size="medium"
visible={visible}
onCancel={onClose}
hideFooter
header="Edit thread name"
className="pb-2"
>
<form ref={formRef} action={formAction} key={`${thread.thread_id}-update-thread-title-form`}>
<Modal.Content className="py-4">
<Label_Shadcn_ htmlFor="thread_title">Provide a name for your thread</Label_Shadcn_>
<Input_Shadcn_
placeholder="Type in a name for the thread..."
type="text"
name="thread_title"
defaultValue={state.data.thread_title}
/>
<Input_Shadcn_ name="thread_id" value={state.data.thread_id} type="hidden" />
</Modal.Content>
<Modal.Separator />
<Modal.Content className="flex flex-row gap-3 justify-end">
<Button type="default">Cancel</Button>
<SubmitButton />
<p aria-live="polite" className="sr-only" role="status">
{state?.message}
</p>
</Modal.Content>
</form>
</Modal>
)
}
export default EditThreadModal
@@ -0,0 +1,17 @@
'use client'
import Link from 'next/link'
import { Button } from 'ui'
const EmptyState = () => {
return (
<div className="border rounded py-6 flex flex-col items-center justify-center gap-y-2">
<p className="text-sm text-foreground-light">No conversations created yet</p>
<Button type="default">
<Link href="/">Start a conversation</Link>
</Button>
</div>
)
}
export default EmptyState
+85
View File
@@ -0,0 +1,85 @@
'use client'
import { timeAgo } from '@/lib/utils'
import Link from 'next/link'
import { useState } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconEdit2,
IconMoreVertical,
IconTrash2,
} from 'ui'
import ConfirmDeleteThreadModal from './ConfirmDeleteThreadModal'
import EditThreadModal from './EditThreadModal'
import { ThreadType } from './Threads'
const Thread = ({ thread }: { thread: ThreadType }) => {
const { created_at, thread_id, thread_title } = thread
const formattedTimeAgo = timeAgo(created_at!)
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
return (
<>
<div
key={thread_id}
className="group w-full flex flex-row items-center gap-6 border rounded pl-5 pr-2 transition bg-surface-100 hover:bg-surface-200 h-14"
>
<div className="flex flex-col grow overflow-hidden">
<Link
className="flex text-sm group-hover:underline truncate"
href={`/${thread.thread_id}/${thread.message_id}`}
>
<span className="truncate">{thread_title}</span>
</Link>
<span className="text-xs text-foreground-lighter font-mono">
Last updated {formattedTimeAgo}
</span>
</div>
<div className="flex shrink">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-1 group">
<IconMoreVertical size={14} className="text-light group-hover:text" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-32" align="end">
<DropdownMenuItem
className="space-x-2"
onClick={() => {
console.log('trying to edit thread')
setEditOpen(true)
}}
>
<IconEdit2 size={14} />
<p>Edit name</p>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2" onClick={() => setDeleteOpen(true)}>
<IconTrash2 size={14} />
<p>Delete thread</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<ConfirmDeleteThreadModal
key={`${thread_id}-delete-dialog`}
thread={thread}
visible={deleteOpen}
onClose={() => setDeleteOpen(false)}
/>
<EditThreadModal
key={`${thread_id}-edit-dialog`}
thread={thread}
visible={editOpen}
onClose={() => setEditOpen(false)}
/>
</>
)
}
export default Thread
+38
View File
@@ -0,0 +1,38 @@
import { createClient } from '@/lib/supabase/server'
import { Database } from '@/types/supabase'
import { cookies } from 'next/headers'
import EmptyState from './EmptyState'
import Thread from './Thread'
export type ThreadType = Database['public']['Views']['profile_threads']['Row']
async function Threads() {
const supabase = createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return <p>Error fetching user details</p>
const { data } = await supabase
.from('profile_threads')
.select()
.eq('user_id', user.id)
.order('created_at', { ascending: false })
const threads = data ?? []
return (
<div className="flex flex-col gap-y-3">
{threads.length > 0 ? (
threads
.sort()
.map((thread) => <Thread key={`thread-item-${thread.thread_id}`} thread={thread} />)
) : (
<EmptyState />
)}
</div>
)
}
export default Threads
+3
View File
@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <div className="px-4 xl:px-0 xl:max-w-4xl mx-auto h-full">{children}</div>
}
+81
View File
@@ -0,0 +1,81 @@
import { createClient } from '@/lib/supabase/server'
import { default as dayjs, default as relativeTime } from 'dayjs'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import Threads from './Threads'
import { Suspense } from 'react'
import ShimmeringLoader from '@/components/Shimmer'
import type { Metadata } from 'next'
dayjs.extend(relativeTime)
export const metadata: Metadata = {
title: 'database.design | Profile',
}
// revalidatePath not working in the create route handler
// force-dynamic to refetch every time if needed
// just a hack for now
// export const dynamic = 'force-dynamic'
const Profile = async () => {
return (
<div className="grid grid-cols-4 gap-x-8 py-6 xl:py-12 gap-y-6 xl:gap-y-0">
<div className="col-span-4 xl:col-span-1 flex flex-col gap-y-6">
<div className="flex items-center gap-x-4">
<Suspense fallback={<p>Loading user...</p>}>
<ProfileDetails />
</Suspense>
</div>
</div>
<div className="col-span-4 xl:col-span-3 flex flex-col gap-y-4">
<h2>Past conversations</h2>
<div className="w-full h-px border-t" />
<Suspense
fallback={
<div className="flex flex-col gap-4">
<ShimmeringLoader className="h-14 bg-surface-100 border rounded" />
<ShimmeringLoader className="h-14 bg-surface-100 border rounded" />
<ShimmeringLoader className="h-14 bg-surface-100 border rounded" />
<ShimmeringLoader className="h-14 bg-surface-100 border rounded opacity-50" />
<ShimmeringLoader className="h-14 bg-surface-100 border rounded opacity-25" />
<ShimmeringLoader className="h-14 bg-surface-100 border rounded opacity-10" />
</div>
}
>
<Threads />
</Suspense>
</div>
</div>
)
}
async function ProfileDetails() {
const supabase = createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/')
return !user ? (
// make this better!
<p>No user found</p>
) : (
<>
<div
className="border border-foreground-lighter rounded-full w-12 h-12 bg-no-repeat bg-center bg-cover"
style={{ backgroundImage: `url('${user.user_metadata.avatar_url}')` }}
/>
<div className="flex flex-col">
<p className="text-lg">{user.user_metadata.full_name}</p>
<p className="text-foreground-light">@{user.user_metadata.user_name}</p>
</div>
</>
)
}
export default Profile
@@ -0,0 +1,2 @@
export const NODE_WIDTH = 700
export const NODE_HEIGHT = 200
@@ -0,0 +1,127 @@
// 'use client'
// import { useQuery } from '@tanstack/react-query'
// import { Modal } from 'ui'
// import { useParams } from 'next/navigation'
// import { AssistantMessage, ReadThreadAPIResult, UserMessage } from '@/lib/types'
// import { useEffect, useMemo } from 'react'
// import { sortBy } from 'lodash'
// import ReactFlow, {
// Background,
// BackgroundVariant,
// ReactFlowProvider,
// useReactFlow,
// MiniMap,
// } from 'reactflow'
// import { getGraphDataFromMessages } from './AllThreadsModal.utils'
// import MessageNode from './MessageNode'
// interface AllThreadsProps {
// visible: boolean
// onClose: () => void
// onSelectMessage: (messageId: string, replyId: string) => void
// }
// // [Joshen] POC idea for forking/branching - not for phase 1
// const AllThreads = ({ visible, onClose, onSelectMessage }: AllThreadsProps) => {
// const params = useParams()
// const reactFlowInstance = useReactFlow()
// const nodeTypes = useMemo(() => ({ message: MessageNode }), [])
// // [Joshen] Scaffolding a query cause im presuming we need a different endpoint to retrieve _all_ threads
// const { data, isSuccess } = useQuery<ReadThreadAPIResult>({
// queryFn: async () => {
// const response = await fetch(`/api/ai/sql/threads/${params.threadId}/read/${params.runId}`, {
// method: 'GET',
// })
// const result = await response.json()
// return result
// },
// queryKey: [params.threadId, params.runId],
// refetchInterval: (options) => {
// const data = options.state.data
// if (data && data.status === 'completed') {
// return Infinity
// }
// return 5000
// },
// enabled: !!(params.threadId && params.runId),
// })
// const messages = useMemo(() => {
// if (isSuccess) return sortBy(data.messages, (m) => m.created_at)
// return []
// }, [data?.messages, isSuccess])
// const userMessages = messages.filter((m) => m.role === 'user') as UserMessage[]
// useEffect(() => {
// if (reactFlowInstance !== undefined && userMessages.length > 0) {
// const { nodes, edges } = getGraphDataFromMessages({
// messages: userMessages,
// onSelectMessage: (message: UserMessage) => {
// const index = messages.indexOf(message)
// const reply = messages[index + 1] as AssistantMessage
// onSelectMessage(message.id, reply.id)
// },
// })
// reactFlowInstance.setNodes(nodes)
// reactFlowInstance.setEdges(edges)
// setTimeout(() => {
// reactFlowInstance.fitView({})
// const viewport = reactFlowInstance.getViewport()
// reactFlowInstance.setViewport({ x: viewport.x - 70, y: 150, zoom: 1 })
// })
// }
// }, [reactFlowInstance, userMessages, messages, onSelectMessage])
// useEffect(() => {
// if (reactFlowInstance !== undefined && visible) {
// reactFlowInstance.fitView({})
// }
// }, [reactFlowInstance, visible])
// return (
// <Modal
// showCloseButton
// hideFooter
// size="xxlarge"
// visible={visible}
// onCancel={onClose}
// header="All threads in current conversation"
// >
// <div className="h-[700px] border-t bg-background">
// <ReactFlow
// nodesDraggable={false}
// edgesUpdatable={false}
// defaultNodes={[]}
// defaultEdges={[]}
// maxZoom={1.1}
// nodeTypes={nodeTypes}
// proOptions={{ hideAttribution: true }}
// >
// <Background gap={16} color="#000" variant={BackgroundVariant.Lines} />
// <MiniMap
// pannable
// zoomable
// nodeColor={'rgba(0,0,0,0.7)'}
// maskColor={'rgba(0,0,0,0.85)'}
// className="border border-control rounded-md shadow-sm"
// />
// </ReactFlow>
// </div>
// </Modal>
// )
// }
// const AllThreadsModal = ({ visible, onClose, onSelectMessage }: AllThreadsProps) => {
// return (
// <ReactFlowProvider>
// <AllThreads visible={visible} onClose={onClose} onSelectMessage={onSelectMessage} />
// </ReactFlowProvider>
// )
// }
// export default AllThreadsModal
@@ -0,0 +1,65 @@
// import { UserMessage } from '@/lib/types'
// import dagre from '@dagrejs/dagre'
// import { Edge, Node, Position } from 'reactflow'
// import { NODE_HEIGHT, NODE_WIDTH } from './AllThreadsModal.constants'
// import { MessageNodeData } from './MessageNode'
// export const getGraphDataFromMessages = ({
// messages,
// onSelectMessage,
// }: {
// messages: UserMessage[]
// onSelectMessage: (message: UserMessage) => void
// }): { nodes: Node<MessageNodeData>[]; edges: Edge[] } => {
// const nodes: Node[] = messages.map((message, idx) => {
// return {
// id: message.id,
// type: 'message',
// data: {
// id: message.id,
// text: message.text,
// isStart: idx === 0,
// isEnd: idx === messages.length - 1,
// onSelectMessage: () => onSelectMessage(message),
// },
// position: { x: 0, y: 0 },
// }
// })
// const edges: Edge[] = []
// messages.forEach((message, idx) => {
// if (idx > 0) {
// edges.push({
// id: `edge-${idx}`,
// source: messages[idx - 1].id,
// target: message.id,
// type: 'smoothstep',
// animated: true,
// })
// }
// })
// const dagreGraph = new dagre.graphlib.Graph()
// dagreGraph.setDefaultEdgeLabel(() => ({}))
// dagreGraph.setGraph({ rankdir: 'TB', ranksep: 50, nodesep: 20 })
// nodes.forEach((node) =>
// dagreGraph.setNode(node.id, { width: NODE_WIDTH / 2, height: NODE_HEIGHT / 2 })
// )
// edges.forEach((edge) => dagreGraph.setEdge(edge.source, edge.target))
// dagre.layout(dagreGraph)
// nodes.forEach((node) => {
// const nodeWithPosition = dagreGraph.node(node.id)
// node.targetPosition = Position.Top
// node.sourcePosition = Position.Bottom
// node.position = {
// x: nodeWithPosition.x - nodeWithPosition.width / 2,
// y: nodeWithPosition.y - nodeWithPosition.height / 2,
// }
// return node
// })
// return { nodes, edges }
// }
@@ -0,0 +1,44 @@
// import { Handle, NodeProps, Position } from 'reactflow'
// import { NODE_HEIGHT, NODE_WIDTH } from './AllThreadsModal.constants'
// export interface MessageNodeData {
// id: string
// text: string
// isStart: boolean
// isEnd: boolean
// onSelectMessage: () => void
// }
// const MessageNode = ({ data }: NodeProps<MessageNodeData>) => {
// const { id, text, isStart, isEnd, onSelectMessage } = data
// return (
// <>
// {!isStart && (
// <Handle
// type="target"
// id="handle-t"
// position={Position.Top}
// style={{ background: 'transparent' }}
// />
// )}
// <div
// className="flex flex-col gap-y-1 rounded bg-surface-100 border border-default p-3 hover:bg-surface-200 transition cursor-pointer"
// style={{ width: NODE_WIDTH / 2 - 10, height: NODE_HEIGHT / 2 }}
// onClick={() => onSelectMessage()}
// >
// <p className="text-xs text-foreground-light font-mono">{id}</p>
// <p className="text-sm">{text}</p>
// </div>
// {!isEnd && (
// <Handle
// type="source"
// id="handle-s"
// position={Position.Bottom}
// style={{ background: 'transparent' }}
// />
// )}
// </>
// )
// }
// export default MessageNode
@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react'
// something a little funny here maybe?
const textArray = [
"We're working hard to compute your results",
'Installing a new flux capacitor',
'Reconfiguring the warp drive',
'Analyzing the space modulator',
'etc....',
]
// this is just an idea.
// maybe a bad one
// needs better animation if we're keeping it
const ChatLoadingAnimation = () => {
const [currentTextIndex, setCurrentTextIndex] = useState(0)
const [currentText, setCurrentText] = useState(textArray[0])
useEffect(() => {
const interval = setInterval(() => {
setCurrentTextIndex((prevIndex) => (prevIndex + 1) % textArray.length)
setCurrentText(textArray[currentTextIndex])
}, 2000)
return () => {
clearInterval(interval)
}
}, [currentTextIndex, textArray])
return (
<div>
<h1>{currentText}</h1>
</div>
)
}
export default ChatLoadingAnimation
@@ -0,0 +1,31 @@
import { CHAT_EXAMPLES } from '@/data/chat-examples'
import { cn } from '@ui/lib/utils/cn'
import { ExternalLink } from 'lucide-react'
export const ChatSuggestions = ({ setInput }: { setInput: (s: string) => void }) => {
const suggestions = CHAT_EXAMPLES
return (
<div className="flex gap-3 mt-4">
{suggestions.map((suggestion, idx) => (
<button
key={idx}
type="button"
className={cn(
'text-xs',
'flex items-center gap-3 !pr-3',
'transition border rounded-full px-3 py-1.5',
'text-light',
'hover:border-stronger hover:text'
)}
onClick={(event) => {
setInput(suggestion.prompt)
event.preventDefault()
}}
>
{suggestion.label}
<ExternalLink size={12} />
</button>
))}
</div>
)
}
@@ -0,0 +1,10 @@
'use server'
import { upsertThreadMessage } from '@/app/actions'
export async function upsertMessageFormAction(formData: FormData) {
const threadId = formData.get('threadId') as string | undefined
const prompt = formData.get('prompt') as string
return upsertThreadMessage(prompt, threadId)
}
@@ -0,0 +1,148 @@
'use client'
import { Loader2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { ChangeEvent, FormHTMLAttributes, ReactNode, forwardRef, useRef } from 'react'
import { useFormStatus } from 'react-dom'
import { TextArea_Shadcn_, cn } from 'ui'
import { ChatSuggestions } from './ChatSuggestions'
import { upsertMessageFormAction } from './action'
export interface FormProps extends FormHTMLAttributes<HTMLFormElement> {
loading?: boolean
disabled?: boolean
message?: string
children?: ReactNode
chatContext: 'new' | 'edit'
}
const SubmitButton = forwardRef<HTMLButtonElement, { isLoading: boolean; canSubmit: boolean }>(
({ canSubmit }, ref) => {
const { pending } = useFormStatus()
return (
<div className="absolute right-1.5 top-1.5 flex gap-3 items-center">
{pending && (
<Loader2 size={22} className="animate-spin w-7 h-7 text-muted" strokeWidth={1} />
)}
<button
title="Send AI prompt"
ref={ref}
type="submit"
disabled={pending || canSubmit}
className={cn(
'transition-all',
'flex items-center justify-center w-7 h-7 border border-control rounded-full mr-0.5 p-1.5 background-alternative',
canSubmit ? 'text-default opacity-100' : 'text-muted opacity-50',
pending ? 'hidden' : ''
)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.5 3V2.25H15V3V10C15 10.5523 14.5522 11 14 11H3.56062L5.53029 12.9697L6.06062 13.5L4.99996 14.5607L4.46963 14.0303L1.39641 10.9571C1.00588 10.5666 1.00588 9.93342 1.39641 9.54289L4.46963 6.46967L4.99996 5.93934L6.06062 7L5.53029 7.53033L3.56062 9.5H13.5V3Z"
fill="currentColor"
></path>
</svg>
</button>
</div>
)
}
)
SubmitButton.displayName = 'SubmitButton'
interface AssistantChatFormProps {
chatContext: string
placeholder: string
canSubmit?: () => Promise<boolean>
}
const AssistantChatForm = ({
chatContext,
placeholder,
canSubmit = () => Promise.resolve(true),
}: AssistantChatFormProps) => {
const { thread_id } = useParams()
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const disabled = false
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!e.shiftKey && (e.key === 'Enter' || e.key === 'NumpadEnter')) {
e.preventDefault()
e.currentTarget.form?.requestSubmit()
}
}
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
const target = e.target
let newHeight = target.scrollHeight + 'px'
const input = target.value
if (!input) {
newHeight = '40px'
}
if (target.style.height !== newHeight) {
target.style.height = newHeight
}
}
return (
<>
<form
className="relative"
action={async (formData) => {
const flag = await canSubmit()
if (flag) {
upsertMessageFormAction(formData)
}
}}
>
<div className={cn('absolute', 'top-2 left-2', 'ml-1 w-6 h-6 rounded-full bg-dbnew')}></div>
<input hidden name="threadId" defaultValue={thread_id} />
<TextArea_Shadcn_
name="prompt"
ref={textAreaRef}
autoFocus
rows={1}
defaultValue=""
disabled={disabled}
contentEditable
required
className={
'transition-all text-sm pl-12 pr-10 rounded-[18px] resize-none box-border leading-6'
}
style={{ height: '40px' }}
placeholder={placeholder}
spellCheck={false}
onKeyDown={handleKeyDown}
onChange={handleChange}
/>
<SubmitButton
canSubmit={(textAreaRef?.current?.value || '').length > 0}
isLoading={disabled}
/>
</form>
{chatContext === 'new' && (
<ChatSuggestions
setInput={(v) => {
if (textAreaRef?.current) textAreaRef.current.value = v
}}
/>
)}
</>
)
}
AssistantChatForm.displayName = 'AssistantChatForm'
export { AssistantChatForm }
@@ -0,0 +1,64 @@
import { Button } from '@ui/components/Button'
import { Github } from 'lucide-react'
import { cookies, headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
export default function LoginForm({ searchParams }: { searchParams?: { message?: string } }) {
const signUp = async () => {
'use server'
const origin = headers().get('origin')
const supabase = createClient()
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: { redirectTo: `${origin}/auth/callback` },
})
if (error) {
console.log(error.message)
return redirect(`/login?message=${error.message}`)
} else {
return redirect(data.url)
}
}
return (
<div className="flex flex-col justify-center h-full w-full px-8 sm:max-w-sm gap-2 mx-auto">
<form
className="animate-in flex-1 flex flex-col w-full justify-center gap-2 text-foreground"
action={signUp}
>
<div className="flex items-center gap-x-2 justify-center mb-4">
<span className="text-foreground-light">Sign in to</span>{' '}
<div className="flex items-center gap-x-1.5 font-mono font-bold">
<span>database</span>
<div className="w-1.5 h-1.5 rounded-full bg-purple-900"></div>
<span>design</span>
</div>
</div>
<div className="grid gap-2 mb-4">
<p className="text-center text-sm ">You need to sign in to generate a schema</p>
<p className="text-center text-xs">Takes just a few seconds</p>
</div>
<Button
className="border text-sm bg-surface-100 rounded-md px-4 py-2 text-foreground mb-2 flex items-center justify-center gap-x-2"
type="default"
htmlType="submit"
icon={<Github size={18} />}
>
Sign in with Github
</Button>
{searchParams?.message && (
<p className="mt-4 p-4 bg-foreground/10 text-foreground text-center">
{searchParams.message}
</p>
)}
</form>
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
import Link from 'next/link'
import ThemeSwitcher from './Header/ThemeSwitcher'
export const links = [
{ title: ` © Supabase`, url: 'https://supabase.com/' },
{ title: 'FAQs', url: '/faq' },
{ title: 'Open Source', url: 'https://supabase.com/open-source' },
{ title: 'Privacy Settings', url: 'https://supabase.com/privacy' },
]
const Footer = () => (
<footer className="border-t py-4 w-full px-4 flex justify-between" role="contentinfo">
<ul className="flex items-center gap-4 text-xs" role="menu">
{links.map((link, index) => (
<li key={index} role="menuitem">
<Link href={link.url}>{link.title}</Link>
</li>
))}
</ul>
<ThemeSwitcher />
</footer>
)
export default Footer
@@ -0,0 +1,55 @@
'use client'
import { logout } from '@/app/actions'
import { User } from '@supabase/supabase-js'
import { HelpCircle, LogOut } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, IconUser } from 'ui'
interface AvatarDropdownProps {
user: User
}
export default function AvatarDropdown({ user }: AvatarDropdownProps) {
return (
<>
<div className="px-2 py-2">
<p className="text-sm text-foreground">{user.user_metadata.full_name}</p>
<p className="text-sm text-foreground-light">{user.user_metadata.email}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link href="/profile">
<DropdownMenuItem className="space-x-2" onClick={() => {}}>
<IconUser size={14} strokeWidth={1.5} className="text-lighter" />
<p>Profile</p>
</DropdownMenuItem>
</Link>
<Link href="/faq">
<DropdownMenuItem className="space-x-2" onClick={() => {}}>
<HelpCircle size={14} strokeWidth={1.5} className="text-lighter" />
<p>FAQs</p>
</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<a href="https://supabase.com" target="_blank" rel="noreferrer">
<DropdownMenuItem className="space-x-2">
<Image alt="supabase" src="/supabase.png" width={14} height={14} />
<p>Supabase</p>
</DropdownMenuItem>
</a>
</DropdownMenuGroup>
<form action={logout}>
<DropdownMenuItem className="space-x-2 w-full" asChild>
<button type="submit">
<LogOut size={14} strokeWidth={1.5} className="text-lighter" />
<p>Sign out</p>
</button>
</DropdownMenuItem>
</form>
</>
)
}
@@ -0,0 +1,29 @@
import Link from 'next/link'
import HeaderActions from './HeaderActions'
import UserDropdown from './UserDropdown'
const Header = async () => {
return (
<nav
role="navigation"
className="bg-background border flex items-center justify-between px-4 min-h-[50px]"
>
<div className="flex items-center gap-x-4">
<Link href="/">
<div className="flex items-center gap-x-1.5 font-mono">
<span>database</span>
<div className="w-1.5 h-1.5 rounded-full bg-dbnew"></div>
<span>design</span>
</div>
</Link>
</div>
<div className="flex gap-3">
<HeaderActions />
<UserDropdown />
</div>
</nav>
)
}
export default Header
@@ -0,0 +1,29 @@
'use client'
import Link from 'next/link'
import { useSelectedLayoutSegment } from 'next/navigation'
import { Button } from 'ui'
import SaveSchemaDropdown from './SaveSchemaDropdown'
import ToggleCodeEditorButton from './ToggleCodeEditorButton'
const HeaderActions = () => {
const segment = useSelectedLayoutSegment()
return (
<div className="flex items-center gap-x-2">
{segment && segment.includes('thread') && (
<div className="hidden xl:flex items-center gap-x-2">
<ToggleCodeEditorButton />
<SaveSchemaDropdown />
<div className="border-r py-3" />
</div>
)}
<Button type="default" className="hidden xl:block">
<Link href="/">New conversation</Link>
</Button>
</div>
)
}
export default HeaderActions
@@ -0,0 +1,32 @@
import { LogIn } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { DropdownMenuItem, DropdownMenuSeparator } from 'ui'
const NoUserDropdown = () => {
return (
<>
<div className="flex flex-col gap-y-2 p-2">
<div className="text-sm">
Sign in to <span className="text-foreground">database.design</span> to save your database
designs!
</div>
</div>
<Link href="/login">
<DropdownMenuItem className="space-x-2">
<LogIn size={14} className="text-lighter" />
<p>Sign in</p>
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<a href="https://supabase.com" target="_blank" rel="noreferrer">
<DropdownMenuItem className="space-x-2">
<Image alt="supabase" src="/supabase.png" width={14} height={14} />
<p>Supabase</p>
</DropdownMenuItem>
</a>
</>
)
}
export default NoUserDropdown
@@ -0,0 +1,72 @@
'use client'
import { getAppStateSnapshot } from '@/lib/state'
import Image from 'next/image'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconChevronDown,
IconClipboard,
IconDownload,
} from 'ui'
const SaveSchemaDropdown = () => {
const copyToClipboard = () => {
const snap = getAppStateSnapshot()
const focused = window.document.hasFocus()
if (focused) {
window.navigator?.clipboard?.writeText(snap.selectedCode)
} else {
console.warn('Unable to copy to clipboard')
}
}
const downloadSQL = () => {
const snap = getAppStateSnapshot()
const blob = new Blob([snap.selectedCode], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.download = 'migration.sql'
link.href = url
link.click()
}
const loadSQLInSupabase = () => {
const snap = getAppStateSnapshot()
window.open(
`https://supabase.com/dashboard/project/_/sql?content=${encodeURIComponent(
snap.selectedCode
)}`,
'_blank'
)
}
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<IconChevronDown />}>
Save schema
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-44" align="end">
<DropdownMenuItem className="space-x-2" onClick={() => copyToClipboard()}>
<IconClipboard size={14} strokeWidth={1.5} className="text-lighter" />
<p>Copy SQL</p>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2" onClick={() => downloadSQL()}>
<IconDownload size={14} strokeWidth={1.5} className="text-lighter" />
<p>Download SQL</p>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2" onClick={() => loadSQLInSupabase()}>
<Image alt="supabase" src="/supabase.png" width={14} height={14} />
<p>Load SQL in Supabase</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default SaveSchemaDropdown
@@ -0,0 +1,6 @@
'use client'
import { ThemeToggle } from 'ui-patterns/ThemeToggle'
export default function ThemeSwitcher() {
return <ThemeToggle />
}
@@ -0,0 +1,15 @@
'use client'
import { useAppStateSnapshot } from '@/lib/state'
import { Button } from 'ui'
const ToggleCodeEditorButton = () => {
const snap = useAppStateSnapshot()
return (
<Button type="default" onClick={() => snap.setHideCode(!snap.hideCode)}>
{snap.hideCode ? 'Show code' : 'Hide code'}
</Button>
)
}
export default ToggleCodeEditorButton
@@ -0,0 +1,48 @@
import { createClient } from '@/lib/supabase/server'
import { User2 } from 'lucide-react'
import { cookies } from 'next/headers'
import { Suspense } from 'react'
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui'
import AvatarDropdown from './AvatarDropdown'
import NoUserDropdown from './NoUserDropdown'
const EmptyUserButton = () => (
<Button type="outline" className="p-1.5 rounded-full" icon={<User2 size={16} />} />
)
export function UserDropdown() {
return (
<Suspense fallback={<EmptyUserButton />}>
<Dropdowns />
</Suspense>
)
}
async function Dropdowns() {
const supabase = createClient()
const {
data: { user },
} = await supabase.auth.getUser()
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild className="flex">
{user ? (
<button
title="User dropdown menu"
className="border border-foreground-lighter rounded-full w-[30px] h-[30px] bg-no-repeat bg-center bg-cover"
style={{ backgroundImage: `url('${user.user_metadata.avatar_url}')` }}
/>
) : (
<EmptyUserButton />
)}
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-64">
{user ? <AvatarDropdown user={user} /> : <NoUserDropdown />}
</DropdownMenuContent>
</DropdownMenu>
)
}
export default UserDropdown
@@ -0,0 +1,30 @@
import { Loader2 } from 'lucide-react'
const GraphLoader = () => {
return (
<div className="h-full w-full text-muted justify-start border-l">
<div className="flex gap-4">
<LineNumbers />
<div>
<div className="mt-4 flex items-center gap-2">
<Loader2 className="animate-spin opacity-100" size={16} strokeWidth={1} />
<span className="text-sm">Loading...</span>
</div>
</div>
</div>
</div>
)
}
export default GraphLoader
const LineNumbers = () => {
const lines = [1, 2, 3, 4, 5, 6]
return (
<div className="grid gap-2 border-r py-4 px-4 text-sm font-mono text-muted">
{lines.map((line) => (
<span key={line}>{line}</span>
))}
</div>
)
}
@@ -0,0 +1,14 @@
import { Loader2 } from 'lucide-react'
const SchemaLoader = () => {
return (
<div className="h-full w-full text-muted justify-start">
<div className="flex items-center m-4 gap-2">
<Loader2 className="animate-spin opacity-100" size={16} strokeWidth={1} />
<span className="text-sm">Loading...</span>
</div>
</div>
)
}
export default SchemaLoader
@@ -0,0 +1,20 @@
export const getTheme = (theme: string) => {
const isDarkMode = theme.includes('dark')
return {
base: isDarkMode ? 'vs-dark' : 'vs', // can also be vs-dark or hc-black
inherit: true, // can also be false to completely replace the builtin rules
rules: [
{ token: '', background: isDarkMode ? '1f1f1f' : 'f0f0f0' },
{
token: '',
background: isDarkMode ? '1f1f1f' : 'f0f0f0',
foreground: isDarkMode ? 'd4d4d4' : '444444',
},
{ token: 'string.sql', foreground: '24b47e' },
{ token: 'comment', foreground: '666666' },
{ token: 'predefined.sql', foreground: isDarkMode ? 'D4D4D4' : '444444' },
],
colors: { 'editor.background': isDarkMode ? '#1f1f1f' : '#f0f0f0' },
}
}
@@ -0,0 +1,101 @@
'use client'
import Editor, { BeforeMount, EditorProps } from '@monaco-editor/react'
import { merge } from 'lodash'
interface MonacoEditorProps {
id: string
language: 'pgsql' | 'json' | 'html'
autofocus?: boolean
defaultValue?: string
isReadOnly?: boolean
onInputChange?: (value?: string) => void
onInputRun?: (value: string) => void
hideLineNumbers?: boolean
loading?: boolean
options?: EditorProps['options']
value?: string
}
const MonacoEditor = ({
id,
language,
defaultValue,
hideLineNumbers = false,
options,
value,
}: MonacoEditorProps) => {
// const monacoRef = useRef<any>()
// const { resolvedTheme } = useTheme()
const beforeMount: BeforeMount = (monaco) => {
// monacoRef.current = monaco
monaco.editor.defineTheme('supabase', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: '', background: '1f1f1f' },
{ token: '', background: '1f1f1f', foreground: 'd4d4d4' },
{ token: 'string.sql', foreground: '24b47e' },
{ token: 'comment', foreground: '666666' },
{ token: 'predefined.sql', foreground: 'D4D4D4' },
],
colors: { 'editor.background': '#1f1f1f' },
})
}
// const onMount: OnMount = async (editor) => {
// // Add margin above first line
// editor.changeViewZones((accessor) => {
// accessor.addZone({
// afterLineNumber: 0,
// heightInPx: 4,
// domNode: document.createElement('div'),
// })
// })
// if (resolvedTheme) {
// const mode: any = getTheme(resolvedTheme)
// monacoRef.current.editor.defineTheme('supabase', mode)
// }
// }
const optionsMerged = merge(
{
tabSize: 2,
fontSize: 13,
readOnly: true,
minimap: { enabled: false },
wordWrap: 'on',
fixedOverflowWidgets: true,
contextmenu: true,
lineNumbers: hideLineNumbers ? 'off' : undefined,
glyphMargin: hideLineNumbers ? false : undefined,
lineNumbersMinChars: hideLineNumbers ? 0 : undefined,
occurrencesHighlight: false,
folding: hideLineNumbers ? false : undefined,
renderLineHighlight: 'none',
selectionHighlight: false,
},
options
)
merge({ cpp: '12' }, { java: '23' }, { python: '35' })
return (
<Editor
path={id}
theme="supabase"
className={'bg-alternative [&>div]:p-0'}
value={value ?? undefined}
defaultLanguage={language}
defaultValue={defaultValue ?? undefined}
loading={false}
options={optionsMerged}
beforeMount={beforeMount}
// onMount={onMount}
/>
)
}
export { MonacoEditor }
@@ -0,0 +1,3 @@
// ReactFlow is scaling everything by the factor of 2
export const NODE_WIDTH = 320
export const NODE_ROW_HEIGHT = 40
@@ -0,0 +1,169 @@
import { PostgresTable } from '@/lib/types'
import dagre from '@dagrejs/dagre'
import { uniqBy } from 'lodash'
import { Edge, Node, Position } from 'reactflow'
import { TableNodeData } from 'ui-patterns/SchemaTableNode'
import { NODE_ROW_HEIGHT, NODE_WIDTH } from './SchemaFlow.constants'
export async function getGraphDataFromTables(tables: PostgresTable[]): Promise<{
nodes: Node<TableNodeData>[]
edges: Edge[]
}> {
if (!tables.length) {
return { nodes: [], edges: [] }
}
const nodes = tables.map((table) => {
const columns = (table.columns || []).map((column) => {
return {
id: column.id,
isPrimary: table.primary_keys.some((pk) => pk.name === column.name),
name: column.name,
format: column.format,
isNullable: column.is_nullable,
isUnique: column.is_unique,
isIdentity: column.is_identity,
}
})
return {
id: `${table.id}`,
type: 'table',
data: {
name: table.name,
isForeign: false,
columns,
},
position: { x: 0, y: 0 },
}
})
const edges: Edge[] = []
// const currentSchema = tables[0].schema
const uniqueRelationships = uniqBy(
tables.flatMap((t) => t.relationships),
'id'
)
for (const rel of uniqueRelationships) {
// TODO: Support [external->this] relationship?
// if (rel.source_schema !== currentSchema) {
// continue
// }
// Create additional [this->foreign] node that we can point to on the graph.
// if (rel.target_table_schema !== currentSchema) {
// nodes.push({
// id: rel.constraint_name,
// type: 'table',
// data: {
// name: `${rel.target_table_schema}.${rel.target_table_name}.${rel.target_column_name}`,
// isForeign: true,
// columns: [],
// },
// position: { x: 0, y: 0 },
// })
// const [source, sourceHandle] = findTablesHandleIds(
// tables,
// rel.source_table_name,
// rel.source_column_name
// )
// if (source) {
// edges.push({
// id: String(rel.id),
// source,
// sourceHandle,
// target: rel.constraint_name,
// targetHandle: rel.constraint_name,
// })
// }
// continue
// }
const [source, sourceHandle] = findTablesHandleIds(
tables,
rel.source_table_name,
rel.source_column_name
)
const [target, targetHandle] = findTablesHandleIds(
tables,
rel.target_table_name,
rel.target_column_name
)
// We do not support [external->this] flow currently.
if (source && target) {
edges.push({
id: String(rel.id),
source,
sourceHandle,
target,
targetHandle,
})
}
}
return getLayoutedElements(nodes, edges)
}
function findTablesHandleIds(
tables: PostgresTable[],
table_name: string,
column_name: string
): [string?, string?] {
for (const table of tables) {
if (table_name !== table.name) continue
for (const column of table.columns || []) {
if (column_name !== column.name) continue
return [String(table.id), column.id]
}
}
return []
}
const getLayoutedElements = (nodes: Node[], edges: Edge[]) => {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({
rankdir: 'TB',
align: 'UR',
nodesep: 25,
ranksep: 50,
})
nodes.forEach((node) => {
dagreGraph.setNode(node.id, {
width: NODE_WIDTH / 2,
height: (NODE_ROW_HEIGHT / 2) * (node.data.columns.length + 1), // columns + header
})
})
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target)
})
dagre.layout(dagreGraph)
nodes.forEach((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
node.targetPosition = Position.Left
node.sourcePosition = Position.Right
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
node.position = {
x: nodeWithPosition.x - nodeWithPosition.width / 2,
y: nodeWithPosition.y - nodeWithPosition.height / 2,
}
return node
})
return { nodes, edges }
}
@@ -0,0 +1,98 @@
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useMemo, useState } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
ReactFlowProvider,
useReactFlow,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { PostgresTable } from '@/lib/types'
import { parseTables } from '@/lib/utils'
import { getGraphDataFromTables } from './SchemaFlow.utils'
import { TableNode } from 'ui-patterns'
interface SchemaGraphProps {
tables?: PostgresTable[]
content: string
}
const SchemaFlowHandler = ({ content }: SchemaGraphProps) => {
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const [tables, setTables] = useState<PostgresTable[]>([])
const reactFlowInstance = useReactFlow()
const nodeTypes = useMemo(() => ({ table: TableNode }), [])
useEffect(() => {
setMounted(true)
const parseTableData = async () => {
try {
const tables = await parseTables(content)
setTables(tables)
} catch (error) {
console.log('error', error)
}
}
parseTableData()
}, [])
useEffect(() => {
getGraphDataFromTables(tables).then(({ nodes, edges }) => {
reactFlowInstance.setNodes(nodes)
reactFlowInstance.setEdges(edges)
setTimeout(() => reactFlowInstance.fitView({ minZoom: 1 }), 50)
})
}, [tables, resolvedTheme, reactFlowInstance])
return (
<>
<div className="h-full grow fade-in">
<ReactFlow
defaultNodes={[]}
defaultEdges={[]}
defaultEdgeOptions={{
type: 'smoothstep',
animated: true,
deletable: false,
style: {
stroke: 'hsl(var(--border-stronger))',
strokeWidth: 0.5,
},
}}
nodeTypes={nodeTypes}
minZoom={0.8}
maxZoom={1.8}
proOptions={{ hideAttribution: true }}
onInit={(instance) => {
instance.fitView()
}}
>
{mounted && (
<Background
gap={16}
className="[&>*]:stroke-foreground-muted opacity-[25%]"
variant={BackgroundVariant.Dots}
color={'inherit'}
/>
)}
</ReactFlow>
</div>
</>
)
}
const ExportedSchemaGraph = ({ content }: SchemaGraphProps) => {
return (
<ReactFlowProvider>
<SchemaFlowHandler content={content} />
</ReactFlowProvider>
)
}
export default ExportedSchemaGraph
+24
View File
@@ -0,0 +1,24 @@
const ANIMATION_DELAY = 150
const ShimmeringLoader = ({ className = '', delayIndex = 0, animationDelay = ANIMATION_DELAY }) => {
return (
<div
className={`shimmering-loader rounded py-3 ${className}`}
style={{
animationFillMode: 'backwards',
animationDelay: `${delayIndex * animationDelay}ms`,
}}
/>
)
}
const GenericSkeletonLoader = () => (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)
export { GenericSkeletonLoader }
export default ShimmeringLoader
@@ -0,0 +1,11 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PropsWithChildren } from 'react'
const queryClient = new QueryClient()
export function ReactQueryProvider({ children }: PropsWithChildren) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
export { ThemeProvider } from 'common'
+15
View File
@@ -0,0 +1,15 @@
const CHAT_EXAMPLES = [
{ label: 'Twitter clone', prompt: 'Create a twitter clone' },
{
label: 'Chat application',
prompt:
'Create a chat application that supports sending messages either through channels or directly between users',
},
{
label: 'User management',
prompt: 'Create a simple user management schema that supports role based access control',
},
{ label: 'To do list', prompt: 'Create a simple schema for me to track a to do list' },
]
export { CHAT_EXAMPLES }
+32
View File
@@ -0,0 +1,32 @@
export type faq = {
question: string
answer: string
}
export const FAQS = [
{
question: 'What is database.design?',
answer:
'database.design is a new way to get ideas out of your head and into code as fast as possible. Supabase provides you tools to making building incredibly fast. This tool focuses on the very first step: turning your idea into a database schema.',
},
{
question: 'How does it work?',
answer: `We use OpenAI under the hood to provide responses to your prompts. Just like ChatGPT, you can fine-tune your responses until you're happy with the schema. We're using React Flow to visualize the schemas.`,
},
{
question: 'Can I see the code?',
answer: 'You sure can. All of the source code is open source and available on Github. ',
},
{
question: 'Can I contribute to this project?',
answer: 'You sure can. All of the source code is open source and available on Github. ',
},
{
question: `What's the license? Can we fork this project and build our own?`,
answer: 'Yes. We release software under the Apache License 2.0.',
},
{
question: 'Can I use this for non-Postgres databases?',
answer: `Not at the moment. We've tailored the responses to try and do a great job returning Postgres-flavored SQL`,
},
]
+27
View File
@@ -0,0 +1,27 @@
import { proxy, snapshot, useSnapshot } from 'valtio'
export const appState = proxy({
hideCode: false,
setHideCode: (value: boolean) => {
appState.hideCode = value
},
selectedCode: '',
setSelectedCode: (value: string) => {
appState.selectedCode = value
},
loginDialogOpen: false,
setLoginDialogOpen: (value: boolean) => {
appState.loginDialogOpen = value
},
runsLoading: [] as string[],
setRunsLoading: (value: string[]) => {
appState.runsLoading = value
},
})
export const getAppStateSnapshot = () => snapshot(appState)
export const useAppStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) =>
useSnapshot(appState, options)
+8
View File
@@ -0,0 +1,8 @@
import { Database } from '@/types/supabase'
import { createBrowserClient } from '@supabase/ssr'
export const createClient = () =>
createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
+37
View File
@@ -0,0 +1,37 @@
import { Database } from '@/types/supabase'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
export const createClient = () => {
const cookieStore = cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options })
} catch (error) {
// The `set` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options })
} catch (error) {
// The `delete` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}
+45
View File
@@ -0,0 +1,45 @@
export type GeneratedTable = Partial<PostgresTable>
export type AssistantMessage = {
id: string
role: 'assistant'
created_at: number
sql: string
json: GeneratedTable[]
}
export type UserMessage = {
id: string
role: 'user'
created_at: number
text: string
}
export type Message = AssistantMessage | UserMessage
export type ReadThreadAPIResult = {
id: string
status: 'loading' | 'completed'
messages: Message[]
}
export type PostgresColumn = {
id: string
name: string
format: string
is_nullable: boolean
is_unique: boolean
is_identity: boolean
}
export type PostgresTable = {
id: string
name: string
primary_keys: { name: string }[]
relationships: {
id: string
target_table_name: string
target_column_name: string
source_table_name: string
source_column_name: string
}[]
columns: PostgresColumn[]
}
+291
View File
@@ -0,0 +1,291 @@
import dayjs from 'dayjs'
import { parseQuery } from 'libpg-query'
import { compact } from 'lodash'
import { z } from 'zod'
import { PostgresColumn, PostgresTable } from './types'
const NameDefinition = z.union([
z.object({
sval: z.string(),
}),
z.object({
str: z.string(),
}),
])
const constraintDefinitionSchema = z.object({
Constraint: z.discriminatedUnion('contype', [
z
.object({
contype: z.literal('CONSTR_PRIMARY'),
keys: z
.array(
z.object({
String: z.object({
sval: z.string(),
}),
})
)
.optional(),
})
.passthrough(),
z.object({
contype: z.literal('CONSTR_IDENTITY'),
}),
z.object({
contype: z.literal('CONSTR_NOTNULL'),
}),
z.object({
contype: z.literal('CONSTR_UNIQUE'),
}),
z.object({
contype: z.literal('CONSTR_DEFAULT'),
}),
z.object({
contype: z.literal('CONSTR_FOREIGN'),
pktable: z.object({
relname: z.string(),
}),
fk_attrs: z
.array(
z.object({
String: NameDefinition,
})
)
.optional(),
pk_attrs: z.array(
z.object({
String: NameDefinition,
})
),
}),
]),
})
const columnDefinitionSchema = z.object({
ColumnDef: z.object({
colname: z.string(),
typeName: z.object({
names: z.array(z.object({ String: NameDefinition })),
}),
constraints: z.array(constraintDefinitionSchema).optional(),
}),
})
const tableDefinitionSchema = z.object({
CreateStmt: z
.object({
relation: z.object({
relname: z.string(),
}),
tableElts: z.array(z.union([columnDefinitionSchema, constraintDefinitionSchema])),
})
// the optional() allows other types of statements, but only CreateStmt will be type-safe, we
// don't care for the other ones.
.optional(),
})
const parseQueryResultSchema = z.object({
stmts: z.array(
z.object({
stmt: tableDefinitionSchema,
})
),
})
const extractNameDefinition = (obj: z.infer<typeof NameDefinition>) => {
if ('sval' in obj) {
return obj?.sval
}
if ('str' in obj) {
return obj?.str
}
return null
}
/**
* Parses SQL into tables compatible with the existing schema visualizer.
*
* TODO: consider running in WebWorker
*/
export async function parseTables(sql: string) {
// Parse SQL using the real Postgres parser (compiled to WASM)
const result = await parseQuery(sql)
const parsedSql = parseQueryResultSchema.safeParse(result)
if (!parsedSql.success) {
console.log(parsedSql.error)
return []
}
// This code generates all columns with their constraints
const pgTables: PostgresTable[] = compact(
parsedSql.data.stmts.map(({ stmt }) => {
if (!stmt.CreateStmt) {
return
}
const statement = stmt.CreateStmt
const columns = compact(
statement.tableElts.map((column) => {
if ('ColumnDef' in column) {
const format = compact(
column.ColumnDef.typeName.names
.map((name) => extractNameDefinition(name.String))
.filter((str) => str !== 'pg_catalog' || !!str)
)[0]
if (!format) {
return undefined
}
const constraints = (column.ColumnDef.constraints || []).map(
(c) => c.Constraint.contype
)
const result: PostgresColumn = {
name: column.ColumnDef.colname,
format: format,
id: column.ColumnDef.colname,
is_nullable: !constraints.includes('CONSTR_NOTNULL'),
is_unique: constraints.includes('CONSTR_UNIQUE'),
is_identity: constraints.includes('CONSTR_IDENTITY'),
}
return result
}
})
)
// This code processes user_id bigint references users (id) SQL.
const columnRelationships = compact(
statement.tableElts.map((column) => {
if ('ColumnDef' in column) {
const found = (column.ColumnDef.constraints || []).find(
(c) => c.Constraint.contype === 'CONSTR_FOREIGN'
)
if (found && found.Constraint.contype === 'CONSTR_FOREIGN') {
const targetColumn = compact(
found.Constraint.pk_attrs.map((name) => extractNameDefinition(name.String))
)[0]
if (!targetColumn) {
return null
}
return {
id: `${statement.relation.relname}_${column.ColumnDef.colname}_${found.Constraint.pktable.relname}_${targetColumn}`,
source_table_name: statement.relation.relname,
source_column_name: column.ColumnDef.colname,
target_table_name: found.Constraint.pktable.relname,
target_column_name: targetColumn,
}
}
}
})
)
// This code processes foreign key (tweet_user_username, tweet_content) references tweets (user_username, content) SQL. It supports composite keys between two tables
const tableRelationships = compact(
statement.tableElts.flatMap((constraint) => {
if ('Constraint' in constraint) {
if (
constraint.Constraint.contype === 'CONSTR_FOREIGN' &&
constraint.Constraint.fk_attrs &&
constraint.Constraint.pk_attrs.length === constraint.Constraint.fk_attrs.length
) {
const pkTable = constraint.Constraint.pktable
const pgAttrs = constraint.Constraint.pk_attrs
return compact(
constraint.Constraint.fk_attrs.map((attr, index) => {
const attrString = extractNameDefinition(attr.String)
const pgAttrString = extractNameDefinition(pgAttrs[index].String)
if (!attrString || !pgAttrString) {
return null
}
return {
id: `${statement.relation.relname}_${attrString}_${pkTable.relname}_${pgAttrString}`,
source_table_name: statement.relation.relname,
source_column_name: attrString,
target_table_name: pkTable.relname,
target_column_name: pgAttrString,
}
})
)
}
}
})
)
const columnPrimaryKeys = compact(
statement.tableElts.map((column) => {
if ('ColumnDef' in column) {
const constraint = (column.ColumnDef.constraints || []).find(
(c) => c.Constraint.contype === 'CONSTR_PRIMARY'
)
if (constraint) {
return { name: column.ColumnDef.colname }
}
}
})
)
// This code processes primary key (user_username, tweet_user_username, tweet_content) SQL.
const tablePrimaryKeys = compact(
statement.tableElts.flatMap((constraint) => {
if ('Constraint' in constraint) {
if (constraint.Constraint.contype === 'CONSTR_PRIMARY' && constraint.Constraint.keys) {
return constraint.Constraint.keys.map((key) => ({ name: key.String.sval }))
}
return undefined
}
})
)
const table: PostgresTable = {
name: statement.relation.relname,
columns,
id: statement.relation.relname,
primary_keys: [...columnPrimaryKeys, ...tablePrimaryKeys],
relationships: [...columnRelationships, ...tableRelationships],
}
return table
})
)
return pgTables
}
export function timeAgo(date: string) {
const createdAt = dayjs(date)
const currentTime = dayjs()
const timeDifference = currentTime.diff(createdAt, 'seconds') // Time difference in seconds
if (timeDifference < 60) {
// Less than 1 minute
return 'just now'
} else if (timeDifference < 3600) {
// Less than 1 hour
return `${Math.floor(timeDifference / 60)} minutes ago`
} else if (timeDifference < 86400) {
// Less than 1 day (24 hours)
return `${Math.floor(timeDifference / 3600)} hours ago`
} else {
// More than 1 day
return createdAt.format('MMM DD, YYYY')
}
}
export function slugify(str: string) {
return str
.toLowerCase() // Convert to lowercase
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/[^\w-]+/g, '') // Remove non-word characters except hyphens
.replace(/--+/g, '-') // Replace multiple consecutive hyphens with a single hyphen
.replace(/^-+|-+$/g, '') // Remove leading and trailing hyphens
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// Redirect to supabase.com until this site is live
// when ready, remove the VERCEL env var at
// https://vercel.com/supabase/database-new/settings/environment-variables
export function middleware(request: NextRequest) {
if (process.env.VERCEL === '1') {
return NextResponse.redirect(new URL('https://supabase.com', request.url))
}
}
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['ui', 'ui-patterns'],
webpack: (config, { dev, isServer, webpack, nextRuntime }) => {
config.module.rules.push({
test: /\.node$/,
use: [
{
loader: 'nextjs-node-loader',
options: {
includeWebpackPublicPath: false,
outputPath: config.output.path,
},
},
],
})
return config
},
}
module.exports = nextConfig
+45
View File
@@ -0,0 +1,45 @@
{
"name": "database-design",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev --port 3002",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dagrejs/dagre": "^1.0.4",
"@monaco-editor/react": "^4.6.0",
"@supabase/ssr": "^0.1.0",
"@tanstack/react-query": "^5.13.4",
"ai": "^2.2.31",
"common": "*",
"config": "*",
"dayjs": "^1.11.10",
"eslint-config-supabase": "*",
"libpg-query": "^15.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.338.0",
"next": "^14.2.3",
"openai": "^4.20.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-flow": "^1.0.3",
"sql-formatter": "^13.1.0",
"ui": "*",
"ui-patterns": "*",
"valtio": "^1.12.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.16",
"@types/react": "^18.2.24",
"@types/react-dom": "^18.2.8",
"autoprefixer": "^10.4.14",
"nextjs-node-loader": "^1.1.5",
"postcss": "^8.4.31",
"typescript": "^5.4.3"
}
}
File renamed without changes.
Loaded 100 of 7571 files, more files were not shown because too many files have changed in this diff. Show more