ee3948b491
合并 codex/retire-legacy-creation,退役旧创作模板业务并保留历史数据壳 保留 master 最新编辑器 Agent、画布和个人页能力 补齐现役编辑器 Agent 的 LLM 与生成结果读取链路 同步 Vite、ESLint、Rust workspace、SpacetimeDB 与文档边界
549 lines
17 KiB
TypeScript
549 lines
17 KiB
TypeScript
import {
|
|
Check,
|
|
CheckSquare,
|
|
MoreHorizontal,
|
|
Pencil,
|
|
Plus,
|
|
Square,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { ApiClientError } from '../../services/apiClient';
|
|
import {
|
|
createEditorProject,
|
|
deleteEditorProject,
|
|
type EditorProjectSnapshot,
|
|
listEditorProjects,
|
|
renameEditorProject,
|
|
} from '../../services/image-editor/editorProjectClient';
|
|
import { readEditorProjectCoverCache } from '../../services/image-editor/editorProjectCoverCache';
|
|
import { useAuthUi } from '../auth/AuthUiContext';
|
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
|
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
|
|
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
|
import {
|
|
PlatformFloatingMenu,
|
|
PlatformFloatingMenuItem,
|
|
} from '../common/PlatformFloatingMenu';
|
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
|
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
|
import { PlatformTextField } from '../common/PlatformTextField';
|
|
import { PlatformToolModalShell } from '../common/PlatformToolModalShell';
|
|
import {
|
|
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
|
|
resolveProjectCoverResource,
|
|
} from './ProjectCanvasCover';
|
|
|
|
type ProjectGalleryViewProps = {
|
|
onOpenProject: (projectId: string, options?: { guide?: boolean }) => void;
|
|
searchKeyword?: string;
|
|
};
|
|
|
|
type RenameDraft = {
|
|
projectId: string;
|
|
title: string;
|
|
};
|
|
|
|
type ProjectCoverObjectUrl = {
|
|
projectId: string;
|
|
url: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
function isUnauthorizedError(error: unknown) {
|
|
return error instanceof ApiClientError && error.status === 401;
|
|
}
|
|
|
|
function formatProjectUpdatedAt(value: string) {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '';
|
|
}
|
|
return new Intl.DateTimeFormat('zh-CN', {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).format(date);
|
|
}
|
|
|
|
export function ProjectGalleryView({
|
|
onOpenProject,
|
|
searchKeyword = '',
|
|
}: ProjectGalleryViewProps) {
|
|
const authUi = useAuthUi();
|
|
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [activeMenuProjectId, setActiveMenuProjectId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [renameDraft, setRenameDraft] = useState<RenameDraft | null>(null);
|
|
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const [localCoverObjectUrls, setLocalCoverObjectUrls] = useState<
|
|
ProjectCoverObjectUrl[]
|
|
>([]);
|
|
const localCoverObjectUrlsRef = useRef<ProjectCoverObjectUrl[]>([]);
|
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
|
const selectedCount = selectedProjectIds.size;
|
|
const allSelected =
|
|
projects.length > 0 && selectedProjectIds.size === projects.length;
|
|
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
|
|
const visibleProjects = useMemo(() => {
|
|
if (!normalizedSearchKeyword) {
|
|
return projects;
|
|
}
|
|
return projects.filter((project) =>
|
|
project.title.toLocaleLowerCase().includes(normalizedSearchKeyword),
|
|
);
|
|
}, [normalizedSearchKeyword, projects]);
|
|
|
|
const localCoverUrlByProjectId = useMemo(
|
|
() =>
|
|
new Map(
|
|
localCoverObjectUrls.map((cover) => [cover.projectId, cover] as const),
|
|
),
|
|
[localCoverObjectUrls],
|
|
);
|
|
|
|
const replaceLocalCoverObjectUrls = useCallback(
|
|
(nextCoverObjectUrls: ProjectCoverObjectUrl[]) => {
|
|
localCoverObjectUrlsRef.current.forEach((cover) =>
|
|
URL.revokeObjectURL(cover.url),
|
|
);
|
|
localCoverObjectUrlsRef.current = nextCoverObjectUrls;
|
|
setLocalCoverObjectUrls(nextCoverObjectUrls);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const refreshProjects = useCallback(() => {
|
|
setIsLoading(true);
|
|
setErrorMessage(null);
|
|
listEditorProjects()
|
|
.then((items) => {
|
|
setProjects(items);
|
|
void readEditorProjectCoverCache(
|
|
items.map((project) => project.projectId),
|
|
)
|
|
.then((cachedCovers) => {
|
|
const nextCoverObjectUrls = [...cachedCovers.values()].map(
|
|
(cover) => ({
|
|
projectId: cover.projectId,
|
|
url: URL.createObjectURL(cover.blob),
|
|
updatedAt: cover.updatedAt,
|
|
}),
|
|
);
|
|
replaceLocalCoverObjectUrls(nextCoverObjectUrls);
|
|
})
|
|
.catch(() => {
|
|
replaceLocalCoverObjectUrls([]);
|
|
});
|
|
setSelectedProjectIds((currentIds) => {
|
|
const availableIds = new Set(
|
|
items.map((project) => project.projectId),
|
|
);
|
|
return new Set(
|
|
[...currentIds].filter((projectId) => availableIds.has(projectId)),
|
|
);
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (isUnauthorizedError(error)) {
|
|
authUi?.openLoginModal(refreshProjects);
|
|
return;
|
|
}
|
|
setErrorMessage(
|
|
error instanceof Error ? error.message : '读取项目列表失败',
|
|
);
|
|
})
|
|
.finally(() => setIsLoading(false));
|
|
}, [authUi, replaceLocalCoverObjectUrls]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
localCoverObjectUrlsRef.current.forEach((cover) =>
|
|
URL.revokeObjectURL(cover.url),
|
|
);
|
|
localCoverObjectUrlsRef.current = [];
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refreshProjects();
|
|
}, [refreshProjects]);
|
|
|
|
const createProject = useCallback(() => {
|
|
if (authUi && !authUi.user) {
|
|
authUi.openLoginModal(createProject);
|
|
return;
|
|
}
|
|
setErrorMessage(null);
|
|
createEditorProject()
|
|
.then((project) => onOpenProject(project.projectId, { guide: true }))
|
|
.catch((error: unknown) => {
|
|
setErrorMessage(
|
|
error instanceof Error ? error.message : '创建项目失败',
|
|
);
|
|
});
|
|
}, [authUi, onOpenProject]);
|
|
|
|
const closeSelectionMode = useCallback(() => {
|
|
setIsSelectionMode(false);
|
|
setSelectedProjectIds(new Set());
|
|
}, []);
|
|
|
|
const toggleProjectSelection = useCallback((projectId: string) => {
|
|
setSelectedProjectIds((currentIds) => {
|
|
const nextIds = new Set(currentIds);
|
|
if (nextIds.has(projectId)) {
|
|
nextIds.delete(projectId);
|
|
} else {
|
|
nextIds.add(projectId);
|
|
}
|
|
return nextIds;
|
|
});
|
|
}, []);
|
|
|
|
const deleteProjects = useCallback((projectIds: string[]) => {
|
|
if (projectIds.length === 0) {
|
|
return;
|
|
}
|
|
setErrorMessage(null);
|
|
void Promise.all(
|
|
projectIds.map((projectId) => deleteEditorProject(projectId)),
|
|
)
|
|
.then(() => {
|
|
setProjects((currentProjects) =>
|
|
currentProjects.filter(
|
|
(project) => !projectIds.includes(project.projectId),
|
|
),
|
|
);
|
|
setSelectedProjectIds((currentIds) => {
|
|
const nextIds = new Set(currentIds);
|
|
projectIds.forEach((projectId) => nextIds.delete(projectId));
|
|
return nextIds;
|
|
});
|
|
setActiveMenuProjectId(null);
|
|
})
|
|
.catch((error: unknown) => {
|
|
setErrorMessage(
|
|
error instanceof Error ? error.message : '删除项目失败',
|
|
);
|
|
});
|
|
}, []);
|
|
|
|
const submitRename = useCallback(() => {
|
|
if (!renameDraft) {
|
|
return;
|
|
}
|
|
const title = renameDraft.title.trim();
|
|
if (!title) {
|
|
return;
|
|
}
|
|
setErrorMessage(null);
|
|
renameEditorProject(renameDraft.projectId, title)
|
|
.then((project) => {
|
|
setProjects((currentProjects) =>
|
|
currentProjects.map((item) =>
|
|
item.projectId === project.projectId ? project : item,
|
|
),
|
|
);
|
|
setRenameDraft(null);
|
|
setActiveMenuProjectId(null);
|
|
})
|
|
.catch((error: unknown) => {
|
|
setErrorMessage(
|
|
error instanceof Error ? error.message : '重命名项目失败',
|
|
);
|
|
});
|
|
}, [renameDraft]);
|
|
|
|
const projectCards = useMemo(
|
|
() =>
|
|
visibleProjects.map((project) => {
|
|
const selected = selectedProjectIds.has(project.projectId);
|
|
const localCover = localCoverUrlByProjectId.get(project.projectId);
|
|
const projectWithLocalCover = localCover
|
|
? {
|
|
...project,
|
|
resources: [
|
|
...project.resources,
|
|
{
|
|
resourceId: `local-cover-cache:${project.projectId}`,
|
|
projectId: project.projectId,
|
|
imageSrc: localCover.url,
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded' as const,
|
|
assetKind: LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
|
|
updatedAt: localCover.updatedAt,
|
|
},
|
|
],
|
|
}
|
|
: project;
|
|
const coverResource = resolveProjectCoverResource(
|
|
projectWithLocalCover,
|
|
);
|
|
return (
|
|
<article
|
|
key={project.projectId}
|
|
className={[
|
|
'project-gallery__card',
|
|
selected ? 'project-gallery__card--selected' : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="project-gallery__card-button"
|
|
onClick={() => {
|
|
if (isSelectionMode) {
|
|
toggleProjectSelection(project.projectId);
|
|
return;
|
|
}
|
|
if (authUi) {
|
|
authUi.requireAuth(() => onOpenProject(project.projectId));
|
|
return;
|
|
}
|
|
onOpenProject(project.projectId);
|
|
}}
|
|
aria-label={`打开项目${project.title}`}
|
|
>
|
|
<PlatformMediaFrame
|
|
src={coverResource?.imageSrc ?? null}
|
|
objectKey={coverResource?.objectKey ?? null}
|
|
alt=""
|
|
fallbackLabel="项目"
|
|
aspect="standard"
|
|
surface="bright"
|
|
refreshKey={
|
|
coverResource?.updatedAt ??
|
|
coverResource?.createdAt ??
|
|
coverResource?.resourceId ??
|
|
null
|
|
}
|
|
className="project-gallery__preview"
|
|
>
|
|
{isSelectionMode ? (
|
|
<span className="project-gallery__checkbox">
|
|
{selected ? <Check className="h-4 w-4" /> : null}
|
|
</span>
|
|
) : null}
|
|
</PlatformMediaFrame>
|
|
<span className="project-gallery__meta">
|
|
<span>{project.title}</span>
|
|
<span>{formatProjectUpdatedAt(project.updatedAt)}</span>
|
|
</span>
|
|
</button>
|
|
{!isSelectionMode ? (
|
|
<div className="project-gallery__card-menu-wrap">
|
|
<PlatformIconButton
|
|
label={`打开项目${project.title}菜单`}
|
|
icon={<MoreHorizontal className="h-4 w-4" />}
|
|
variant="surfaceFloating"
|
|
className="h-8 w-8"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setActiveMenuProjectId((currentProjectId) =>
|
|
currentProjectId === project.projectId
|
|
? null
|
|
: project.projectId,
|
|
);
|
|
}}
|
|
/>
|
|
{activeMenuProjectId === project.projectId ? (
|
|
<PlatformFloatingMenu>
|
|
<PlatformFloatingMenuItem
|
|
icon={<Pencil className="h-4 w-4" />}
|
|
onClick={() =>
|
|
setRenameDraft({
|
|
projectId: project.projectId,
|
|
title: project.title,
|
|
})
|
|
}
|
|
>
|
|
重命名
|
|
</PlatformFloatingMenuItem>
|
|
<PlatformFloatingMenuItem
|
|
icon={<Trash2 className="h-4 w-4" />}
|
|
onClick={() => deleteProjects([project.projectId])}
|
|
>
|
|
删除
|
|
</PlatformFloatingMenuItem>
|
|
</PlatformFloatingMenu>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</article>
|
|
);
|
|
}),
|
|
[
|
|
activeMenuProjectId,
|
|
authUi,
|
|
deleteProjects,
|
|
isSelectionMode,
|
|
localCoverUrlByProjectId,
|
|
onOpenProject,
|
|
selectedProjectIds,
|
|
toggleProjectSelection,
|
|
visibleProjects,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<main className="project-gallery" aria-label="项目">
|
|
<header className="project-gallery__header">
|
|
<div className="project-gallery__heading">
|
|
<h1>项目</h1>
|
|
<span>{projects.length} 个画布项目</span>
|
|
</div>
|
|
<div className="project-gallery__header-actions">
|
|
<PlatformActionButton
|
|
tone="secondary"
|
|
size="sm"
|
|
onClick={() => setIsSelectionMode(true)}
|
|
disabled={projects.length === 0}
|
|
>
|
|
选择
|
|
</PlatformActionButton>
|
|
<PlatformActionButton size="sm" onClick={createProject}>
|
|
<Plus className="h-4 w-4" />
|
|
新建
|
|
</PlatformActionButton>
|
|
</div>
|
|
</header>
|
|
|
|
{errorMessage ? (
|
|
<PlatformStatusMessage
|
|
tone="error"
|
|
surface="platform"
|
|
className="project-gallery__error"
|
|
role="alert"
|
|
>
|
|
{errorMessage}
|
|
</PlatformStatusMessage>
|
|
) : null}
|
|
|
|
{isLoading ? (
|
|
<PlatformEmptyState surface="subpanel" size="panel">
|
|
正在读取项目
|
|
</PlatformEmptyState>
|
|
) : visibleProjects.length === 0 && normalizedSearchKeyword ? (
|
|
<PlatformEmptyState surface="subpanel" size="panel">
|
|
没有匹配项目
|
|
</PlatformEmptyState>
|
|
) : projects.length === 0 ? (
|
|
<PlatformEmptyState
|
|
asChild="button"
|
|
surface="subpanel"
|
|
size="panel"
|
|
className="project-gallery__new-card"
|
|
onClick={createProject}
|
|
>
|
|
<Plus className="h-6 w-6" />
|
|
<span>新建项目</span>
|
|
</PlatformEmptyState>
|
|
) : (
|
|
<section className="project-gallery__grid">{projectCards}</section>
|
|
)}
|
|
|
|
<PlatformToolModalShell
|
|
open={Boolean(renameDraft)}
|
|
title="重命名"
|
|
size="sm"
|
|
closeLabel="关闭重命名"
|
|
onClose={() => setRenameDraft(null)}
|
|
footer={
|
|
<>
|
|
<PlatformActionButton
|
|
type="button"
|
|
tone="secondary"
|
|
onClick={() => setRenameDraft(null)}
|
|
>
|
|
取消
|
|
</PlatformActionButton>
|
|
<PlatformActionButton
|
|
type="submit"
|
|
form="project-gallery-rename-form"
|
|
>
|
|
保存
|
|
</PlatformActionButton>
|
|
</>
|
|
}
|
|
>
|
|
{renameDraft ? (
|
|
<form
|
|
id="project-gallery-rename-form"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
submitRename();
|
|
}}
|
|
>
|
|
<PlatformTextField
|
|
aria-label="项目名称"
|
|
value={renameDraft.title}
|
|
onChange={(event) =>
|
|
setRenameDraft((currentDraft) =>
|
|
currentDraft
|
|
? { ...currentDraft, title: event.target.value }
|
|
: currentDraft,
|
|
)
|
|
}
|
|
autoFocus
|
|
/>
|
|
</form>
|
|
) : null}
|
|
</PlatformToolModalShell>
|
|
|
|
{isSelectionMode ? (
|
|
<PlatformBatchActionToolbar>
|
|
<PlatformActionButton
|
|
tone="secondary"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedProjectIds(
|
|
allSelected
|
|
? new Set()
|
|
: new Set(projects.map((project) => project.projectId)),
|
|
);
|
|
}}
|
|
>
|
|
{allSelected ? (
|
|
<CheckSquare className="h-4 w-4" />
|
|
) : (
|
|
<Square className="h-4 w-4" />
|
|
)}
|
|
{selectedCount > 0
|
|
? `${allSelected ? '取消全选' : '全选'} · 已选 ${selectedCount}`
|
|
: '全选'}
|
|
</PlatformActionButton>
|
|
<PlatformActionButton
|
|
tone="warning"
|
|
size="sm"
|
|
disabled={selectedCount === 0}
|
|
onClick={() => deleteProjects([...selectedProjectIds])}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
删除
|
|
</PlatformActionButton>
|
|
<PlatformActionButton
|
|
tone="secondary"
|
|
size="sm"
|
|
onClick={closeSelectionMode}
|
|
>
|
|
取消
|
|
</PlatformActionButton>
|
|
</PlatformBatchActionToolbar>
|
|
) : null}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default ProjectGalleryView;
|