jan/web-app/src/containers/ThreadList.tsx

291 lines
9.1 KiB
TypeScript

import {
DndContext,
closestCenter,
useSensor,
useSensors,
PointerSensor,
KeyboardSensor,
} from '@dnd-kit/core'
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
IconDots,
IconStarFilled,
IconTrash,
IconEdit,
IconStar,
} from '@tabler/icons-react'
import { useThreads } from '@/hooks/useThreads'
import { cn } from '@/lib/utils'
import { route } from '@/constants/routes'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from '@/i18n/react-i18next-compat'
import { DialogClose, DialogFooter, DialogHeader } from '@/components/ui/dialog'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { memo, useMemo, useState } from 'react'
import { useNavigate, useMatches } from '@tanstack/react-router'
import { toast } from 'sonner'
import { Input } from '@/components/ui/input'
const SortableItem = memo(({ thread }: { thread: Thread }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: thread.id, disabled: true })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const { toggleFavorite, deleteThread, renameThread } = useThreads()
const { t } = useTranslation()
const [openDropdown, setOpenDropdown] = useState(false)
const navigate = useNavigate()
// Check if current route matches this thread's detail page
const matches = useMatches()
const isActive = matches.some(
(match) =>
match.routeId === '/threads/$threadId' &&
'threadId' in match.params &&
match.params.threadId === thread.id
)
const handleClick = () => {
if (!isDragging) {
navigate({ to: route.threadsDetail, params: { threadId: thread.id } })
}
}
const plainTitleForRename = useMemo(() => {
// Basic HTML stripping for simple span tags.
// If thread.title is undefined or null, treat as empty string before replace.
return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '')
}, [thread.title])
const [title, setTitle] = useState(plainTitleForRename || t('common:newThread'))
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
className={cn(
'mb-1 rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/thread-list transition-all',
isDragging ? 'cursor-move' : 'cursor-pointer',
isActive && 'bg-left-panel-fg/10'
)}
>
<div className="py-1 pr-2 truncate">
<span>{thread.title || t('common:newThread')}</span>
</div>
<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/thread-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">
{thread.isFavorite ? (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
toggleFavorite(thread.id)
}}
>
<IconStarFilled />
<span>{t('common:unstar')}</span>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
toggleFavorite(thread.id)
}}
>
<IconStar />
<span>{t('common:star')}</span>
</DropdownMenuItem>
)}
<Dialog
onOpenChange={(open) => {
if (!open) {
setOpenDropdown(false)
setTitle(plainTitleForRename || t('common:newThread'))
}
}}
>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconEdit />
<span>{t('common:rename')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:threadTitle')}</DialogTitle>
<Input
value={title}
onChange={(e) => {
setTitle(e.target.value)
}}
className="mt-2"
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<Button
disabled={!title}
onClick={() => {
renameThread(thread.id, title)
setOpenDropdown(false)
toast.success(t('common:toast.renameThread.title'), {
id: 'rename-thread',
description: t('common:toast.renameThread.description', { title }),
})
}}
>
{t('common:rename')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
<DropdownMenuSeparator />
<Dialog
onOpenChange={(open) => {
if (!open) setOpenDropdown(false)
}}
>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconTrash />
<span>{t('common:delete')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:deleteThread')}</DialogTitle>
<DialogDescription>
{t('common:dialogs.deleteThread.description')}
</DialogDescription>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
deleteThread(thread.id)
setOpenDropdown(false)
toast.success(t('common:toast.deleteThread.title'), {
id: 'delete-thread',
description: t('common:toast.deleteThread.description'),
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}}
>
{t('common:delete')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
})
type ThreadListProps = {
threads: Thread[]
isFavoriteSection?: boolean
}
function ThreadList({ threads }: ThreadListProps) {
const sortedThreads = useMemo(() => {
return threads.sort((a, b) => {
return (b.updated || 0) - (a.updated || 0)
})
}, [threads])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
delay: 200,
tolerance: 5,
},
}),
useSensor(KeyboardSensor)
)
return (
<DndContext sensors={sensors} collisionDetection={closestCenter}>
<SortableContext
items={sortedThreads.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
{sortedThreads.map((thread, index) => (
<SortableItem key={index} thread={thread} />
))}
</SortableContext>
</DndContext>
)
}
export default memo(ThreadList)