jan/web-app/src/containers/LeftPanel.tsx
2025-05-19 13:00:28 +07:00

331 lines
13 KiB
TypeScript

import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils'
import {
IconLayoutSidebar,
IconDots,
IconCirclePlusFilled,
IconSettingsFilled,
IconTrash,
IconStar,
IconMessageFilled,
IconAppsFilled,
IconX,
IconSearch,
IconClipboardSmileFilled,
} from '@tabler/icons-react'
import { route } from '@/constants/routes'
import ThreadList from './ThreadList'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useThreads } from '@/hooks/useThreads'
import { useTranslation } from 'react-i18next'
import { useMemo, useState } from 'react'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { DownloadManagement } from './DownloadManegement'
const mainMenus = [
{
title: 'common.newChat',
icon: IconCirclePlusFilled,
route: route.home,
},
{
title: 'Assistant',
icon: IconClipboardSmileFilled,
route: route.assistant,
},
{
title: 'common.hub',
icon: IconAppsFilled,
route: route.hub,
},
{
title: 'common.settings',
icon: IconSettingsFilled,
route: route.settings.general,
},
]
const LeftPanel = () => {
const { open, setLeftPanel } = useLeftPanel()
const { t } = useTranslation()
const navigate = useNavigate()
const [searchTerm, setSearchTerm] = useState('')
const currentPath = useRouterState({
select: (state) => state.location.pathname,
})
const { deleteAllThreads, unstarAllThreads, getFilteredThreads, threads } =
useThreads()
const filteredThreads = useMemo(() => {
return getFilteredThreads(searchTerm)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getFilteredThreads, searchTerm, threads])
// Memoize categorized threads based on filteredThreads
const favoritedThreads = useMemo(() => {
return filteredThreads.filter((t) => t.isFavorite)
}, [filteredThreads])
const unFavoritedThreads = useMemo(() => {
return filteredThreads.filter((t) => !t.isFavorite)
}, [filteredThreads])
const [openDropdown, setOpenDropdown] = useState(false)
return (
<aside
className={cn(
'w-48 shrink-0 rounded-lg m-1.5 mr-0 text-left-panel-fg',
open ? 'block' : 'hidden'
)}
>
<div className="relative h-8">
<button
className="absolute top-1/2 right-0 -translate-y-1/2 z-20"
onClick={() => setLeftPanel(!open)}
>
<div className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out">
<IconLayoutSidebar size={18} className="text-left-panel-fg" />
</div>
</button>
</div>
<div className="flex flex-col justify-between h-[calc(100%-32px)] mt-0">
<div className="flex flex-col justify-between h-full">
<div className="relative mb-4 mx-1 mt-2">
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />
<input
type="text"
placeholder={t('common.search')}
className="w-full px-2 pl-7 py-1 bg-left-panel-fg/10 rounded text-left-panel-fg focus:outline-none focus:ring-1 focus:ring-left-panel-fg/10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-left-panel-fg/70 hover:text-left-panel-fg"
onClick={() => setSearchTerm('')}
>
<IconX size={14} />
</button>
)}
</div>
<div className="flex flex-col w-full h-full overflow-hidden">
<div className="h-full overflow-y-auto overflow-x-hidden">
{favoritedThreads.length > 0 && (
<>
<div className="flex items-center justify-between mb-2">
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold sticky top-0">
{t('common.favorites')}
</span>
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="size-6 flex cursor-pointer items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10">
<IconDots
size={18}
className="text-left-panel-fg/60"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
onClick={() => {
unstarAllThreads()
toast.success('All Threads Unfavorited', {
id: 'unfav-all-threads',
description:
'All threads have been removed from your favorites.',
})
}}
>
<IconStar size={16} />
<span>{t('common.unstarAll')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex flex-col mb-4">
<ThreadList
threads={favoritedThreads}
isFavoriteSection={true}
/>
{favoritedThreads.length === 0 && (
<p className="text-xs text-left-panel-fg/50 px-1 font-semibold">
{t('chat.status.empty', { ns: 'chat' })}
</p>
)}
</div>
</>
)}
{unFavoritedThreads.length > 0 && (
<div className="flex items-center justify-between mb-2">
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold">
{t('common.recents')}
</span>
<div className="relative">
<Dialog
onOpenChange={(open) => {
if (!open) setOpenDropdown(false)
}}
>
<DropdownMenu
open={openDropdown}
onOpenChange={(open) => setOpenDropdown(open)}
>
<DropdownMenuTrigger asChild>
<button
className="size-6 flex cursor-pointer items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<IconDots
size={18}
className="text-left-panel-fg/60"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DialogTrigger asChild>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
>
<IconTrash size={16} />
<span>{t('common.deleteAll')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Thread</DialogTitle>
<DialogDescription>
Are you sure you want to delete this thread?
This action cannot be undone.
</DialogDescription>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Cancel
</Button>
</DialogClose>
<Button
variant="destructive"
size="sm"
onClick={() => {
deleteAllThreads()
toast.success('Delete All Thread', {
id: 'delete-thread',
description:
'All thread has been permanently deleted.',
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}}
>
Delete
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</DropdownMenuContent>
</DropdownMenu>
</Dialog>
</div>
</div>
)}
{filteredThreads.length === 0 && searchTerm.length > 0 && (
<div className="px-1 mt-2">
<div className="flex items-center gap-1 text-left-panel-fg/80">
<IconSearch size={18} />
<h6 className="font-medium text-base">No results found</h6>
</div>
<p className="text-left-panel-fg/60 mt-1 text-xs leading-relaxed">
We couldn't find any chats matching your search. Try a
different keyword.
</p>
</div>
)}
{Object.keys(threads).length === 0 && !searchTerm && (
<>
<div className="px-1 mt-2">
<div className="flex items-center gap-1 text-left-panel-fg/80">
<IconMessageFilled size={18} />
<h6 className="font-medium text-base">No threads yet</h6>
</div>
<p className="text-left-panel-fg/60 mt-1 text-xs leading-relaxed">
Start a new conversation to see your thread history here.
</p>
</div>
</>
)}
<div className="flex flex-col mb-4">
<ThreadList threads={unFavoritedThreads} />
</div>
</div>
</div>
<div className="space-y-1 py-1 mt-2">
{mainMenus.map((menu) => {
const isActive =
currentPath.includes(route.settings.index) &&
menu.route.includes(route.settings.index)
return (
<Link
key={menu.title}
to={menu.route}
className={cn(
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
isActive
? 'bg-left-panel-fg/10'
: '[&.active]:bg-left-panel-fg/10'
)}
>
<menu.icon size={18} className="text-left-panel-fg/70" />
<span className="font-medium text-left-panel-fg/90">
{t(menu.title)}
</span>
</Link>
)
})}
</div>
<DownloadManagement />
</div>
</div>
</aside>
)
}
export default LeftPanel