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:
@@ -1,4 +1,4 @@
|
||||
# database.new
|
||||
# database.design
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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({})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
74
apps/database-new/app/faq/page.tsx
Normal file
74
apps/database-new/app/faq/page.tsx
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
37
apps/database-new/app/profile/ConfirmDeleteThreadModal.tsx
Normal file
37
apps/database-new/app/profile/ConfirmDeleteThreadModal.tsx
Normal 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
|
||||
37
apps/database-new/app/profile/EditThreadModal.tsx
Normal file
37
apps/database-new/app/profile/EditThreadModal.tsx
Normal 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
|
||||
16
apps/database-new/app/profile/EmptyState.tsx
Normal file
16
apps/database-new/app/profile/EmptyState.tsx
Normal 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
|
||||
66
apps/database-new/app/profile/Thread.tsx
Normal file
66
apps/database-new/app/profile/Thread.tsx
Normal 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
|
||||
57
apps/database-new/app/profile/Threads.tsx
Normal file
57
apps/database-new/app/profile/Threads.tsx
Normal 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
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
24
apps/database-new/components/Footer.tsx
Normal file
24
apps/database-new/components/Footer.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -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} />}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
49
apps/database-new/components/Header/NoUserDropdown.tsx
Normal file
49
apps/database-new/components/Header/NoUserDropdown.tsx
Normal 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
|
||||
76
apps/database-new/components/Header/SaveSchemaDropdown.tsx
Normal file
76
apps/database-new/components/Header/SaveSchemaDropdown.tsx
Normal 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
|
||||
6
apps/database-new/components/Header/ThemeSwitcher.tsx
Normal file
6
apps/database-new/components/Header/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
'use client'
|
||||
import ThemeToggle from '@ui/components/ThemeProvider/ThemeToggle'
|
||||
|
||||
export default function ThemeSwitcher() {
|
||||
return <ThemeToggle />
|
||||
}
|
||||
@@ -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'))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -52,7 +52,7 @@ const TablesGraph = ({ tables }: SchemaGraphProps) => {
|
||||
},
|
||||
}}
|
||||
nodeTypes={nodeTypes}
|
||||
minZoom={1}
|
||||
minZoom={0.8}
|
||||
maxZoom={1.8}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onInit={(instance) => {
|
||||
|
||||
23
apps/database-new/lib/actions.ts
Normal file
23
apps/database-new/lib/actions.ts
Normal 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')
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "database-new",
|
||||
"name": "database-design",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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
3
package-lock.json
generated
@@ -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
|
||||
},
|
||||
|
||||
@@ -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}\"",
|
||||
|
||||
Reference in New Issue
Block a user