152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
|
|
import {
|
||
|
|
CloudSyncOutlined,
|
||
|
|
DeleteOutlined,
|
||
|
|
EditOutlined,
|
||
|
|
FolderOpenOutlined,
|
||
|
|
MenuFoldOutlined,
|
||
|
|
MenuUnfoldOutlined,
|
||
|
|
ReloadOutlined,
|
||
|
|
} from "@ant-design/icons";
|
||
|
|
import { useCallback, useMemo, useState } from "react";
|
||
|
|
import type { WebProjectSummary } from "../../types";
|
||
|
|
|
||
|
|
interface ProjectSidebarProps {
|
||
|
|
projects: WebProjectSummary[];
|
||
|
|
activeId: string | null;
|
||
|
|
collapsed: boolean;
|
||
|
|
filterMode?: "chat" | "image" | "video";
|
||
|
|
loading?: boolean;
|
||
|
|
error?: string | null;
|
||
|
|
onToggle: () => void;
|
||
|
|
onSelect: (id: string) => void;
|
||
|
|
onRefresh: () => void;
|
||
|
|
onRename: (id: string, title: string) => void;
|
||
|
|
onDelete: (id: string) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatRelativeTime(dateStr: string): string {
|
||
|
|
const then = new Date(dateStr).getTime();
|
||
|
|
if (!Number.isFinite(then)) return "";
|
||
|
|
const diff = Date.now() - then;
|
||
|
|
if (diff < 60_000) return "just now";
|
||
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`;
|
||
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} h ago`;
|
||
|
|
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} d ago`;
|
||
|
|
return new Date(dateStr).toLocaleDateString("zh-CN");
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ProjectSidebar({
|
||
|
|
projects,
|
||
|
|
activeId,
|
||
|
|
collapsed,
|
||
|
|
filterMode,
|
||
|
|
loading,
|
||
|
|
error,
|
||
|
|
onToggle,
|
||
|
|
onSelect,
|
||
|
|
onRefresh,
|
||
|
|
onRename,
|
||
|
|
onDelete,
|
||
|
|
}: ProjectSidebarProps) {
|
||
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||
|
|
const [editValue, setEditValue] = useState("");
|
||
|
|
|
||
|
|
const filteredProjects = useMemo(
|
||
|
|
() => filterMode ? projects.filter((p) => p.mode === filterMode) : projects,
|
||
|
|
[projects, filterMode],
|
||
|
|
);
|
||
|
|
|
||
|
|
const startRename = useCallback((project: WebProjectSummary) => {
|
||
|
|
setEditingId(project.id);
|
||
|
|
setEditValue(project.name);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const commitRename = useCallback(() => {
|
||
|
|
if (editingId && editValue.trim()) {
|
||
|
|
onRename(editingId, editValue.trim());
|
||
|
|
}
|
||
|
|
setEditingId(null);
|
||
|
|
}, [editValue, editingId, onRename]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<aside className={`conversation-sidebar${collapsed ? " is-collapsed" : ""}`}>
|
||
|
|
<div className="conversation-sidebar__header">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="conversation-sidebar__toggle"
|
||
|
|
onClick={onToggle}
|
||
|
|
aria-label={collapsed ? "Expand project sidebar" : "Collapse project sidebar"}
|
||
|
|
>
|
||
|
|
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||
|
|
</button>
|
||
|
|
{!collapsed && (
|
||
|
|
<div className="conversation-sidebar__header-actions">
|
||
|
|
<button type="button" className="conversation-sidebar__new" onClick={() => onSelect("")}>
|
||
|
|
<FolderOpenOutlined /> 新对话
|
||
|
|
</button>
|
||
|
|
<button type="button" className="conversation-sidebar__icon-button" onClick={onRefresh} aria-label="刷新记录">
|
||
|
|
<ReloadOutlined />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{!collapsed && (
|
||
|
|
<div className="conversation-sidebar__list">
|
||
|
|
{error ? (
|
||
|
|
<div className="conversation-sidebar__empty">
|
||
|
|
<CloudSyncOutlined />
|
||
|
|
<span>Server data failed to load</span>
|
||
|
|
</div>
|
||
|
|
) : filteredProjects.length === 0 ? (
|
||
|
|
<div className="conversation-sidebar__empty">
|
||
|
|
<FolderOpenOutlined />
|
||
|
|
<span>{loading ? "正在加载记录..." : "暂无该类型记录"}</span>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
filteredProjects.map((project) => (
|
||
|
|
<div
|
||
|
|
key={project.id}
|
||
|
|
className={`conversation-sidebar__item${project.id === activeId ? " is-active" : ""}`}
|
||
|
|
>
|
||
|
|
{editingId === project.id ? (
|
||
|
|
<input
|
||
|
|
className="conversation-sidebar__rename-input"
|
||
|
|
value={editValue}
|
||
|
|
onChange={(event) => setEditValue(event.target.value)}
|
||
|
|
onBlur={commitRename}
|
||
|
|
onKeyDown={(event) => {
|
||
|
|
if (event.key === "Enter") commitRename();
|
||
|
|
if (event.key === "Escape") setEditingId(null);
|
||
|
|
}}
|
||
|
|
autoFocus
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="conversation-sidebar__item-main"
|
||
|
|
onClick={() => onSelect(project.id)}
|
||
|
|
>
|
||
|
|
<span className="conversation-sidebar__item-title">{project.name}</span>
|
||
|
|
<span className="conversation-sidebar__item-time">
|
||
|
|
{project.source === "server" ? formatRelativeTime(project.updatedAt) : "preview"}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<div className="conversation-sidebar__item-actions">
|
||
|
|
<button type="button" aria-label="Rename project" onClick={() => startRename(project)}>
|
||
|
|
<EditOutlined />
|
||
|
|
</button>
|
||
|
|
<button type="button" aria-label="Delete project" onClick={() => onDelete(project.id)}>
|
||
|
|
<DeleteOutlined />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</aside>
|
||
|
|
);
|
||
|
|
}
|