Files
socweb/frontend/components/gemini/GeminiSidebar.tsx
T
gongzhiyongandClaude Sonnet 4.6 e766564f34 fix: sidebar menu closes on mouseLeave before user can click option
- 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>
2026-04-08 20:31:59 +08:00

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>
);
}