diff --git a/.vscode/settings.json b/.vscode/settings.json index 4a5415d1..a3933350 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,20 @@ { "deno.enablePaths": ["supabase/functions"], "deno.lint": true, - "deno.unstable": true + "deno.unstable": true, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } diff --git a/README.md b/README.md index c6d70ade..f9eba283 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ How is this possible? [PGlite](https://pglite.dev/), a WASM version of Postgres This is a monorepo split into the following projects: -- [Frontend (Next.js)](./apps/postgres-new/): This contains the primary web app built with Next.js -- [Backend (pg-gateway)](./apps/db-service/): This serves S3-backed PGlite databases over the PG wire protocol using [pg-gateway](https://github.com/supabase-community/pg-gateway) +- [Frontend (Next.js)](./apps/web/): This contains the primary web app built with Next.js +- [Backend (pg-gateway)](./apps/proxy/): This serves S3-backed PGlite databases over the PG wire protocol using [pg-gateway](https://github.com/supabase-community/pg-gateway) ## Video diff --git a/apps/db-service/.dockerignore b/apps/db-service/.dockerignore deleted file mode 100644 index 47719bef..00000000 --- a/apps/db-service/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -fly.toml -Dockerfile -.dockerignore -node_modules -.git diff --git a/apps/db-service/src/index.ts b/apps/db-service/src/index.ts deleted file mode 100644 index 9d9b6018..00000000 --- a/apps/db-service/src/index.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { PGlite, PGliteInterface } from '@electric-sql/pglite' -import { vector } from '@electric-sql/pglite/vector' -import { mkdir, readFile, access, rm } from 'node:fs/promises' -import net from 'node:net' -import { createReadStream } from 'node:fs' -import { pipeline } from 'node:stream/promises' -import { createGunzip } from 'node:zlib' -import { extract } from 'tar' -import { hashMd5Password, PostgresConnection, TlsOptions } from 'pg-gateway' - -const dataMount = process.env.DATA_MOUNT ?? './data' -const s3fsMount = process.env.S3FS_MOUNT ?? './s3' -const wildcardDomain = process.env.WILDCARD_DOMAIN ?? 'db.example.com' - -const dumpDir = `${s3fsMount}/dbs` -const tlsDir = `${s3fsMount}/tls` -const dbDir = `${dataMount}/dbs` - -await mkdir(dumpDir, { recursive: true }) -await mkdir(dbDir, { recursive: true }) -await mkdir(tlsDir, { recursive: true }) - -const tls: TlsOptions = { - key: await readFile(`${tlsDir}/key.pem`), - cert: await readFile(`${tlsDir}/cert.pem`), -} - -function getIdFromServerName(serverName: string) { - // The left-most subdomain contains the ID - // ie. 12345.db.example.com -> 12345 - const [id] = serverName.split('.') - return id -} - -async function fileExists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -const server = net.createServer((socket) => { - let db: PGliteInterface - - const connection = new PostgresConnection(socket, { - serverVersion: '16.3 (PGlite 0.2.0)', - authMode: 'md5Password', - tls, - async validateCredentials(credentials) { - if (credentials.authMode === 'md5Password') { - const { hash, salt } = credentials - const expectedHash = await hashMd5Password('postgres', 'postgres', salt) - return hash === expectedHash - } - return false - }, - async onTlsUpgrade({ tlsInfo }) { - if (!tlsInfo) { - connection.sendError({ - severity: 'FATAL', - code: '08000', - message: `ssl connection required`, - }) - connection.socket.end() - return - } - - if (!tlsInfo.sniServerName) { - connection.sendError({ - severity: 'FATAL', - code: '08000', - message: `ssl sni extension required`, - }) - connection.socket.end() - return - } - - if (!tlsInfo.sniServerName.endsWith(wildcardDomain)) { - connection.sendError({ - severity: 'FATAL', - code: '08000', - message: `unknown server ${tlsInfo.sniServerName}`, - }) - connection.socket.end() - return - } - - const databaseId = getIdFromServerName(tlsInfo.sniServerName) - - console.log(`Serving database '${databaseId}'`) - - const dbPath = `${dbDir}/${databaseId}`; - - if (!(await fileExists(dbPath))) { - console.log(`Database '${databaseId}' is not cached, downloading...`) - - const dumpPath = `${dumpDir}/${databaseId}.tar.gz`; - - if (!(await fileExists(dumpPath))) { - connection.sendError({ - severity: 'FATAL', - code: 'XX000', - message: `database ${databaseId} not found`, - }) - connection.socket.end() - return - } - - // Create a directory for the database - await mkdir(dbPath, { recursive: true }); - - try { - // Extract the .tar.gz file - await pipeline( - createReadStream(dumpPath), - createGunzip(), - extract({ cwd: dbPath }) - ); - } catch (error) { - console.error(error); - await rm(dbPath, { recursive: true, force: true }); // Clean up the partially created directory - connection.sendError({ - severity: 'FATAL', - code: 'XX000', - message: `Error extracting database: ${(error as Error).message}`, - }); - connection.socket.end(); - return; - } - } - - db = new PGlite(dbPath, { - extensions: { - vector, - }, - }) - }, - async onStartup() { - if (!db) { - console.log('PGlite instance undefined. Was onTlsUpgrade never called?') - connection.sendError({ - severity: 'FATAL', - code: 'XX000', - message: `error loading database`, - }) - connection.socket.end() - return true - } - - // Wait for PGlite to be ready before further processing - await db.waitReady - return false - }, - async onMessage(data, { isAuthenticated }) { - // Only forward messages to PGlite after authentication - if (!isAuthenticated) { - return false - } - - // Forward raw message to PGlite - try { - const responseData = await db.execProtocolRaw(data) - connection.sendData(responseData) - } catch (err) { - console.error(err) - } - return true - }, - }) - - socket.on('end', async () => { - console.log('Client disconnected') - await db?.close() - }) -}) - -server.listen(5432, async () => { - console.log('Server listening on port 5432') -}) \ No newline at end of file diff --git a/apps/postgres-new/app/api/databases/[id]/upload/route.ts b/apps/postgres-new/app/api/databases/[id]/upload/route.ts deleted file mode 100644 index 390780a0..00000000 --- a/apps/postgres-new/app/api/databases/[id]/upload/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { S3Client } from '@aws-sdk/client-s3' -import { Upload } from '@aws-sdk/lib-storage' -import { NextRequest } from 'next/server' -import { createGzip } from 'zlib' -import { Readable } from 'stream' - -const wildcardDomain = process.env.WILDCARD_DOMAIN ?? 'db.example.com' -const s3Client = new S3Client({ endpoint: process.env.S3_ENDPOINT, forcePathStyle: true }) - -export async function POST(req: NextRequest, { params }: { params: { id: string } }) { - if (!req.body) { - return new Response( - JSON.stringify({ - success: false, - error: 'Missing request body', - }), - { - status: 400, - } - ) - } - - const databaseId = params.id - const key = `dbs/${databaseId}.tar.gz` - - const gzip = createGzip() - const body = Readable.from(streamToAsyncIterable(req.body)) - - const upload = new Upload({ - client: s3Client, - params: { - Bucket: process.env.S3_BUCKET, - Key: key, - Body: body.pipe(gzip), - }, - }) - - await upload.done() - - return new Response( - JSON.stringify({ - success: true, - data: { - serverName: `${databaseId}.${wildcardDomain}`, - }, - }), - { headers: { 'content-type': 'application/json' } } - ) -} - -async function* streamToAsyncIterable(stream: ReadableStream) { - const reader = stream.getReader() - try { - while (true) { - const { done, value } = await reader.read() - if (done) return - yield value - } - } finally { - reader.releaseLock() - } -} \ No newline at end of file diff --git a/apps/postgres-new/components/sidebar.tsx b/apps/postgres-new/components/sidebar.tsx deleted file mode 100644 index 3cb87929..00000000 --- a/apps/postgres-new/components/sidebar.tsx +++ /dev/null @@ -1,496 +0,0 @@ -'use client' - -import { AnimatePresence, m } from 'framer-motion' -import { - ArrowLeftToLine, - ArrowRightToLine, - Database as DbIcon, - Download, - Loader, - LogOut, - MoreVertical, - PackagePlus, - Pencil, - Trash2, - Upload, -} from 'lucide-react' -import Link from 'next/link' -import { useParams, useRouter } from 'next/navigation' -import { useState } from 'react' -import { Button } from '~/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog' -import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip' -import { useDatabaseDeleteMutation } from '~/data/databases/database-delete-mutation' -import { useDatabaseUpdateMutation } from '~/data/databases/database-update-mutation' -import { useDatabasesQuery } from '~/data/databases/databases-query' -import { useDeployWaitlistCreateMutation } from '~/data/deploy-waitlist/deploy-waitlist-create-mutation' -import { useIsOnDeployWaitlistQuery } from '~/data/deploy-waitlist/deploy-waitlist-query' -import { Database } from '~/lib/db' -import { downloadFile, titleToKebabCase } from '~/lib/util' -import { cn } from '~/lib/utils' -import { useApp } from './app-provider' -import { CodeBlock } from './code-block' -import SignInButton from './sign-in-button' -import ThemeDropdown from './theme-dropdown' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from './ui/dropdown-menu' - -export default function Sidebar() { - const { user, signOut, focusRef, isSignInDialogOpen, setIsSignInDialogOpen } = useApp() - let { id: currentDatabaseId } = useParams<{ id: string }>() - const router = useRouter() - const { data: databases, isLoading: isLoadingDatabases } = useDatabasesQuery() - const [showSidebar, setShowSidebar] = useState(true) - - return ( - <> - { - setIsSignInDialogOpen(open) - }} - > - - - Sign in to create a database -
- -

Why do I need to sign in?

-

- Even though your Postgres databases run{' '} - - directly in the browser - - , we still need to connect to an API that runs the large language model (required for - all database interactions). -

-

We ask you to sign in to prevent API abuse.

-
- -
- -
- - {showSidebar && ( - -
- - - - - - - -

Close sidebar

-
-
- - - -
- {databases && databases.length > 0 ? ( - - {databases.map((database) => ( - - - - ))} - - ) : ( -
- {isLoadingDatabases ? ( - - ) : ( - <> - - No databases - - )} -
- )} - - - - {user && ( - - - - )} -
- )} -
- {!showSidebar && ( -
-
- - - - - - - -

Open sidebar

-
-
- - - - - - - -

New database

-
-
-
-
- - - - - - - -

Toggle theme

-
-
- {user && ( - - - - - - - -

Sign out

-
-
- )} -
-
- )} - - ) -} - -type DatabaseMenuItemProps = { - database: Database - isActive: boolean -} - -function DatabaseMenuItem({ database, isActive }: DatabaseMenuItemProps) { - const router = useRouter() - const { user, dbManager } = useApp() - const [isPopoverOpen, setIsPopoverOpen] = useState(false) - const { mutateAsync: deleteDatabase } = useDatabaseDeleteMutation() - const { mutateAsync: updateDatabase } = useDatabaseUpdateMutation() - - const [isRenaming, setIsRenaming] = useState(false) - const [isDeployDialogOpen, setIsDeployDialogOpen] = useState(false) - - const { data: isOnDeployWaitlist } = useIsOnDeployWaitlistQuery() - const { mutateAsync: joinDeployWaitlist } = useDeployWaitlistCreateMutation() - - return ( - <> - { - setIsDeployDialogOpen(open) - }} - > - - - Deployments are in Private Alpha -
- -

What are deployments?

-

- Deploy your database to a serverless PGlite instance so that it can be accessed outside - the browser using any Postgres client: -

- - {`psql "postgres://postgres:@/postgres"`} - -
- - {!isOnDeployWaitlist ? ( - - ) : ( - -

🎉 You're on the waitlist!

-

We'll send you an email when you have access to deploy.

-
- )} -
-
- -
- - - {database.name ?? 'My database'} - { - setIsPopoverOpen(open) - if (!open) { - setIsRenaming(false) - } - }} - open={isPopoverOpen} - > - { - e.preventDefault() - setIsPopoverOpen(true) - }} - > - - - - - {isRenaming ? ( -
{ - e.preventDefault() - - if (e.target instanceof HTMLFormElement) { - const formData = new FormData(e.target) - const name = formData.get('name') - - if (typeof name === 'string') { - await updateDatabase({ ...database, name }) - } - } - - setIsPopoverOpen(false) - setIsRenaming(false) - }} - > - -
- ) : ( -
- { - e.preventDefault() - setIsRenaming(true) - }} - > - - Rename - - { - e.preventDefault() - - if (!dbManager) { - throw new Error('dbManager is not available') - } - - const db = await dbManager.getDbInstance(database.id) - const dumpBlob = await db.dumpDataDir() - - const fileName = `${titleToKebabCase(database.name ?? 'My Database')}-${Date.now()}` - const file = new File([dumpBlob], fileName, { type: dumpBlob.type }) - - downloadFile(file) - }} - > - - - Download - - { - e.preventDefault() - - setIsDeployDialogOpen(true) - setIsPopoverOpen(false) - }} - disabled={user === undefined} - > - - Deploy - - - { - e.preventDefault() - setIsPopoverOpen(false) - await deleteDatabase({ id: database.id }) - - if (isActive) { - router.push('/') - } - }} - > - - Delete - -
- )} -
-
- - - ) -} diff --git a/apps/proxy/.dockerignore b/apps/proxy/.dockerignore new file mode 100644 index 00000000..aeabd0b9 --- /dev/null +++ b/apps/proxy/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.git +.vscode +.github +dist +.next + +apps +!apps/proxy +!packages/supabase/dist \ No newline at end of file diff --git a/apps/db-service/.env.example b/apps/proxy/.env.example similarity index 100% rename from apps/db-service/.env.example rename to apps/proxy/.env.example diff --git a/apps/db-service/Dockerfile b/apps/proxy/Dockerfile similarity index 70% rename from apps/db-service/Dockerfile rename to apps/proxy/Dockerfile index 35a239e5..8dc94f05 100644 --- a/apps/db-service/Dockerfile +++ b/apps/proxy/Dockerfile @@ -1,7 +1,7 @@ # syntax = docker/dockerfile:1 # Adjust NODE_VERSION as desired -ARG NODE_VERSION=20.4.0 +ARG NODE_VERSION=20.16.0 FROM node:${NODE_VERSION}-bookworm as base LABEL fly_launch_runtime="NodeJS" @@ -37,18 +37,17 @@ RUN apt-get update -qq && \ pkg-config \ build-essential -# Install node modules -COPY --link package.json . -RUN npm install --production=false - -# Copy application code +# Copy everything COPY --link . . +# Install dependencies +RUN npm install -w @postgres-new/proxy --production=false + # Build app -RUN npm run build +RUN npm run -w @postgres-new/proxy build # Remove development dependencies -RUN npm prune --production +RUN npm prune -w @postgres-new/proxy --production # Final stage for app image FROM base @@ -60,11 +59,14 @@ RUN apt-get update && \ && rm -rf /var/lib/apt/lists/* COPY --from=build-s3fs /usr/local/bin/s3fs /usr/local/bin/s3fs -COPY --from=build-app /app /app +# We copy the root node_modules +COPY --from=build-app /app/node_modules /app/node_modules +# We copy the proxy folder +COPY --from=build-app /app/apps/proxy /app/proxy EXPOSE 5432 -ENTRYPOINT [ "./entrypoint.sh" ] +ENTRYPOINT [ "./proxy/entrypoint.sh" ] # Start the server by default, this can be overwritten at runtime -CMD [ "node", "dist/index.js" ] +CMD [ "node", "proxy/dist/index.js" ] diff --git a/apps/db-service/README.md b/apps/proxy/README.md similarity index 89% rename from apps/db-service/README.md rename to apps/proxy/README.md index 1d3afcb0..79995c1f 100644 --- a/apps/db-service/README.md +++ b/apps/proxy/README.md @@ -48,7 +48,7 @@ If want to develop locally without dealing with containers or underlying storage ### With `s3fs` and DNS tools (Docker) -To simulate an environment closer to production, you can test the service with DBs backed by `s3fs` using Minio and Docker. This approach also adds a local DNS server which forwards all wildcard DNS requests to `*.db.example.com` to the `db-service` so that you don't have to keep changing your `/etc/hosts` file. +To simulate an environment closer to production, you can test the service with DBs backed by `s3fs` using Minio and Docker. This approach also adds a local DNS server which forwards all wildcard DNS requests to `*.db.example.com` to the `proxy` so that you don't have to keep changing your `/etc/hosts` file. 1. Start CoreDNS (handles local wildcard DNS) and Minio (local s3-compatible server): ```shell @@ -65,16 +65,16 @@ To simulate an environment closer to production, you can test the service with D 1. Run the `pg-gateway` server: ```shell - docker compose up --build db-service + docker compose up --build proxy ``` - This will build the container (if it's not cached) then run the Node `db-service`. All DBs will live under `/mnt/s3/dbs`. + This will build the container (if it's not cached) then run the Node `proxy`. All DBs will live under `/mnt/s3/dbs`. 1. Connect to the server via `psql`: ```shell npm run psql -- "host=12345.db.example.com port=5432 user=postgres" ``` - This uses a wrapped version of `psql` that runs in a Docker container under the hood. We do this in order to resolve all `*.db.example.com` addresses to the `db-service`. + This uses a wrapped version of `psql` that runs in a Docker container under the hood. We do this in order to resolve all `*.db.example.com` addresses to the `proxy`. > Note the very first time a DB is created will be very slow (`s3fs` writes are slow with that many file handles) so expect this to hang for a while. Subsequent requests will be much quicker. This is temporary anyway - in the future the DB will have to already exist in `/mnt/s3/dbs/` in order to connect. @@ -86,6 +86,6 @@ docker compose down ## Deployment -The db-service is deployed on Fly.io. +The proxy is deployed on Fly.io. A Tigris bucket is used to store the DB tarballs and the TLS certificates. \ No newline at end of file diff --git a/apps/db-service/docker-compose.yml b/apps/proxy/docker-compose.yml similarity index 92% rename from apps/db-service/docker-compose.yml rename to apps/proxy/docker-compose.yml index a2c91ba6..b5660ea0 100644 --- a/apps/db-service/docker-compose.yml +++ b/apps/proxy/docker-compose.yml @@ -1,8 +1,9 @@ services: - db-service: - image: db-service + proxy: + image: proxy build: - context: . + context: ../.. + dockerfile: apps/proxy/Dockerfile env_file: - .env ports: @@ -57,7 +58,7 @@ services: context: ./tools/dns environment: WILDCARD_DOMAIN: db.example.com - SERVICE_NAME: db-service + SERVICE_NAME: proxy networks: default: ipv4_address: 172.20.0.10 diff --git a/apps/db-service/entrypoint.sh b/apps/proxy/entrypoint.sh similarity index 100% rename from apps/db-service/entrypoint.sh rename to apps/proxy/entrypoint.sh diff --git a/apps/db-service/fly.toml b/apps/proxy/fly.toml similarity index 77% rename from apps/db-service/fly.toml rename to apps/proxy/fly.toml index 35897bc2..ff52e855 100644 --- a/apps/db-service/fly.toml +++ b/apps/proxy/fly.toml @@ -1,4 +1,5 @@ -app = 'postgres-new-dev' +app = 'postgres-new-proxy' + primary_region = 'yyz' [[services]] @@ -20,7 +21,3 @@ soft_limit = 20 memory = '1gb' cpu_kind = 'shared' cpus = 1 - -[mounts] -source = "postgres_new_data" -destination = "/mnt/data" diff --git a/apps/db-service/package.json b/apps/proxy/package.json similarity index 54% rename from apps/db-service/package.json rename to apps/proxy/package.json index 40861c64..701c01d8 100644 --- a/apps/db-service/package.json +++ b/apps/proxy/package.json @@ -1,19 +1,22 @@ { - "name": "db-service", + "name": "@postgres-new/proxy", "type": "module", "scripts": { "start": "node dist/index.js", "dev": "tsx src/index.ts", - "build": "tsc -b", + "build": "tsc", "generate:certs": "scripts/generate-certs.sh", - "psql": "docker compose run --rm -i psql psql" + "psql": "docker compose run --rm -i psql psql", + "type-check": "tsc --noEmit" }, "dependencies": { - "@electric-sql/pglite": "0.2.0-alpha.9", - "pg-gateway": "^0.2.5-alpha.2", + "@electric-sql/pglite": "0.2.0", + "@supabase/supabase-js": "^2.45.1", + "pg-gateway": "0.3.0-alpha.6", "tar": "^7.4.3" }, "devDependencies": { + "@postgres-new/supabase": "*", "@types/node": "^20.14.11", "@types/tar": "^6.1.13", "tsx": "^4.16.2", diff --git a/apps/db-service/scripts/generate-certs.sh b/apps/proxy/scripts/generate-certs.sh similarity index 100% rename from apps/db-service/scripts/generate-certs.sh rename to apps/proxy/scripts/generate-certs.sh diff --git a/apps/proxy/src/index.ts b/apps/proxy/src/index.ts new file mode 100644 index 00000000..9d48d302 --- /dev/null +++ b/apps/proxy/src/index.ts @@ -0,0 +1,245 @@ +import { PGlite, PGliteInterface } from '@electric-sql/pglite' +import { vector } from '@electric-sql/pglite/vector' +import { mkdir, readFile, access, rm } from 'node:fs/promises' +import net from 'node:net' +import path from 'node:path' +import { createReadStream } from 'node:fs' +import { pipeline } from 'node:stream/promises' +import { createGunzip } from 'node:zlib' +import { extract } from 'tar' +import { PostgresConnection, ScramSha256Data, TlsOptions } from 'pg-gateway' +import { createClient } from '@supabase/supabase-js' +import type { Database } from '@postgres-new/supabase' + +const supabaseUrl = process.env.SUPABASE_URL ?? 'http://127.0.0.1:54321' +const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? '' +const dataMount = process.env.DATA_MOUNT ?? './data' +const s3fsMount = process.env.S3FS_MOUNT ?? './s3' +const wildcardDomain = process.env.WILDCARD_DOMAIN ?? 'db.example.com' +const packageJson = JSON.parse( + await readFile(path.join(import.meta.dirname, '..', 'package.json'), 'utf8') +) as { + dependencies: { + '@electric-sql/pglite': string + } +} +const pgliteVersion = `(PGlite ${packageJson.dependencies['@electric-sql/pglite']})` + +const dumpDir = `${s3fsMount}/dbs` +const tlsDir = `${s3fsMount}/tls` +const dbDir = `${dataMount}/dbs` + +await mkdir(dumpDir, { recursive: true }) +await mkdir(dbDir, { recursive: true }) +await mkdir(tlsDir, { recursive: true }) + +const tls: TlsOptions = { + key: await readFile(`${tlsDir}/key.pem`), + cert: await readFile(`${tlsDir}/cert.pem`), +} + +function getIdFromServerName(serverName: string) { + // The left-most subdomain contains the ID + // ie. 12345.db.example.com -> 12345 + const [id] = serverName.split('.') + return id +} + +const PostgresErrorCodes = { + ConnectionException: '08000', +} as const + +function sendFatalError(connection: PostgresConnection, code: string, message: string): Error { + connection.sendError({ + severity: 'FATAL', + code, + message, + }) + connection.socket.end() + return new Error(message) +} + +async function fileExists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +const supabase = createClient(supabaseUrl, supabaseKey) + +const server = net.createServer((socket) => { + let db: PGliteInterface + + const connection = new PostgresConnection(socket, { + serverVersion: async () => { + const { + rows: [{ version }], + } = await db.query<{ version: string }>( + `select current_setting('server_version') as version;` + ) + const serverVersion = `${version} ${pgliteVersion}` + console.log(serverVersion) + return serverVersion + }, + auth: { + method: 'scram-sha-256', + async getScramSha256Data(_, { tlsInfo }) { + if (!tlsInfo?.sniServerName) { + throw sendFatalError( + connection, + PostgresErrorCodes.ConnectionException, + 'sniServerName required in TLS info' + ) + } + + const databaseId = getIdFromServerName(tlsInfo.sniServerName) + const { data, error } = await supabase + .from('deployed_databases') + .select('auth_method, auth_data') + .eq('database_id', databaseId) + .single() + + if (error) { + throw sendFatalError( + connection, + PostgresErrorCodes.ConnectionException, + `Error getting auth data for database ${databaseId}` + ) + } + + if (data === null) { + throw sendFatalError( + connection, + PostgresErrorCodes.ConnectionException, + `Database ${databaseId} not found` + ) + } + + if (data.auth_method !== 'scram-sha-256') { + throw sendFatalError( + connection, + PostgresErrorCodes.ConnectionException, + `Unsupported auth method for database ${databaseId}: ${data.auth_method}` + ) + } + + return data.auth_data as ScramSha256Data + }, + }, + tls, + async onTlsUpgrade({ tlsInfo }) { + if (!tlsInfo?.sniServerName) { + connection.sendError({ + severity: 'FATAL', + code: '08000', + message: `ssl sni extension required`, + }) + connection.socket.end() + return + } + + if (!tlsInfo.sniServerName.endsWith(wildcardDomain)) { + connection.sendError({ + severity: 'FATAL', + code: '08000', + message: `unknown server ${tlsInfo.sniServerName}`, + }) + connection.socket.end() + return + } + }, + async onAuthenticated({ tlsInfo }) { + // at this point we know sniServerName is set + const databaseId = getIdFromServerName(tlsInfo!.sniServerName!) + + console.log(`Serving database '${databaseId}'`) + + const dbPath = `${dbDir}/${databaseId}` + + if (!(await fileExists(dbPath))) { + console.log(`Database '${databaseId}' is not cached, downloading...`) + + const dumpPath = `${dumpDir}/${databaseId}.tar.gz` + + if (!(await fileExists(dumpPath))) { + connection.sendError({ + severity: 'FATAL', + code: 'XX000', + message: `database ${databaseId} not found`, + }) + connection.socket.end() + return + } + + // Create a directory for the database + await mkdir(dbPath, { recursive: true }) + + try { + // Extract the .tar.gz file + await pipeline(createReadStream(dumpPath), createGunzip(), extract({ cwd: dbPath })) + } catch (error) { + console.error(error) + await rm(dbPath, { recursive: true, force: true }) // Clean up the partially created directory + connection.sendError({ + severity: 'FATAL', + code: 'XX000', + message: `Error extracting database: ${(error as Error).message}`, + }) + connection.socket.end() + return + } + } + + db = new PGlite({ + dataDir: dbPath, + extensions: { + vector, + }, + }) + await db.waitReady + const { rows } = await db.query("SELECT 1 FROM pg_roles WHERE rolname = 'readonly_postgres';") + if (rows.length === 0) { + await db.exec(` + CREATE USER readonly_postgres; + GRANT pg_read_all_data TO readonly_postgres; + `) + } + await db.close() + db = new PGlite({ + dataDir: dbPath, + username: 'readonly_postgres', + extensions: { + vector, + }, + }) + await db.waitReady + }, + async onMessage(data, { isAuthenticated }) { + // Only forward messages to PGlite after authentication + if (!isAuthenticated) { + return false + } + + // Forward raw message to PGlite + try { + const responseData = await db.execProtocolRaw(data) + connection.sendData(responseData) + } catch (err) { + console.error(err) + } + return true + }, + }) + + socket.on('close', async () => { + console.log('Client disconnected') + await db?.close() + }) +}) + +server.listen(5432, async () => { + console.log('Server listening on port 5432') +}) diff --git a/apps/certbot-service/.env.example b/apps/proxy/tools/certbot/.env.example similarity index 100% rename from apps/certbot-service/.env.example rename to apps/proxy/tools/certbot/.env.example diff --git a/apps/certbot-service/Dockerfile b/apps/proxy/tools/certbot/Dockerfile similarity index 100% rename from apps/certbot-service/Dockerfile rename to apps/proxy/tools/certbot/Dockerfile diff --git a/apps/certbot-service/README.md b/apps/proxy/tools/certbot/README.md similarity index 100% rename from apps/certbot-service/README.md rename to apps/proxy/tools/certbot/README.md diff --git a/apps/certbot-service/certbot.sh b/apps/proxy/tools/certbot/certbot.sh similarity index 100% rename from apps/certbot-service/certbot.sh rename to apps/proxy/tools/certbot/certbot.sh diff --git a/apps/certbot-service/deploy-hook.sh b/apps/proxy/tools/certbot/deploy-hook.sh similarity index 100% rename from apps/certbot-service/deploy-hook.sh rename to apps/proxy/tools/certbot/deploy-hook.sh diff --git a/apps/certbot-service/docker-compose.yml b/apps/proxy/tools/certbot/docker-compose.yml similarity index 100% rename from apps/certbot-service/docker-compose.yml rename to apps/proxy/tools/certbot/docker-compose.yml diff --git a/apps/certbot-service/entrypoint.sh b/apps/proxy/tools/certbot/entrypoint.sh similarity index 100% rename from apps/certbot-service/entrypoint.sh rename to apps/proxy/tools/certbot/entrypoint.sh diff --git a/apps/certbot-service/fly.toml b/apps/proxy/tools/certbot/fly.toml similarity index 98% rename from apps/certbot-service/fly.toml rename to apps/proxy/tools/certbot/fly.toml index 483d258f..888f61c0 100644 --- a/apps/certbot-service/fly.toml +++ b/apps/proxy/tools/certbot/fly.toml @@ -1,2 +1,3 @@ app = 'postgres-new-certbot' + primary_region = 'yyz' diff --git a/apps/db-service/tools/dns/Corefile b/apps/proxy/tools/dns/Corefile similarity index 77% rename from apps/db-service/tools/dns/Corefile rename to apps/proxy/tools/dns/Corefile index aed85c65..29d5a269 100644 --- a/apps/db-service/tools/dns/Corefile +++ b/apps/proxy/tools/dns/Corefile @@ -1,5 +1,5 @@ .:53 { - # Resolve all wildcard domain requests to the db-service + # Resolve all wildcard domain requests to the proxy template IN ANY {$WILDCARD_DOMAIN} { answer "{{ .Name }} 60 IN CNAME {$SERVICE_NAME}" } diff --git a/apps/db-service/tools/dns/Dockerfile b/apps/proxy/tools/dns/Dockerfile similarity index 100% rename from apps/db-service/tools/dns/Dockerfile rename to apps/proxy/tools/dns/Dockerfile diff --git a/apps/db-service/tsconfig.json b/apps/proxy/tsconfig.json similarity index 100% rename from apps/db-service/tsconfig.json rename to apps/proxy/tsconfig.json diff --git a/apps/postgres-new/.env.example b/apps/web/.env.example similarity index 86% rename from apps/postgres-new/.env.example rename to apps/web/.env.example index 5b2835bc..535bef03 100644 --- a/apps/postgres-new/.env.example +++ b/apps/web/.env.example @@ -1,10 +1,11 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY="" NEXT_PUBLIC_SUPABASE_URL="" NEXT_PUBLIC_IS_PREVIEW=true +NEXT_PUBLIC_WILDCARD_DOMAIN=db.example.com OPENAI_API_KEY="" S3_ENDPOINT=http://localhost:9000 S3_BUCKET=test AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin -WILDCARD_DOMAIN=db.example.com + diff --git a/apps/postgres-new/README.md b/apps/web/README.md similarity index 96% rename from apps/postgres-new/README.md rename to apps/web/README.md index 414e0744..f4be247d 100644 --- a/apps/postgres-new/README.md +++ b/apps/web/README.md @@ -1,4 +1,4 @@ -# postgres-new +# @postgres-new/web In-browser Postgres sandbox with AI assistance. Built on Next.js. @@ -25,7 +25,7 @@ Authentication and users are managed by a [Supabase](https://supabase.com/) data ## Development -From this directory (`./apps/postgres-new`): +From this directory (`./apps/web`): 1. Install dependencies: ```shell diff --git a/apps/postgres-new/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts similarity index 100% rename from apps/postgres-new/app/api/chat/route.ts rename to apps/web/app/api/chat/route.ts diff --git a/apps/web/app/api/databases/[id]/reset-password/route.ts b/apps/web/app/api/databases/[id]/reset-password/route.ts new file mode 100644 index 00000000..4e7839f1 --- /dev/null +++ b/apps/web/app/api/databases/[id]/reset-password/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '~/utils/supabase/server' +import { createScramSha256Data } from 'pg-gateway' +import { generateDatabasePassword } from '~/utils/generate-database-password' + +export type DatabaseResetPasswordResponse = + | { + success: true + data: { + password: string + } + } + | { + success: false + error: string + } + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } } +): Promise> { + const supabase = createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json( + { + success: false, + error: 'Unauthorized', + }, + { status: 401 } + ) + } + + const databaseId = params.id + + const { data: existingDeployedDatabase } = await supabase + .from('deployed_databases') + .select('id') + .eq('database_id', databaseId) + .maybeSingle() + + if (!existingDeployedDatabase) { + return NextResponse.json( + { + success: false, + error: `Database ${databaseId} was not found`, + }, + { status: 404 } + ) + } + + const password = generateDatabasePassword() + + await supabase + .from('deployed_databases') + .update({ + auth_data: createScramSha256Data(password), + }) + .eq('database_id', databaseId) + + return NextResponse.json({ + success: true, + data: { + password, + }, + }) +} diff --git a/apps/web/app/api/databases/[id]/route.ts b/apps/web/app/api/databases/[id]/route.ts new file mode 100644 index 00000000..7611322b --- /dev/null +++ b/apps/web/app/api/databases/[id]/route.ts @@ -0,0 +1,71 @@ +import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3' +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '~/utils/supabase/server' + +const s3Client = new S3Client({ endpoint: process.env.S3_ENDPOINT, forcePathStyle: true }) + +export type DatabaseDeleteResponse = + | { + success: true + } + | { + success: false + error: string + } + +export async function DELETE( + _req: NextRequest, + { params }: { params: { id: string } } +): Promise> { + const supabase = createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json( + { + success: false, + error: 'Unauthorized', + }, + { status: 401 } + ) + } + + const databaseId = params.id + + const { data: existingDeployedDatabase } = await supabase + .from('deployed_databases') + .select('id') + .eq('database_id', databaseId) + .maybeSingle() + + if (!existingDeployedDatabase) { + return NextResponse.json( + { + success: false, + error: `Database ${databaseId} was not found`, + }, + { status: 404 } + ) + } + + await supabase.from('deployed_databases').delete().eq('database_id', databaseId) + + const key = `dbs/${databaseId}.tar.gz` + try { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: process.env.S3_BUCKET, + Key: key, + }) + ) + } catch (error) { + console.error(`Error deleting S3 object ${key}:`, error) + } + + return NextResponse.json({ + success: true, + }) +} diff --git a/apps/web/app/api/databases/[id]/upload/route.ts b/apps/web/app/api/databases/[id]/upload/route.ts new file mode 100644 index 00000000..e5b04bae --- /dev/null +++ b/apps/web/app/api/databases/[id]/upload/route.ts @@ -0,0 +1,142 @@ +import { S3Client } from '@aws-sdk/client-s3' +import { Upload } from '@aws-sdk/lib-storage' +import { NextRequest, NextResponse } from 'next/server' +import { createGzip } from 'zlib' +import { Readable } from 'stream' +import { createClient } from '~/utils/supabase/server' +import { createScramSha256Data } from 'pg-gateway' +import { generateDatabasePassword } from '~/utils/generate-database-password' + +const wildcardDomain = process.env.NEXT_PUBLIC_WILDCARD_DOMAIN ?? 'db.example.com' +const s3Client = new S3Client({ endpoint: process.env.S3_ENDPOINT, forcePathStyle: true }) + +export type DatabaseUploadResponse = + | { + success: true + data: { + username: string + password?: string + host: string + port: number + databaseName: string + } + } + | { + success: false + error: string + } + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } } +): Promise> { + const supabase = createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json( + { + success: false, + error: 'Unauthorized', + }, + { status: 401 } + ) + } + + const data = await req.formData() + + const dump = data.get('dump') as File | null + const name = data.get('name') as string | null + const createdAt = data.get('created_at') as string | null + + if (!dump || !name || !createdAt) { + return NextResponse.json( + { + success: false, + error: 'Missing fields', + }, + { status: 400 } + ) + } + + const dumpSizeInMB = dump.size / (1024 * 1024) + if (dumpSizeInMB > 100) { + return NextResponse.json( + { + success: false, + error: "You can't deploy a database that is bigger than 100MB", + }, + { status: 413 } + ) + } + + const databaseId = params.id + const key = `dbs/${databaseId}.tar.gz` + + const gzip = createGzip() + const body = Readable.from(streamToAsyncIterable(dump.stream())) + + const upload = new Upload({ + client: s3Client, + params: { + Bucket: process.env.S3_BUCKET, + Key: key, + Body: body.pipe(gzip), + }, + }) + + await upload.done() + + const { data: existingDeployedDatabase } = await supabase + .from('deployed_databases') + .select('id') + .eq('database_id', databaseId) + .maybeSingle() + + let password: string | undefined + + if (existingDeployedDatabase) { + await supabase + .from('deployed_databases') + .update({ + deployed_at: 'now()', + }) + .eq('database_id', databaseId) + } else { + password = generateDatabasePassword() + await supabase.from('deployed_databases').insert({ + database_id: databaseId, + name, + created_at: createdAt, + auth_method: 'scram-sha-256', + auth_data: createScramSha256Data(password), + }) + } + + return NextResponse.json({ + success: true, + data: { + username: 'readonly_postgres', + password, + host: `${databaseId}.${wildcardDomain}`, + port: 5432, + databaseName: 'postgres', + }, + }) +} + +async function* streamToAsyncIterable(stream: ReadableStream) { + const reader = stream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) return + yield value + } + } finally { + reader.releaseLock() + } +} diff --git a/apps/postgres-new/app/apple-icon.png b/apps/web/app/apple-icon.png similarity index 100% rename from apps/postgres-new/app/apple-icon.png rename to apps/web/app/apple-icon.png diff --git a/apps/postgres-new/app/db/[id]/page.tsx b/apps/web/app/db/[id]/page.tsx similarity index 100% rename from apps/postgres-new/app/db/[id]/page.tsx rename to apps/web/app/db/[id]/page.tsx diff --git a/apps/postgres-new/app/favicon.ico b/apps/web/app/favicon.ico similarity index 100% rename from apps/postgres-new/app/favicon.ico rename to apps/web/app/favicon.ico diff --git a/apps/postgres-new/app/globals.css b/apps/web/app/globals.css similarity index 100% rename from apps/postgres-new/app/globals.css rename to apps/web/app/globals.css diff --git a/apps/postgres-new/app/icon.svg b/apps/web/app/icon.svg similarity index 100% rename from apps/postgres-new/app/icon.svg rename to apps/web/app/icon.svg diff --git a/apps/postgres-new/app/layout.tsx b/apps/web/app/layout.tsx similarity index 100% rename from apps/postgres-new/app/layout.tsx rename to apps/web/app/layout.tsx diff --git a/apps/postgres-new/app/opengraph-image.png b/apps/web/app/opengraph-image.png similarity index 100% rename from apps/postgres-new/app/opengraph-image.png rename to apps/web/app/opengraph-image.png diff --git a/apps/postgres-new/app/page.tsx b/apps/web/app/page.tsx similarity index 89% rename from apps/postgres-new/app/page.tsx rename to apps/web/app/page.tsx index c0cc19a9..f76ae1fd 100644 --- a/apps/postgres-new/app/page.tsx +++ b/apps/web/app/page.tsx @@ -5,8 +5,8 @@ import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo } from 'react' import { useApp } from '~/components/app-provider' import Workspace from '~/components/workspace' -import { useDatabaseCreateMutation } from '~/data/databases/database-create-mutation' -import { useDatabaseUpdateMutation } from '~/data/databases/database-update-mutation' +import { useLocalDatabaseCreateMutation } from '~/data/local-databases/local-database-create-mutation' +import { useLocalDatabaseUpdateMutation } from '~/data/local-databases/local-database-update-mutation' // Use a DNS safe alphabet const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 16) @@ -19,8 +19,8 @@ export default function Page() { const { dbManager } = useApp() const router = useRouter() - const { mutateAsync: createDatabase } = useDatabaseCreateMutation() - const { mutateAsync: updateDatabase } = useDatabaseUpdateMutation() + const { mutateAsync: createDatabase } = useLocalDatabaseCreateMutation() + const { mutateAsync: updateDatabase } = useLocalDatabaseUpdateMutation() /** * Preloads next empty database so that it is ready immediately. diff --git a/apps/postgres-new/assets/github-icon.tsx b/apps/web/assets/github-icon.tsx similarity index 100% rename from apps/postgres-new/assets/github-icon.tsx rename to apps/web/assets/github-icon.tsx diff --git a/apps/postgres-new/components.json b/apps/web/components.json similarity index 100% rename from apps/postgres-new/components.json rename to apps/web/components.json diff --git a/apps/postgres-new/components/ai-icon-animation/ai-icon-animation-style.module.css b/apps/web/components/ai-icon-animation/ai-icon-animation-style.module.css similarity index 100% rename from apps/postgres-new/components/ai-icon-animation/ai-icon-animation-style.module.css rename to apps/web/components/ai-icon-animation/ai-icon-animation-style.module.css diff --git a/apps/postgres-new/components/ai-icon-animation/ai-icon-animation.tsx b/apps/web/components/ai-icon-animation/ai-icon-animation.tsx similarity index 100% rename from apps/postgres-new/components/ai-icon-animation/ai-icon-animation.tsx rename to apps/web/components/ai-icon-animation/ai-icon-animation.tsx diff --git a/apps/postgres-new/components/ai-icon-animation/index.tsx b/apps/web/components/ai-icon-animation/index.tsx similarity index 100% rename from apps/postgres-new/components/ai-icon-animation/index.tsx rename to apps/web/components/ai-icon-animation/index.tsx diff --git a/apps/postgres-new/components/app-provider.tsx b/apps/web/components/app-provider.tsx similarity index 93% rename from apps/postgres-new/components/app-provider.tsx rename to apps/web/components/app-provider.tsx index 3d1c79a3..257e87d1 100644 --- a/apps/postgres-new/components/app-provider.tsx +++ b/apps/web/components/app-provider.tsx @@ -27,7 +27,6 @@ const dbManager = typeof window !== 'undefined' ? new DbManager() : undefined export default function AppProvider({ children }: AppProps) { const [isLoadingUser, setIsLoadingUser] = useState(true) const [user, setUser] = useState() - const [isSignInDialogOpen, setIsSignInDialogOpen] = useState(false) const focusRef = useRef(null) @@ -111,8 +110,6 @@ export default function AppProvider({ children }: AppProps) { isLoadingUser, signIn, signOut, - isSignInDialogOpen, - setIsSignInDialogOpen, focusRef, isPreview, dbManager, @@ -134,8 +131,6 @@ export type AppContextValues = { isLoadingUser: boolean signIn: () => Promise signOut: () => Promise - isSignInDialogOpen: boolean - setIsSignInDialogOpen: (open: boolean) => void focusRef: RefObject isPreview: boolean dbManager?: DbManager diff --git a/apps/postgres-new/components/chat-message.tsx b/apps/web/components/chat-message.tsx similarity index 100% rename from apps/postgres-new/components/chat-message.tsx rename to apps/web/components/chat-message.tsx diff --git a/apps/postgres-new/components/chat.tsx b/apps/web/components/chat.tsx similarity index 96% rename from apps/postgres-new/components/chat.tsx rename to apps/web/components/chat.tsx index 65b40f75..7eec23a4 100644 --- a/apps/postgres-new/components/chat.tsx +++ b/apps/web/components/chat.tsx @@ -25,6 +25,7 @@ import { useApp } from './app-provider' import ChatMessage from './chat-message' import SignInButton from './sign-in-button' import { useWorkspace } from './workspace' +import { SignInDialog } from './sign-in-dialog' export function getInitialMessages(tables: TablesData): Message[] { return [ @@ -48,7 +49,7 @@ export function getInitialMessages(tables: TablesData): Message[] { } export default function Chat() { - const { user, isLoadingUser, focusRef, setIsSignInDialogOpen } = useApp() + const { user, isLoadingUser, focusRef } = useApp() const [inputFocusState, setInputFocusState] = useState(false) const { @@ -326,14 +327,11 @@ export default function Chat() {

To prevent abuse we ask you to sign in before chatting with AI.

-

{ - setIsSignInDialogOpen(true) - }} - > - Why do I need to sign in? -

+ +

+ Why do I need to sign in? +

+
)} @@ -380,14 +378,11 @@ export default function Chat() {

To prevent abuse we ask you to sign in before chatting with AI.

-

{ - setIsSignInDialogOpen(true) - }} - > - Why do I need to sign in? -

+ +

+ Why do I need to sign in? +

+
)} diff --git a/apps/postgres-new/components/code-accordion.tsx b/apps/web/components/code-accordion.tsx similarity index 100% rename from apps/postgres-new/components/code-accordion.tsx rename to apps/web/components/code-accordion.tsx diff --git a/apps/postgres-new/components/code-block.tsx b/apps/web/components/code-block.tsx similarity index 90% rename from apps/postgres-new/components/code-block.tsx rename to apps/web/components/code-block.tsx index 69bed4f3..acd04303 100644 --- a/apps/postgres-new/components/code-block.tsx +++ b/apps/web/components/code-block.tsx @@ -6,9 +6,10 @@ * TODO: Redesign this component */ +import { Copy } from 'lucide-react' import { useTheme } from 'next-themes' -import { Children, ReactNode, useState } from 'react' -import { Light as SyntaxHighlighter, SyntaxHighlighterProps } from 'react-syntax-highlighter' +import { Children, type ReactNode, useState } from 'react' +import { Light as SyntaxHighlighter, type SyntaxHighlighterProps } from 'react-syntax-highlighter' import curl from 'highlightjs-curl' import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash' @@ -23,6 +24,7 @@ import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql' import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript' import { cn } from '~/lib/utils' +import { Button } from './ui/button' export interface CodeBlockProps { title?: ReactNode @@ -69,9 +71,10 @@ export const CodeBlock = ({ const handleCopy = () => { setCopied(true) + navigator.clipboard.writeText(value ?? '') setTimeout(() => { setCopied(false) - }, 1000) + }, 2000) } // Extract string when `children` has a single string node @@ -168,11 +171,24 @@ export const CodeBlock = ({ {!hideCopy && (value || children) && className ? (
+ > + + ) : null} ) : ( diff --git a/apps/web/components/deployed-database-fields.tsx b/apps/web/components/deployed-database-fields.tsx new file mode 100644 index 00000000..657a442e --- /dev/null +++ b/apps/web/components/deployed-database-fields.tsx @@ -0,0 +1,65 @@ +import { CopyIcon } from 'lucide-react' +import { useState } from 'react' +import { Button } from '~/components/ui/button' +import { Input } from '~/components/ui/input' +import { Label } from '~/components/ui/label' +import { DeployedDatabaseCreateResult } from '~/data/deployed-databases/deployed-database-create-mutation' + +export type DeployedDatabaseFieldsProps = DeployedDatabaseCreateResult + +export function DeployedDatabaseFields(props: DeployedDatabaseFieldsProps) { + const port = '5432' + const password = props.password ?? '[The password for your database]' + const connectionStringPassword = props.password + ? encodeURIComponent(props.password) + : '[YOUR-PASSWORD]' + const connectionString = `postgresql://${props.username}:${connectionStringPassword}@${props.host}/${props.databaseName}` + + return ( +
+ + + + + + +
+ ) +} + +function CopyableField(props: { label: string; value: string; disableCopy?: boolean }) { + return ( +
+ + +
+ ) +} + +function CopyableInput(props: { value: string; disableCopy?: boolean }) { + const [isCopying, setIsCopying] = useState(false) + + function handleCopy(value: string) { + setIsCopying(true) + navigator.clipboard.writeText(value) + setTimeout(() => { + setIsCopying(false) + }, 2000) + } + + return ( +
+ + {!props.disableCopy && ( + + )} +
+ ) +} diff --git a/apps/postgres-new/components/framer-features.ts b/apps/web/components/framer-features.ts similarity index 100% rename from apps/postgres-new/components/framer-features.ts rename to apps/web/components/framer-features.ts diff --git a/apps/postgres-new/components/ide.tsx b/apps/web/components/ide.tsx similarity index 100% rename from apps/postgres-new/components/ide.tsx rename to apps/web/components/ide.tsx diff --git a/apps/postgres-new/components/layout.tsx b/apps/web/components/layout.tsx similarity index 92% rename from apps/postgres-new/components/layout.tsx rename to apps/web/components/layout.tsx index e3ad7b0d..50d634b6 100644 --- a/apps/postgres-new/components/layout.tsx +++ b/apps/web/components/layout.tsx @@ -8,7 +8,8 @@ import { PropsWithChildren } from 'react' import { TooltipProvider } from '~/components/ui/tooltip' import { useBreakpoint } from '~/lib/use-breakpoint' import { useApp } from './app-provider' -import Sidebar from './sidebar' +import Sidebar from './sidebar/sidebar' +import { Toaster } from '~/components/ui/toaster' const loadFramerFeatures = () => import('./framer-features').then((res) => res.default) @@ -36,6 +37,7 @@ export default function Layout({ children }: LayoutProps) { + ) diff --git a/apps/postgres-new/components/markdown-accordion.tsx b/apps/web/components/markdown-accordion.tsx similarity index 100% rename from apps/postgres-new/components/markdown-accordion.tsx rename to apps/web/components/markdown-accordion.tsx diff --git a/apps/postgres-new/components/providers.tsx b/apps/web/components/providers.tsx similarity index 100% rename from apps/postgres-new/components/providers.tsx rename to apps/web/components/providers.tsx diff --git a/apps/postgres-new/components/schema/graph.tsx b/apps/web/components/schema/graph.tsx similarity index 100% rename from apps/postgres-new/components/schema/graph.tsx rename to apps/web/components/schema/graph.tsx diff --git a/apps/postgres-new/components/schema/legend.tsx b/apps/web/components/schema/legend.tsx similarity index 100% rename from apps/postgres-new/components/schema/legend.tsx rename to apps/web/components/schema/legend.tsx diff --git a/apps/postgres-new/components/schema/table-graph.tsx b/apps/web/components/schema/table-graph.tsx similarity index 100% rename from apps/postgres-new/components/schema/table-graph.tsx rename to apps/web/components/schema/table-graph.tsx diff --git a/apps/postgres-new/components/schema/table-node.tsx b/apps/web/components/schema/table-node.tsx similarity index 100% rename from apps/postgres-new/components/schema/table-node.tsx rename to apps/web/components/schema/table-node.tsx diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-actions.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-actions.tsx new file mode 100644 index 00000000..60126aba --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-actions.tsx @@ -0,0 +1,85 @@ +import { MoreVertical } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '~/components/ui/dropdown-menu' +import { cn } from '~/lib/utils' +import { DatabaseItemRenameAction, RenameDatabaseForm } from './database-item-rename-action' +import { useState } from 'react' +import { DatabaseItemDownloadAction } from './database-item-download-action' +import { DatabaseItemDeployAction } from './database-item-deploy-action/database-item-deploy-action' +import { DatabaseItemDeleteAction } from './database-item-delete-action/database-item-delete-action' +import { Button } from '~/components/ui/button' +import type { Database } from '~/data/databases/database-type' + +export type DatabaseItemActionsProps = { + database: Database + isActive: boolean +} + +export function DatabaseItemActions(props: DatabaseItemActionsProps) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const [hasOpenDialog, setHasOpenDialog] = useState(false) + const [isRenaming, setIsRenaming] = useState(false) + + function handleDialogOpenChange(open: boolean) { + setHasOpenDialog(open) + if (open === false) { + setIsDropdownOpen(false) + } + } + + return ( + + + + + + + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/confirm-database-delete-alert.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/confirm-database-delete-alert.tsx new file mode 100644 index 00000000..b13bdd35 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/confirm-database-delete-alert.tsx @@ -0,0 +1,78 @@ +import { AlertDialogPortal } from '@radix-ui/react-alert-dialog' +import { Loader } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { MouseEvent, useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '~/components/ui/alert-dialog' +import { useDatabasesDeleteMutation } from '~/data/databases/database-delete-mutation' +import type { Database } from '~/data/databases/database-type' + +type ConfirmDatabaseDeleteAlertProps = { + children: React.ReactNode + database: Database + isActive: boolean + onOpenChange: (open: boolean) => void +} + +export function ConfirmDatabaseDeleteAlert(props: ConfirmDatabaseDeleteAlertProps) { + const router = useRouter() + const [isOpen, setIsOpen] = useState(false) + const { deleteDatabase, isLoading: isDeleting } = useDatabasesDeleteMutation() + + function handleOpenChange(open: boolean) { + setIsOpen(open) + props.onOpenChange(open) + } + + async function handleDelete(e: MouseEvent) { + e.preventDefault() + await deleteDatabase(props.database) + setIsOpen(false) + if (props.isActive) { + router.push('/') + } + } + + return ( + + {props.children} + + + + Delete database? + + This will permanently delete "{props.database.name}". + {props.database.deployment && ' All connected applications will lose access.'} + + + + Cancel + + {isDeleting ? ( + + {' '} + Deleting + + ) : ( + 'Delete' + )} + + + + + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/database-item-delete-action.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/database-item-delete-action.tsx new file mode 100644 index 00000000..cfe5caa6 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-delete-action/database-item-delete-action.tsx @@ -0,0 +1,30 @@ +import { Trash2 } from 'lucide-react' +import { DropdownMenuItem } from '~/components/ui/dropdown-menu' +import type { Database } from '~/data/databases/database-type' +import { ConfirmDatabaseDeleteAlert } from './confirm-database-delete-alert' + +export type DatabaseItemDeleteActionProps = { + database: Database + isActive: boolean + onDialogOpenChange: (isOpen: boolean) => void +} + +export function DatabaseItemDeleteAction(props: DatabaseItemDeleteActionProps) { + return ( + + { + e.preventDefault() + }} + > + + Delete + + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/confirm-redeploy-database-alert.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/confirm-redeploy-database-alert.tsx new file mode 100644 index 00000000..9f50396b --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/confirm-redeploy-database-alert.tsx @@ -0,0 +1,91 @@ +import { AlertDialogPortal } from '@radix-ui/react-alert-dialog' +import { Loader } from 'lucide-react' +import { MouseEvent, useEffect, useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '~/components/ui/alert-dialog' +import { useToast } from '~/components/ui/use-toast' +import type { Database } from '~/data/databases/database-type' +import { + DeployedDatabaseCreateResult, + useDeployedDatabaseCreateMutation, +} from '~/data/deployed-databases/deployed-database-create-mutation' + +type ConfirmDatabaseRedeployAlertProps = { + children: React.ReactNode + database: Database + onSuccess: (data: DeployedDatabaseCreateResult) => void + onOpenChange: (open: boolean) => void +} + +export function ConfirmDatabaseRedeployAlert(props: ConfirmDatabaseRedeployAlertProps) { + const [isOpen, setIsOpen] = useState(false) + const { mutateAsync: deployDatabase, isPending: isDeploying } = + useDeployedDatabaseCreateMutation() + const { toast } = useToast() + + function handleOpenChange(open: boolean) { + setIsOpen(open) + props.onOpenChange(open) + } + + async function handleDeploy(e: MouseEvent) { + e.preventDefault() + try { + const data = await deployDatabase({ + createdAt: props.database.createdAt, + databaseId: props.database.id, + name: props.database.name, + }) + props.onSuccess(data) + } catch (error) { + toast({ + title: 'Database deployment failed', + description: (error as Error).message, + }) + } finally { + setIsOpen(false) + } + } + + return ( + + {props.children} + + + + Redeploy database? + + This will replace the existing "{props.database.name}" with its current version. + + + + Cancel + + {isDeploying ? ( + + {' '} + Redeploying + + ) : ( + 'Redeploy' + )} + + + + + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-deployed-dialog.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-deployed-dialog.tsx new file mode 100644 index 00000000..0f06b5a9 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-deployed-dialog.tsx @@ -0,0 +1,53 @@ +import { DeployedDatabaseFields } from '~/components/deployed-database-fields' +import { + Dialog, + DialogContent, + DialogHeader, + DialogPortal, + DialogTitle, +} from '~/components/ui/dialog' +import type { Database } from '~/data/databases/database-type' +import { DeployedDatabaseCreateResult } from '~/data/deployed-databases/deployed-database-create-mutation' + +export type DatabaseDeployedDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + database: Database +} & DeployedDatabaseCreateResult + +export function DatabaseDeployedDialog(props: DatabaseDeployedDialogProps) { + return ( + + + + + Database {props.database.name} deployed +
+ +

+ Your database has been deployed to a serverless{' '} + + PGlite + {' '} + instance so that it can be accessed outside the browser using any Postgres client. +

+ + {props.password && ( +
+

+ Please{' '} + save your password, + it will not be shown again! +

+
+ )} + + +
+ ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-item-deploy-action.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-item-deploy-action.tsx new file mode 100644 index 00000000..bc5014d0 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-deploy-action/database-item-deploy-action.tsx @@ -0,0 +1,124 @@ +import { Loader, Upload } from 'lucide-react' +import { useState } from 'react' +import { useApp } from '~/components/app-provider' +import { DropdownMenuItem } from '~/components/ui/dropdown-menu' +import type { Database } from '~/data/databases/database-type' +import { + DeployedDatabaseCreateResult, + useDeployedDatabaseCreateMutation, +} from '~/data/deployed-databases/deployed-database-create-mutation' +import { DatabaseDeployedDialog } from './database-deployed-dialog' +import { ConfirmDatabaseRedeployAlert } from './confirm-redeploy-database-alert' + +export type DatabaseItemDeployActionProps = { + database: Database + onDialogOpenChange: (isOpen: boolean) => void +} + +export function DatabaseItemDeployAction(props: DatabaseItemDeployActionProps) { + const [deployResult, setDeployResult] = useState(null) + const [isDialogOpen, setIsDialogOpen] = useState(false) + + function handleDeploySuccess(data: DeployedDatabaseCreateResult) { + setDeployResult(data) + setIsDialogOpen(true) + props.onDialogOpenChange(true) + } + + return ( + <> + {props.database.deployment ? ( + + ) : ( + + )} + {deployResult && ( + + )} + + ) +} + +type DatabaseItemDeployActionMenuItemProps = { + database: Database + onDeploySuccess: (data: DeployedDatabaseCreateResult) => void +} + +function DatabaseItemDeployActionMenuItem(props: DatabaseItemDeployActionMenuItemProps) { + const { user } = useApp() + const { mutateAsync: deployDatabase, isPending: isDeploying } = + useDeployedDatabaseCreateMutation() + + async function handleMenuItemSelect(e: Event) { + e.preventDefault() + + const deploymentResult = await deployDatabase({ + databaseId: props.database.id, + createdAt: props.database.createdAt, + name: props.database.name, + }) + + props.onDeploySuccess(deploymentResult) + } + + return ( + + {isDeploying ? ( + + ) : ( + + )} + + Deploy + + ) +} + +type DatabaseItemRedeployActionMenuItemProps = { + database: Database + onDeploySuccess: (data: DeployedDatabaseCreateResult) => void + onDialogOpenChange: (open: boolean) => void +} + +function DatabaseItemRedeployActionMenuItem(props: DatabaseItemRedeployActionMenuItemProps) { + const { user } = useApp() + + return ( + + { + e.preventDefault() + }} + > + + Redeploy + + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-download-action.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-download-action.tsx new file mode 100644 index 00000000..fd85918f --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-download-action.tsx @@ -0,0 +1,32 @@ +import { Download } from 'lucide-react' +import { useApp } from '~/components/app-provider' +import { DropdownMenuItem } from '~/components/ui/dropdown-menu' +import type { Database } from '~/data/databases/database-type' +import { downloadFile, titleToKebabCase } from '~/lib/util' + +export type DatabaseItemDownloadActionProps = { database: Database } + +export function DatabaseItemDownloadAction(props: DatabaseItemDownloadActionProps) { + const { dbManager } = useApp() + + async function handleMenuItemSelect(e: Event) { + if (!dbManager) { + throw new Error('dbManager is not available') + } + + const db = await dbManager.getDbInstance(props.database.id) + const dumpBlob = await db.dumpDataDir() + + const fileName = `${titleToKebabCase(props.database.name ?? 'My Database')}-${Date.now()}` + const file = new File([dumpBlob], fileName, { type: dumpBlob.type }) + + downloadFile(file) + } + + return ( + + + Download + + ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-rename-action.tsx b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-rename-action.tsx new file mode 100644 index 00000000..1e0af6bf --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item-actions/database-item-rename-action.tsx @@ -0,0 +1,47 @@ +import { Pencil } from 'lucide-react' +import { DropdownMenuItem } from '~/components/ui/dropdown-menu' +import type { Database } from '~/data/databases/database-type' +import { useLocalDatabaseUpdateMutation } from '~/data/local-databases/local-database-update-mutation' + +export function DatabaseItemRenameAction(props: { + database: Database + onSelect: (e: Event) => void +}) { + return ( + + + Rename + + ) +} + +export function RenameDatabaseForm(props: { database: Database; onSuccess: () => void }) { + const { mutateAsync: updateDatabase } = useLocalDatabaseUpdateMutation() + + return ( +
{ + e.preventDefault() + if (e.target instanceof HTMLFormElement) { + const formData = new FormData(e.target) + const name = formData.get('name') + + if (typeof name === 'string') { + await updateDatabase({ ...props.database, name }) + } + } + props.onSuccess() + }} + > + +
+ ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/database-item.tsx b/apps/web/components/sidebar/database-list/database-item/database-item.tsx new file mode 100644 index 00000000..e3ccd9ec --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/database-item.tsx @@ -0,0 +1,52 @@ +import type { Database } from '~/data/databases/database-type' +import Link from 'next/link' +import { cn } from '~/lib/utils' +import { DatabaseItemActions } from './database-item-actions/database-item-actions' +import { CloudIcon } from 'lucide-react' +import { DeployedDatabaseDialog } from './deployed-database-dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip' +import { Button } from '~/components/ui/button' + +type DatabaseItemProps = { + database: Database + isActive: boolean +} + +export function DatabaseItem(props: DatabaseItemProps) { + const databaseName = props.database.name ?? 'My database' + + return ( +
+ {props.database.deployment ? ( + + + + + + + Database deployed + + + + ) : ( +
+ )} + + {databaseName} + +
+ +
+
+ ) +} diff --git a/apps/web/components/sidebar/database-list/database-item/deployed-database-dialog.tsx b/apps/web/components/sidebar/database-list/database-item/deployed-database-dialog.tsx new file mode 100644 index 00000000..1a404ec1 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-item/deployed-database-dialog.tsx @@ -0,0 +1,99 @@ +import { Loader, LoaderIcon, RefreshCwIcon } from 'lucide-react' +import { useState } from 'react' +import { + DeployedDatabaseFields, + DeployedDatabaseFieldsProps, +} from '~/components/deployed-database-fields' +import { Button } from '~/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogPortal, + DialogTitle, + DialogTrigger, +} from '~/components/ui/dialog' +import type { Database } from '~/data/databases/database-type' +import { useDeployedDatabaseResetPasswordMutation } from '~/data/deployed-databases/deployed-database-reset-password-mutation' + +type DeployedDatabaseDialogProps = { + database: Database + children: React.ReactNode +} + +export function DeployedDatabaseDialog(props: DeployedDatabaseDialogProps) { + const [password, setPassword] = useState() + const { mutateAsync: resetDatabasePassword, isPending: isResettingDatabasePassword } = + useDeployedDatabaseResetPasswordMutation() + + // TODO: maybe store these infos as part of the Database type + const fields: DeployedDatabaseFieldsProps = { + username: 'readonly_postgres', + databaseName: 'postgres', + host: `${props.database.id}.${process.env.NEXT_PUBLIC_WILDCARD_DOMAIN}`, + port: 5432, + password, + } + + async function handleResetPassword() { + const result = await resetDatabasePassword({ databaseId: props.database.id }) + setPassword(result.password) + } + + return ( + + {props.children} + + + + Database {props.database.name} +
+ +

+ Your database is deployed to a serverless{' '} + + PGlite + {' '} + instance so that it can be accessed outside the browser using any Postgres client. +

+ +
+ {password ? ( +

+ Please{' '} + save your password, + it will not be shown again! +

+ ) : ( +

+ Forgot your database password? + +

+ )} +
+ + +
+ ) +} diff --git a/apps/web/components/sidebar/database-list/database-list.tsx b/apps/web/components/sidebar/database-list/database-list.tsx new file mode 100644 index 00000000..26e8b500 --- /dev/null +++ b/apps/web/components/sidebar/database-list/database-list.tsx @@ -0,0 +1,52 @@ +import { m } from 'framer-motion' +import { useParams } from 'next/navigation' +import { useDatabasesQuery } from '~/data/databases/databases-query' +import { DatabaseItem } from './database-item/database-item' +import { Database as DatabaseIcon, Loader } from 'lucide-react' + +export type DatabaseListProps = {} + +export function DatabaseList(props: DatabaseListProps) { + const { id: currentDatabaseId } = useParams<{ id: string }>() + const { databases, isLoading: isLoadingDatabases } = useDatabasesQuery() + + if (isLoadingDatabases) { + return ( +
+ +
+ ) + } + + if (databases.length === 0) { + return ( +
+ + No databases +
+ ) + } + + return ( + + {databases.map((database) => ( + + + + ))} + + ) +} diff --git a/apps/web/components/sidebar/sidebar-footer/sidebar-footer.tsx b/apps/web/components/sidebar/sidebar-footer/sidebar-footer.tsx new file mode 100644 index 00000000..b0e18955 --- /dev/null +++ b/apps/web/components/sidebar/sidebar-footer/sidebar-footer.tsx @@ -0,0 +1,71 @@ +import { m } from 'framer-motion' +import ThemeDropdown from './theme-dropdown' +import { useApp } from '~/components/app-provider' +import { Button } from '~/components/ui/button' +import { LogOut } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip' + +export type SidebarFooterProps = {} + +export function SidebarFooter(props: SidebarFooterProps) { + const { user, signOut } = useApp() + + async function handleSignOut() { + await signOut() + } + + return ( + <> + + + + {user && ( + + + + )} + + ) +} + +export type CollapsedSidebarFooterProps = {} + +export function CollapsedSidebparFooter(props: CollapsedSidebarFooterProps) { + const { user, signOut } = useApp() + + async function handleSignOut() { + await signOut() + } + + return ( +
+ + + + + + + +

Toggle theme

+
+
+ {user && ( + + + + + + + +

Sign out

+
+
+ )} +
+ ) +} diff --git a/apps/postgres-new/components/theme-dropdown.tsx b/apps/web/components/sidebar/sidebar-footer/theme-dropdown.tsx similarity index 93% rename from apps/postgres-new/components/theme-dropdown.tsx rename to apps/web/components/sidebar/sidebar-footer/theme-dropdown.tsx index 9f002382..73123740 100644 --- a/apps/postgres-new/components/theme-dropdown.tsx +++ b/apps/web/components/sidebar/sidebar-footer/theme-dropdown.tsx @@ -1,13 +1,13 @@ 'use client' import { Moon, Sun } from 'lucide-react' -import { Button } from './ui/button' +import { Button } from '~/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from './ui/dropdown-menu' +} from '~/components/ui/dropdown-menu' import { useTheme } from 'next-themes' import { cn } from '~/lib/utils' diff --git a/apps/web/components/sidebar/sidebar-header.tsx b/apps/web/components/sidebar/sidebar-header.tsx new file mode 100644 index 00000000..d7d89435 --- /dev/null +++ b/apps/web/components/sidebar/sidebar-header.tsx @@ -0,0 +1,111 @@ +import { m } from 'framer-motion' +import { ArrowLeftToLine, ArrowRightToLine, PackagePlus } from 'lucide-react' +import { Button } from '~/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip' +import { useApp } from '~/components/app-provider' +import { useRouter } from 'next/navigation' +import { SignInDialog } from '~/components/sign-in-dialog' + +export type SidebarHeaderProps = { + onCollapse: () => void +} + +export function SidebarHeader(props: SidebarHeaderProps) { + const { focusRef, user } = useApp() + const router = useRouter() + + return ( +
+ + + + + + + +

Close sidebar

+
+
+ + + + + +
+ ) +} + +export type CollapsedSidebarHeaderProps = { + onExpand: () => void +} + +export function CollapsedSidebarHeader(props: CollapsedSidebarHeaderProps) { + const { focusRef, user } = useApp() + const router = useRouter() + + return ( +
+ + + + + + + +

Open sidebar

+
+
+ + + + + + + + + +

New database

+
+
+
+ ) +} diff --git a/apps/web/components/sidebar/sidebar.tsx b/apps/web/components/sidebar/sidebar.tsx new file mode 100644 index 00000000..3a4eabaf --- /dev/null +++ b/apps/web/components/sidebar/sidebar.tsx @@ -0,0 +1,41 @@ +'use client' + +import { AnimatePresence, m } from 'framer-motion' +import { useState } from 'react' +import React from 'react' +import { DatabaseList } from './database-list/database-list' +import { CollapsedSidebarHeader, SidebarHeader } from './sidebar-header' +import { CollapsedSidebparFooter, SidebarFooter } from './sidebar-footer/sidebar-footer' + +export default function Sidebar() { + const [isCollapsed, setIsCollapsed] = useState(false) + + if (isCollapsed) { + return ( +
+ setIsCollapsed(false)} /> + +
+ ) + } + + return ( + + + setIsCollapsed(true)} /> + + + + + ) +} diff --git a/apps/postgres-new/components/sign-in-button.tsx b/apps/web/components/sign-in-button.tsx similarity index 88% rename from apps/postgres-new/components/sign-in-button.tsx rename to apps/web/components/sign-in-button.tsx index a61f748b..3b68b603 100644 --- a/apps/postgres-new/components/sign-in-button.tsx +++ b/apps/web/components/sign-in-button.tsx @@ -1,8 +1,9 @@ import GitHubIcon from '~/assets/github-icon' -import { useApp } from './app-provider' +import { useApp } from '~/components/app-provider' export default function SignInButton() { const { signIn } = useApp() + return (