chatgpt2api / web /src /app /image /components /image-sidebar.tsx
tx1538's picture
Upload 179 files
9d7ddb9 verified
Raw
History Blame
7.73 kB
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { LoaderCircle, MessageSquarePlus, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getImageConversationStats, type ImageConversation } from "@/store/image-conversations";
type ImageSidebarProps = {
conversations: ImageConversation[];
isLoadingHistory: boolean;
selectedConversationId: string | null;
onCreateDraft: () => void;
onClearHistory: () => void | Promise<void>;
onSelectConversation: (id: string) => void;
onDeleteConversation: (id: string) => void | Promise<void>;
onRenameConversation: (id: string, title: string) => void | Promise<void>;
formatConversationTime: (value: string) => string;
hideActionButtons?: boolean;
};
export function ImageSidebar({
conversations,
isLoadingHistory,
selectedConversationId,
onCreateDraft,
onClearHistory,
onSelectConversation,
onDeleteConversation,
onRenameConversation,
formatConversationTime,
hideActionButtons = false,
}: ImageSidebarProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const editInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingId && editInputRef.current) {
editInputRef.current.focus();
editInputRef.current.select();
}
}, [editingId]);
const startRename = useCallback((conversation: ImageConversation, e: React.MouseEvent) => {
e.stopPropagation();
setEditingId(conversation.id);
setEditingTitle(conversation.title);
}, []);
const commitRename = useCallback(() => {
const trimmed = editingTitle.trim();
if (editingId && trimmed) {
void onRenameConversation(editingId, trimmed);
}
setEditingId(null);
setEditingTitle("");
}, [editingId, editingTitle, onRenameConversation]);
const cancelRename = useCallback(() => {
setEditingId(null);
setEditingTitle("");
}, []);
return (
<aside className="h-full min-h-0 overflow-hidden">
<div className="flex h-full min-h-0 flex-col gap-2 py-1 sm:gap-3 sm:py-2">
{!hideActionButtons && (
<div className="flex items-center gap-2">
<Button className="h-10 flex-1 rounded-xl bg-stone-950 text-white hover:bg-stone-800" onClick={onCreateDraft}>
<MessageSquarePlus className="size-4" />
新建对话
</Button>
<Button
variant="outline"
className="h-10 rounded-xl border-stone-200 bg-white/85 px-3 text-stone-600 hover:bg-white"
onClick={() => void onClearHistory()}
disabled={conversations.length === 0}
>
<Trash2 className="size-4" />
</Button>
</div>
)}
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto [scrollbar-color:rgba(120,113,108,.45)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-stone-400/45 [&::-webkit-scrollbar-track]:bg-transparent",
hideActionButtons ? "space-y-1 pr-0" : "space-y-2 pr-1",
)}
>
{isLoadingHistory ? (
<div className="flex items-center gap-2 px-2 py-3 text-sm text-stone-500">
<LoaderCircle className="size-4 animate-spin" />
正在读取会话记录
</div>
) : conversations.length === 0 ? (
<div className="px-2 py-3 text-sm leading-6 text-stone-500">还没有图片记录,输入提示词后会在这里显示。</div>
) : (
conversations.map((conversation) => {
const active = conversation.id === selectedConversationId;
const stats = getImageConversationStats(conversation);
return (
<div
key={conversation.id}
className={cn(
"group relative w-full border-l-2 text-left transition",
hideActionButtons ? "px-4 py-3.5" : "px-3 py-2 sm:py-3",
active
? "border-stone-900 bg-black/[0.035] text-stone-950"
: "border-transparent text-stone-700 hover:border-stone-300 hover:bg-white/40",
)}
>
<button
type="button"
onClick={() => onSelectConversation(conversation.id)}
className="block w-full pr-8 text-left"
>
<div className={cn("truncate font-semibold", hideActionButtons ? "text-base" : "text-sm")}>
{editingId === conversation.id ? (
<input
ref={editInputRef}
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") cancelRename();
}}
onClick={(e) => e.stopPropagation()}
className="w-full truncate rounded border border-stone-300 bg-white px-1 py-0.5 text-sm outline-none focus:border-stone-500"
/>
) : (
<span className="truncate">{conversation.title}</span>
)}
</div>
<div className={cn("mt-1 text-xs", active ? "text-stone-500" : "text-stone-400")}>
{conversation.turns.length} 轮 · {formatConversationTime(conversation.updatedAt)}
</div>
{stats.running > 0 || stats.queued > 0 ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px]">
{stats.running > 0 ? (
<span className="rounded-full bg-blue-50 px-2 py-1 text-blue-600">处理中 {stats.running}</span>
) : null}
{stats.queued > 0 ? (
<span className="rounded-full bg-amber-50 px-2 py-1 text-amber-700">排队 {stats.queued}</span>
) : null}
</div>
) : null}
</button>
<div className="absolute top-2.5 right-1.5 flex items-center gap-0.5 opacity-100 transition sm:opacity-0 sm:group-hover:opacity-100">
<button
type="button"
onClick={(e) => startRename(conversation, e)}
className="inline-flex size-7 items-center justify-center rounded-md text-stone-400 hover:bg-stone-100 hover:text-stone-600"
aria-label="重命名会话"
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
onClick={() => void onDeleteConversation(conversation.id)}
className="inline-flex size-7 items-center justify-center rounded-md text-stone-400 hover:bg-stone-100 hover:text-rose-500"
aria-label="删除会话"
>
<Trash2 className="size-4" />
</button>
</div>
</div>
);
})
)}
</div>
</div>
</aside>
);
}