Files
makelore/src/components/layout/ImageWorkspaceSidebar.tsx
T

364 lines
14 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import {
FolderKanban,
Loader2,
Pencil,
Plus,
RefreshCw,
Trash2,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import { useAuthStore } from '@/stores/auth';
import type { DesignWorkspaceSummary } from '../../../shared/image-workspace';
type ImageWorkspaceSidebarProps = {
sidebarCollapsed: boolean;
};
type ProjectDialogState =
| { mode: 'create'; project: null }
| { mode: 'rename'; project: DesignWorkspaceSummary };
function formatUpdatedAt(value: string): string {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return '';
const date = new Date(timestamp);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
}
export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) {
const authenticated = useAuthStore((state) => state.isAuthenticated());
const navigate = useNavigate();
const status = useImageWorkspaceStore((state) => state.status);
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId);
const workspace = useImageWorkspaceStore((state) => state.workspace);
const deletingWorkspaceId = useImageWorkspaceStore((state) => state.deletingWorkspaceId);
const workspaceError = useImageWorkspaceStore((state) => state.error);
const load = useImageWorkspaceStore((state) => state.load);
const createProject = useImageWorkspaceStore((state) => state.createProject);
const deleteProject = useImageWorkspaceStore((state) => state.deleteProject);
const renameProject = useImageWorkspaceStore((state) => state.renameProject);
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
const [dialog, setDialog] = useState<ProjectDialogState | null>(null);
const [projectName, setProjectName] = useState('');
const [dialogError, setDialogError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<DesignWorkspaceSummary | null>(null);
const [deleteConfirmation, setDeleteConfirmation] = useState('');
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
if (status === 'idle' || (status === 'auth-required' && authenticated)) void load();
}, [authenticated, load, status]);
useEffect(() => {
const openCreateDialog = () => {
setProjectName('');
setDialogError(null);
setDialog({ mode: 'create', project: null });
};
window.addEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog);
return () => window.removeEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog);
}, []);
const sortedProjects = useMemo(
() => [...(bootstrap?.workspaces ?? [])].sort(
(left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt),
),
[bootstrap?.workspaces],
);
const openCreateDialog = () => {
setProjectName('');
setDialogError(null);
setDialog({ mode: 'create', project: null });
};
const openRenameDialog = (project: DesignWorkspaceSummary) => {
setProjectName(project.title);
setDialogError(null);
setDialog({ mode: 'rename', project });
};
const saveProject = async () => {
const title = projectName.trim();
if (!title) {
setDialogError('请输入项目名称');
return;
}
setSaving(true);
setDialogError(null);
try {
if (dialog?.mode === 'rename') {
await renameProject(dialog.project.workspaceId, title);
toast.success('项目名称已更新');
} else {
await createProject(title);
navigate('/image-canvas');
}
setDialog(null);
} catch (error) {
setDialogError(error instanceof Error ? error.message : '保存项目失败');
} finally {
setSaving(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
if (deleteConfirmation !== deleteTarget.title) {
setDeleteError('请输入完整项目名称');
return;
}
setDeleteError(null);
try {
await deleteProject(deleteTarget.workspaceId);
setDeleteTarget(null);
setDeleteConfirmation('');
toast.success('设计项目已删除');
} catch (error) {
setDeleteError(error instanceof Error ? error.message : '删除项目失败');
}
};
const chooseProject = (project: DesignWorkspaceSummary) => {
void selectProject(project.workspaceId);
navigate('/image-canvas');
};
if (sidebarCollapsed) {
return (
<div data-testid="sidebar-image-workspace" className="flex flex-col items-center gap-2 px-2 py-3">
<Button
type="button"
variant="ghost"
size="icon"
aria-label="新建设计项目"
onClick={openCreateDialog}
>
<Plus className="h-4 w-4" />
</Button>
</div>
);
}
return (
<div data-testid="sidebar-image-workspace" className="flex min-h-0 flex-1 flex-col px-2 pb-3 pt-2 font-sans">
<div className="space-y-1">
<button
type="button"
className="motion-press flex min-h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-sm font-medium text-foreground transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
onClick={openCreateDialog}
>
<Plus className="h-4 w-4 text-brand" />
新建设计项目
</button>
</div>
<div className="mb-2 mt-5 flex items-center justify-between px-2">
<span className="text-xs font-medium text-muted-foreground">设计项目</span>
{(status === 'error' || status === 'unavailable') && (
<button
type="button"
aria-label="重新加载设计项目"
className="rounded-lg p-1 text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
onClick={() => void load()}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto pr-1">
{status === 'loading' && (
<div className="space-y-2 px-1" aria-label="正在加载设计项目">
{[0, 1, 2].map((item) => (
<div key={item} className="h-12 animate-pulse rounded-xl bg-surface-subtle" />
))}
</div>
)}
{(status === 'error' || status === 'unavailable') && (
<div
data-testid="sidebar-image-workspace-unavailable"
className="rounded-xl bg-surface-subtle px-3 py-3 text-xs leading-5 text-muted-foreground"
>
{workspaceError ?? 'AI 设计暂时不可用'}
</div>
)}
{status === 'ready' && sortedProjects.length === 0 && (
<button
type="button"
onClick={openCreateDialog}
className="w-full rounded-xl border border-dashed border-border px-3 py-5 text-left transition-colors hover:border-brand/35 hover:bg-brand/[0.03]"
>
<span className="block text-sm font-medium text-foreground">从一个设计方向开始</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
对话和表单会一起完善同一份设计。
</span>
</button>
)}
{sortedProjects.map((project) => {
const active = project.workspaceId === activeWorkspaceId;
const pending = project.workspaceId === activeWorkspaceId && !workspace;
return (
<div
key={project.workspaceId}
className={cn(
'group/project relative rounded-xl transition-colors',
active ? 'bg-brand/[0.08]' : 'hover:bg-surface-subtle',
)}
>
<button
type="button"
className="flex min-h-12 w-full items-center gap-2.5 rounded-xl px-2.5 py-2 pr-16 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30"
onClick={() => chooseProject(project)}
>
{pending ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-brand" />
) : (
<FolderKanban className={cn('h-4 w-4 shrink-0', active ? 'text-brand' : 'text-muted-foreground')} />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{project.title}
</span>
<span className="mt-0.5 block text-[11px] text-muted-foreground">
设计版本 {project.specificationRevision} · {formatUpdatedAt(project.updatedAt)}
</span>
</span>
</button>
<div className="absolute right-1.5 top-2 flex opacity-0 transition-opacity group-hover/project:opacity-100 focus-within:opacity-100">
<button
type="button"
aria-label={`重命名 ${project.title}`}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-background hover:text-foreground"
onClick={() => openRenameDialog(project)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`删除 ${project.title}`}
className="rounded-lg p-1.5 text-muted-foreground hover:bg-background hover:text-destructive"
onClick={() => {
setDeleteTarget(project);
setDeleteConfirmation('');
setDeleteError(null);
}}
>
{deletingWorkspaceId === project.workspaceId
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Trash2 className="h-3.5 w-3.5" />}
</button>
</div>
</div>
);
})}
</div>
<Dialog open={dialog !== null} onOpenChange={(open) => !open && !saving && setDialog(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{dialog?.mode === 'rename' ? '重命名设计项目' : '新建设计项目'}</DialogTitle>
<DialogDescription>
创建后会立即生成一份空白设计表单,可通过对话或直接编辑完善。
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="design-project-name" className="text-sm font-medium text-foreground">
项目名称
</label>
<Input
id="design-project-name"
autoFocus
value={projectName}
placeholder="例如:秋季新品主视觉"
onChange={(event) => setProjectName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !saving) void saveProject();
}}
/>
{dialogError && <p className="text-sm text-destructive">{dialogError}</p>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" disabled={saving} onClick={() => setDialog(null)}>
取消
</Button>
<Button type="button" disabled={saving} onClick={() => void saveProject()}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{dialog?.mode === 'rename' ? '保存名称' : '创建项目'}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open && !deletingWorkspaceId) setDeleteTarget(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>删除设计项目</DialogTitle>
<DialogDescription>
这会隐藏项目、设计表单、任务与资产。已另存到电脑的文件不受影响。
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label htmlFor="delete-design-project" className="text-sm font-medium text-foreground">
输入“{deleteTarget?.title ?? ''}”确认
</label>
<Input
id="delete-design-project"
value={deleteConfirmation}
onChange={(event) => setDeleteConfirmation(event.target.value)}
/>
{deleteError && <p className="text-sm text-destructive">{deleteError}</p>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
disabled={Boolean(deletingWorkspaceId)}
onClick={() => setDeleteTarget(null)}
>
取消
</Button>
<Button
type="button"
variant="destructive"
disabled={Boolean(deletingWorkspaceId)}
onClick={() => void confirmDelete()}
>
{deletingWorkspaceId && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
删除项目
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}