修复项目封面本地兜底
新增项目封面 IndexedDB 本地缓存。 封面 Blob 生成后先写入本地缓存,上传失败仍可展示。 项目列表优先服务端封面,其次本地缓存,再回退画布图层。 补充封面兜底测试和项目记忆文档。
This commit is contained in:
@@ -191,6 +191,14 @@
|
||||
- 验证:Network 中 `/api/editor/projects*`、`PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/common/PlatformMediaFrame.tsx`、`src/services/assetReadUrlService.ts`。
|
||||
|
||||
## 图片画布项目封面上传失败要有本地展示兜底
|
||||
|
||||
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
|
||||
- 原因:项目封面快照需要先在浏览器生成 Blob,再上传 OSS 并创建 `assetKind: "project-cover-snapshot"` 项目资源;本地 dev 或 OSS CORS 异常时,Blob 生成成功但上传失败,服务端不会产生正式封面资源。
|
||||
- 处理:服务端 `project-cover-snapshot` 仍是跨设备正式封面;前端在生成封面 Blob 后立即把 Blob 以项目 ID 写入 IndexedDB,仅作为当前浏览器展示兜底。项目列表读取时优先使用服务端封面资源,其次使用本地 IndexedDB 封面,最后才退回可见画布图层或占位。IndexedDB 兜底不得写入项目快照、不得进入 `editor_project_resource`,也不得替代 OSS / asset object 正式持久化。
|
||||
- 验证:`npm run test -- src/components/project/ProjectCanvasCover.test.ts src/components/project/ProjectGalleryView.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx` 覆盖服务端封面优先、本地缓存兜底、上传失败仍保留本地封面缓存;浏览器 smoke 可在 `/project` 对没有服务端封面的项目写入 `genarrative-editor-project-covers` IndexedDB 记录,刷新后应显示 `blob:` 封面图。
|
||||
- 关联:`src/services/image-editor/editorProjectCoverCache.ts`、`src/components/project/ProjectGalleryView.tsx`、`src/components/project/ProjectCanvasCover.tsx`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`。
|
||||
|
||||
## 图片画布框选预览要复用源图换签缓存
|
||||
|
||||
- 现象:UI 设计素材提取或快速编辑框选时,画布上的红色框选还在,但底部“框选区域预览”卡片变成空白。
|
||||
|
||||
@@ -16,6 +16,7 @@ const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const createProjectCoverSnapshotBlobMock = vi.hoisted(() => vi.fn());
|
||||
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const putEditorProjectCoverCacheMock = vi.hoisted(() => vi.fn());
|
||||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY =
|
||||
@@ -48,6 +49,10 @@ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectCoverCache', () => ({
|
||||
putEditorProjectCoverCache: putEditorProjectCoverCacheMock,
|
||||
}));
|
||||
|
||||
function createLayer(
|
||||
id: string,
|
||||
overrides: Partial<CanvasLayer> = {},
|
||||
@@ -167,7 +172,10 @@ function ProjectPersistenceHarness({
|
||||
</span>
|
||||
<span data-testid="generation-dialogs">
|
||||
{generationDialogs
|
||||
.map((dialog) => `${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`)
|
||||
.map(
|
||||
(dialog) =>
|
||||
`${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`,
|
||||
)
|
||||
.join(',')}
|
||||
</span>
|
||||
<button
|
||||
@@ -390,6 +398,7 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
createProjectCoverSnapshotBlobMock.mockResolvedValue(null);
|
||||
putEditorProjectCoverCacheMock.mockResolvedValue(undefined);
|
||||
uploadEditorMediaAssetFileMock.mockResolvedValue({
|
||||
src: 'https://signed.example.test/project-cover.png',
|
||||
objectKey:
|
||||
@@ -405,9 +414,7 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
expect(screen.getByTestId('project-title').textContent).toBe('空画布项目');
|
||||
expect(screen.getByTestId('project-rename').textContent).toBe(
|
||||
'空画布项目',
|
||||
);
|
||||
expect(screen.getByTestId('project-rename').textContent).toBe('空画布项目');
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'append' }).click();
|
||||
@@ -521,6 +528,12 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'editor-project-default',
|
||||
blob: expect.any(Blob),
|
||||
}),
|
||||
);
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'image',
|
||||
@@ -557,6 +570,66 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the local project cover cache when snapshot upload fails', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '本地封面画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-cover',
|
||||
resourceId: 'resource-cover-source',
|
||||
title: '封面源图',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-cover-source',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/generated/source.png',
|
||||
objectKey: 'generated/source.png',
|
||||
assetObjectId: 'asset-object-source',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
const coverBlob = new Blob(['cover'], { type: 'image/png' });
|
||||
createProjectCoverSnapshotBlobMock.mockResolvedValueOnce(coverBlob);
|
||||
uploadEditorMediaAssetFileMock.mockRejectedValueOnce(
|
||||
new TypeError('Failed to fetch'),
|
||||
);
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith({
|
||||
projectId: 'editor-project-default',
|
||||
blob: coverBlob,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(createEditorProjectResourceMock).not.toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
assetKind: 'project-cover-snapshot',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads and saves viewport scale with display zoom semantics', async () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import { putEditorProjectCoverCache } from '../../services/image-editor/editorProjectCoverCache';
|
||||
import {
|
||||
createEditorProjectResource,
|
||||
type EditorProjectResourceSnapshot,
|
||||
@@ -173,7 +180,10 @@ function writeEditorProjectSessionCache(project: EditorProjectSnapshot) {
|
||||
};
|
||||
try {
|
||||
const serialized = JSON.stringify(cached);
|
||||
storage.setItem(editorProjectSessionCacheKey(project.projectId), serialized);
|
||||
storage.setItem(
|
||||
editorProjectSessionCacheKey(project.projectId),
|
||||
serialized,
|
||||
);
|
||||
storage.setItem(EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, serialized);
|
||||
} catch {
|
||||
// ignore quota or privacy mode failures
|
||||
@@ -265,8 +275,9 @@ export function useImageCanvasProjectPersistence({
|
||||
const pendingProjectResourceLayersRef = useRef<PendingProjectResourceLayer[]>(
|
||||
[],
|
||||
);
|
||||
const pendingProjectLayoutSaveRef =
|
||||
useRef<PendingProjectLayoutSave | null>(null);
|
||||
const pendingProjectLayoutSaveRef = useRef<PendingProjectLayoutSave | null>(
|
||||
null,
|
||||
);
|
||||
const isProjectLayoutSaveRunningRef = useRef(false);
|
||||
const skipNextProjectLayoutSaveRef = useRef(false);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
@@ -360,6 +371,10 @@ export function useImageCanvasProjectPersistence({
|
||||
if (coverSnapshotUploadRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
void putEditorProjectCoverCache({
|
||||
projectId: nextProjectId,
|
||||
blob,
|
||||
});
|
||||
const coverFile = new File([blob], `${nextProjectId}-cover.png`, {
|
||||
type: 'image/png',
|
||||
});
|
||||
@@ -510,7 +525,9 @@ export function useImageCanvasProjectPersistence({
|
||||
setLayers(nextLayers);
|
||||
if (nextLayers.length) {
|
||||
queueProjectLayoutSave(readyProjectId, {
|
||||
viewport: viewportToCanvasDisplayViewport(refs.viewportRef.current),
|
||||
viewport: viewportToCanvasDisplayViewport(
|
||||
refs.viewportRef.current,
|
||||
),
|
||||
layers: serializeCanvasLayout({
|
||||
layers: nextLayers,
|
||||
canvasGenerationDialogs:
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import { resolveProjectCoverSnapshotResource } from './ProjectCanvasCover';
|
||||
import {
|
||||
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
|
||||
resolveProjectCoverResource,
|
||||
resolveProjectCoverSnapshotResource,
|
||||
} from './ProjectCanvasCover';
|
||||
|
||||
function project(
|
||||
resources: EditorProjectSnapshot['resources'],
|
||||
layers: EditorProjectSnapshot['layers'] = [],
|
||||
): EditorProjectSnapshot {
|
||||
return {
|
||||
projectId: 'editor-project-cover',
|
||||
title: '封面项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
layers,
|
||||
resources,
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
};
|
||||
@@ -118,3 +123,190 @@ describe('resolveProjectCoverSnapshotResource', () => {
|
||||
expect(cover?.resourceId).toBe('resource-snake-cover');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProjectCoverResource', () => {
|
||||
it('prefers the saved cover snapshot over canvas layer resources', () => {
|
||||
const cover = resolveProjectCoverResource(
|
||||
project(
|
||||
[
|
||||
{
|
||||
resourceId: 'resource-layer',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '/generated/layer.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-cover',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '/generated/cover.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'project-cover-snapshot',
|
||||
},
|
||||
],
|
||||
[{ layerId: 'layer-top', resourceId: 'resource-layer', zIndex: 9 }],
|
||||
),
|
||||
);
|
||||
|
||||
expect(cover?.resourceId).toBe('resource-cover');
|
||||
});
|
||||
|
||||
it('uses local cached covers only when no saved cover snapshot exists', () => {
|
||||
const cover = resolveProjectCoverResource(
|
||||
project([
|
||||
{
|
||||
resourceId: 'resource-local-cover',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: 'blob:local-cover',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
|
||||
updatedAt: '2026-06-12T09:10:00.000Z',
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-saved-cover',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '/generated/cover.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'project-cover-snapshot',
|
||||
updatedAt: '2026-06-12T09:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(cover?.resourceId).toBe('resource-saved-cover');
|
||||
|
||||
const localCover = resolveProjectCoverResource(
|
||||
project([
|
||||
{
|
||||
resourceId: 'resource-local-cover',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: 'blob:local-cover',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(localCover?.resourceId).toBe('resource-local-cover');
|
||||
});
|
||||
|
||||
it('falls back to the top visible canvas layer resource', () => {
|
||||
const cover = resolveProjectCoverResource(
|
||||
project(
|
||||
[
|
||||
{
|
||||
resourceId: 'resource-back',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '/generated/back.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-front',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '/generated/front.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ layerId: 'layer-back', resourceId: 'resource-back', zIndex: 1 },
|
||||
{ layerId: 'layer-front', resourceId: 'resource-front', zIndex: 3 },
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(cover?.resourceId).toBe('resource-front');
|
||||
});
|
||||
|
||||
it('ignores hidden and audio layers when falling back', () => {
|
||||
const cover = resolveProjectCoverResource(
|
||||
project(
|
||||
[
|
||||
{
|
||||
resourceId: 'resource-hidden',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '/generated/hidden.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-audio',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '/generated/audio.mp3',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-visible',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '/generated/visible.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
layerId: 'layer-hidden',
|
||||
resourceId: 'resource-hidden',
|
||||
hidden: true,
|
||||
zIndex: 9,
|
||||
},
|
||||
{
|
||||
layerId: 'layer-audio',
|
||||
resourceId: 'resource-audio',
|
||||
mediaType: 'audio',
|
||||
zIndex: 8,
|
||||
},
|
||||
{
|
||||
layerId: 'layer-visible',
|
||||
resourceId: 'resource-visible',
|
||||
zIndex: 1,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(cover?.resourceId).toBe('resource-visible');
|
||||
});
|
||||
|
||||
it('returns null when the project has no visible layer image resource', () => {
|
||||
const cover = resolveProjectCoverResource(
|
||||
project(
|
||||
[
|
||||
{
|
||||
resourceId: 'resource-hidden',
|
||||
projectId: 'editor-project-cover',
|
||||
imageSrc: '/generated/hidden.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
layerId: 'layer-hidden',
|
||||
resourceId: 'resource-hidden',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(cover).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
EditorProjectLayerSnapshot,
|
||||
EditorProjectResourceSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
@@ -6,12 +7,25 @@ import { PROJECT_COVER_SNAPSHOT_ASSET_KIND } from '../image-editor/ImageCanvasPr
|
||||
|
||||
const PROJECT_COVER_SNAPSHOT_ASSET_KIND_LEGACY =
|
||||
'editor_project_cover_snapshot';
|
||||
export const LOCAL_PROJECT_COVER_CACHE_ASSET_KIND = 'local-project-cover-cache';
|
||||
|
||||
type ProjectResourceSnakeKind = {
|
||||
asset_kind?: string | null;
|
||||
src?: string | null;
|
||||
};
|
||||
|
||||
type ProjectCoverResourceCandidate = EditorProjectResourceSnapshot & {
|
||||
imageSrc: string;
|
||||
};
|
||||
|
||||
function stringFromUnknown(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function numberFromUnknown(value: unknown, fallback: number) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function readProjectCoverSnapshotKind(resource: EditorProjectResourceSnapshot) {
|
||||
const legacyResource = resource as ProjectResourceSnakeKind;
|
||||
return (
|
||||
@@ -39,6 +53,31 @@ function hasProjectCoverSnapshotSource(
|
||||
return Boolean(resourceObjectKey || resourceImageSrc);
|
||||
}
|
||||
|
||||
function hasProjectCoverSource(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
): resource is ProjectCoverResourceCandidate {
|
||||
return hasProjectCoverSnapshotSource(resource);
|
||||
}
|
||||
|
||||
function isDedicatedProjectCoverSnapshot(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
) {
|
||||
return [
|
||||
PROJECT_COVER_SNAPSHOT_ASSET_KIND,
|
||||
PROJECT_COVER_SNAPSHOT_ASSET_KIND_LEGACY,
|
||||
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
|
||||
].includes(readProjectCoverSnapshotKind(resource));
|
||||
}
|
||||
|
||||
function isLocalProjectCoverCacheResource(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
) {
|
||||
return (
|
||||
readProjectCoverSnapshotKind(resource) ===
|
||||
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND
|
||||
);
|
||||
}
|
||||
|
||||
function projectCoverSnapshotObjectTimestamp(
|
||||
resource: EditorProjectResourceSnapshot,
|
||||
) {
|
||||
@@ -71,13 +110,16 @@ export function resolveProjectCoverSnapshotResource(
|
||||
[...project.resources]
|
||||
.filter(
|
||||
(resource) =>
|
||||
[
|
||||
PROJECT_COVER_SNAPSHOT_ASSET_KIND,
|
||||
PROJECT_COVER_SNAPSHOT_ASSET_KIND_LEGACY,
|
||||
].includes(readProjectCoverSnapshotKind(resource)) &&
|
||||
hasProjectCoverSnapshotSource(resource),
|
||||
isDedicatedProjectCoverSnapshot(resource) &&
|
||||
hasProjectCoverSource(resource),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
if (
|
||||
isLocalProjectCoverCacheResource(left) !==
|
||||
isLocalProjectCoverCacheResource(right)
|
||||
) {
|
||||
return isLocalProjectCoverCacheResource(left) ? 1 : -1;
|
||||
}
|
||||
const byUpdatedAt =
|
||||
projectCoverSnapshotSortScore(right) -
|
||||
projectCoverSnapshotSortScore(left);
|
||||
@@ -85,3 +127,48 @@ export function resolveProjectCoverSnapshotResource(
|
||||
})[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function readLayerResourceId(layer: EditorProjectLayerSnapshot) {
|
||||
return stringFromUnknown(layer.resourceId);
|
||||
}
|
||||
|
||||
function isLayerVisible(layer: EditorProjectLayerSnapshot) {
|
||||
return layer.hidden !== true && layer.mediaType !== 'audio';
|
||||
}
|
||||
|
||||
function layerCoverSortScore(layer: EditorProjectLayerSnapshot, index: number) {
|
||||
return {
|
||||
zIndex: numberFromUnknown(layer.zIndex, 0),
|
||||
index,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectCoverResource(project: EditorProjectSnapshot) {
|
||||
const snapshot = resolveProjectCoverSnapshotResource(project);
|
||||
if (snapshot) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const resourcesById = new Map(
|
||||
project.resources
|
||||
.filter((resource) => !isDedicatedProjectCoverSnapshot(resource))
|
||||
.filter(hasProjectCoverSource)
|
||||
.map((resource) => [resource.resourceId, resource]),
|
||||
);
|
||||
|
||||
return (
|
||||
project.layers
|
||||
.map((layer, index) => ({ layer, index }))
|
||||
.filter(({ layer }) => isLayerVisible(layer))
|
||||
.flatMap(({ layer, index }) => {
|
||||
const resource = resourcesById.get(readLayerResourceId(layer));
|
||||
return resource
|
||||
? [{ resource, score: layerCoverSortScore(layer, index) }]
|
||||
: [];
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const byZIndex = right.score.zIndex - left.score.zIndex;
|
||||
return byZIndex || right.score.index - left.score.index;
|
||||
})[0]?.resource ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ComponentProps, ContextType } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
@@ -13,6 +13,7 @@ const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const deleteEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const readEditorProjectCoverCacheMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
||||
|
||||
@@ -44,6 +45,10 @@ vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
||||
deleteEditorProject: deleteEditorProjectMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectCoverCache', () => ({
|
||||
readEditorProjectCoverCache: readEditorProjectCoverCacheMock,
|
||||
}));
|
||||
|
||||
const projectItems = [
|
||||
{
|
||||
projectId: 'editor-project-1',
|
||||
@@ -75,20 +80,22 @@ const projectItems = [
|
||||
function renderProjectGalleryView(
|
||||
props: Partial<ComponentProps<typeof ProjectGalleryView>> = {},
|
||||
) {
|
||||
return render(
|
||||
<ProjectGalleryView
|
||||
onOpenProject={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return render(<ProjectGalleryView onOpenProject={vi.fn()} {...props} />);
|
||||
}
|
||||
|
||||
describe('ProjectGalleryView', () => {
|
||||
beforeEach(() => {
|
||||
readEditorProjectCoverCacheMock.mockResolvedValue(new Map());
|
||||
URL.createObjectURL = vi.fn(() => 'blob:project-cover-cache');
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
listEditorProjectsMock.mockReset();
|
||||
createEditorProjectMock.mockReset();
|
||||
renameEditorProjectMock.mockReset();
|
||||
deleteEditorProjectMock.mockReset();
|
||||
readEditorProjectCoverCacheMock.mockReset();
|
||||
});
|
||||
|
||||
it('opens a project from the gallery card', async () => {
|
||||
@@ -99,7 +106,9 @@ describe('ProjectGalleryView', () => {
|
||||
renderProjectGalleryView({ onOpenProject });
|
||||
|
||||
await screen.findByText('角色设定板');
|
||||
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '打开项目角色设定板' }),
|
||||
);
|
||||
|
||||
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
|
||||
});
|
||||
@@ -168,9 +177,7 @@ describe('ProjectGalleryView', () => {
|
||||
|
||||
await screen.findByText('封面项目');
|
||||
|
||||
expect(
|
||||
document.querySelector('.project-gallery__canvas-cover'),
|
||||
).toBeNull();
|
||||
expect(document.querySelector('.project-gallery__canvas-cover')).toBeNull();
|
||||
expect(
|
||||
document
|
||||
.querySelector('.project-gallery__preview img')
|
||||
@@ -178,6 +185,115 @@ describe('ProjectGalleryView', () => {
|
||||
).toBe('data:image/png;base64,cover');
|
||||
});
|
||||
|
||||
it('falls back to a visible canvas layer image when no cover snapshot exists', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
{
|
||||
projectId: 'editor-project-layer-cover',
|
||||
title: '图层封面项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-back',
|
||||
resourceId: 'resource-back',
|
||||
title: '背景图',
|
||||
zIndex: 1,
|
||||
},
|
||||
{
|
||||
layerId: 'layer-front',
|
||||
resourceId: 'resource-front',
|
||||
title: '前景图',
|
||||
zIndex: 2,
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-front',
|
||||
projectId: 'editor-project-layer-cover',
|
||||
imageSrc: 'data:image/png;base64,front',
|
||||
width: 160,
|
||||
height: 120,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
{
|
||||
resourceId: 'resource-back',
|
||||
projectId: 'editor-project-layer-cover',
|
||||
imageSrc: 'data:image/png;base64,back',
|
||||
width: 300,
|
||||
height: 180,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T08:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
renderProjectGalleryView();
|
||||
|
||||
await screen.findByText('图层封面项目');
|
||||
|
||||
expect(
|
||||
document
|
||||
.querySelector('.project-gallery__preview img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('data:image/png;base64,front');
|
||||
});
|
||||
|
||||
it('uses locally cached cover snapshots before falling back to canvas layers', async () => {
|
||||
readEditorProjectCoverCacheMock.mockResolvedValueOnce(
|
||||
new Map([
|
||||
[
|
||||
'editor-project-local-cover',
|
||||
{
|
||||
projectId: 'editor-project-local-cover',
|
||||
blob: new Blob(['cover'], { type: 'image/png' }),
|
||||
updatedAt: '2026-06-12T09:30:00.000Z',
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
listEditorProjectsMock.mockResolvedValueOnce([
|
||||
{
|
||||
projectId: 'editor-project-local-cover',
|
||||
title: '本地封面项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-front',
|
||||
resourceId: 'resource-front',
|
||||
title: '前景图',
|
||||
zIndex: 2,
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-front',
|
||||
projectId: 'editor-project-local-cover',
|
||||
imageSrc: 'data:image/png;base64,front',
|
||||
width: 160,
|
||||
height: 120,
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T08:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
renderProjectGalleryView();
|
||||
|
||||
await screen.findByText('本地封面项目');
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document
|
||||
.querySelector('.project-gallery__preview img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('blob:project-cover-cache');
|
||||
});
|
||||
expect(readEditorProjectCoverCacheMock).toHaveBeenCalledWith([
|
||||
'editor-project-local-cover',
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens the login modal when project list loading is unauthorized', async () => {
|
||||
const openLoginModal = vi.fn();
|
||||
listEditorProjectsMock.mockRejectedValueOnce(
|
||||
@@ -190,9 +306,7 @@ describe('ProjectGalleryView', () => {
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
|
||||
<ProjectGalleryView
|
||||
onOpenProject={vi.fn()}
|
||||
/>
|
||||
<ProjectGalleryView onOpenProject={vi.fn()} />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
@@ -211,14 +325,14 @@ describe('ProjectGalleryView', () => {
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue({ requireAuth })}>
|
||||
<ProjectGalleryView
|
||||
onOpenProject={onOpenProject}
|
||||
/>
|
||||
<ProjectGalleryView onOpenProject={onOpenProject} />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
await screen.findByText('角色设定板');
|
||||
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '打开项目角色设定板' }),
|
||||
);
|
||||
|
||||
expect(requireAuth).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(onOpenProject).not.toHaveBeenCalled();
|
||||
@@ -248,7 +362,9 @@ describe('ProjectGalleryView', () => {
|
||||
renderProjectGalleryView();
|
||||
|
||||
await screen.findByText('角色设定板');
|
||||
await user.click(screen.getByRole('button', { name: '打开项目角色设定板菜单' }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '打开项目角色设定板菜单' }),
|
||||
);
|
||||
await user.click(screen.getByRole('menuitem', { name: /重命名/u }));
|
||||
await user.clear(screen.getByLabelText('项目名称'));
|
||||
await user.type(screen.getByLabelText('项目名称'), '新角色设定板');
|
||||
@@ -260,7 +376,9 @@ describe('ProjectGalleryView', () => {
|
||||
);
|
||||
await screen.findByText('新角色设定板');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开项目场景草图菜单' }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '打开项目场景草图菜单' }),
|
||||
);
|
||||
await user.click(screen.getByRole('menuitem', { name: /删除/u }));
|
||||
|
||||
expect(deleteEditorProjectMock).toHaveBeenCalledWith('editor-project-2');
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Square,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
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';
|
||||
@@ -30,7 +31,10 @@ import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { resolveProjectCoverSnapshotResource } from './ProjectCanvasCover';
|
||||
import {
|
||||
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
|
||||
resolveProjectCoverResource,
|
||||
} from './ProjectCanvasCover';
|
||||
|
||||
type ProjectGalleryViewProps = {
|
||||
onOpenProject: (projectId: string) => void;
|
||||
@@ -41,6 +45,12 @@ type RenameDraft = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
type ProjectCoverObjectUrl = {
|
||||
projectId: string;
|
||||
url: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function isUnauthorizedError(error: unknown) {
|
||||
return error instanceof ApiClientError && error.status === 401;
|
||||
}
|
||||
@@ -58,9 +68,7 @@ function formatProjectUpdatedAt(value: string) {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function ProjectGalleryView({
|
||||
onOpenProject,
|
||||
}: ProjectGalleryViewProps) {
|
||||
export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -72,19 +80,60 @@ export function ProjectGalleryView({
|
||||
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 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));
|
||||
const availableIds = new Set(
|
||||
items.map((project) => project.projectId),
|
||||
);
|
||||
return new Set(
|
||||
[...currentIds].filter((projectId) => availableIds.has(projectId)),
|
||||
);
|
||||
@@ -100,7 +149,16 @@ export function ProjectGalleryView({
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [authUi]);
|
||||
}, [authUi, replaceLocalCoverObjectUrls]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
localCoverObjectUrlsRef.current.forEach((cover) =>
|
||||
URL.revokeObjectURL(cover.url),
|
||||
);
|
||||
localCoverObjectUrlsRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshProjects();
|
||||
@@ -138,34 +196,33 @@ export function ProjectGalleryView({
|
||||
});
|
||||
}, []);
|
||||
|
||||
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 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) {
|
||||
@@ -197,7 +254,28 @@ export function ProjectGalleryView({
|
||||
() =>
|
||||
projects.map((project) => {
|
||||
const selected = selectedProjectIds.has(project.projectId);
|
||||
const coverSnapshot = resolveProjectCoverSnapshotResource(project);
|
||||
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}
|
||||
@@ -225,16 +303,16 @@ export function ProjectGalleryView({
|
||||
aria-label={`打开项目${project.title}`}
|
||||
>
|
||||
<PlatformMediaFrame
|
||||
src={coverSnapshot?.imageSrc ?? null}
|
||||
objectKey={coverSnapshot?.objectKey ?? null}
|
||||
src={coverResource?.imageSrc ?? null}
|
||||
objectKey={coverResource?.objectKey ?? null}
|
||||
alt=""
|
||||
fallbackLabel="项目"
|
||||
aspect="standard"
|
||||
surface="bright"
|
||||
refreshKey={
|
||||
coverSnapshot?.updatedAt ??
|
||||
coverSnapshot?.createdAt ??
|
||||
coverSnapshot?.resourceId ??
|
||||
coverResource?.updatedAt ??
|
||||
coverResource?.createdAt ??
|
||||
coverResource?.resourceId ??
|
||||
null
|
||||
}
|
||||
className="project-gallery__preview"
|
||||
@@ -297,6 +375,7 @@ export function ProjectGalleryView({
|
||||
authUi,
|
||||
deleteProjects,
|
||||
isSelectionMode,
|
||||
localCoverUrlByProjectId,
|
||||
onOpenProject,
|
||||
projects,
|
||||
selectedProjectIds,
|
||||
@@ -372,7 +451,10 @@ export function ProjectGalleryView({
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton type="submit" form="project-gallery-rename-form">
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
form="project-gallery-rename-form"
|
||||
>
|
||||
保存
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
@@ -433,7 +515,11 @@ export function ProjectGalleryView({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton tone="secondary" size="sm" onClick={closeSelectionMode}>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={closeSelectionMode}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
</PlatformBatchActionToolbar>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
const PROJECT_COVER_CACHE_DB_NAME = 'genarrative-editor-project-covers';
|
||||
const PROJECT_COVER_CACHE_DB_VERSION = 1;
|
||||
const PROJECT_COVER_CACHE_STORE_NAME = 'covers';
|
||||
const PROJECT_COVER_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type CachedEditorProjectCover = {
|
||||
projectId: string;
|
||||
blob: Blob;
|
||||
updatedAt: string;
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
function canUseIndexedDb() {
|
||||
return typeof indexedDB !== 'undefined';
|
||||
}
|
||||
|
||||
function idbRequestToPromise<T>(request: IDBRequest<T>) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
request.addEventListener('success', () => resolve(request.result));
|
||||
request.addEventListener('error', () => {
|
||||
reject(request.error ?? new Error('IndexedDB request failed'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openProjectCoverCacheDb() {
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
if (!canUseIndexedDb()) {
|
||||
reject(new Error('IndexedDB unavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(
|
||||
PROJECT_COVER_CACHE_DB_NAME,
|
||||
PROJECT_COVER_CACHE_DB_VERSION,
|
||||
);
|
||||
request.addEventListener('upgradeneeded', () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(PROJECT_COVER_CACHE_STORE_NAME)) {
|
||||
db.createObjectStore(PROJECT_COVER_CACHE_STORE_NAME, {
|
||||
keyPath: 'projectId',
|
||||
});
|
||||
}
|
||||
});
|
||||
request.addEventListener('success', () => resolve(request.result));
|
||||
request.addEventListener('error', () => {
|
||||
reject(request.error ?? new Error('IndexedDB open failed'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function withProjectCoverCacheStore<T>(
|
||||
mode: IDBTransactionMode,
|
||||
run: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
) {
|
||||
const db = await openProjectCoverCacheDb();
|
||||
try {
|
||||
const transaction = db.transaction(PROJECT_COVER_CACHE_STORE_NAME, mode);
|
||||
const result = await idbRequestToPromise(
|
||||
run(transaction.objectStore(PROJECT_COVER_CACHE_STORE_NAME)),
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
transaction.addEventListener('complete', () => resolve());
|
||||
transaction.addEventListener('abort', () => {
|
||||
reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
|
||||
});
|
||||
transaction.addEventListener('error', () => {
|
||||
reject(transaction.error ?? new Error('IndexedDB transaction failed'));
|
||||
});
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function isFreshCachedCover(
|
||||
cover: CachedEditorProjectCover | undefined,
|
||||
): cover is CachedEditorProjectCover {
|
||||
return Boolean(
|
||||
cover?.projectId &&
|
||||
cover.blob instanceof Blob &&
|
||||
cover.cachedAt > Date.now() - PROJECT_COVER_CACHE_MAX_AGE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function putEditorProjectCoverCache({
|
||||
projectId,
|
||||
blob,
|
||||
updatedAt = new Date().toISOString(),
|
||||
}: {
|
||||
projectId: string;
|
||||
blob: Blob;
|
||||
updatedAt?: string;
|
||||
}) {
|
||||
const normalizedProjectId = projectId.trim();
|
||||
if (!normalizedProjectId || !canUseIndexedDb()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withProjectCoverCacheStore('readwrite', (store) =>
|
||||
store.put({
|
||||
projectId: normalizedProjectId,
|
||||
blob,
|
||||
updatedAt,
|
||||
cachedAt: Date.now(),
|
||||
} satisfies CachedEditorProjectCover),
|
||||
);
|
||||
} catch {
|
||||
// IndexedDB is best-effort local fallback only.
|
||||
}
|
||||
}
|
||||
|
||||
export async function readEditorProjectCoverCache(
|
||||
projectIds: readonly string[],
|
||||
) {
|
||||
const normalizedProjectIds = [
|
||||
...new Set(projectIds.map((id) => id.trim())),
|
||||
].filter(Boolean);
|
||||
if (!normalizedProjectIds.length || !canUseIndexedDb()) {
|
||||
return new Map<string, CachedEditorProjectCover>();
|
||||
}
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await openProjectCoverCacheDb();
|
||||
const transaction = db.transaction(
|
||||
PROJECT_COVER_CACHE_STORE_NAME,
|
||||
'readonly',
|
||||
);
|
||||
const store = transaction.objectStore(PROJECT_COVER_CACHE_STORE_NAME);
|
||||
const covers = await Promise.all(
|
||||
normalizedProjectIds.map((projectId) =>
|
||||
idbRequestToPromise(
|
||||
store.get(projectId) as IDBRequest<
|
||||
CachedEditorProjectCover | undefined
|
||||
>,
|
||||
),
|
||||
),
|
||||
);
|
||||
return new Map(
|
||||
covers
|
||||
.filter(isFreshCachedCover)
|
||||
.map((cover) => [cover.projectId, cover]),
|
||||
);
|
||||
} catch {
|
||||
return new Map<string, CachedEditorProjectCover>();
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user