misc UI fixes (#20)
This commit is contained in:
commit
572ce91708
28 changed files with 523 additions and 313 deletions
|
|
@ -1,9 +1,10 @@
|
||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
import { kv } from '@vercel/kv'
|
import { kv } from '@vercel/kv'
|
||||||
import { auth } from '@/auth'
|
|
||||||
|
|
||||||
|
import { auth } from '@/auth'
|
||||||
import { type Chat } from '@/lib/types'
|
import { type Chat } from '@/lib/types'
|
||||||
|
|
||||||
export async function getChats(userId?: string | null) {
|
export async function getChats(userId?: string | null) {
|
||||||
|
|
@ -32,12 +33,8 @@ export async function getChats(userId?: string | null) {
|
||||||
export async function getChat(id: string, userId: string) {
|
export async function getChat(id: string, userId: string) {
|
||||||
const chat = await kv.hgetall<Chat>(`chat:${id}`)
|
const chat = await kv.hgetall<Chat>(`chat:${id}`)
|
||||||
|
|
||||||
if (!chat) {
|
if (!chat || (userId && chat.userId !== userId)) {
|
||||||
throw new Error('Not found')
|
return null
|
||||||
}
|
|
||||||
|
|
||||||
if (userId && chat.userId !== userId) {
|
|
||||||
throw new Error('Unauthorized')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return chat
|
return chat
|
||||||
|
|
@ -47,41 +44,36 @@ export async function removeChat({ id, path }: { id: string; path: string }) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error('Unauthorized')
|
return {
|
||||||
|
error: 'Unauthorized'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uid = await kv.hget<string>(`chat:${id}`, 'userId')
|
const uid = await kv.hget<string>(`chat:${id}`, 'userId')
|
||||||
|
|
||||||
if (uid !== session?.user?.id) {
|
if (uid !== session?.user?.id) {
|
||||||
throw new Error('Unauthorized')
|
return {
|
||||||
|
error: 'Unauthorized'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await kv.del(`chat:${id}`)
|
await kv.del(`chat:${id}`)
|
||||||
await kv.zrem(`user:chat:${session.user.id}`, `chat:${id}`)
|
await kv.zrem(`user:chat:${session.user.id}`, `chat:${id}`)
|
||||||
|
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
revalidatePath(path)
|
return revalidatePath(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearChats() {
|
export async function clearChats() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
if (!session) {
|
if (!session?.user?.id) {
|
||||||
throw new Error('Unauthorized')
|
return {
|
||||||
|
error: 'Unauthorized'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.user?.id) {
|
const chats: string[] = await kv.zrange(`user:chat:${session.user.id}`, 0, -1)
|
||||||
throw new Error('Unauthorized')
|
|
||||||
}
|
|
||||||
|
|
||||||
const chats: string[] = await kv.zrange(
|
|
||||||
`user:chat:${session.user.id}`,
|
|
||||||
0,
|
|
||||||
-1,
|
|
||||||
{
|
|
||||||
rev: true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const pipeline = kv.pipeline()
|
const pipeline = kv.pipeline()
|
||||||
|
|
||||||
|
|
@ -93,6 +85,7 @@ export async function clearChats() {
|
||||||
await pipeline.exec()
|
await pipeline.exec()
|
||||||
|
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
|
return redirect('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSharedChat(id: string) {
|
export async function getSharedChat(id: string) {
|
||||||
|
|
@ -108,16 +101,10 @@ export async function getSharedChat(id: string) {
|
||||||
export async function shareChat(chat: Chat) {
|
export async function shareChat(chat: Chat) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
if (!session) {
|
if (!session?.user?.id || session.user.id !== chat.userId) {
|
||||||
throw new Error('Unauthorized')
|
return {
|
||||||
|
error: 'Unauthorized'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.user?.id) {
|
|
||||||
throw new Error('Unauthorized')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chat.userId !== session.user?.id) {
|
|
||||||
throw new Error('Unauthorized')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { kv } from '@vercel/kv'
|
import { kv } from '@vercel/kv'
|
||||||
import { OpenAIStream, StreamingTextResponse } from 'ai-connector'
|
import { OpenAIStream, StreamingTextResponse } from 'ai-connector'
|
||||||
import { Configuration, OpenAIApi } from 'openai-edge'
|
import { Configuration, OpenAIApi } from 'openai-edge'
|
||||||
import { nanoid } from '@/lib/utils'
|
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
|
import { nanoid } from '@/lib/utils'
|
||||||
|
|
||||||
export const runtime = 'edge'
|
export const runtime = 'edge'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { type Metadata } from 'next'
|
import { type Metadata } from 'next'
|
||||||
import { auth } from '@/auth'
|
import { notFound, redirect } from 'next/navigation'
|
||||||
|
|
||||||
import { Chat } from '@/components/chat'
|
import { auth } from '@/auth'
|
||||||
import { getChat } from '@/app/actions'
|
import { getChat } from '@/app/actions'
|
||||||
|
import { Chat } from '@/components/chat'
|
||||||
|
|
||||||
// export const runtime = 'edge'
|
// export const runtime = 'edge'
|
||||||
export const preferredRegion = 'home'
|
export const preferredRegion = 'home'
|
||||||
|
|
@ -16,16 +17,30 @@ export interface ChatPageProps {
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params
|
params
|
||||||
}: ChatPageProps): Promise<Metadata> {
|
}: ChatPageProps): Promise<Metadata> {
|
||||||
const { user } = await auth()
|
const session = await auth()
|
||||||
const chat = await getChat(params.id, user?.id ?? '')
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await getChat(params.id, session.user.id)
|
||||||
return {
|
return {
|
||||||
title: chat?.title.slice(0, 50) ?? 'Chat'
|
title: chat?.title.slice(0, 50) ?? 'Chat'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ChatPage({ params }: ChatPageProps) {
|
export default async function ChatPage({ params }: ChatPageProps) {
|
||||||
const { user } = await auth()
|
const session = await auth()
|
||||||
const chat = await getChat(params.id, user?.id ?? '')
|
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect('/sign-in')
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await getChat(params.id, session.user.id)
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
return <Chat id={chat.id} initialMessages={chat.messages} />
|
return <Chat id={chat.id} initialMessages={chat.messages} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,10 @@ export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
>
|
>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Providers attribute="class" defaultTheme="system" enableSystem>
|
<Providers attribute="class" defaultTheme="system" enableSystem>
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex flex-col flex-1 bg-muted/50">{children}</main>
|
<main className="flex flex-1 flex-col bg-muted/50">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
<TailwindIndicator />
|
<TailwindIndicator />
|
||||||
</Providers>
|
</Providers>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
import { FooterText } from '@/components/footer'
|
|
||||||
import { LoginButton } from '@/components/login-button'
|
import { LoginButton } from '@/components/login-button'
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full min-h-screen">
|
<div className="flex h-[calc(100vh-theme(spacing.16))] items-center justify-center py-10">
|
||||||
<div className="space-y-4">
|
|
||||||
<LoginButton />
|
<LoginButton />
|
||||||
<FooterText />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { type Message } from 'ai-connector'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { IconCheck, IconCopy } from '@/components/ui/icons'
|
import { IconCheck, IconCopy } from '@/components/ui/icons'
|
||||||
import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { type Message } from 'ai-connector'
|
|
||||||
|
|
||||||
interface ChatMessageActionsProps extends React.ComponentProps<'div'> {
|
interface ChatMessageActionsProps extends React.ComponentProps<'div'> {
|
||||||
message: Message
|
message: Message
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { type UseChatHelpers } from 'ai-connector/react'
|
import { type UseChatHelpers } from 'ai-connector/react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ExternalLink } from '@/components/external-link'
|
|
||||||
import { PromptForm } from '@/components/prompt-form'
|
import { PromptForm } from '@/components/prompt-form'
|
||||||
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
|
import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
|
||||||
import { IconRefresh, IconStop } from '@/components/ui/icons'
|
import { IconRefresh, IconStop } from '@/components/ui/icons'
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,10 @@ import { ChatScrollAnchor } from '@/components/chat-scroll-anchor'
|
||||||
|
|
||||||
export interface ChatProps extends React.ComponentProps<'div'> {
|
export interface ChatProps extends React.ComponentProps<'div'> {
|
||||||
initialMessages?: Message[]
|
initialMessages?: Message[]
|
||||||
|
|
||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chat({ id, title, initialMessages, className }: ChatProps) {
|
export function Chat({ id, initialMessages, className }: ChatProps) {
|
||||||
const { messages, append, reload, stop, isLoading, input, setInput } =
|
const { messages, append, reload, stop, isLoading, input, setInput } =
|
||||||
useChat({
|
useChat({
|
||||||
initialMessages,
|
initialMessages,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { toast } from 'react-hot-toast'
|
||||||
|
|
||||||
|
import { ServerActionResult } from '@/lib/types'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -18,7 +20,7 @@ import {
|
||||||
import { IconSpinner } from '@/components/ui/icons'
|
import { IconSpinner } from '@/components/ui/icons'
|
||||||
|
|
||||||
interface ClearHistoryProps {
|
interface ClearHistoryProps {
|
||||||
clearChats: () => Promise<void>
|
clearChats: () => ServerActionResult<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClearHistory({ clearChats }: ClearHistoryProps) {
|
export function ClearHistory({ clearChats }: ClearHistoryProps) {
|
||||||
|
|
@ -49,7 +51,13 @@ export function ClearHistory({ clearChats }: ClearHistoryProps) {
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await clearChats()
|
const result = await clearChats()
|
||||||
|
|
||||||
|
if (result && 'error' in result) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
router.push('/')
|
router.push('/')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { ExternalLink } from '@/components/external-link'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ExternalLink } from '@/components/external-link'
|
||||||
|
|
||||||
export function FooterText({ className, ...props }: React.ComponentProps<'p'>) {
|
export function FooterText({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,30 @@
|
||||||
import { Suspense, use } from 'react'
|
import * as React from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { clearChats } from '@/app/actions'
|
||||||
import { buttonVariants } from '@/components/ui/button'
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
import { Sidebar } from '@/components/sidebar'
|
import { Sidebar } from '@/components/sidebar'
|
||||||
import { SidebarList } from '@/components/sidebar-list'
|
import { SidebarList } from '@/components/sidebar-list'
|
||||||
import { IconGitHub, IconSeparator, IconVercel } from '@/components/ui/icons'
|
import { IconGitHub, IconSeparator, IconVercel } from '@/components/ui/icons'
|
||||||
|
|
||||||
import { SidebarFooter } from '@/components/sidebar-footer'
|
import { SidebarFooter } from '@/components/sidebar-footer'
|
||||||
import { ThemeToggle } from '@/components/theme-toggle'
|
import { ThemeToggle } from '@/components/theme-toggle'
|
||||||
import { ClearHistory } from '@/components/clear-history'
|
import { ClearHistory } from '@/components/clear-history'
|
||||||
import { clearChats } from '@/app/actions'
|
import { UserMenu } from '@/components/user-menu'
|
||||||
import Link from 'next/link'
|
import { LoginButton } from '@/components/login-button'
|
||||||
import { auth } from '@/auth'
|
|
||||||
import { UserMenu } from './ui/user-menu'
|
|
||||||
import { LoginButton } from './login-button'
|
|
||||||
|
|
||||||
export async function Header() {
|
export async function Header() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 flex items-center justify-between w-full h-16 px-4 border-b shrink-0 bg-gradient-to-b from-background/10 via-background/50 to-background/80 backdrop-blur-xl">
|
<header className="sticky top-0 z-50 flex h-16 w-full shrink-0 items-center justify-between border-b bg-gradient-to-b from-background/10 via-background/50 to-background/80 px-4 backdrop-blur-xl">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{session?.user ? (
|
{session?.user ? (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
<Suspense fallback={<div className="flex-1 overflow-auto" />}>
|
<React.Suspense fallback={<div className="flex-1 overflow-auto" />}>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<SidebarList userId={session?.user?.id} />
|
<SidebarList userId={session?.user?.id} />
|
||||||
</Suspense>
|
</React.Suspense>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<ClearHistory clearChats={clearChats} />
|
<ClearHistory clearChats={clearChats} />
|
||||||
|
|
@ -33,12 +32,21 @@ export async function Header() {
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
) : (
|
) : (
|
||||||
<Link href="https://vercel.com" target="_blank" rel="nofollow">
|
<Link href="https://vercel.com" target="_blank" rel="nofollow">
|
||||||
<IconVercel className="w-6 h-6 mr-2" />
|
<IconVercel className="mr-2 h-6 w-6" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<IconSeparator className="w-6 h-6 text-muted-foreground/50" />
|
<IconSeparator className="h-6 w-6 text-muted-foreground/50" />
|
||||||
{session?.user ? <UserMenu session={session} /> : <LoginButton />}
|
{session?.user ? (
|
||||||
|
<UserMenu user={session.user} />
|
||||||
|
) : (
|
||||||
|
<LoginButton
|
||||||
|
variant="link"
|
||||||
|
showGithubIcon={false}
|
||||||
|
text="Login"
|
||||||
|
className="-ml-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
|
@ -49,7 +57,7 @@ export async function Header() {
|
||||||
className={cn(buttonVariants({ variant: 'outline' }))}
|
className={cn(buttonVariants({ variant: 'outline' }))}
|
||||||
>
|
>
|
||||||
<IconGitHub />
|
<IconGitHub />
|
||||||
<span className="hidden ml-2 md:flex">GitHub</span>
|
<span className="ml-2 hidden md:flex">GitHub</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/vercel/nextjs-ai-chatbot/"
|
href="https://github.com/vercel/nextjs-ai-chatbot/"
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import { signIn } from 'next-auth/react'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button, type ButtonProps } from '@/components/ui/button'
|
import { Button, type ButtonProps } from '@/components/ui/button'
|
||||||
import { IconGitHub, IconSpinner } from '@/components/ui/icons'
|
import { IconGitHub, IconSpinner } from '@/components/ui/icons'
|
||||||
import { signIn } from 'next-auth/react'
|
|
||||||
|
|
||||||
interface LoginButtonProps extends ButtonProps {
|
interface LoginButtonProps extends ButtonProps {
|
||||||
|
showGithubIcon?: boolean
|
||||||
text?: string
|
text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginButton({
|
export function LoginButton({
|
||||||
className,
|
|
||||||
text = 'Login with GitHub',
|
text = 'Login with GitHub',
|
||||||
|
showGithubIcon = true,
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}: LoginButtonProps) {
|
}: LoginButtonProps) {
|
||||||
const [isLoading, setIsLoading] = React.useState(false)
|
const [isLoading, setIsLoading] = React.useState(false)
|
||||||
|
|
@ -31,9 +33,9 @@ export function LoginButton({
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<IconSpinner className="mr-2 animate-spin" />
|
<IconSpinner className="mr-2 animate-spin" />
|
||||||
) : (
|
) : showGithubIcon ? (
|
||||||
<IconGitHub className="mr-2" />
|
<IconGitHub className="mr-2" />
|
||||||
)}
|
) : null}
|
||||||
{text}
|
{text}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||||
import { ThemeProviderProps } from 'next-themes/dist/types'
|
import { ThemeProviderProps } from 'next-themes/dist/types'
|
||||||
|
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
export function Providers({ children, ...props }: ThemeProviderProps) {
|
export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ import {
|
||||||
|
|
||||||
interface SidebarActionsProps {
|
interface SidebarActionsProps {
|
||||||
chat: Chat
|
chat: Chat
|
||||||
removeChat: (args: { id: string; path: string }) => Promise<void>
|
removeChat: (args: { id: string; path: string }) => ServerActionResult<void>
|
||||||
shareChat: (chat: Chat) => ServerActionResult<Chat>
|
shareChat: (chat: Chat) => ServerActionResult<Chat>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,8 +150,8 @@ export function SidebarActions({
|
||||||
|
|
||||||
const result = await shareChat(chat)
|
const result = await shareChat(chat)
|
||||||
|
|
||||||
if (!('id' in result)) {
|
if (result && 'error' in result) {
|
||||||
toast.error(result.message)
|
toast.error(result.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,10 +189,16 @@ export function SidebarActions({
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
startRemoveTransition(async () => {
|
startRemoveTransition(async () => {
|
||||||
await removeChat({
|
const result = await removeChat({
|
||||||
id: chat.id,
|
id: chat.id,
|
||||||
path: chat.path
|
path: chat.path
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (result && 'error' in result) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setDeleteDialogOpen(false)
|
setDeleteDialogOpen(false)
|
||||||
router.push('/')
|
router.push('/')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { getChats, removeChat, shareChat } from '@/app/actions'
|
import { getChats, removeChat, shareChat } from '@/app/actions'
|
||||||
import { SidebarActions } from '@/components/sidebar-actions'
|
import { SidebarActions } from '@/components/sidebar-actions'
|
||||||
|
|
||||||
import { SidebarItem } from '@/components/sidebar-item'
|
import { SidebarItem } from '@/components/sidebar-item'
|
||||||
|
|
||||||
export interface SidebarListProps {
|
export interface SidebarListProps {
|
||||||
|
|
@ -10,8 +9,6 @@ export interface SidebarListProps {
|
||||||
export async function SidebarList({ userId }: SidebarListProps) {
|
export async function SidebarList({ userId }: SidebarListProps) {
|
||||||
const chats = await getChats(userId)
|
const chats = await getChats(userId)
|
||||||
|
|
||||||
console.log(chats)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{chats?.length ? (
|
{chats?.length ? (
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary hover:bg-primary/80 border-transparent text-primary-foreground",
|
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground",
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
|
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
outline: "text-foreground",
|
outline: 'text-foreground'
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default'
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
128
components/ui/dropdown-menu.tsx
Normal file
128
components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1.5 text-sm font-semibold',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuRadioGroup
|
||||||
|
}
|
||||||
|
|
@ -442,6 +442,40 @@ function IconUsers({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IconExternalLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M224 104a8 8 0 0 1-16 0V59.32l-66.33 66.34a8 8 0 0 1-11.32-11.32L196.68 48H152a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8Zm-40 24a8 8 0 0 0-8 8v72H48V80h72a8 8 0 0 0 0-16H48a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-72a8 8 0 0 0-8-8Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconChevronUpDown({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
className={cn('h-4 w-4', className)}
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M181.66 170.34a8 8 0 0 1 0 11.32l-48 48a8 8 0 0 1-11.32 0l-48-48a8 8 0 0 1 11.32-11.32L128 212.69l42.34-42.35a8 8 0 0 1 11.32 0Zm-96-84.68L128 43.31l42.34 42.35a8 8 0 0 0 11.32-11.32l-48-48a8 8 0 0 0-11.32 0l-48 48a8 8 0 0 0 11.32 11.32Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconNextChat,
|
IconNextChat,
|
||||||
|
|
@ -467,5 +501,7 @@ export {
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconClose,
|
IconClose,
|
||||||
IconShare,
|
IconShare,
|
||||||
IconUsers
|
IconUsers,
|
||||||
|
IconExternalLink,
|
||||||
|
IconChevronUpDown
|
||||||
}
|
}
|
||||||
|
|
|
||||||
123
components/ui/select.tsx
Normal file
123
components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
IconArrowDown,
|
||||||
|
IconCheck,
|
||||||
|
IconChevronUpDown
|
||||||
|
} from '@/components/ui/icons'
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<IconChevronUpDown className="opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80',
|
||||||
|
position === 'popper' && 'translate-y-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<IconCheck className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator
|
||||||
|
}
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
// import { type Session } from 'next-auth'
|
|
||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
||||||
import { signOut } from 'next-auth/react'
|
|
||||||
|
|
||||||
import Image from 'next/image'
|
|
||||||
|
|
||||||
export interface UserMenuProps {
|
|
||||||
session: any // Session
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserMenu({ session }: UserMenuProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger asChild>
|
|
||||||
<button className="focus:outline-none">
|
|
||||||
{session.user?.image ? (
|
|
||||||
<Image
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className="w-6 h-6 transition-opacity duration-300 rounded-full select-none ring-zinc-100/10 ring-1 hover:opacity-80"
|
|
||||||
src={session.user?.image ? `${session.user.image}&s=60` : ''}
|
|
||||||
alt={session.user.name ?? 'Avatar'}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 p-2 rounded-full select-none shrink-0 bg-gradient-to-r from-cyan-500 to-blue-500">
|
|
||||||
<div
|
|
||||||
className="font-medium uppercase text-zinc-100"
|
|
||||||
style={{ fontSize: 12 }}
|
|
||||||
>
|
|
||||||
{session.user?.name ? session.user?.name.slice(0, 2) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
className="min-w-[200px] bg-white dark:bg-zinc-950 rounded-lg shadow-lg text-zinc-900 dark:text-zinc-400 overflow-hidden border border-transparent dark:border-zinc-700 focus:outline-none relative z-20 py-2"
|
|
||||||
sideOffset={8}
|
|
||||||
align="end"
|
|
||||||
>
|
|
||||||
<DropdownMenu.Item className="px-3 py-2 focus:outline-none">
|
|
||||||
<div className="text-xs font-medium">{session.user?.name}</div>
|
|
||||||
<div className="text-xs text-zinc-500">{session.user?.email}</div>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
|
|
||||||
<DropdownMenu.Separator className="my-1 h-[1px] bg-zinc-100 dark:bg-zinc-800" />
|
|
||||||
<DropdownMenu.Item className="px-3 py-2 text-xs transition-colors duration-200 cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-800 focus:outline-none ">
|
|
||||||
<a
|
|
||||||
href="https://vercel.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center justify-between w-full"
|
|
||||||
>
|
|
||||||
<span>Vercel Homepage</span>
|
|
||||||
<span>
|
|
||||||
<svg
|
|
||||||
fill="none"
|
|
||||||
height={16}
|
|
||||||
shapeRendering="geometricPrecision"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width={16}
|
|
||||||
aria-hidden="true"
|
|
||||||
className="mr-1"
|
|
||||||
>
|
|
||||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" />
|
|
||||||
<path d="M15 3h6v6" />
|
|
||||||
<path d="M10 14L21 3" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
className="px-3 py-2 text-xs transition-colors duration-200 cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-800 focus:outline-none"
|
|
||||||
onClick={() => signOut()}
|
|
||||||
>
|
|
||||||
Log Out
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
UserMenu.displayName = 'UserMenu'
|
|
||||||
65
components/user-menu.tsx
Normal file
65
components/user-menu.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { type Session } from 'next-auth'
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { IconExternalLink } from '@/components/ui/icons'
|
||||||
|
|
||||||
|
export interface UserMenuProps {
|
||||||
|
user: Session['user']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu({ user }: UserMenuProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="pl-0">
|
||||||
|
{user?.image ? (
|
||||||
|
<Image
|
||||||
|
className="h-6 w-6 select-none rounded-full ring-1 ring-zinc-100/10 transition-opacity duration-300 hover:opacity-80"
|
||||||
|
src={user?.image ? `${user.image}&s=60` : ''}
|
||||||
|
alt={user.name ?? 'Avatar'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-7 w-7 shrink-0 select-none items-center justify-center rounded-full bg-muted/50 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
{user?.name ? user?.name.slice(0, 2) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="ml-2">{user?.name}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent sideOffset={8} align="start" className="w-[180px]">
|
||||||
|
<DropdownMenuItem className="flex-col items-start">
|
||||||
|
<div className="text-xs font-medium">{user?.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{user?.email}</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a
|
||||||
|
href="https://vercel.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex w-full items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
Vercel Homepage
|
||||||
|
<IconExternalLink className="ml-auto h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => signOut()} className="text-xs">
|
||||||
|
Log Out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
const scrollToEnd = () => {
|
|
||||||
window.scrollTo({
|
|
||||||
top: document.body.scrollHeight,
|
|
||||||
left: 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFollowScroll(shouldFollow: boolean) {
|
|
||||||
const shouldFollowRef = useRef(shouldFollow)
|
|
||||||
const scrollRef = useRef(false)
|
|
||||||
const triggeredBySelfRef = useRef(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
if (triggeredBySelfRef.current) {
|
|
||||||
triggeredBySelfRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
scrollRef.current = true
|
|
||||||
}
|
|
||||||
window.addEventListener('scroll', handleScroll)
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!scrollRef.current && shouldFollowRef.current) {
|
|
||||||
setTimeout(() => {
|
|
||||||
triggeredBySelfRef.current = true
|
|
||||||
scrollToEnd()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
shouldFollowRef.current = shouldFollow
|
|
||||||
if (!shouldFollow) {
|
|
||||||
// Reset scrollRef
|
|
||||||
scrollRef.current = false
|
|
||||||
}
|
|
||||||
}, [shouldFollow])
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
const useLocalStorage = <T>(
|
|
||||||
key: string,
|
|
||||||
initialValue: T
|
|
||||||
): [T, (value: T) => void] => {
|
|
||||||
const [storedValue, setStoredValue] = useState(initialValue)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Retrieve from localStorage
|
|
||||||
const item = window.localStorage.getItem(key)
|
|
||||||
if (item) {
|
|
||||||
setStoredValue(JSON.parse(item))
|
|
||||||
}
|
|
||||||
}, [key])
|
|
||||||
|
|
||||||
const setValue = (value: T) => {
|
|
||||||
// Save state
|
|
||||||
setStoredValue(value)
|
|
||||||
// Save to localStorage
|
|
||||||
window.localStorage.setItem(key, JSON.stringify(value))
|
|
||||||
}
|
|
||||||
return [storedValue, setValue]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useLocalStorage
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
export default function useScroll(threshold: number) {
|
|
||||||
const [scrolled, setScrolled] = useState(false)
|
|
||||||
|
|
||||||
const onScroll = useCallback(() => {
|
|
||||||
setScrolled(window.pageYOffset > threshold)
|
|
||||||
}, [threshold])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener('scroll', onScroll)
|
|
||||||
return () => window.removeEventListener('scroll', onScroll)
|
|
||||||
}, [onScroll])
|
|
||||||
|
|
||||||
return scrolled
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
export default function useWindowSize() {
|
|
||||||
const [windowSize, setWindowSize] = useState<{
|
|
||||||
width: number | undefined
|
|
||||||
height: number | undefined
|
|
||||||
}>({
|
|
||||||
width: undefined,
|
|
||||||
height: undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Handler to call on window resize
|
|
||||||
function handleResize() {
|
|
||||||
// Set window width/height to state
|
|
||||||
setWindowSize({
|
|
||||||
width: window.innerWidth,
|
|
||||||
height: window.innerHeight
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add event listener
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
|
|
||||||
// Call handler right away so state gets updated with initial window size
|
|
||||||
handleResize()
|
|
||||||
|
|
||||||
// Remove event listener on cleanup
|
|
||||||
return () => window.removeEventListener('resize', handleResize)
|
|
||||||
}, []) // Empty array ensures that effect is only run on mount
|
|
||||||
|
|
||||||
return {
|
|
||||||
windowSize,
|
|
||||||
isMobile: typeof windowSize?.width === 'number' && windowSize?.width < 768,
|
|
||||||
isDesktop: typeof windowSize?.width === 'number' && windowSize?.width >= 768
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,4 +10,9 @@ export interface Chat extends Record<string, any> {
|
||||||
sharePath?: string
|
sharePath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerActionResult<Result> = Promise<Result | Error>
|
export type ServerActionResult<Result> = Promise<
|
||||||
|
| Result
|
||||||
|
| {
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-select": "^1.2.2",
|
||||||
"@radix-ui/react-separator": "^1.0.3",
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
|
|
|
||||||
50
pnpm-lock.yaml
generated
50
pnpm-lock.yaml
generated
|
|
@ -20,6 +20,9 @@ dependencies:
|
||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.0.2
|
specifier: ^2.0.2
|
||||||
version: 2.0.2(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
version: 2.0.2(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-select':
|
||||||
|
specifier: ^1.2.2
|
||||||
|
version: 1.2.2(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||||
'@radix-ui/react-separator':
|
'@radix-ui/react-separator':
|
||||||
specifier: ^1.0.3
|
specifier: ^1.0.3
|
||||||
version: 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
version: 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
|
@ -458,6 +461,12 @@ packages:
|
||||||
tslib: 2.5.0
|
tslib: 2.5.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@radix-ui/number@1.0.1:
|
||||||
|
resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==}
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.21.5
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/primitive@1.0.1:
|
/@radix-ui/primitive@1.0.1:
|
||||||
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
|
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -897,6 +906,47 @@ packages:
|
||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-select@1.2.2(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.21.5
|
||||||
|
'@radix-ui/number': 1.0.1
|
||||||
|
'@radix-ui/primitive': 1.0.1
|
||||||
|
'@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
'@radix-ui/react-context': 1.0.1(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.0.4(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
'@radix-ui/react-focus-scope': 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-id': 1.0.1(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
'@radix-ui/react-popper': 1.1.2(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-portal': 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@radix-ui/react-slot': 1.0.2(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
'@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
'@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
'@types/react': 18.2.6
|
||||||
|
'@types/react-dom': 18.2.4
|
||||||
|
aria-hidden: 1.2.3
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
react-remove-scroll: 2.5.5(@types/react@18.2.6)(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@radix-ui/react-separator@1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0):
|
/@radix-ui/react-separator@1.0.3(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==}
|
resolution: {integrity: sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue