310 lines
14 KiB
TypeScript
310 lines
14 KiB
TypeScript
import { memo, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
ChevronDown,
|
|
LoaderCircle,
|
|
MoreHorizontal,
|
|
PanelLeftClose,
|
|
PanelLeftOpen,
|
|
PencilLine,
|
|
Plus,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import type { ChatHistoryBucket } from './types';
|
|
import blueLogo from '../../assets/images/login/blue_logo.png';
|
|
import { useI18n } from '../../i18n';
|
|
|
|
type ChatHistoryPanelProps = {
|
|
buckets: ChatHistoryBucket[];
|
|
selectedConversationId?: string;
|
|
loading?: boolean;
|
|
onNewChat?: () => void;
|
|
onSelectConversation?: (conversationId: string) => void;
|
|
onRenameConversation?: (conversationId: string) => void;
|
|
onDeleteConversation?: (conversationId: string) => void;
|
|
};
|
|
|
|
type MenuState = {
|
|
conversationId: string;
|
|
} | null;
|
|
|
|
function cx(...classes: Array<string | false | null | undefined>): string {
|
|
return classes.filter(Boolean).join(' ');
|
|
}
|
|
|
|
function ChatHistoryPanel({
|
|
buckets,
|
|
selectedConversationId,
|
|
loading,
|
|
onNewChat,
|
|
onSelectConversation,
|
|
onRenameConversation,
|
|
onDeleteConversation,
|
|
}: ChatHistoryPanelProps) {
|
|
const { t } = useI18n();
|
|
const panelRef = useRef<HTMLElement | null>(null);
|
|
const [collapsedBuckets, setCollapsedBuckets] = useState<Record<string, boolean>>({});
|
|
const [menuState, setMenuState] = useState<MenuState>(null);
|
|
const [isCompact, setIsCompact] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setCollapsedBuckets((current) => {
|
|
const next: Record<string, boolean> = {};
|
|
const currentKeys = Object.keys(current);
|
|
let changed = currentKeys.length !== buckets.length;
|
|
|
|
for (const bucket of buckets) {
|
|
next[bucket.key] = current[bucket.key] ?? false;
|
|
if (current[bucket.key] !== next[bucket.key]) {
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return changed ? next : current;
|
|
});
|
|
}, [buckets]);
|
|
|
|
useEffect(() => {
|
|
if (!menuState) {
|
|
return;
|
|
}
|
|
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (!target) {
|
|
setMenuState(null);
|
|
return;
|
|
}
|
|
|
|
if (target.closest('[data-chat-history-menu="true"]')) {
|
|
return;
|
|
}
|
|
|
|
if (!panelRef.current?.contains(target)) {
|
|
setMenuState(null);
|
|
return;
|
|
}
|
|
|
|
if (!target.closest('[data-chat-history-menu-toggle="true"]')) {
|
|
setMenuState(null);
|
|
}
|
|
};
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setMenuState(null);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('pointerdown', handlePointerDown);
|
|
document.addEventListener('keydown', handleEscape);
|
|
|
|
return () => {
|
|
document.removeEventListener('pointerdown', handlePointerDown);
|
|
document.removeEventListener('keydown', handleEscape);
|
|
};
|
|
}, [menuState]);
|
|
|
|
const panelWidthClass = isCompact ? 'md:w-[70px] lg:w-[70px]' : 'md:w-[240px] lg:w-[252px]';
|
|
const toggleSidebarLabel = isCompact
|
|
? t('conversation.historyPanel.expandSidebar')
|
|
: t('conversation.historyPanel.collapseSidebar');
|
|
|
|
return (
|
|
<aside
|
|
ref={panelRef}
|
|
className={cx(
|
|
'flex h-full min-h-0 w-full flex-none flex-col transition-[width] duration-300',
|
|
panelWidthClass,
|
|
)}
|
|
>
|
|
<div className="flex h-full min-h-0 flex-col bg-[#fbfcfe] px-3 py-3 dark:border-[#2a2a2d] dark:bg-[#1b1b1d]">
|
|
<div className={cx('flex items-center justify-between gap-3', isCompact && 'flex-col items-center')}>
|
|
{!isCompact ? (
|
|
<div className={cx('flex min-w-0 items-center gap-3', isCompact && 'flex-col gap-2')}>
|
|
<div className="flex h-12 w-12 flex-none items-center justify-center overflow-hidden rounded-2xl border border-white bg-white shadow-[0_6px_16px_rgba(15,23,42,0.08)]">
|
|
<img className="h-full w-full object-cover" src={blueLogo} alt="YINIAN" />
|
|
</div>
|
|
<div className="truncate whitespace-nowrap text-[20px] font-semibold tracking-[0.06em] text-[#111827] dark:text-gray-50">
|
|
YINIAN
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<button
|
|
type="button"
|
|
className={cx(
|
|
'inline-flex h-12 w-12 items-center justify-center rounded-lg border text-[#64748b] transition-colors hover:text-[#111827] dark:border-[#2a2a2d] dark:text-gray-300 dark:hover:border-[#3a3a3f] dark:hover:text-gray-100',
|
|
isCompact ? 'border-[#e3eaf3]' : 'border-transparent',
|
|
)}
|
|
title={toggleSidebarLabel}
|
|
aria-label={toggleSidebarLabel}
|
|
onClick={() => {
|
|
setMenuState(null);
|
|
setIsCompact((current) => !current);
|
|
}}
|
|
>
|
|
{isCompact ? <PanelLeftOpen /> : <PanelLeftClose />}
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className={cx(
|
|
'mt-4 inline-flex h-12 items-center justify-center gap-2 rounded-lg border border-[#e3eaf3] bg-white px-4 text-[15px] font-medium text-[#111827] shadow-[0_4px_14px_rgba(15,23,42,0.05)] transition-all hover:-translate-y-px hover:border-[#d5e3f4] hover:shadow-[0_10px_24px_rgba(15,23,42,0.08)] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-100 dark:hover:border-[#3a3a3f]',
|
|
isCompact && 'px-0',
|
|
)}
|
|
title={t('conversation.newConversation')}
|
|
aria-label={t('conversation.newConversation')}
|
|
onClick={onNewChat}
|
|
>
|
|
<Plus className="h-5 w-5 flex-none" />
|
|
{!isCompact ? <span className="whitespace-nowrap">{t('conversation.newConversation')}</span> : null}
|
|
</button>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto pt-4">
|
|
{loading ? (
|
|
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-[#dbe7f4] bg-white p-4 text-sm text-[#94a3b8] shadow-[0_4px_14px_rgba(15,23,42,0.04)] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-400">
|
|
<LoaderCircle className="h-5 w-5 animate-spin text-[#8bb7ff]" />
|
|
{t('conversation.historyPanel.loading')}
|
|
</div>
|
|
) : null}
|
|
|
|
{!isCompact
|
|
? buckets.map((bucket) => {
|
|
const isCollapsed = collapsedBuckets[bucket.key] ?? false;
|
|
|
|
return (
|
|
<section key={bucket.key} className="mb-4 last:mb-0">
|
|
{bucket.sessions.length ? (
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between rounded-lg border border-[#e5edf7] p-2 text-left transition-colors dark:border-[#2f3136] dark:bg-[#202024]"
|
|
aria-expanded={!isCollapsed}
|
|
onClick={() => {
|
|
setMenuState(null);
|
|
setCollapsedBuckets((current) => ({
|
|
...current,
|
|
[bucket.key]: !current[bucket.key],
|
|
}));
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[14px] font-medium text-[#94a3b8] dark:text-gray-400">{bucket.label}</span>
|
|
<span className="text-[11px] text-[#c2cad6] dark:text-gray-600">{bucket.sessions.length}</span>
|
|
</div>
|
|
<ChevronDown
|
|
className={cx(
|
|
'h-4 w-4 text-[#b2bccb] transition-transform duration-200 dark:text-gray-500',
|
|
isCollapsed && '-rotate-90',
|
|
)}
|
|
/>
|
|
</button>
|
|
) : null}
|
|
|
|
{!isCollapsed ? (
|
|
<ul className="mt-2 space-y-2">
|
|
{bucket.sessions.map((session) => {
|
|
const isActive = session.conversationId === selectedConversationId;
|
|
const isMenuOpen = menuState?.conversationId === session.conversationId;
|
|
const canDelete = Boolean(onDeleteConversation) && session.canDelete !== false;
|
|
|
|
return (
|
|
<li key={session.conversationId}>
|
|
<div className="group relative">
|
|
<button
|
|
type="button"
|
|
className={cx(
|
|
'flex w-full items-center gap-2 rounded-lg px-2 py-2 pr-11 text-left text-[12px] transition-all',
|
|
isActive
|
|
? 'text-[#111827] shadow-[0_4px_14px_rgba(15,23,42,0.06)] dark:text-gray-50'
|
|
: 'border border-transparent text-[#5b6472] hover:border-[#e5edf7] hover:bg-white hover:text-[#111827] hover:shadow-[0_4px_14px_rgba(15,23,42,0.05)] dark:text-gray-400 dark:hover:border-[#2a2a2d] dark:hover:bg-[#202024] dark:hover:text-gray-100',
|
|
)}
|
|
onClick={() => {
|
|
setMenuState(null);
|
|
onSelectConversation?.(session.conversationId);
|
|
}}
|
|
>
|
|
<span
|
|
className={cx(
|
|
'h-2 w-2 flex-none rounded-full transition-colors',
|
|
isActive ? 'bg-[#9fc4ff]' : 'bg-[#c7dcff]',
|
|
)}
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate">{session.title}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
data-chat-history-menu-toggle="true"
|
|
className={cx(
|
|
'absolute right-2 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border border-transparent text-[#9aa5b1] transition-all hover:bg-[#f1f5f9] hover:text-[#111827] dark:hover:bg-[#2a2a2d] dark:hover:text-gray-100',
|
|
isActive || isMenuOpen
|
|
? 'opacity-100'
|
|
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100',
|
|
)}
|
|
title={t('conversation.historyPanel.moreActions')}
|
|
aria-label={t('conversation.historyPanel.moreActions')}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setMenuState((current) =>
|
|
current?.conversationId === session.conversationId
|
|
? null
|
|
: { conversationId: session.conversationId },
|
|
);
|
|
}}
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</button>
|
|
|
|
{isMenuOpen ? (
|
|
<div
|
|
data-chat-history-menu="true"
|
|
className="absolute right-1 top-full z-20 mt-2 w-32 overflow-hidden rounded-[14px] border border-[#e5edf7] bg-white shadow-[0_16px_28px_rgba(15,23,42,0.12)] dark:border-[#2f3136] dark:bg-[#202024]"
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm text-[#111827] transition-colors hover:bg-[#f4f8fc] dark:text-gray-100 dark:hover:bg-[#2a2a2d]"
|
|
disabled={!onRenameConversation}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setMenuState(null);
|
|
onRenameConversation?.(session.conversationId);
|
|
}}
|
|
>
|
|
<PencilLine className="h-4 w-4 text-[#94a3b8]" />
|
|
{t('conversation.historyPanel.rename')}
|
|
</button>
|
|
{canDelete ? (
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 border-t border-[#eef3f9] px-3 py-2.5 text-left text-sm text-[#ef4444] transition-colors hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#f5a2a2] dark:border-[#2f3136] dark:hover:bg-[#2a2a2d]"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setMenuState(null);
|
|
onDeleteConversation?.(session.conversationId);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
{t('conversation.historyPanel.delete')}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : null}
|
|
</section>
|
|
);
|
|
})
|
|
: null}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export default memo(ChatHistoryPanel);
|