Makelore 2.0 initial clean snapshot

This commit is contained in:
inman committed 2026-07-29 17:22:35 +08:00
commit b8ca3f8eea
694 files changed
+139782

No files matched your search

@@ -0,0 +1,297 @@
import { useEffect, useState } from 'react';
import { Bot, FolderKanban, Loader2, Plus, RefreshCw, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
type ImageWorkspaceSidebarProps = {
sidebarCollapsed: boolean;
};
export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) {
const navigate = useNavigate();
const status = useImageWorkspaceStore((state) => state.status);
const snapshot = useImageWorkspaceStore((state) => state.snapshot);
const activeProjectId = useImageWorkspaceStore((state) => state.activeProjectId);
const activeAgentIds = useImageWorkspaceStore((state) => state.activeAgentIds);
const workspaceError = useImageWorkspaceStore((state) => state.error);
const load = useImageWorkspaceStore((state) => state.load);
const createProject = useImageWorkspaceStore((state) => state.createProject);
const addAgent = useImageWorkspaceStore((state) => state.addAgent);
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
const selectAgent = useImageWorkspaceStore((state) => state.selectAgent);
const [projectsOpen, setProjectsOpen] = useState(true);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [projectName, setProjectName] = useState('');
const [createError, setCreateError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [addingAgentProjectId, setAddingAgentProjectId] = useState<string | null>(null);
const [agentError, setAgentError] = useState<string | null>(null);
useEffect(() => {
if (status === 'idle') void load();
}, [load, status]);
useEffect(() => {
const openCreateDialog = () => {
setProjectName('');
setCreateError(null);
setCreateDialogOpen(true);
};
window.addEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog);
return () => window.removeEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog);
}, []);
const openCreateDialog = () => {
setProjectName('');
setCreateError(null);
setCreateDialogOpen(true);
};
const closeCreateDialog = () => {
if (creating) return;
setCreateDialogOpen(false);
setCreateError(null);
};
const handleCreateProject = async () => {
const name = projectName.trim();
if (!name) {
setCreateError('请输入项目名称');
return;
}
setCreating(true);
setCreateError(null);
try {
await createProject(name);
setCreateDialogOpen(false);
navigate('/image-canvas');
} catch (error) {
setCreateError(error instanceof Error ? error.message : String(error));
} finally {
setCreating(false);
}
};
const handleAddAgent = async (projectId: string) => {
setAddingAgentProjectId(projectId);
setAgentError(null);
try {
await addAgent(projectId);
} catch (error) {
setAgentError(error instanceof Error ? error.message : String(error));
} finally {
setAddingAgentProjectId(null);
}
};
const handleSelectProject = (projectId: string) => {
selectProject(projectId);
navigate('/image-canvas');
};
return (
<div data-testid="sidebar-image-workspace" className="min-h-0">
<Button
type="button"
data-testid="sidebar-create-image-project"
className={cn(
'mb-2 w-full border-2 border-[#26384D] bg-[#E9EFF5] text-[#26384D] shadow-[3px_3px_0_#26384D] hover:bg-[#DCE6EF]',
sidebarCollapsed && 'px-0',
)}
aria-label="新建项目"
onClick={openCreateDialog}
disabled={status !== 'ready'}
>
<Plus className={cn('h-4 w-4', !sidebarCollapsed && 'mr-2')} />
{!sidebarCollapsed && '新建项目'}
</Button>
<div className="mt-4 border-t-2 border-[#DCE6EF] pt-4">
<button
type="button"
onClick={() => setProjectsOpen((open) => !open)}
className={cn(
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm font-black transition-colors hover:bg-[#FFFFFF]',
sidebarCollapsed && 'justify-center px-0',
)}
>
<FolderKanban className="h-[18px] w-[18px] shrink-0" />
{!sidebarCollapsed ? (
<>
<span className="min-w-0 flex-1 truncate">项目</span>
<span className="text-xs">{projectsOpen ? '收起' : '展开'}</span>
</>
) : null}
</button>
{projectsOpen && !sidebarCollapsed ? (
<div data-testid="sidebar-image-projects" className="mt-2 space-y-2">
{status === 'idle' || status === 'loading' ? (
<div className="flex items-center gap-2 rounded-lg border-2 border-dashed border-[#DCE6EF] bg-white/70 p-3 text-xs font-bold text-[#68788B]">
<Loader2 className="h-4 w-4 animate-spin" />
正在读取创作空间
</div>
) : null}
{status === 'unavailable' || status === 'error' ? (
<div
data-testid="sidebar-image-workspace-unavailable"
className="rounded-lg border-2 border-dashed border-[#26384D] bg-[#FFFFFF] p-3 text-xs font-bold text-[#68788B]"
>
<p className="font-black text-[#26384D]">创作空间暂不可用</p>
{workspaceError ? <p className="mt-1 break-words">{workspaceError}</p> : null}
<button
type="button"
className="mt-3 flex items-center gap-1.5 rounded-md border-2 border-[#26384D] bg-white px-2 py-1.5 font-black text-[#26384D]"
onClick={() => void load()}
>
<RefreshCw className="h-3.5 w-3.5" />
重试
</button>
</div>
) : null}
{status === 'ready' && snapshot?.projects.length === 0 ? (
<div className="rounded-lg border-2 border-dashed border-[#DCE6EF] bg-white/70 p-3 text-xs font-bold text-[#68788B]">
暂无项目。新建项目后,创作空间会自动创建默认 Agent。
</div>
) : null}
{status === 'ready' ? snapshot?.projects.map((project) => {
const active = project.id === activeProjectId;
const selectedAgentId = activeAgentIds[project.id];
return (
<section
key={project.id}
data-testid={`sidebar-image-project-${project.id}`}
className={cn(
'rounded-lg border-2 p-3 text-left',
active
? 'border-[#26384D] bg-[#DCE6EF] shadow-[4px_4px_0_#26384D]'
: 'border-[#DCE6EF] bg-[#FFFFFF]',
)}
>
<button
type="button"
className="flex w-full items-center gap-2 text-left"
onClick={() => handleSelectProject(project.id)}
>
<FolderKanban className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm font-black">{project.name}</span>
</button>
<div className="mt-3 space-y-1.5" aria-label={`${project.name} 的 Agent`}>
{project.agents.map((agent) => (
<button
key={agent.id}
type="button"
aria-pressed={active && selectedAgentId === agent.id}
onClick={() => {
selectProject(project.id);
selectAgent(project.id, agent.id);
navigate('/image-canvas');
}}
className={cn(
'flex w-full items-center gap-2 rounded-md border-2 border-[#26384D] px-2 py-1.5 text-left text-xs font-black',
active && selectedAgentId === agent.id ? 'bg-white' : 'bg-white/60',
)}
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-[#26384D] bg-[#E9EFF5]">
{agent.avatarUrl ? (
<img src={agent.avatarUrl} alt="" className="h-full w-full object-cover" />
) : (
<Bot className="h-3.5 w-3.5" />
)}
</span>
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
</button>
))}
<button
type="button"
aria-label={`为 ${project.name} 添加 Agent`}
className="flex w-full items-center justify-center gap-1.5 rounded-md border-2 border-dashed border-[#26384D] bg-white/70 px-2 py-1.5 text-xs font-black"
onClick={() => void handleAddAgent(project.id)}
disabled={addingAgentProjectId === project.id}
>
{addingAgentProjectId === project.id
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-3.5 w-3.5" />}
添加 Agent
</button>
</div>
</section>
);
}) : null}
{agentError ? <p role="alert" className="rounded-md bg-[#ffd6d6] p-2 text-xs font-black">{agentError}</p> : null}
</div>
) : null}
</div>
{createDialogOpen ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="create-image-project-dialog-title"
className="w-full max-w-sm rounded-lg border-4 border-[#26384D] bg-[#FFFFFF] p-4 text-[#26384D] shadow-[8px_8px_0_#26384D]"
>
<div className="flex items-start justify-between gap-3">
<div>
<h2 id="create-image-project-dialog-title" className="text-xl font-black">新建项目</h2>
<p className="mt-1 text-xs font-bold text-[#68788B]">只需填写名称,创作空间会自动创建默认 Agent。</p>
</div>
<button
type="button"
aria-label="关闭新建项目"
className="flex h-8 w-8 items-center justify-center rounded-md border-2 border-[#26384D] bg-white"
onClick={closeCreateDialog}
disabled={creating}
>
<X className="h-4 w-4" />
</button>
</div>
<form
className="mt-4 space-y-3"
onSubmit={(event) => {
event.preventDefault();
void handleCreateProject();
}}
>
<label className="block text-xs font-black" htmlFor="image-project-name">项目名称</label>
<input
id="image-project-name"
value={projectName}
onChange={(event) => {
setProjectName(event.target.value);
setCreateError(null);
}}
autoFocus
disabled={creating}
className="w-full rounded-md border-2 border-[#26384D] bg-white px-3 py-2 text-sm font-bold outline-none focus:ring-2 focus:ring-[#DCE6EF]"
/>
{createError ? <p role="alert" className="rounded-md bg-[#ffd6d6] p-2 text-xs font-black">{createError}</p> : null}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={closeCreateDialog} disabled={creating}>取消</Button>
<Button
type="submit"
disabled={creating || !projectName.trim()}
className="border-2 border-[#26384D] bg-[#DCE6EF] font-black text-[#26384D]"
>
{creating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
创建项目
</Button>
</div>
</form>
</div>
</div>
) : null}
</div>
);
}