Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user