# Conflicts: # src-tauri/src/core/setup.rs # src-tauri/src/lib.rs # web-app/src/hooks/useChat.ts
695 lines
26 KiB
TypeScript
695 lines
26 KiB
TypeScript
import { Link, useRouterState, useNavigate } from '@tanstack/react-router'
|
|
import { useLeftPanel } from '@/hooks/useLeftPanel'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
IconLayoutSidebar,
|
|
IconDots,
|
|
IconCirclePlus,
|
|
IconSettings,
|
|
IconStar,
|
|
IconFolderPlus,
|
|
IconMessage,
|
|
IconApps,
|
|
IconX,
|
|
IconSearch,
|
|
IconClipboardSmile,
|
|
IconFolder,
|
|
IconPencil,
|
|
IconTrash,
|
|
} from '@tabler/icons-react'
|
|
import { route } from '@/constants/routes'
|
|
import ThreadList from './ThreadList'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { PlatformFeatures } from '@/lib/platform/const'
|
|
import { PlatformFeature } from '@/lib/platform/types'
|
|
import { AuthLoginButton } from '@/containers/auth/AuthLoginButton'
|
|
import { UserProfileMenu } from '@/containers/auth/UserProfileMenu'
|
|
import { useAuth } from '@/hooks/useAuth'
|
|
|
|
import { useThreads } from '@/hooks/useThreads'
|
|
import { useThreadManagement } from '@/hooks/useThreadManagement'
|
|
|
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
|
import { useMemo, useState, useEffect, useRef } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { DownloadManagement } from '@/containers/DownloadManegement'
|
|
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
|
import { useClickOutside } from '@/hooks/useClickOutside'
|
|
|
|
import { DeleteAllThreadsDialog } from '@/containers/dialogs'
|
|
import AddProjectDialog from '@/containers/dialogs/AddProjectDialog'
|
|
import { DeleteProjectDialog } from '@/containers/dialogs/DeleteProjectDialog'
|
|
|
|
const mainMenus = [
|
|
{
|
|
title: 'common:newChat',
|
|
icon: IconCirclePlus,
|
|
route: route.home,
|
|
isEnabled: true,
|
|
},
|
|
{
|
|
title: 'common:projects.title',
|
|
icon: IconFolderPlus,
|
|
route: route.project,
|
|
isEnabled: true,
|
|
},
|
|
]
|
|
|
|
const secondaryMenus = [
|
|
{
|
|
title: 'common:assistants',
|
|
icon: IconClipboardSmile,
|
|
route: route.assistant,
|
|
isEnabled: PlatformFeatures[PlatformFeature.ASSISTANTS],
|
|
},
|
|
{
|
|
title: 'common:hub',
|
|
icon: IconApps,
|
|
route: route.hub.index,
|
|
isEnabled: PlatformFeatures[PlatformFeature.MODEL_HUB],
|
|
},
|
|
{
|
|
title: 'common:settings',
|
|
icon: IconSettings,
|
|
route: route.settings.general,
|
|
isEnabled: true,
|
|
},
|
|
]
|
|
|
|
const LeftPanel = () => {
|
|
const open = useLeftPanel((state) => state.open)
|
|
const setLeftPanel = useLeftPanel((state) => state.setLeftPanel)
|
|
const { t } = useTranslation()
|
|
const navigate = useNavigate()
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const { isAuthenticated } = useAuth()
|
|
|
|
const isSmallScreen = useSmallScreen()
|
|
const prevScreenSizeRef = useRef<boolean | null>(null)
|
|
const isInitialMountRef = useRef(true)
|
|
const panelRef = useRef<HTMLElement>(null)
|
|
const searchContainerRef = useRef<HTMLDivElement>(null)
|
|
const searchContainerMacRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Determine if we're in a resizable context (large screen with panel open)
|
|
const isResizableContext = !isSmallScreen && open
|
|
|
|
// Use click outside hook for panel with debugging
|
|
useClickOutside(
|
|
() => {
|
|
if (isSmallScreen && open) {
|
|
setLeftPanel(false)
|
|
}
|
|
},
|
|
null,
|
|
[
|
|
panelRef.current,
|
|
searchContainerRef.current,
|
|
searchContainerMacRef.current,
|
|
]
|
|
)
|
|
|
|
// Auto-collapse panel only when window is resized
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
const currentIsSmallScreen = window.innerWidth <= 768
|
|
|
|
// Skip on initial mount
|
|
if (isInitialMountRef.current) {
|
|
isInitialMountRef.current = false
|
|
prevScreenSizeRef.current = currentIsSmallScreen
|
|
return
|
|
}
|
|
|
|
// Only trigger if the screen size actually changed
|
|
if (
|
|
prevScreenSizeRef.current !== null &&
|
|
prevScreenSizeRef.current !== currentIsSmallScreen
|
|
) {
|
|
if (currentIsSmallScreen && open) {
|
|
setLeftPanel(false)
|
|
} else if (!open) {
|
|
setLeftPanel(true)
|
|
}
|
|
prevScreenSizeRef.current = currentIsSmallScreen
|
|
}
|
|
}
|
|
|
|
// Add resize listener
|
|
window.addEventListener('resize', handleResize)
|
|
|
|
// Initialize the previous screen size on mount
|
|
if (isInitialMountRef.current) {
|
|
prevScreenSizeRef.current = window.innerWidth <= 768
|
|
isInitialMountRef.current = false
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize)
|
|
}
|
|
}, [setLeftPanel, open])
|
|
|
|
|
|
const currentPath = useRouterState({
|
|
select: (state) => state.location.pathname,
|
|
})
|
|
|
|
const deleteAllThreads = useThreads((state) => state.deleteAllThreads)
|
|
const unstarAllThreads = useThreads((state) => state.unstarAllThreads)
|
|
const getFilteredThreads = useThreads((state) => state.getFilteredThreads)
|
|
const threads = useThreads((state) => state.threads)
|
|
|
|
const { folders, addFolder, updateFolder, deleteFolder, getFolderById } =
|
|
useThreadManagement()
|
|
|
|
// Project dialog states
|
|
const [projectDialogOpen, setProjectDialogOpen] = useState(false)
|
|
const [editingProjectKey, setEditingProjectKey] = useState<string | null>(
|
|
null
|
|
)
|
|
const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] =
|
|
useState(false)
|
|
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(
|
|
null
|
|
)
|
|
|
|
const filteredThreads = useMemo(() => {
|
|
return getFilteredThreads(searchTerm)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [getFilteredThreads, searchTerm, threads])
|
|
|
|
const filteredProjects = useMemo(() => {
|
|
if (!searchTerm) return folders
|
|
return folders.filter((folder) =>
|
|
folder.name.toLowerCase().includes(searchTerm.toLowerCase())
|
|
)
|
|
}, [folders, searchTerm])
|
|
|
|
// 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 && !t.metadata?.project)
|
|
}, [filteredThreads])
|
|
|
|
// Project handlers
|
|
const handleProjectDelete = (id: string) => {
|
|
setDeletingProjectId(id)
|
|
setDeleteProjectConfirmOpen(true)
|
|
}
|
|
|
|
const confirmProjectDelete = () => {
|
|
if (deletingProjectId) {
|
|
deleteFolder(deletingProjectId)
|
|
setDeleteProjectConfirmOpen(false)
|
|
setDeletingProjectId(null)
|
|
}
|
|
}
|
|
|
|
const handleProjectSave = (name: string) => {
|
|
if (editingProjectKey) {
|
|
updateFolder(editingProjectKey, name)
|
|
} else {
|
|
const newProject = addFolder(name)
|
|
// Navigate to the newly created project
|
|
navigate({
|
|
to: '/project/$projectId',
|
|
params: { projectId: newProject.id },
|
|
})
|
|
}
|
|
setProjectDialogOpen(false)
|
|
setEditingProjectKey(null)
|
|
}
|
|
|
|
// Disable body scroll when panel is open on small screens
|
|
useEffect(() => {
|
|
if (isSmallScreen && open) {
|
|
document.body.style.overflow = 'hidden'
|
|
} else {
|
|
document.body.style.overflow = ''
|
|
}
|
|
|
|
return () => {
|
|
document.body.style.overflow = ''
|
|
}
|
|
}, [isSmallScreen, open])
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop overlay for small screens */}
|
|
{isSmallScreen && open && !IS_IOS && !IS_ANDROID && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 backdrop-blur z-30"
|
|
onClick={(e) => {
|
|
// Don't close if clicking on search container or if currently searching
|
|
if (
|
|
searchContainerRef.current?.contains(e.target as Node) ||
|
|
searchContainerMacRef.current?.contains(e.target as Node)
|
|
) {
|
|
return
|
|
}
|
|
setLeftPanel(false)
|
|
}}
|
|
/>
|
|
)}
|
|
<aside
|
|
ref={panelRef}
|
|
className={cn(
|
|
'text-left-panel-fg overflow-hidden',
|
|
// Resizable context: full height and width, no margins
|
|
isResizableContext && 'h-full w-full',
|
|
// Small screen context: fixed positioning and styling
|
|
isSmallScreen &&
|
|
'fixed h-full pb-[calc(env(safe-area-inset-bottom)+env(safe-area-inset-top))] bg-main-view z-50 md:border border-left-panel-fg/10 px-1 w-full md:w-48',
|
|
// Default context: original styling
|
|
!isResizableContext &&
|
|
!isSmallScreen &&
|
|
'w-48 shrink-0 rounded-lg m-1.5 mr-0',
|
|
// Visibility controls
|
|
open
|
|
? 'opacity-100 visibility-visible'
|
|
: 'w-0 absolute -top-100 -left-100 visibility-hidden'
|
|
)}
|
|
>
|
|
<div className="relative h-10">
|
|
<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 data-[state=open]:bg-left-panel-fg/10">
|
|
<IconLayoutSidebar size={18} className="text-left-panel-fg" />
|
|
</div>
|
|
</button>
|
|
{!IS_MACOS && (
|
|
<div
|
|
ref={searchContainerRef}
|
|
className={cn(
|
|
'relative top-1.5 mb-4 mt-1 z-50',
|
|
isResizableContext
|
|
? 'mx-2 w-[calc(100%-48px)]'
|
|
: 'mx-1 w-[calc(100%-32px)]'
|
|
)}
|
|
data-ignore-outside-clicks
|
|
>
|
|
<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 pl-7 pr-8 py-1 bg-left-panel-fg/10 rounded-sm 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={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation() // prevent bubbling
|
|
setSearchTerm('')
|
|
}}
|
|
>
|
|
<IconX size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-y-1 overflow-hidden mt-0 !h-[calc(100%-42px)]">
|
|
<div className="space-y-1 py-1">
|
|
{IS_MACOS && (
|
|
<div
|
|
ref={searchContainerMacRef}
|
|
className={cn('relative mb-2 mt-1 mx-1')}
|
|
data-ignore-outside-clicks
|
|
>
|
|
<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 pl-7 pr-8 py-1 bg-left-panel-fg/10 rounded-sm 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
|
|
data-ignore-outside-clicks
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-left-panel-fg/70 hover:text-left-panel-fg"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation() // prevent bubbling
|
|
setSearchTerm('')
|
|
}}
|
|
>
|
|
<IconX size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{mainMenus.map((menu) => {
|
|
if (!menu.isEnabled) {
|
|
return null
|
|
}
|
|
|
|
// Handle authentication menu specially
|
|
if (menu.title === 'common:authentication') {
|
|
return (
|
|
<div key={menu.title}>
|
|
<div className="mx-1 my-2 border-t border-left-panel-fg/5" />
|
|
{isAuthenticated ? (
|
|
<UserProfileMenu />
|
|
) : (
|
|
<AuthLoginButton />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Regular menu items must have route and icon
|
|
if (!menu.route || !menu.icon) return null
|
|
|
|
const isActive = (() => {
|
|
// Settings routes
|
|
if (menu.route.includes(route.settings.index)) {
|
|
return currentPath.includes(route.settings.index)
|
|
}
|
|
|
|
// Default exact match for other routes
|
|
return currentPath === menu.route
|
|
})()
|
|
return (
|
|
<Link
|
|
key={menu.title}
|
|
to={menu.route}
|
|
onClick={() => isSmallScreen && setLeftPanel(false)}
|
|
data-test-id={`menu-${menu.title}`}
|
|
activeOptions={{ exact: true }}
|
|
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'
|
|
)}
|
|
>
|
|
<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>
|
|
|
|
{filteredProjects.length > 0 && (
|
|
<div className="space-y-1 py-1">
|
|
<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:projects.title')}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col max-h-[140px] overflow-y-scroll">
|
|
{filteredProjects
|
|
.slice()
|
|
.sort((a, b) => b.updated_at - a.updated_at)
|
|
.map((folder) => {
|
|
const ProjectItem = () => {
|
|
const [openDropdown, setOpenDropdown] = useState(false)
|
|
const isProjectActive =
|
|
currentPath === `/project/${folder.id}`
|
|
|
|
return (
|
|
<div key={folder.id} className="mb-1">
|
|
<div
|
|
className={cn(
|
|
'rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/project-list transition-all cursor-pointer',
|
|
isProjectActive && 'bg-left-panel-fg/10'
|
|
)}
|
|
>
|
|
<Link
|
|
to="/project/$projectId"
|
|
params={{ projectId: folder.id }}
|
|
onClick={() =>
|
|
isSmallScreen && setLeftPanel(false)
|
|
}
|
|
className="py-1 pr-2 truncate flex items-center gap-2 flex-1"
|
|
>
|
|
<IconFolder
|
|
size={16}
|
|
className="text-left-panel-fg/70 shrink-0"
|
|
/>
|
|
<span className="text-sm text-left-panel-fg/90 truncate">
|
|
{folder.name}
|
|
</span>
|
|
</Link>
|
|
<div className="flex items-center">
|
|
<DropdownMenu
|
|
open={openDropdown}
|
|
onOpenChange={(open) => setOpenDropdown(open)}
|
|
>
|
|
<DropdownMenuTrigger asChild>
|
|
<IconDots
|
|
size={14}
|
|
className="text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded group-hover/project-list:data-[state=closed]:size-5 size-5 data-[state=closed]:size-0"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}}
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="bottom" align="end">
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setEditingProjectKey(folder.id)
|
|
setProjectDialogOpen(true)
|
|
}}
|
|
>
|
|
<IconPencil size={16} />
|
|
<span>Edit</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleProjectDelete(folder.id)
|
|
}}
|
|
>
|
|
<IconTrash size={16} />
|
|
<span>Delete</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return <ProjectItem key={folder.id} />
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col h-full overflow-y-scroll w-[calc(100%+6px)]">
|
|
<div className="flex flex-col w-full h-full overflow-y-auto overflow-x-hidden mb-3">
|
|
<div className="h-full w-full overflow-y-auto">
|
|
{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(
|
|
t('common:toast.allThreadsUnfavorited.title'),
|
|
{
|
|
id: 'unfav-all-threads',
|
|
description: t(
|
|
'common:toast.allThreadsUnfavorited.description'
|
|
),
|
|
}
|
|
)
|
|
}}
|
|
>
|
|
<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">
|
|
<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"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}}
|
|
>
|
|
<IconDots
|
|
size={18}
|
|
className="text-left-panel-fg/60"
|
|
/>
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="bottom" align="end">
|
|
<DeleteAllThreadsDialog
|
|
onDeleteAll={deleteAllThreads}
|
|
/>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</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">
|
|
{t('common:noResultsFound')}
|
|
</h6>
|
|
</div>
|
|
<p className="text-left-panel-fg/60 mt-1 text-xs leading-relaxed">
|
|
{t('common:noResultsFoundDesc')}
|
|
</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">
|
|
<IconMessage size={18} />
|
|
<h6 className="font-medium text-base">
|
|
{t('common:noThreadsYet')}
|
|
</h6>
|
|
</div>
|
|
<p className="text-left-panel-fg/60 mt-1 text-xs leading-relaxed">
|
|
{t('common:noThreadsYetDesc')}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="flex flex-col">
|
|
<ThreadList threads={unFavoritedThreads} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{secondaryMenus.map((menu) => {
|
|
if (!menu.isEnabled) {
|
|
return null
|
|
}
|
|
|
|
// Regular menu items must have route and icon
|
|
if (!menu.route || !menu.icon) return null
|
|
|
|
const isActive = (() => {
|
|
// Settings routes
|
|
if (menu.route.includes(route.settings.index)) {
|
|
return currentPath.includes(route.settings.index)
|
|
}
|
|
|
|
// Default exact match for other routes
|
|
return currentPath === menu.route
|
|
})()
|
|
return (
|
|
<Link
|
|
key={menu.title}
|
|
to={menu.route}
|
|
onClick={() => isSmallScreen && setLeftPanel(false)}
|
|
data-test-id={`menu-${menu.title}`}
|
|
activeOptions={{ exact: true }}
|
|
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'
|
|
)}
|
|
>
|
|
<menu.icon size={18} className="text-left-panel-fg/70" />
|
|
<span className="font-medium text-left-panel-fg/90">
|
|
{t(menu.title)}
|
|
</span>
|
|
</Link>
|
|
)
|
|
})}
|
|
|
|
{PlatformFeatures[PlatformFeature.AUTHENTICATION] && (
|
|
<div className="space-y-1 shrink-0 py-1">
|
|
<div>
|
|
<div className="mx-1 my-2 border-t border-left-panel-fg/5" />
|
|
{isAuthenticated ? <UserProfileMenu /> : <AuthLoginButton />}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DownloadManagement />
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Project Dialogs */}
|
|
<AddProjectDialog
|
|
open={projectDialogOpen}
|
|
onOpenChange={setProjectDialogOpen}
|
|
editingKey={editingProjectKey}
|
|
initialData={
|
|
editingProjectKey ? getFolderById(editingProjectKey) : undefined
|
|
}
|
|
onSave={handleProjectSave}
|
|
/>
|
|
<DeleteProjectDialog
|
|
open={deleteProjectConfirmOpen}
|
|
onOpenChange={setDeleteProjectConfirmOpen}
|
|
onConfirm={confirmProjectDelete}
|
|
projectName={
|
|
deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined
|
|
}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default LeftPanel
|