- Remove menu close from onMouseLeave (was closing before user reaches dropdown) - Add global document click listener to close menu when clicking outside Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
PencilLine,
|
|
PuzzleIcon,
|
|
MoreHorizontal,
|
|
Settings,
|
|
Pencil,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
|
|
interface Conversation {
|
|
id: string;
|
|
title: string;
|
|
}
|
|
|
|
interface GeminiSidebarProps {
|
|
isOpen: boolean;
|
|
conversations: Conversation[];
|
|
activeConversationId: string | null;
|
|
onNewChat: () => void;
|
|
onSelectConversation: (id: string) => void;
|
|
onRenameConversation?: (id: string, title: string) => void;
|
|
onDeleteConversation?: (id: string) => void;
|
|
onOpenExtensions?: () => void;
|
|
connectedExtensionsCount?: number;
|
|
}
|
|
|
|
const GemIcon = () => (
|
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
<defs>
|
|
<linearGradient id="gemGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stopColor="#4285f4" />
|
|
<stop offset="100%" stopColor="#a855f7" />
|
|
</linearGradient>
|
|
</defs>
|
|
<path
|
|
d="M14 2C14 2 16.5 9.5 20 13C23.5 16.5 26 14 26 14C26 14 23.5 16.5 20 20C16.5 23.5 14 26 14 26C14 26 11.5 23.5 8 20C4.5 16.5 2 14 2 14C2 14 4.5 11.5 8 8C11.5 4.5 14 2 14 2Z"
|
|
fill="url(#gemGradient)"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
export function GeminiSidebar({
|
|
isOpen,
|
|
conversations,
|
|
activeConversationId,
|
|
onNewChat,
|
|
onSelectConversation,
|
|
onRenameConversation,
|
|
onDeleteConversation,
|
|
onOpenExtensions,
|
|
connectedExtensionsCount = 0,
|
|
}: GeminiSidebarProps) {
|
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (renamingId && renameInputRef.current) {
|
|
renameInputRef.current.focus();
|
|
renameInputRef.current.select();
|
|
}
|
|
}, [renamingId]);
|
|
|
|
useEffect(() => {
|
|
if (!menuOpenId) return;
|
|
const close = () => setMenuOpenId(null);
|
|
document.addEventListener("click", close);
|
|
return () => document.removeEventListener("click", close);
|
|
}, [menuOpenId]);
|
|
|
|
const handleMenuClick = useCallback((e: React.MouseEvent, convId: string) => {
|
|
e.stopPropagation();
|
|
setMenuOpenId((prev) => (prev === convId ? null : convId));
|
|
}, []);
|
|
|
|
const handleRenameStart = useCallback((conv: Conversation) => {
|
|
setRenamingId(conv.id);
|
|
setRenameValue(conv.title);
|
|
setMenuOpenId(null);
|
|
}, []);
|
|
|
|
const handleRenameSubmit = useCallback((id: string) => {
|
|
const trimmed = renameValue.trim();
|
|
if (trimmed && onRenameConversation) {
|
|
onRenameConversation(id, trimmed);
|
|
}
|
|
setRenamingId(null);
|
|
setRenameValue("");
|
|
}, [renameValue, onRenameConversation]);
|
|
|
|
const handleDelete = useCallback((e: React.MouseEvent, id: string) => {
|
|
e.stopPropagation();
|
|
setMenuOpenId(null);
|
|
onDeleteConversation?.(id);
|
|
}, [onDeleteConversation]);
|
|
|
|
return (
|
|
<aside
|
|
className={cn(
|
|
"fixed left-0 top-0 h-full z-20 flex flex-col transition-all duration-300 ease-in-out",
|
|
"bg-[var(--gem-surface)] border-r border-[var(--gem-border)]",
|
|
isOpen ? "w-[260px] opacity-100" : "w-0 opacity-0 overflow-hidden pointer-events-none"
|
|
)}
|
|
aria-label="Sidebar navigation"
|
|
>
|
|
{/* Logo */}
|
|
<div className="flex items-center gap-2.5 px-4 pt-5 pb-4">
|
|
<GemIcon />
|
|
<span className="text-xl font-semibold bg-gradient-to-r from-[var(--gem-blue)] to-[var(--gem-purple)] bg-clip-text text-transparent select-none">
|
|
运营大脑
|
|
</span>
|
|
</div>
|
|
|
|
{/* New Chat Button */}
|
|
<div className="px-3 mb-4">
|
|
<button
|
|
onClick={onNewChat}
|
|
className="w-full flex items-center gap-2.5 px-4 py-2.5 rounded-full border border-[var(--gem-border)] text-[var(--gem-text)] text-sm font-medium hover:bg-[var(--gem-surface-2)] transition-colors duration-150 cursor-pointer"
|
|
aria-label="新建对话"
|
|
>
|
|
<PencilLine size={16} className="text-[var(--gem-text-muted)]" />
|
|
<span>新建对话</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Recent conversations */}
|
|
<div className="flex-1 overflow-y-auto px-2 scrollbar-thin">
|
|
<p className="text-xs font-medium text-[var(--gem-text-muted)] px-3 mb-2 uppercase tracking-wide">
|
|
最近的对话
|
|
</p>
|
|
<nav aria-label="Recent conversations">
|
|
<ul className="space-y-0.5">
|
|
{conversations.map((conv) => (
|
|
<li key={conv.id} className="relative">
|
|
{renamingId === conv.id ? (
|
|
<div className="flex items-center px-3 py-2">
|
|
<input
|
|
ref={renameInputRef}
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleRenameSubmit(conv.id);
|
|
if (e.key === "Escape") { setRenamingId(null); setRenameValue(""); }
|
|
}}
|
|
onBlur={() => handleRenameSubmit(conv.id)}
|
|
className="w-full bg-[var(--gem-surface-2)] border border-[var(--gem-border-hover)] rounded-lg px-2 py-1 text-sm text-[var(--gem-text)] outline-none"
|
|
aria-label="重命名对话"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => onSelectConversation(conv.id)}
|
|
onMouseEnter={() => setHoveredId(conv.id)}
|
|
onMouseLeave={() => setHoveredId(null)}
|
|
className={cn(
|
|
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm text-left transition-colors duration-150 cursor-pointer group",
|
|
activeConversationId === conv.id
|
|
? "bg-[var(--gem-surface-2)] text-[var(--gem-text)]"
|
|
: "text-[var(--gem-text-secondary)] hover:bg-[var(--gem-surface-2)] hover:text-[var(--gem-text)]"
|
|
)}
|
|
aria-current={activeConversationId === conv.id ? "page" : undefined}
|
|
>
|
|
<span className="truncate flex-1">{conv.title}</span>
|
|
{(hoveredId === conv.id || menuOpenId === conv.id) && (
|
|
<span
|
|
onClick={(e) => handleMenuClick(e, conv.id)}
|
|
className="flex-shrink-0 ml-1 p-0.5 rounded hover:bg-[var(--gem-surface-3)] text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] transition-colors cursor-pointer"
|
|
role="button"
|
|
aria-label="更多选项"
|
|
aria-haspopup="menu"
|
|
>
|
|
<MoreHorizontal size={16} />
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{/* Dropdown menu */}
|
|
{menuOpenId === conv.id && (
|
|
<div
|
|
className="absolute right-2 top-full mt-1 w-36 bg-[var(--gem-surface-2)] border border-[var(--gem-border)] rounded-xl shadow-2xl z-30 py-1 overflow-hidden"
|
|
role="menu"
|
|
>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleRenameStart(conv); }}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--gem-text)] hover:bg-[var(--gem-surface-3)] transition-colors cursor-pointer"
|
|
role="menuitem"
|
|
>
|
|
<Pencil size={14} className="text-[var(--gem-text-muted)]" />
|
|
<span>重命名</span>
|
|
</button>
|
|
<button
|
|
onClick={(e) => handleDelete(e, conv.id)}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
|
|
role="menuitem"
|
|
>
|
|
<Trash2 size={14} />
|
|
<span>删除</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Bottom nav */}
|
|
<div className="border-t border-[var(--gem-border)] pt-2 pb-4 px-2">
|
|
<nav aria-label="App navigation">
|
|
<ul className="space-y-0.5">
|
|
{[
|
|
{ icon: PuzzleIcon, label: "扩展程序", onClick: onOpenExtensions },
|
|
{ icon: Settings, label: "设置", onClick: undefined },
|
|
].map(({ icon: Icon, label, onClick }) => (
|
|
<li key={label}>
|
|
<button
|
|
onClick={onClick}
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-[var(--gem-text-muted)] hover:bg-[var(--gem-surface-2)] hover:text-[var(--gem-text)] transition-colors duration-150 cursor-pointer"
|
|
>
|
|
<Icon size={18} />
|
|
<span>{label}</span>
|
|
{label === "扩展程序" && connectedExtensionsCount > 0 && (
|
|
<span className="ml-auto text-xs bg-[var(--gem-blue)] text-white px-1.5 py-0.5 rounded-full">
|
|
{connectedExtensionsCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
|
|
{/* User avatar */}
|
|
<div className="flex items-center gap-3 px-3 pt-3 mt-1 border-t border-[var(--gem-border)]">
|
|
<div
|
|
className="w-8 h-8 rounded-full bg-gradient-to-br from-[var(--gem-blue)] to-[var(--gem-purple)] flex items-center justify-center text-white text-sm font-semibold flex-shrink-0"
|
|
aria-label="用户头像"
|
|
>
|
|
U
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm text-[var(--gem-text)] font-medium truncate">用户</p>
|
|
<p className="text-xs text-[var(--gem-text-muted)] truncate">user@gmail.com</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|