claude-code-cchui/src/components/layout/Sidebar.tsx
gaoziman aa469438c2 refactor(components): 全局组件适配暗色主题
- 聊天页面 header 和输入区域背景色使用 CSS 变量
- ChatInput 输入框背景色适配
- ModelSelector/ToolsDropdown 下拉菜单背景色适配
- QuickActions 按钮背景色适配
- Sidebar 侧边栏及下拉菜单背景色适配
- UserMenu 悬停效果颜色适配
2025-12-19 15:58:15 +08:00

231 lines
8.4 KiB
TypeScript

'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { Plus, PanelLeft, Trash2, MoreHorizontal, Loader2 } from 'lucide-react';
import { UserMenu } from '@/components/ui/UserMenu';
import { cn } from '@/lib/utils';
import { useConversations } from '@/hooks/useConversations';
import { useSettings } from '@/hooks/useSettings';
import type { User } from '@/types';
import type { Conversation } from '@/drizzle/schema';
import { useState } from 'react';
interface SidebarProps {
user: User;
chatHistories?: { id: string; title: string }[]; // 保持向后兼容
isOpen?: boolean;
onToggle?: () => void;
}
export function Sidebar({ user, isOpen = true }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const { conversations, loading, createConversation, deleteConversation } = useConversations();
const { settings } = useSettings();
const [creatingChat, setCreatingChat] = useState(false);
const [menuOpen, setMenuOpen] = useState<string | null>(null);
// 创建新对话
const handleNewChat = async () => {
if (creatingChat) return;
try {
setCreatingChat(true);
const newConversation = await createConversation({
model: settings?.defaultModel || 'claude-sonnet-4-20250514',
tools: settings?.defaultTools || [],
enableThinking: settings?.enableThinking || false,
});
router.push(`/chat/${newConversation.conversationId}`);
} catch (error) {
console.error('Failed to create conversation:', error);
} finally {
setCreatingChat(false);
}
};
// 删除对话
const handleDeleteConversation = async (conversationId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
await deleteConversation(conversationId);
setMenuOpen(null);
// 如果当前正在查看被删除的对话,跳转到首页
if (pathname === `/chat/${conversationId}`) {
router.push('/');
}
} catch (error) {
console.error('Failed to delete conversation:', error);
}
};
// 按时间分组对话
const groupedConversations = groupConversationsByTime(conversations);
return (
<>
{/* 侧边栏 */}
<aside
className={cn(
'fixed top-0 left-0 bottom-0 z-50 bg-[var(--color-bg-primary)] border-r border-[var(--color-border-light)] flex flex-col transition-all duration-300 ease-in-out overflow-hidden',
isOpen ? 'w-[var(--sidebar-width)]' : 'w-0 border-r-0'
)}
>
{/* 品牌 Header */}
<header className="p-4">
<h1 className="text-lg font-bold text-[var(--color-text-primary)]">cchcode</h1>
</header>
{/* 新建对话按钮 */}
<div className="px-4 py-2">
<button
onClick={handleNewChat}
disabled={creatingChat}
className="flex items-center gap-2 text-[var(--color-primary)] text-sm font-medium py-2 hover:opacity-80 transition-opacity disabled:opacity-50"
>
{creatingChat ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Plus size={18} />
)}
<span>New chat</span>
</button>
</div>
{/* 聊天列表 */}
<nav className="flex-1 overflow-y-auto px-2 flex flex-col gap-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-[var(--color-text-tertiary)]" />
</div>
) : conversations.length === 0 ? (
<div className="px-3 py-4 text-sm text-[var(--color-text-tertiary)] text-center">
No conversations yet
</div>
) : (
Object.entries(groupedConversations).map(([group, items]) => (
<div key={group}>
<h2 className="px-3 py-2 text-xs font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider">
{group}
</h2>
{items.map((conversation) => {
const isActive = pathname === `/chat/${conversation.conversationId}`;
return (
<div
key={conversation.conversationId}
className="relative group"
>
<Link
href={`/chat/${conversation.conversationId}`}
className={cn(
'block px-3 py-2 rounded-lg text-sm cursor-pointer transition-colors truncate pr-8',
isActive
? 'bg-[var(--color-bg-tertiary)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
)}
>
{conversation.title}
</Link>
{/* 更多操作按钮 */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(menuOpen === conversation.conversationId ? null : conversation.conversationId);
}}
className={cn(
'absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity',
'hover:bg-[var(--color-bg-hover)]'
)}
>
<MoreHorizontal size={14} className="text-[var(--color-text-tertiary)]" />
</button>
{/* 下拉菜单 */}
{menuOpen === conversation.conversationId && (
<div className="absolute right-0 top-full mt-1 bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-lg shadow-lg py-1 z-10 min-w-[120px]">
<button
onClick={(e) => handleDeleteConversation(conversation.conversationId, e)}
className="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-[var(--color-bg-hover)] flex items-center gap-2"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
);
})}
</div>
))
)}
</nav>
{/* 用户信息 Footer - 使用 UserMenu 弹出菜单 */}
<footer className="p-4 border-t border-[var(--color-border-light)] mt-auto">
<UserMenu user={user} />
</footer>
</aside>
{/* 移动端遮罩 */}
{isOpen && (
<div
className="fixed inset-0 bg-black/20 z-40 md:hidden"
onClick={() => setMenuOpen(null)}
/>
)}
</>
);
}
// 侧边栏切换按钮
export function SidebarToggle({ onClick }: { onClick?: () => void }) {
return (
<button
onClick={onClick}
className="w-9 h-9 flex items-center justify-center rounded-lg text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-secondary)] transition-colors"
title="Toggle sidebar"
>
<PanelLeft size={20} />
</button>
);
}
// 按时间分组对话
function groupConversationsByTime(conversations: Conversation[]): Record<string, Conversation[]> {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
const groups: Record<string, Conversation[]> = {};
conversations.forEach((conversation) => {
const date = new Date(conversation.lastMessageAt || conversation.createdAt || now);
let group: string;
if (date >= today) {
group = 'Today';
} else if (date >= yesterday) {
group = 'Yesterday';
} else if (date >= weekAgo) {
group = 'Past 7 days';
} else if (date >= monthAgo) {
group = 'Past 30 days';
} else {
group = 'Older';
}
if (!groups[group]) {
groups[group] = [];
}
groups[group].push(conversation);
});
return groups;
}