Chore/db visualizer 3 (#19177)

* Midway doing something

* Small refactor

* Some mobile responsiveness

* bit fix

* Update name

* One more name

* One more name, again

* Footer and faqs

* Fix

* Style fixes

* Style fixes

* Add titles

* Add CTAs to copy, download or load SQL in supabase

* Styling

* mobile

* Add suggestions

* Add delete action

* Cleanup

* Add delete thread and edit thread modals

* Restore server action

* Fix build error

* Import common theme switcher

* Add file

* Restore thread list

* force revalidating profile path

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Terry Sutton
2023-11-24 02:18:14 -03:30
committed by GitHub
parent 941d32dcdb
commit 4c20bb0d52
38 changed files with 674 additions and 381 deletions

View File

@@ -1,4 +1,4 @@
# database.new
# database.design
## Overview

View File

@@ -1,10 +1,16 @@
'use client'
import { Chat } from '@/components/Chat/Chat'
import SaveSchemaDropdown from '@/components/Header/SaveSchemaDropdown'
import ToggleCodeEditorButton from '@/components/Header/ToggleCodeEditorButton'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-row items-center justify-between bg-alternative h-full">
<div className="flex flex-col-reverse items-between xl:flex-row xl:items-center xl:justify-between bg-alternative h-full">
<Chat />
<div className="block xl:hidden flex items-center gap-x-2 justify-end border-t py-2 px-2 bg-background">
<ToggleCodeEditorButton />
<SaveSchemaDropdown />
</div>
{children}
</div>
)

View File

@@ -0,0 +1,13 @@
import OpenAI from 'openai'
const openai = new OpenAI()
export async function POST(req: Request, { params }: { params: { threadId: string } }) {
if (!req.body) {
return Response.error()
}
const kill = await openai.beta.threads.del(params.threadId)
console.log({ kill })
return Response.json({})
}

View File

@@ -1,6 +1,7 @@
import { cookies } from 'next/headers'
import { createClient } from '@/lib/supabase/server'
import OpenAI from 'openai'
import { revalidatePath } from 'next/cache'
const openai = new OpenAI()
@@ -52,5 +53,7 @@ export async function POST(req: Request) {
console.error(error)
}
revalidatePath('/profile')
return Response.json({ threadId: thread.id, runId: run.id })
}

View File

@@ -0,0 +1,74 @@
import type { Metadata } from 'next'
type faq = {
question: string
answer: string
}
export const metadata: Metadata = {
title: 'database.design | Faq',
}
const FAQS = [
{
question: 'What is database.design?',
answer:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Doloremque molestias dicta nobis explicabo maiores officia blanditiis cupiditate, quibusdam debitis! Dignissimos ducimus aut temporibus ea, repellat consectetur quisquam molestiae recusandae rem.',
},
{
question: 'How does it work?',
answer:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Doloremque molestias dicta nobis explicabo maiores officia blanditiis cupiditate, quibusdam debitis! Dignissimos ducimus aut temporibus ea, repellat consectetur quisquam molestiae recusandae rem.',
},
{
question: 'Can I see the code?',
answer:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Doloremque molestias dicta nobis explicabo maiores officia blanditiis cupiditate, quibusdam debitis! Dignissimos ducimus aut temporibus ea, repellat consectetur quisquam molestiae recusandae rem.',
},
{
question: 'Can I use this for non-Postgres databases?',
answer:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Doloremque molestias dicta nobis explicabo maiores officia blanditiis cupiditate, quibusdam debitis! Dignissimos ducimus aut temporibus ea, repellat consectetur quisquam molestiae recusandae rem.',
},
]
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
}
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"
>
<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

View File

@@ -1,9 +1,10 @@
import '@ui/layout/ai-icon-animation/ai-icon-animation-style.module.css'
import './globals.css'
import Header from '@/components/Header'
import Header from '@/components/Header/Header'
import { ReactQueryProvider, ThemeProvider } from '@/components/providers'
import type { Metadata } from 'next'
import Footer from '@/components/Footer'
const defaultUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
@@ -11,7 +12,7 @@ const defaultUrl = process.env.VERCEL_URL
export const metadata: Metadata = {
metadataBase: new URL(defaultUrl),
title: 'database.new',
title: 'database.design',
description: 'Generate schemas from your ideas',
}
@@ -26,6 +27,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<main role="main" className="grow">
{children}
</main>
<Footer />
</div>
</ReactQueryProvider>
</ThemeProvider>

View File

@@ -1,8 +1,7 @@
import Link from 'next/link'
import { headers, cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { ArrowLeft } from 'lucide-react'
import { Github } from 'lucide-react'
import { cookies, headers } from 'next/headers'
import { redirect } from 'next/navigation'
export default function Login({ searchParams }: { searchParams: { message: string } }) {
const signUp = async (formData: FormData) => {
@@ -14,9 +13,7 @@ export default function Login({ searchParams }: { searchParams: { message: strin
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${origin}/auth/callback`,
},
options: { redirectTo: `${origin}/auth/callback` },
})
if (error) {
@@ -28,23 +25,23 @@ export default function Login({ searchParams }: { searchParams: { message: strin
}
return (
<div className="flex-1 flex flex-col w-full px-8 sm:max-w-md justify-center gap-2 mx-auto mt-24">
<Link
href="/"
className="absolute left-8 top-8 py-2 px-4 rounded-md no-underline text-foreground bg-btn-background hover:bg-btn-background-hover flex items-center gap-2 text-sm"
>
<ArrowLeft size={14} />
Back
</Link>
<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}
>
<button className="border border-foreground/20 rounded-md px-4 py-2 text-foreground mb-2">
Sign up with Github
<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>
<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">
<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}

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next'
import ChatInput from '@/components/Chat/ChatInput'
import { createClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
export const metadata: Metadata = {
title: 'database.design | Create',
}
const NewThread = async () => {
const cookieStore = cookies()
const supabase = createClient(cookieStore)

View File

@@ -0,0 +1,37 @@
import { Modal } from 'ui'
import { ThreadType } from './Threads'
import { deleteThread } from '@/lib/actions'
const ConfirmDeleteThreadModal = ({
thread,
onClose,
}: {
thread?: ThreadType
onClose: () => void
}) => {
const deleteCurrentThread = () => {
const threadID = thread?.thread_id
onClose()
deleteThread(threadID!)
}
return (
<Modal
variant="danger"
alignFooter="right"
size="small"
visible={thread !== undefined}
onCancel={onClose}
onConfirm={async () => {
await deleteCurrentThread()
}}
header="Confirm to delete thread?"
>
<Modal.Content className="py-4">
<p className="text-sm">Once the thread is deleted, it cannot be recovered.</p>
</Modal.Content>
</Modal>
)
}
export default ConfirmDeleteThreadModal

View File

@@ -0,0 +1,37 @@
import { Input, Modal } from 'ui'
import { ThreadType } from './Threads'
import { useEffect, useState } from 'react'
const EditThreadModal = ({ thread, onClose }: { thread?: ThreadType; onClose: () => void }) => {
const [value, setValue] = useState('')
useEffect(() => {
if (thread !== undefined) setValue(thread.thread_title)
}, [thread])
const updateThread = () => {
// Logic here
onClose()
}
return (
<Modal
alignFooter="right"
size="medium"
visible={thread !== undefined}
onCancel={onClose}
onConfirm={updateThread}
header="Edit thread name"
>
<Modal.Content className="py-4">
<Input
label="Provide a name for your thread"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</Modal.Content>
</Modal>
)
}
export default EditThreadModal

View File

@@ -0,0 +1,16 @@
'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="/new">Start a conversation</Link>
</Button>
</div>
)
}
export default EmptyState

View File

@@ -0,0 +1,66 @@
'use client'
import { timeAgo } from '@/lib/utils'
import Link from 'next/link'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconEdit2,
IconMoreVertical,
IconTrash2,
} from 'ui'
import { ThreadType } from './Threads'
const Thread = ({
thread,
handleThreadActions,
onSelectEdit,
onSelectDelete,
}: {
thread: ThreadType
handleThreadActions: (formData: FormData) => void
onSelectEdit: () => void
onSelectDelete: () => void
}) => {
const formattedTimeAgo = timeAgo(thread.created_at)
//[Joshen] Just FYI Terry sorry i had to peel out your form component here which handled the delete
// Ideal UX for delete is to have a confirmation modal, and edit to be in a modal too so need client
// <form action={handleThreadActions} className="flex gap-2 items-center">
// <input type="hidden" name="threadID" value={thread.thread_id} />
// </form>
return (
<div
key={thread.id}
className="group flex items-center justify-between border rounded w-full px-4 py-2 transition bg-surface-100 hover:bg-surface-200"
>
<div className="flex flex-col gap-y-1">
<Link className="text-sm hover:underline" href={`/${thread.thread_id}/${thread.run_id}`}>
{thread.thread_title}
</Link>
<p className="text-xs text-foreground-light">Last updated {formattedTimeAgo}</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger>
<Button type="text" icon={<IconMoreVertical />} className="px-1" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-32" align="center">
<DropdownMenuItem className="space-x-2" onClick={onSelectEdit}>
<IconEdit2 size={14} />
<p>Edit name</p>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2" onClick={onSelectDelete}>
<IconTrash2 size={14} />
<p>Delete thread</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export default Thread

View File

@@ -0,0 +1,57 @@
'use client'
import { Database } from '@/types/supabase'
import EmptyState from './EmptyState'
import Thread from './Thread'
import { useState, useEffect } from 'react'
import ConfirmDeleteThreadModal from './ConfirmDeleteThreadModal'
import EditThreadModal from './EditThreadModal'
export type ThreadType = Database['public']['Tables']['threads']['Row']
interface ThreadsProps {
threads: ThreadType[]
handleThreadActions: (formData: FormData) => void
}
const Threads = ({ threads, handleThreadActions }: ThreadsProps) => {
// To circumvent hydration errors, although not sure why its happening
const [mounted, setMounted] = useState(false)
const [selectedThreadToEdit, setSelectedThreadToEdit] = useState<ThreadType>()
const [selectedThreadToDelete, setSelectedThreadToDelete] = useState<ThreadType>()
useEffect(() => {
setMounted(true)
}, [])
return (
mounted && (
<>
<div className="flex flex-col gap-y-3">
{threads.length > 0 ? (
threads.map((thread) => (
<Thread
key={thread.id}
thread={thread}
handleThreadActions={handleThreadActions}
onSelectEdit={() => setSelectedThreadToEdit(thread)}
onSelectDelete={() => setSelectedThreadToDelete(thread)}
/>
))
) : (
<EmptyState />
)}
</div>
<ConfirmDeleteThreadModal
thread={selectedThreadToDelete}
onClose={() => setSelectedThreadToDelete(undefined)}
/>
<EditThreadModal
thread={selectedThreadToEdit}
onClose={() => setSelectedThreadToEdit(undefined)}
/>
</>
)
)
}
export default Threads

View File

@@ -5,5 +5,5 @@ import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
export default function Layout({ children }: { children: React.ReactNode }) {
return <div className="max-w-4xl mx-auto h-full">{children}</div>
return <div className="px-4 xl:px-0 xl:max-w-4xl mx-auto h-full">{children}</div>
}

View File

@@ -1,100 +0,0 @@
'use client'
import { useConversationsQuery } from '@/data/conversations-query'
import dayjs from 'dayjs'
import Link from 'next/link'
import { Button, IconChevronRight, Input } from 'ui'
export default function Profile() {
const MOCK_PROFILE = {
name: 'J-Dog',
username: 'joshenlim',
email: 'joshen@supabase.io',
avatar: 'https://i.pinimg.com/564x/d1/0d/89/d10d890537309f146f92f9af9d70cf83.jpg',
}
const { data, isLoading, isSuccess } = useConversationsQuery({ userId: '' })
const conversations = data ?? []
return (
<div className="grid grid-cols-4 gap-x-8 py-12">
<div className="col-span-1 flex flex-col gap-y-6">
<div className="flex items-center gap-x-4">
<div
className="w-14 h-14 rounded-full border bg-no-repeat bg-center bg-cover"
style={{ backgroundImage: `url('${MOCK_PROFILE.avatar}')` }}
/>
<div className="flex flex-col">
<p className="text-xl">{MOCK_PROFILE.name}</p>
<p className="text-foreground-light">@{MOCK_PROFILE.username}</p>
</div>
</div>
{/* [Joshen] If we want some form action here */}
<div className="flex flex-col gap-y-2">
<Input size="tiny" label="Name" value={MOCK_PROFILE.name} />
<Input size="tiny" label="Username" value={MOCK_PROFILE.username} />
<Input size="tiny" label="Email" value={MOCK_PROFILE.email} />
</div>
<div className="flex items-center justify-end gap-x-2">
<Button type="default">Cancel</Button>
<Button type="primary">Save changes</Button>
</div>
</div>
<div className="col-span-3 flex flex-col gap-y-4">
<p>Past conversations</p>
<div className="w-full h-px border-t" />
<div className="flex flex-col gap-y-3">
{isLoading && (
<div className="flex flex-col gap-y-3">
<div className="rounded w-full shimmering-loader py-7" />
<div className="rounded w-full shimmering-loader py-7" />
</div>
)}
{!isLoading && conversations.length === 0 && (
<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="/new">Start a conversation</Link>
</Button>
</div>
)}
{isSuccess &&
conversations.map((conversation) => {
const hoursFromNow = dayjs().diff(dayjs(conversation.updatedAt), 'hours')
const formattedTimeFromNow = dayjs(conversation.updatedAt).fromNow()
const formattedUpdatedAt = dayjs(conversation.updatedAt).format('DD MMM YYYY, HH:mm')
return (
<Link
key={conversation.id}
href={`/${conversation.threadId}/${conversation.runId}`}
>
<div className="group flex items-center justify-between border rounded w-full px-4 py-2 transition bg-surface-100 hover:bg-surface-200">
<div className="flex flex-col gap-y-1">
<p className="text-sm">{conversation.name}</p>
<p className="text-xs text-foreground-light">
Last updated at{' '}
{hoursFromNow > 6 ? `on ${formattedUpdatedAt}` : formattedTimeFromNow}
</p>
</div>
<Button
type="text"
icon={<IconChevronRight size={16} strokeWidth={2} />}
className="transition opacity-0 group-hover:opacity-100 px-1"
/>
</div>
</Link>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -1,14 +1,17 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs'
import { redirect } from 'next/navigation'
import { deleteThread } from '@/lib/actions'
import { createClient } from '@/lib/supabase/server'
import { default as dayjs, default as relativeTime } from 'dayjs'
import { cookies } from 'next/headers'
import Link from 'next/link'
import { ChevronRight } from 'lucide-react'
import { timeAgo } from '@/lib/utils'
import { redirect } from 'next/navigation'
import Threads from './Threads'
dayjs.extend(relativeTime)
// 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 () => {
const cookieStore = cookies()
const supabase = createClient(cookieStore)
@@ -19,49 +22,43 @@ const Profile = async () => {
if (!user) redirect('/')
const { data: threads } = await supabase.from('threads').select().eq('user_id', user.id)
const { data } = await supabase.from('threads').select().eq('user_id', user.id)
const threads = data ?? []
async function handleThreadActions(formData: FormData) {
'use server'
const action = formData.get('action') as string
const threadID = formData.get('threadID') as string
if (!threadID) return
if (action === 'delete') {
deleteThread(threadID)
}
}
return (
<div className="grid grid-cols-4 gap-x-8 py-12">
<div className="col-span-1 flex flex-col gap-y-6">
<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">
<div
className="border border-foreground-lighter rounded-full w-[30px] h-[30px] bg-no-repeat bg-center bg-cover"
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-xl">{user.user_metadata.full_name}</p>
<p className="text-lg">{user.user_metadata.full_name}</p>
<p className="text-foreground-light">@{user.user_metadata.user_name}</p>
</div>
</div>
</div>
<div className="col-span-3 flex flex-col gap-y-4">
<div className="col-span-4 xl:col-span-3 flex flex-col gap-y-4">
<p>Past conversations</p>
<div className="w-full h-px border-t" />
<div className="flex flex-col gap-y-3">
{threads
? threads.map((thread) => {
const formattedTimeAgo = timeAgo(thread.created_at)
return (
<Link key={thread.id} href={`/${thread.thread_id}/${thread.run_id}`}>
<div className="group flex items-center justify-between border rounded w-full px-4 py-2 transition bg-surface-100 hover:bg-surface-200">
<div className="flex flex-col gap-y-1">
<p className="text-sm">{thread.thread_title}</p>
<p className="text-xs text-foreground-light">
Last updated {formattedTimeAgo}
</p>
</div>
<ChevronRight size={16} strokeWidth={2} />
</div>
</Link>
)
})
: 'no threads yet'}
</div>
<Threads threads={threads} handleThreadActions={handleThreadActions} />
</div>
</div>
)

View File

@@ -60,9 +60,9 @@ export const Chat = () => {
<div
className={cn(
'bg',
'border-r relative',
'border-t xl:border-t-0 xl:border-r relative',
'flex flex-col h-full border-r',
'w-[400px] 2xl:w-[500px]'
'w-full xl:w-[400px] 2xl:w-[500px]'
)}
>
<div className="flex flex-col grow items-between">
@@ -72,7 +72,7 @@ export const Chat = () => {
</div>
) : (
<ScrollArea className="grow h-px">
<div className="flex flex-col py-6">
<div className="flex flex-col py-2 xl:py-6">
{userMessages.map((message, idx) => {
const index = messages.indexOf(message)
const reply = messages[index + 1] as AssistantMessage

View File

@@ -2,6 +2,7 @@
import { useMutation } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { Input } from 'ui'
@@ -9,8 +10,24 @@ interface ChatInputParams {
userID: string | undefined
}
const suggestions = [
{ 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' },
]
const ChatInput = ({ userID }: ChatInputParams) => {
const router = useRouter()
const [value, setValue] = useState('')
const { mutate, isPending, isSuccess } = useMutation({
mutationFn: async (prompt: string) => {
const body = { prompt, userID } // Include userID in the body object
@@ -31,32 +48,62 @@ const ChatInput = ({ userID }: ChatInputParams) => {
return (
<>
<div className="flex items-center gap-x-1.5 font-mono text-xl">
<div className="flex items-center gap-x-1.5 font-mono font-bold text-xl">
<span>database</span>
<div className="w-1.5 h-1.5 rounded-full bg-purple-900"></div>
<span>new</span>
<span>design</span>
</div>
<Input
autoFocus
size="xlarge"
className="w-11/12 max-w-xl shadow"
inputClassName="rounded-full"
size="large"
value={value}
className="w-10/12 xl:w-11/12 max-w-xl shadow"
inputClassName="rounded-full text-sm pr-10"
placeholder="e.g Create a Telegram-like chat application"
disabled={isPending || isSuccess}
onKeyDown={(e) => {
if (e.code === 'Enter') {
const value = (e.target as any).value
if (value.length > 0) mutate(value)
}
}}
onChange={(e) => setValue(e.target.value)}
actions={
isPending || isSuccess ? (
<div className="mr-3">
<Loader2 size={18} className="animate-spin" />
<div className="mr-1">
<Loader2 size={22} className="animate-spin" />
</div>
) : null
) : (
<div className="flex items-center justify-center w-7 h-7 bg-surface-300 border border-control rounded-full mr-0.5 p-1.5">
<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>
</div>
)
}
/>
<div className="flex items-center space-x-2">
{suggestions.map((suggestion, idx) => (
<button
key={idx}
className="border rounded-full px-4 py-2"
onClick={() => setValue(suggestion.prompt)}
>
<p className="text-xs">{suggestion.label}</p>
</button>
))}
</div>
</>
)
}

View File

@@ -27,7 +27,7 @@ const UserChat = ({ message, reply, isLatest, isSelected, isLoading }: UserChatP
<div
className={cn(
'group',
'transition flex w-full gap-x-5 px-8 hover:bg-surface-200/50 cursor-pointer border-r',
'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'
)}

View File

@@ -1,12 +1,12 @@
import Editor, { BeforeMount, EditorProps, OnMount } from '@monaco-editor/react'
import { merge, noop } from 'lodash'
import { merge } from 'lodash'
import { useTheme } from 'next-themes'
import { useEffect, useRef } from 'react'
import { format } from 'sql-formatter'
import { cn } from 'ui'
import { getTheme } from './CodeEditor.utils'
import { useAppStateSnapshot } from '@/lib/state'
import { getTheme } from './CodeEditor.utils'
interface MonacoEditorProps {
id: string
@@ -26,11 +26,15 @@ export const CodeEditor = ({ content = '' }: { content: string }) => {
const snap = useAppStateSnapshot()
const code = format(content, { language: 'postgresql' })
useEffect(() => {
snap.setSelectedCode(code)
}, [code])
return (
<div
className={cn(
snap.hideCode ? 'max-w-0' : 'max-w-lg 2xl:max-w-xl',
'w-full border-l',
'w-full xl:border-l',
'grow flex flex-col h-full'
)}
>
@@ -44,20 +48,12 @@ const MonacoEditor = ({
language,
defaultValue,
hideLineNumbers = false,
onInputChange = noop,
options,
value,
}: MonacoEditorProps) => {
const monacoRef = useRef<any>()
const { resolvedTheme } = useTheme()
useEffect(() => {
if (resolvedTheme && monacoRef.current) {
const mode: any = getTheme(resolvedTheme)
monacoRef.current.editor.defineTheme('supabase', mode)
}
}, [resolvedTheme, monacoRef])
const beforeMount: BeforeMount = (monaco) => {
monacoRef.current = monaco
monaco.editor.defineTheme('supabase', {
@@ -74,7 +70,7 @@ const MonacoEditor = ({
})
}
const onMount: OnMount = async (editor, monaco) => {
const onMount: OnMount = async (editor) => {
// Add margin above first line
editor.changeViewZones((accessor) => {
accessor.addZone({
@@ -121,7 +117,6 @@ const MonacoEditor = ({
options={optionsMerged}
beforeMount={beforeMount}
onMount={onMount}
onChange={onInputChange}
/>
)
}

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 = () => (
<div className="border-t py-4 w-full px-4 flex justify-between">
<ul className="flex items-center gap-4 text-xs">
{links.map((link, index) => (
<li key={index}>
<Link href={link.url}>{link.title}</Link>
</li>
))}
</ul>
<ThemeSwitcher />
</div>
)
export default Footer

View File

@@ -1,144 +0,0 @@
/* eslint-disable @next/next/no-img-element */
'use client'
import { LogIn, LogOut, Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconUser,
} from 'ui'
import { useMessagesQuery } from '@/data/messages-query'
import { useAppStateSnapshot } from '@/lib/state'
import { UserMessage } from '@/lib/types'
import { Separator } from '@ui/components/Modal/Modal'
const Header = () => {
const snap = useAppStateSnapshot()
const { threadId, runId, messageId }: { threadId: string; runId: string; messageId: string } =
useParams()
const [mounted, setMounted] = useState(false)
const { setTheme, resolvedTheme } = useTheme()
const isConversation = threadId !== undefined && runId !== undefined
useEffect(() => {
setMounted(true)
}, [])
const { data } = useMessagesQuery({ threadId, runId, enabled: isConversation })
const selectedMessage = data?.messages.find((m) => m.id === messageId) as UserMessage
// [Joshen] Fetch user profile here
const isLoggedIn = true
const MOCK_PROFILE = {
name: 'J-Dog',
username: 'joshenlim',
email: 'joshen@supabase.io',
avatar: 'https://i.pinimg.com/564x/d1/0d/89/d10d890537309f146f92f9af9d70cf83.jpg',
}
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">
<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-purple-900"></div>
<span>new</span>
</div>
{selectedMessage !== undefined && (
<p title={selectedMessage.text} className="truncate border-l text-sm px-4">
{selectedMessage.text}
</p>
)}
</div>
<div className="flex items-center gap-x-2">
{isConversation && (
<>
(
<Button type="default" onClick={() => snap.setHideCode(!snap.hideCode)}>
{snap.hideCode ? 'Show code' : 'Hide code'}
</Button>
<div className="border-r py-3" />)
</>
)}
<Button type="default">
<Link href="/new">New conversation</Link>
</Button>
<Button type="default">
<Link href="/login">Login</Link>
</Button>
<Button
type="outline"
className="px-1"
icon={mounted && resolvedTheme === 'dark' ? <Moon size={16} /> : <Sun size={16} />}
onClick={() => (resolvedTheme === 'dark' ? setTheme('light') : setTheme('dark'))}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild className="flex">
{isLoggedIn ? (
<button
className="border border-foreground-lighter rounded-full w-[30px] h-[30px] bg-no-repeat bg-center bg-cover"
style={{ backgroundImage: `url('${MOCK_PROFILE.avatar}')` }}
/>
) : (
<Button type="outline" className="p-1.5 rounded-full" icon={<IconUser size={16} />} />
)}
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-48">
{isLoggedIn ? (
<>
<div className="px-2 py-2">
<p className="text-xs text-foreground">{MOCK_PROFILE.name}</p>
<p className="text-xs text-foreground-light">{MOCK_PROFILE.email}</p>
</div>
<Link href="/profile">
<DropdownMenuItem className="space-x-2" onClick={() => {}}>
<IconUser size={14} />
<p>Profile</p>
</DropdownMenuItem>
</Link>
<Separator />
<a href="https://supabase.com" target="_blank" rel="noreferrer">
<DropdownMenuItem className="space-x-2">
<img alt="supabase" src="/supabase.png" className="w-[14px]" />
<p>Supabase</p>
</DropdownMenuItem>
</a>
<DropdownMenuItem className="space-x-2" onClick={() => {}}>
<LogOut size={14} />
<p>Sign out</p>
</DropdownMenuItem>
</>
) : (
<div className="flex flex-col gap-y-2 p-2">
<div className="text-xs">
Sign in to <span className="text-foreground">database.new</span> to save your
conversations!
</div>
<Button type="default" icon={<LogIn size={14} />}>
Sign in
</Button>
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</nav>
)
}
export default Header

View File

@@ -1,5 +1,5 @@
'use client'
import { LogIn, LogOut } from 'lucide-react'
import { HelpCircle, LogIn, LogOut } from 'lucide-react'
import { User } from '@supabase/supabase-js'
import Link from 'next/link'
import Image from 'next/image'
@@ -10,7 +10,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
IconUser,
Separator,
SidePanel,
} from 'ui'
interface AvatarDropdownProps {
@@ -40,11 +40,17 @@ export default function AvatarDropdown({ currentUser, signout }: AvatarDropdownP
</div>
<Link href="/profile">
<DropdownMenuItem className="space-x-2" onClick={() => {}}>
<IconUser size={14} />
<IconUser size={14} strokeWidth={2} />
<p>Profile</p>
</DropdownMenuItem>
</Link>
<Separator />
<Link href="/faq">
<DropdownMenuItem className="space-x-2" onClick={() => {}}>
<HelpCircle size={14} />
<p>FAQs</p>
</DropdownMenuItem>
</Link>
<SidePanel.Separator />
<a href="https://supabase.com" target="_blank" rel="noreferrer">
<DropdownMenuItem className="space-x-2">
<Image alt="supabase" src="/supabase.png" width={14} height={14} />
@@ -59,7 +65,7 @@ export default function AvatarDropdown({ currentUser, signout }: AvatarDropdownP
) : (
<div className="flex flex-col gap-y-2 p-2">
<div className="text-xs">
Sign in to <span className="text-foreground">database.new</span> to save your
Sign in to <span className="text-foreground">database.design</span> to save your
conversations!
</div>
<Button type="default" icon={<LogIn size={14} />}>

View File

@@ -14,9 +14,9 @@ const CurrentThreadName = () => {
const selectedMessage = data?.messages.find((m) => m.id === messageId) as UserMessage
return (
<div className="flex items-center gap-x-4">
<div className="hidden xl:block flex items-center gap-x-4">
{selectedMessage !== undefined && (
<p title={selectedMessage.text} className="truncate border-l text-sm px-4">
<p title={selectedMessage.text} className="truncate max-w-[700px] border-l text-sm px-4">
{selectedMessage.text}
</p>
)}

View File

@@ -1,7 +1,7 @@
import HeaderActions from './Header/HeaderActions'
import HeaderActions from './HeaderActions'
import { createClient } from '@/lib/supabase/server'
import { cookies } from 'next/headers'
import CurrentThreadName from './Header/CurrentThreadName'
import CurrentThreadName from './CurrentThreadName'
import Link from 'next/link'
const Header = async () => {
@@ -22,9 +22,10 @@ const Header = async () => {
<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-purple-900"></div>
<span>new</span>
<span>design</span>
</div>
</Link>
<CurrentThreadName />
</div>

View File

@@ -1,14 +1,16 @@
'use client'
import { User } from '@supabase/supabase-js'
import { Button } from 'ui'
import ThemeSwitcherButton from './ThemeSwitcherButton'
import AvatarDropdown from './AvatarDropdown'
import Link from 'next/link'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useSelectedLayoutSegment } from 'next/navigation'
import { useAppStateSnapshot } from '@/lib/state'
import { createClient } from '@/lib/supabase/client'
import { User } from '@supabase/supabase-js'
import Link from 'next/link'
import { useRouter, useSelectedLayoutSegment } from 'next/navigation'
import { useState } from 'react'
import { Button } from 'ui'
import AvatarDropdown from './AvatarDropdown'
import NoUserDropdown from './NoUserDropdown'
import SaveSchemaDropdown from './SaveSchemaDropdown'
import ThemeSwitcherButton from './ThemeSwitcher'
import ToggleCodeEditorButton from './ToggleCodeEditorButton'
interface HeaderActionsProps {
user: User | null
@@ -17,7 +19,6 @@ const HeaderActions = ({ user }: HeaderActionsProps) => {
const supabase = createClient()
const router = useRouter()
const segment = useSelectedLayoutSegment()
const snap = useAppStateSnapshot()
const [currentUser, setCurrentUser] = useState<User | null>(user)
@@ -34,26 +35,22 @@ const HeaderActions = ({ user }: HeaderActionsProps) => {
return (
<div className="flex items-center gap-x-2">
<Button type="default">
<Link href="/new">New conversation</Link>
</Button>
{segment && segment.includes('thread') && (
<>
<Button type="default" onClick={() => snap.setHideCode(!snap.hideCode)}>
{snap.hideCode ? 'Show code' : 'Hide code'}
</Button>
<div className="hidden xl:flex items-center gap-x-2">
<ToggleCodeEditorButton />
<SaveSchemaDropdown />
<div className="border-r py-3" />
</>
</div>
)}
<ThemeSwitcherButton />
<Button type="default" className="hidden xl:block">
<Link href="/new">New conversation</Link>
</Button>
{currentUser ? (
<AvatarDropdown currentUser={currentUser} signout={signout} />
) : (
<Button type="default">
<Link href="/login">Login</Link>
</Button>
<NoUserDropdown />
)}
</div>
)

View File

@@ -0,0 +1,49 @@
import { LogIn } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconUser,
SidePanel,
} from 'ui'
const NoUserDropdown = () => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild className="flex">
<Button
type="outline"
className="p-1.5 rounded-full"
icon={<IconUser size={16} strokeWidth={2} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-48">
<div className="flex flex-col gap-y-2 p-2">
<div className="text-xs">
Sign in to <span className="text-foreground">database.design</span> to save your
conversations!
</div>
</div>
<Link href="/login">
<DropdownMenuItem className="space-x-2">
<LogIn size={14} />
<p>Sign in</p>
</DropdownMenuItem>
</Link>
<SidePanel.Separator />
<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>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default NoUserDropdown

View File

@@ -0,0 +1,76 @@
import { getAppStateSnapshot, useAppStateSnapshot } from '@/lib/state'
import Image from 'next/image'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconChevronDown,
IconClipboard,
IconDownload,
} from 'ui'
const SaveSchemaDropdown = () => {
const snap = useAppStateSnapshot()
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>
<DropdownMenuTrigger asChild>
<Button
type="default"
onClick={() => snap.setHideCode(!snap.hideCode)}
iconRight={<IconChevronDown />}
>
Save schema
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-44" align="center">
<DropdownMenuItem className="space-x-2" onClick={() => copyToClipboard()}>
<IconClipboard size={14} strokeWidth={2} />
<p>Copy SQL</p>
</DropdownMenuItem>
<DropdownMenuItem className="space-x-2" onClick={() => downloadSQL()}>
<IconDownload size={14} strokeWidth={2} />
<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

View File

@@ -0,0 +1,6 @@
'use client'
import ThemeToggle from '@ui/components/ThemeProvider/ThemeToggle'
export default function ThemeSwitcher() {
return <ThemeToggle />
}

View File

@@ -1,16 +0,0 @@
'use client'
import { Button } from 'ui'
import { useTheme } from 'next-themes'
import { Moon, Sun } from 'lucide-react'
export default function ThemeSwitcherButton() {
const { setTheme, resolvedTheme } = useTheme()
return (
<Button
type="outline"
className="px-1"
icon={resolvedTheme === 'dark' ? <Moon size={16} /> : <Sun size={16} />}
onClick={() => (resolvedTheme === 'dark' ? setTheme('light') : setTheme('dark'))}
/>
)
}

View File

@@ -0,0 +1,13 @@
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

View File

@@ -52,7 +52,7 @@ const TablesGraph = ({ tables }: SchemaGraphProps) => {
},
}}
nodeTypes={nodeTypes}
minZoom={1}
minZoom={0.8}
maxZoom={1.8}
proOptions={{ hideAttribution: true }}
onInit={(instance) => {

View File

@@ -0,0 +1,23 @@
'use server'
import { revalidatePath } from 'next/cache'
import OpenAI from 'openai'
import { createClient } from './supabase/server'
import { cookies } from 'next/headers'
const openai = new OpenAI()
export async function deleteThread(threadID: string) {
'use server'
const cookieStore = cookies()
const supabase = createClient(cookieStore)
try {
await supabase.from('threads').delete().eq('thread_id', threadID)
} catch (error) {
if (error) console.error('Error deleting thread: ', error)
}
await openai.beta.threads.del(threadID)
revalidatePath('/profile')
}

View File

@@ -5,6 +5,11 @@ export const appState = proxy({
setHideCode: (value: boolean) => {
appState.hideCode = value
},
selectedCode: '',
setSelectedCode: (value: string) => {
appState.selectedCode = value
},
})
export const getAppStateSnapshot = () => snapshot(appState)

View File

@@ -1,5 +1,5 @@
{
"name": "database-new",
"name": "database-design",
"version": "0.0.0",
"private": true,
"scripts": {

View File

@@ -1,6 +1,6 @@
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "database-new"
project_id = "database-design"
[api]
enabled = true
@@ -121,7 +121,7 @@ client_id = "env(GITHUB_CLIENT_ID)"
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(GITHUB_CLIENT_SECRET)"
# Overrides the Github Authorization callback URL. Leave this empty if you've set the url in the Github oath app.
redirect_uri = ""
redirect_uri = "http://127.0.0.1:54321/auth/v1/callback"
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""

3
package-lock.json generated
View File

@@ -28,6 +28,7 @@
}
},
"apps/database-new": {
"name": "database-design",
"version": "0.0.0",
"dependencies": {
"@dagrejs/dagre": "^1.0.4",
@@ -15077,7 +15078,7 @@
"node": ">=12"
}
},
"node_modules/database-new": {
"node_modules/database-design": {
"resolved": "apps/database-new",
"link": true
},

View File

@@ -18,7 +18,7 @@
"dev:studio": "turbo run dev --filter=studio --parallel",
"dev:docs": "turbo run dev --filter=docs --parallel",
"dev:www": "turbo run dev --filter=www --parallel",
"dev:database-new": "turbo run dev --filter=database-new --parallel",
"dev:database-design": "turbo run dev --filter=database-design --parallel",
"lint": "turbo run lint",
"typecheck": "turbo --continue typecheck",
"format": "prettier --write \"apps/**/*.{js,jsx,ts,tsx,css,md,mdx,json}\"",