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([]); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); const [activeMenuProjectId, setActiveMenuProjectId] = useState( null, ); const [renameDraft, setRenameDraft] = useState(null); const [selectedProjectIds, setSelectedProjectIds] = useState>( () => new Set(), ); const [localCoverObjectUrls, setLocalCoverObjectUrls] = useState< ProjectCoverObjectUrl[] >([]); const localCoverObjectUrlsRef = useRef([]); 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 (
{!isSelectionMode ? (
} variant="surfaceFloating" className="h-8 w-8" onClick={(event) => { event.stopPropagation(); setActiveMenuProjectId((currentProjectId) => currentProjectId === project.projectId ? null : project.projectId, ); }} /> {activeMenuProjectId === project.projectId ? ( } onClick={() => setRenameDraft({ projectId: project.projectId, title: project.title, }) } > 重命名 } onClick={() => deleteProjects([project.projectId])} > 删除 ) : null}
) : null}
); }), [ activeMenuProjectId, authUi, deleteProjects, isSelectionMode, localCoverUrlByProjectId, onOpenProject, selectedProjectIds, toggleProjectSelection, visibleProjects, ], ); return (

项目

{projects.length} 个画布项目
setIsSelectionMode(true)} disabled={projects.length === 0} > 选择 新建
{errorMessage ? ( {errorMessage} ) : null} {isLoading ? ( 正在读取项目 ) : visibleProjects.length === 0 && normalizedSearchKeyword ? ( 没有匹配项目 ) : projects.length === 0 ? ( 新建项目 ) : (
{projectCards}
)} setRenameDraft(null)} footer={ <> setRenameDraft(null)} > 取消 保存 } > {renameDraft ? ( {isSelectionMode ? ( { setSelectedProjectIds( allSelected ? new Set() : new Set(projects.map((project) => project.projectId)), ); }} > {allSelected ? ( ) : ( )} {selectedCount > 0 ? `${allSelected ? '取消全选' : '全选'} · 已选 ${selectedCount}` : '全选'} deleteProjects([...selectedProjectIds])} > 删除 取消 ) : null}
); } export default ProjectGalleryView;