Merge branch 'codex/editor-asset-library' of https://git.genarrative.world/GenarrativeAI/Genarrative into codex/editor-asset-library
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from './App';
|
||||
|
||||
vi.mock('./components/platform-entry/PlatformEntryFlowShell', async () => {
|
||||
const React = await import('react');
|
||||
type PlatformEntryFlowShellProps = ComponentProps<
|
||||
typeof import('./components/platform-entry/PlatformEntryFlowShell').PlatformEntryFlowShell
|
||||
>;
|
||||
|
||||
return {
|
||||
PlatformEntryFlowShell: ({ setSelectionStage }: PlatformEntryFlowShellProps) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
setSelectionStage('image-editor', {
|
||||
path: '/editor/canvas?projectid=project-from-test',
|
||||
});
|
||||
},
|
||||
},
|
||||
'打开最近项目',
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./RpgRuntimeApp', () => ({
|
||||
RpgRuntimeApp: () => <div>运行态</div>,
|
||||
}));
|
||||
|
||||
describe('App navigation history', () => {
|
||||
it('keeps project canvas navigation as one history entry with projectid', async () => {
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
const pushStateSpy = vi.spyOn(window.history, 'pushState');
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<App />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开最近项目' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/editor/canvas');
|
||||
expect(window.location.search).toBe('?projectid=project-from-test');
|
||||
});
|
||||
expect(pushStateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pushStateSpy).toHaveBeenCalledWith(
|
||||
null,
|
||||
'',
|
||||
'/editor/canvas?projectid=project-from-test',
|
||||
);
|
||||
|
||||
window.history.back();
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/creation');
|
||||
expect(window.location.search).toBe('');
|
||||
});
|
||||
|
||||
pushStateSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+7
-4
@@ -67,10 +67,13 @@ export default function App() {
|
||||
readPublicWorkCodeFromLocationSearch(window.location.search),
|
||||
);
|
||||
|
||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(resolvePathForSelectionStage(stage));
|
||||
}, []);
|
||||
const setSelectionStage = useCallback(
|
||||
(stage: SelectionStage, options?: { path?: string }) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(options?.path ?? resolvePathForSelectionStage(stage));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const syncStageFromHistory = () => {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ContextType } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { CreationLandingView } from './CreationLandingView';
|
||||
|
||||
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectMock = vi.hoisted(() => vi.fn());
|
||||
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
||||
|
||||
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn((action: () => void) => action()),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
musicVolume: 0.5,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', () => ({
|
||||
listEditorProjects: listEditorProjectsMock,
|
||||
createEditorProject: createEditorProjectMock,
|
||||
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
||||
}));
|
||||
|
||||
const projectItems = [
|
||||
{
|
||||
projectId: 'project-newest',
|
||||
title: '最新项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
projectId: 'project-old',
|
||||
title: '旧项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-17T10:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
type RenderCreationLandingOptions = {
|
||||
authValue?: AuthValue | null;
|
||||
publicGalleryEntries?: PlatformPublicGalleryCard[];
|
||||
};
|
||||
|
||||
function renderCreationLanding({
|
||||
authValue = createAuthValue(),
|
||||
publicGalleryEntries = [],
|
||||
}: RenderCreationLandingOptions = {}) {
|
||||
const result = render(
|
||||
<AuthUiContext.Provider value={authValue}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
onOpenCommunity={vi.fn()}
|
||||
publicGalleryEntries={publicGalleryEntries}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('CreationLandingView', () => {
|
||||
afterEach(() => {
|
||||
listEditorProjectsMock.mockReset();
|
||||
createEditorProjectMock.mockReset();
|
||||
loadEditorAssetLibraryMock.mockReset();
|
||||
});
|
||||
|
||||
it('renders the Taonier creation landing modules without external branding', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
expect(
|
||||
screen.getByRole('main', { name: '陶泥儿创作主页' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
name: '陶泥儿 - 开启全民精品游戏创作',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('登录即送100泥点,可以免费制作50个素材')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '创作工具' })).toBeTruthy();
|
||||
expect(screen.getByText('游戏视觉规范')).toBeTruthy();
|
||||
expect(screen.getByRole('heading', { name: '陶泥儿精选' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '素材包' })).toBeTruthy();
|
||||
expect(screen.queryByText('精选入口')).toBeNull();
|
||||
expect(screen.queryByText(/Meowa|Discord/u)).toBeNull();
|
||||
expect(await screen.findByText('暂无素材')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows recent projects only for logged in users', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenProject = vi.fn();
|
||||
const onOpenProjects = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<CreationLandingView
|
||||
onOpenProject={onOpenProject}
|
||||
onOpenProjects={onOpenProjects}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '最近项目' })).toBeTruthy();
|
||||
expect(screen.getByText('最新项目')).toBeTruthy();
|
||||
expect(screen.getByText('旧项目')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '查看全部' }));
|
||||
await user.click(screen.getByRole('button', { name: /最新项目/u }));
|
||||
|
||||
expect(onOpenProjects).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenProject).toHaveBeenCalledWith('project-newest');
|
||||
});
|
||||
|
||||
it('hides recent projects and opens login when an anonymous user starts creation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openLoginModal = vi.fn();
|
||||
|
||||
renderCreationLanding({
|
||||
authValue: createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('heading', { name: '最近项目' })).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: /开始创作/u }));
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(createEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a new project and opens it from the hero action', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenProject = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
createEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'created-project',
|
||||
title: '未命名画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<CreationLandingView
|
||||
onOpenProject={onOpenProject}
|
||||
onOpenProjects={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /开始创作/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onOpenProject).toHaveBeenCalledWith('created-project');
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the community entry from the hero without starting project creation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenCommunity = vi.fn();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [], assets: [] });
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
onOpenCommunity={onOpenCommunity}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '玩家社区' }));
|
||||
|
||||
expect(onOpenCommunity).toHaveBeenCalledTimes(1);
|
||||
expect(createEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders real account assets in showcase tabs without mock rows', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-character',
|
||||
folderId: 'folder-1',
|
||||
label: '角色英雄',
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: 'character hero prompt',
|
||||
actualPrompt: null,
|
||||
model: 'character-model',
|
||||
provider: 'provider',
|
||||
taskId: 'task-1',
|
||||
authorName: '创作者A',
|
||||
priceMudPoints: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderCreationLanding();
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '角色' }));
|
||||
|
||||
const assetCard = await screen.findByText('角色英雄');
|
||||
const card = assetCard.closest('.creation-landing__asset-card');
|
||||
expect(card).toBeTruthy();
|
||||
expect(within(card as HTMLElement).getByText('character hero prompt')).toBeTruthy();
|
||||
expect(within(card as HTMLElement).getByText('创作者A')).toBeTruthy();
|
||||
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses Taonier featured as the account asset waterfall instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [],
|
||||
assets: [
|
||||
{
|
||||
assetId: 'asset-pack-a',
|
||||
folderId: 'folder-1',
|
||||
label: '素材包 A',
|
||||
imageSrc: 'data:image/png;base64,a',
|
||||
width: 512,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
prompt: '素材包提示词',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
{
|
||||
assetId: 'asset-pack-b',
|
||||
folderId: 'folder-1',
|
||||
label: '素材包 B',
|
||||
imageSrc: 'data:image/png;base64,b',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: '第二组素材',
|
||||
actualPrompt: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
taskId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { container } = renderCreationLanding();
|
||||
|
||||
expect(await screen.findByText('素材包 A')).toBeTruthy();
|
||||
expect(screen.getByText('素材包 B')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByLabelText('用户素材瀑布流').className,
|
||||
).toContain('creation-landing__asset-waterfall');
|
||||
const firstPreview = container.querySelector(
|
||||
'.creation-landing__asset-preview',
|
||||
) as HTMLElement | null;
|
||||
expect(firstPreview?.style.aspectRatio).toBe('512 / 640');
|
||||
expect(screen.queryByText('经典 RPG 体验')).toBeNull();
|
||||
expect(screen.queryByText('拼图关卡创作')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to real public user assets for the featured waterfall', async () => {
|
||||
const publicAsset = {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'work-public-asset',
|
||||
profileId: 'profile-public-asset',
|
||||
publicWorkCode: 'PZ-PUBLIC-ASSET',
|
||||
ownerUserId: 'owner-public-asset',
|
||||
authorDisplayName: '公开作者',
|
||||
worldName: '公开素材组合',
|
||||
subtitle: '公开素材副标题',
|
||||
summaryText: '公开用户生成素材提示词',
|
||||
coverImageSrc: 'https://assets.example.test/public-asset.png',
|
||||
coverSlides: [
|
||||
{
|
||||
id: 'slide-1',
|
||||
imageSrc: 'https://assets.example.test/public-asset-slide.png',
|
||||
label: '公开素材图',
|
||||
},
|
||||
],
|
||||
themeTags: ['素材包'],
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-18T09:00:00.000Z',
|
||||
updatedAt: '2026-06-18T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const { container } = renderCreationLanding({
|
||||
authValue: createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
}),
|
||||
publicGalleryEntries: [publicAsset],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('公开素材组合')).toBeTruthy();
|
||||
const card = screen
|
||||
.getByText('公开素材组合')
|
||||
.closest('.creation-landing__asset-card');
|
||||
expect(card).toBeTruthy();
|
||||
expect(
|
||||
within(card as HTMLElement).getByText('公开用户生成素材提示词'),
|
||||
).toBeTruthy();
|
||||
expect(within(card as HTMLElement).getByText('公开作者')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.creation-landing__asset-waterfall'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('暂无素材')).toBeNull();
|
||||
expect(screen.queryByText('精选入口')).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { Image as ImageIcon, Loader2 } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
@@ -534,6 +534,7 @@ import {
|
||||
PlatformEntryHomeView,
|
||||
type PlatformHomeTab,
|
||||
} from './PlatformEntryHomeView';
|
||||
import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
|
||||
import { usePlatformDesktopLayout } from './platformEntryResponsive';
|
||||
import {
|
||||
buildCreationHubFallbackItems,
|
||||
@@ -1285,6 +1286,13 @@ const ProjectGalleryView = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const CreationLandingView = lazy(async () => {
|
||||
const module = await import('../creation-home/CreationLandingView');
|
||||
return {
|
||||
default: module.CreationLandingView,
|
||||
};
|
||||
});
|
||||
|
||||
const UnifiedCreationWorkspace = lazy(async () => {
|
||||
const module = await import('../unified-creation/UnifiedCreationWorkspace');
|
||||
return {
|
||||
@@ -1620,6 +1628,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
: 'platform-theme--light';
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||
const [isCreationCommunityOpen, setIsCreationCommunityOpen] =
|
||||
useState(false);
|
||||
const [draftGenerationPointNotice, setDraftGenerationPointNotice] =
|
||||
useState<DraftGenerationPointNotice | null>(null);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
@@ -3597,6 +3607,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
const openCreationTypePicker = useCallback(() => {
|
||||
setSelectionStage('creation-home');
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const openProjectGallery = useCallback(() => {
|
||||
setSelectionStage('project');
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const openCreationCommunity = useCallback(() => {
|
||||
setIsCreationCommunityOpen(true);
|
||||
}, []);
|
||||
|
||||
const openEditorProject = useCallback((projectId: string) => {
|
||||
setSelectionStage('image-editor', {
|
||||
path: `/editor/canvas?projectid=${encodeURIComponent(projectId)}`,
|
||||
});
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const openCreationTypeModal = useCallback(() => {
|
||||
if (!prepareCreationLaunch()) {
|
||||
return;
|
||||
}
|
||||
@@ -15129,29 +15157,164 @@ export function PlatformEntryFlowShellImpl({
|
||||
) : null}
|
||||
</Suspense>
|
||||
);
|
||||
const creationStartContent = (
|
||||
<div className="image-editor-creation-entry-stack">
|
||||
<button
|
||||
type="button"
|
||||
className="image-editor-creation-entry"
|
||||
onClick={() => setSelectionStage('image-editor')}
|
||||
aria-label="打开图片编辑器"
|
||||
>
|
||||
<span className="image-editor-creation-entry__icon">
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="image-editor-creation-entry__body">
|
||||
<span>图片编辑器</span>
|
||||
<span>画布工具</span>
|
||||
</span>
|
||||
</button>
|
||||
{renderCreationHubContent('start-only', '正在加载创作大厅...')}
|
||||
</div>
|
||||
const creationStartContent = renderCreationHubContent(
|
||||
'start-only',
|
||||
'正在加载创作大厅...',
|
||||
);
|
||||
const draftHubContent = renderCreationHubContent(
|
||||
'works-only',
|
||||
'正在加载草稿列表...',
|
||||
);
|
||||
const creationLandingContent = (
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载创作主页..." />}>
|
||||
<CreationLandingView
|
||||
onOpenProject={openEditorProject}
|
||||
onOpenProjects={openProjectGallery}
|
||||
onOpenCommunity={openCreationCommunity}
|
||||
publicGalleryEntries={[
|
||||
...featuredGalleryEntries,
|
||||
...latestGalleryEntries,
|
||||
]}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
const handlePlatformHomeTabChange = useCallback(
|
||||
(tab: PlatformHomeTab) => {
|
||||
platformBootstrap.setPlatformTab(tab);
|
||||
if (selectionStage === 'creation-home' && tab !== 'create') {
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
}
|
||||
},
|
||||
[platformBootstrap, selectionStage, setSelectionStage],
|
||||
);
|
||||
const platformHomeView = (
|
||||
<PlatformEntryHomeView
|
||||
activeTab={
|
||||
selectionStage === 'creation-home'
|
||||
? 'create'
|
||||
: platformBootstrap.platformTab
|
||||
}
|
||||
onTabChange={handlePlatformHomeTabChange}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={platformBootstrap.saveEntries}
|
||||
saveError={platformBootstrap.saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={latestGalleryEntries}
|
||||
myEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
historyEntries={platformBootstrap.historyEntries}
|
||||
profileDashboard={platformBootstrap.profileDashboard}
|
||||
isLoadingPlatform={platformBootstrap.isLoadingPlatform}
|
||||
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
|
||||
hasUnreadDraftUpdate={hasUnreadDraftUpdates}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
profileGenerationQueueStatus={profileExternalGenerationQueueStatus}
|
||||
isDesktopLayout={isDesktopLayout}
|
||||
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
|
||||
platformError={
|
||||
platformBootstrap.isLoadingPlatform
|
||||
? null
|
||||
: (platformBootstrapErrorForDisplay ??
|
||||
sessionController.agentWorkspaceRestoreError)
|
||||
}
|
||||
dashboardError={
|
||||
platformBootstrap.isLoadingDashboard
|
||||
? null
|
||||
: platformBootstrap.dashboardError
|
||||
}
|
||||
createTabContent={
|
||||
selectionStage === 'creation-home'
|
||||
? creationLandingContent
|
||||
: creationStartContent
|
||||
}
|
||||
draftTabContent={draftHubContent}
|
||||
onContinueGame={handleContinueGame}
|
||||
onResumeSave={(entry) => {
|
||||
if (
|
||||
(entry.worldType ?? '').toLowerCase() === 'puzzle' ||
|
||||
entry.worldKey.startsWith('puzzle:')
|
||||
) {
|
||||
void resumePuzzleSaveArchive(entry);
|
||||
return;
|
||||
}
|
||||
void platformBootstrap.handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCreationTypeModal}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={openPublicGalleryDetail}
|
||||
onOpenChildMotionDemo={startChildMotionDemo}
|
||||
onOpenBabyLoveDrawing={startBabyLoveDrawingRuntime}
|
||||
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
||||
recommendRuntimeContent={recommendRuntimeContent}
|
||||
activeRecommendEntryKey={activeRecommendEntryKey}
|
||||
isRecommendRuntimeReady={isActiveRecommendRuntimeReady}
|
||||
isStartingRecommendEntry={
|
||||
isStartingRecommendEntry ||
|
||||
isBigFishBusy ||
|
||||
(isPuzzleBusy &&
|
||||
!(activeRecommendRuntimeKind === 'puzzle' && puzzleRun)) ||
|
||||
(isPuzzleClearBusy &&
|
||||
!(
|
||||
activeRecommendRuntimeKind === 'puzzle-clear' &&
|
||||
puzzleClearRun
|
||||
)) ||
|
||||
isMatch3DBusy ||
|
||||
isSquareHoleBusy ||
|
||||
isVisualNovelBusy ||
|
||||
isWoodenFishBusy
|
||||
}
|
||||
recommendRuntimeError={activeRecommendRuntimeError}
|
||||
onSelectNextRecommendEntry={(activeEntryKey) =>
|
||||
selectAdjacentRecommendRuntimeEntry(1, activeEntryKey)
|
||||
}
|
||||
onSelectPreviousRecommendEntry={(activeEntryKey) =>
|
||||
selectAdjacentRecommendRuntimeEntry(-1, activeEntryKey)
|
||||
}
|
||||
onLikeRecommendEntry={(entry) => {
|
||||
likePublicWork(entry);
|
||||
}}
|
||||
onShareRecommendEntry={(entry) => {
|
||||
openRecommendShareModal(entry);
|
||||
}}
|
||||
onRemixRecommendEntry={(entry) => {
|
||||
remixPublicWork(entry);
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onDeleteLibraryEntry={(entry) => {
|
||||
handleDeleteLibraryEntry(entry);
|
||||
}}
|
||||
deletingLibraryEntryId={deletingCreationWorkId}
|
||||
onSearchPublicCode={(keyword) => {
|
||||
void handlePublicCodeSearch(keyword);
|
||||
}}
|
||||
isSearchingPublicCode={isSearchingPublicCode}
|
||||
profilePlayStats={profilePlayStats}
|
||||
isProfilePlayStatsOpen={isProfilePlayStatsOpen}
|
||||
isProfilePlayStatsLoading={isProfilePlayStatsLoading}
|
||||
profilePlayStatsError={profilePlayStatsError}
|
||||
onCloseProfilePlayStats={() => {
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
}}
|
||||
onOpenPlayedWork={openPlayedWork}
|
||||
onOpenFeedback={openProfileFeedback}
|
||||
onOpenProjects={openProjectGallery}
|
||||
onOpenProfileDashboardCard={(cardKey) => {
|
||||
if (cardKey === 'playedWorks') {
|
||||
openProfilePlayedWorks();
|
||||
return;
|
||||
}
|
||||
if (platformBootstrap.dashboardError) {
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
}
|
||||
}}
|
||||
onRechargeSuccess={platformBootstrap.refreshProfileDashboard}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -15181,16 +15344,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
>
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载项目..." />}>
|
||||
<ProjectGalleryView
|
||||
onOpenProject={(projectId) => {
|
||||
setSelectionStage('image-editor');
|
||||
pushAppHistoryPath(
|
||||
`/editor/canvas?projectid=${encodeURIComponent(projectId)}`,
|
||||
);
|
||||
}}
|
||||
onOpenProject={openEditorProject}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
{selectionStage === 'creation-home' && (
|
||||
<motion.div
|
||||
key="creation-home"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
{platformHomeView}
|
||||
</motion.div>
|
||||
)}
|
||||
{selectionStage === 'platform' && (
|
||||
<motion.div
|
||||
key="platform-home"
|
||||
@@ -15199,123 +15368,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
<PlatformEntryHomeView
|
||||
activeTab={platformBootstrap.platformTab}
|
||||
onTabChange={platformBootstrap.setPlatformTab}
|
||||
hasSavedGame={hasSavedGame}
|
||||
savedSnapshot={savedSnapshot}
|
||||
saveEntries={platformBootstrap.saveEntries}
|
||||
saveError={platformBootstrap.saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={latestGalleryEntries}
|
||||
myEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
historyEntries={platformBootstrap.historyEntries}
|
||||
profileDashboard={platformBootstrap.profileDashboard}
|
||||
isLoadingPlatform={platformBootstrap.isLoadingPlatform}
|
||||
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
|
||||
hasUnreadDraftUpdate={hasUnreadDraftUpdates}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
profileGenerationQueueStatus={profileExternalGenerationQueueStatus}
|
||||
isDesktopLayout={isDesktopLayout}
|
||||
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
|
||||
platformError={
|
||||
platformBootstrap.isLoadingPlatform
|
||||
? null
|
||||
: (platformBootstrapErrorForDisplay ??
|
||||
sessionController.agentWorkspaceRestoreError)
|
||||
}
|
||||
dashboardError={
|
||||
platformBootstrap.isLoadingDashboard
|
||||
? null
|
||||
: platformBootstrap.dashboardError
|
||||
}
|
||||
createTabContent={creationStartContent}
|
||||
draftTabContent={draftHubContent}
|
||||
onContinueGame={handleContinueGame}
|
||||
onResumeSave={(entry) => {
|
||||
if (
|
||||
(entry.worldType ?? '').toLowerCase() === 'puzzle' ||
|
||||
entry.worldKey.startsWith('puzzle:')
|
||||
) {
|
||||
void resumePuzzleSaveArchive(entry);
|
||||
return;
|
||||
}
|
||||
void platformBootstrap.handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCreationTypePicker}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={openPublicGalleryDetail}
|
||||
onOpenChildMotionDemo={startChildMotionDemo}
|
||||
onOpenBabyLoveDrawing={startBabyLoveDrawingRuntime}
|
||||
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
|
||||
recommendRuntimeContent={recommendRuntimeContent}
|
||||
activeRecommendEntryKey={activeRecommendEntryKey}
|
||||
isRecommendRuntimeReady={isActiveRecommendRuntimeReady}
|
||||
isStartingRecommendEntry={
|
||||
isStartingRecommendEntry ||
|
||||
isBigFishBusy ||
|
||||
(isPuzzleBusy &&
|
||||
!(activeRecommendRuntimeKind === 'puzzle' && puzzleRun)) ||
|
||||
(isPuzzleClearBusy &&
|
||||
!(
|
||||
activeRecommendRuntimeKind === 'puzzle-clear' &&
|
||||
puzzleClearRun
|
||||
)) ||
|
||||
isMatch3DBusy ||
|
||||
isSquareHoleBusy ||
|
||||
isVisualNovelBusy ||
|
||||
isWoodenFishBusy
|
||||
}
|
||||
recommendRuntimeError={activeRecommendRuntimeError}
|
||||
onSelectNextRecommendEntry={(activeEntryKey) =>
|
||||
selectAdjacentRecommendRuntimeEntry(1, activeEntryKey)
|
||||
}
|
||||
onSelectPreviousRecommendEntry={(activeEntryKey) =>
|
||||
selectAdjacentRecommendRuntimeEntry(-1, activeEntryKey)
|
||||
}
|
||||
onLikeRecommendEntry={(entry) => {
|
||||
likePublicWork(entry);
|
||||
}}
|
||||
onShareRecommendEntry={(entry) => {
|
||||
openRecommendShareModal(entry);
|
||||
}}
|
||||
onRemixRecommendEntry={(entry) => {
|
||||
remixPublicWork(entry);
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onDeleteLibraryEntry={(entry) => {
|
||||
handleDeleteLibraryEntry(entry);
|
||||
}}
|
||||
deletingLibraryEntryId={deletingCreationWorkId}
|
||||
onSearchPublicCode={(keyword) => {
|
||||
void handlePublicCodeSearch(keyword);
|
||||
}}
|
||||
isSearchingPublicCode={isSearchingPublicCode}
|
||||
profilePlayStats={profilePlayStats}
|
||||
isProfilePlayStatsOpen={isProfilePlayStatsOpen}
|
||||
isProfilePlayStatsLoading={isProfilePlayStatsLoading}
|
||||
profilePlayStatsError={profilePlayStatsError}
|
||||
onCloseProfilePlayStats={() => {
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
}}
|
||||
onOpenPlayedWork={openPlayedWork}
|
||||
onOpenFeedback={openProfileFeedback}
|
||||
onOpenProjects={() => setSelectionStage('project')}
|
||||
onOpenProfileDashboardCard={(cardKey) => {
|
||||
if (cardKey === 'playedWorks') {
|
||||
openProfilePlayedWorks();
|
||||
return;
|
||||
}
|
||||
if (platformBootstrap.dashboardError) {
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
}
|
||||
}}
|
||||
onRechargeSuccess={platformBootstrap.refreshProfileDashboard}
|
||||
/>
|
||||
{platformHomeView}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -17713,6 +17766,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.5rem]"
|
||||
/>
|
||||
{isCreationCommunityOpen ? (
|
||||
<PlatformProfileReferralModal
|
||||
panel="community"
|
||||
center={null}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={() => setIsCreationCommunityOpen(false)}
|
||||
onCopyInvite={() => undefined}
|
||||
onRedeemCodeChange={() => undefined}
|
||||
onSubmitRedeemCode={() => undefined}
|
||||
/>
|
||||
) : null}
|
||||
<PlatformAcknowledgeStatusDialog
|
||||
open={Boolean(workNotFoundRecoveryDialog)}
|
||||
status="error"
|
||||
|
||||
@@ -13,6 +13,7 @@ export type CustomWorldRuntimeLaunchOptions = {
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'creation-home'
|
||||
| 'project'
|
||||
| 'image-editor'
|
||||
| 'profile-feedback'
|
||||
@@ -85,7 +86,10 @@ export type SyncedAgentDraftResult = {
|
||||
|
||||
export type PlatformEntryFlowShellProps = {
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
setSelectionStage: (
|
||||
stage: SelectionStage,
|
||||
options?: { path?: string },
|
||||
) => void;
|
||||
initialPublicWorkCode?: string | null;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
|
||||
platform: true,
|
||||
'creation-home': true,
|
||||
project: true,
|
||||
'image-editor': true,
|
||||
'profile-feedback': false,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
EditorProjectLayerSnapshot,
|
||||
EditorProjectResourceSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
export const PROJECT_CANVAS_COVER_SIZE = { width: 320, height: 240 } as const;
|
||||
export const PROJECT_CANVAS_COVER_VIEWPORT_SIZE = {
|
||||
width: 900,
|
||||
height: 640,
|
||||
} as const;
|
||||
|
||||
export type ProjectCanvasCoverLayer = {
|
||||
layerId: string;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
export type ProjectCanvasCoverClassNames = {
|
||||
root?: string;
|
||||
empty?: string;
|
||||
layer?: string;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
export type ProjectCanvasCoverProps = {
|
||||
project: EditorProjectSnapshot;
|
||||
classNames?: ProjectCanvasCoverClassNames;
|
||||
};
|
||||
|
||||
function numberFromLayer(
|
||||
layer: EditorProjectLayerSnapshot,
|
||||
key: string,
|
||||
fallback: number,
|
||||
) {
|
||||
const value = layer[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringFromLayer(layer: EditorProjectLayerSnapshot, key: string) {
|
||||
const value = layer[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isLayerHidden(layer: EditorProjectLayerSnapshot) {
|
||||
return layer.hidden === true;
|
||||
}
|
||||
|
||||
function joinClassNames(...classNames: Array<string | undefined>) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function resolveProjectCanvasCoverLayers(
|
||||
project: EditorProjectSnapshot,
|
||||
): ProjectCanvasCoverLayer[] {
|
||||
const resourcesById = new Map<string, EditorProjectResourceSnapshot>(
|
||||
project.resources.map((resource) => [resource.resourceId, resource]),
|
||||
);
|
||||
|
||||
return project.layers
|
||||
.filter((layer) => !isLayerHidden(layer))
|
||||
.map((layer) => {
|
||||
const resource = resourcesById.get(layer.resourceId);
|
||||
const imageSrc = stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
layerId: layer.layerId,
|
||||
title: stringFromLayer(layer, 'title') || '画布图片',
|
||||
imageSrc,
|
||||
x: numberFromLayer(layer, 'x', 0),
|
||||
y: numberFromLayer(layer, 'y', 0),
|
||||
width: Math.max(1, numberFromLayer(layer, 'width', resource?.width ?? 320)),
|
||||
height: Math.max(1, numberFromLayer(layer, 'height', resource?.height ?? 320)),
|
||||
zIndex: numberFromLayer(layer, 'zIndex', 0),
|
||||
} satisfies ProjectCanvasCoverLayer;
|
||||
})
|
||||
.filter((layer): layer is ProjectCanvasCoverLayer => Boolean(layer))
|
||||
.sort((left, right) => left.zIndex - right.zIndex);
|
||||
}
|
||||
|
||||
export function ProjectCanvasCover({
|
||||
project,
|
||||
classNames,
|
||||
}: ProjectCanvasCoverProps) {
|
||||
const coverLayers = resolveProjectCanvasCoverLayers(project);
|
||||
if (!coverLayers.length) {
|
||||
return (
|
||||
<span
|
||||
className={joinClassNames(
|
||||
'project-gallery__preview-empty',
|
||||
classNames?.empty,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const safeScale =
|
||||
project.viewport.scale > 0 && Number.isFinite(project.viewport.scale)
|
||||
? project.viewport.scale
|
||||
: 1;
|
||||
const viewportCenterX =
|
||||
(PROJECT_CANVAS_COVER_VIEWPORT_SIZE.width / 2 - project.viewport.x) /
|
||||
safeScale;
|
||||
const viewportCenterY =
|
||||
(PROJECT_CANVAS_COVER_VIEWPORT_SIZE.height / 2 - project.viewport.y) /
|
||||
safeScale;
|
||||
const worldPreviewWidth = PROJECT_CANVAS_COVER_VIEWPORT_SIZE.width / safeScale;
|
||||
const worldPreviewHeight =
|
||||
PROJECT_CANVAS_COVER_VIEWPORT_SIZE.height / safeScale;
|
||||
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
|
||||
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
|
||||
const scale = Math.min(
|
||||
PROJECT_CANVAS_COVER_SIZE.width / worldPreviewWidth,
|
||||
PROJECT_CANVAS_COVER_SIZE.height / worldPreviewHeight,
|
||||
);
|
||||
const offsetX = -previewMinX * scale;
|
||||
const offsetY = -previewMinY * scale;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={joinClassNames('project-gallery__canvas-cover', classNames?.root)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{coverLayers.map((layer) => (
|
||||
<span
|
||||
key={layer.layerId}
|
||||
className={joinClassNames(
|
||||
'project-gallery__canvas-cover-layer',
|
||||
classNames?.layer,
|
||||
)}
|
||||
style={{
|
||||
left: offsetX + layer.x * scale,
|
||||
top: offsetY + layer.y * scale,
|
||||
width: layer.width * scale,
|
||||
height: layer.height * scale,
|
||||
zIndex: layer.zIndex,
|
||||
}}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={layer.imageSrc}
|
||||
alt=""
|
||||
className={joinClassNames(
|
||||
'project-gallery__canvas-cover-image',
|
||||
classNames?.image,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,9 @@ import {
|
||||
deleteEditorProject,
|
||||
listEditorProjects,
|
||||
renameEditorProject,
|
||||
type EditorProjectLayerSnapshot,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
|
||||
@@ -33,9 +30,7 @@ import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
|
||||
const PROJECT_COVER_SIZE = { width: 320, height: 240 };
|
||||
const PROJECT_COVER_VIEWPORT_SIZE = { width: 900, height: 640 };
|
||||
import { ProjectCanvasCover } from './ProjectCanvasCover';
|
||||
|
||||
type ProjectGalleryViewProps = {
|
||||
onOpenProject: (projectId: string) => void;
|
||||
@@ -50,109 +45,6 @@ function isUnauthorizedError(error: unknown) {
|
||||
return error instanceof ApiClientError && error.status === 401;
|
||||
}
|
||||
|
||||
type ProjectCoverLayer = {
|
||||
layerId: string;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
function numberFromLayer(layer: EditorProjectLayerSnapshot, key: string, fallback: number) {
|
||||
const value = layer[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringFromLayer(layer: EditorProjectLayerSnapshot, key: string) {
|
||||
const value = layer[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isLayerHidden(layer: EditorProjectLayerSnapshot) {
|
||||
return layer.hidden === true;
|
||||
}
|
||||
|
||||
function resolveProjectCoverLayers(project: EditorProjectSnapshot): ProjectCoverLayer[] {
|
||||
const resourcesById = new Map<string, EditorProjectResourceSnapshot>(
|
||||
project.resources.map((resource) => [resource.resourceId, resource]),
|
||||
);
|
||||
|
||||
return project.layers
|
||||
.filter((layer) => !isLayerHidden(layer))
|
||||
.map((layer) => {
|
||||
const resource = resourcesById.get(layer.resourceId);
|
||||
const imageSrc = stringFromLayer(layer, 'src') || resource?.imageSrc.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
layerId: layer.layerId,
|
||||
title: stringFromLayer(layer, 'title') || '画布图片',
|
||||
imageSrc,
|
||||
x: numberFromLayer(layer, 'x', 0),
|
||||
y: numberFromLayer(layer, 'y', 0),
|
||||
width: Math.max(1, numberFromLayer(layer, 'width', resource?.width ?? 320)),
|
||||
height: Math.max(1, numberFromLayer(layer, 'height', resource?.height ?? 320)),
|
||||
zIndex: numberFromLayer(layer, 'zIndex', 0),
|
||||
} satisfies ProjectCoverLayer;
|
||||
})
|
||||
.filter((layer): layer is ProjectCoverLayer => Boolean(layer))
|
||||
.sort((left, right) => left.zIndex - right.zIndex);
|
||||
}
|
||||
|
||||
function ProjectCanvasCover({ project }: { project: EditorProjectSnapshot }) {
|
||||
const coverLayers = resolveProjectCoverLayers(project);
|
||||
if (!coverLayers.length) {
|
||||
return <span className="project-gallery__preview-empty" />;
|
||||
}
|
||||
|
||||
const safeScale =
|
||||
project.viewport.scale > 0 && Number.isFinite(project.viewport.scale)
|
||||
? project.viewport.scale
|
||||
: 1;
|
||||
const viewportCenterX =
|
||||
(PROJECT_COVER_VIEWPORT_SIZE.width / 2 - project.viewport.x) / safeScale;
|
||||
const viewportCenterY =
|
||||
(PROJECT_COVER_VIEWPORT_SIZE.height / 2 - project.viewport.y) / safeScale;
|
||||
const worldPreviewWidth = PROJECT_COVER_VIEWPORT_SIZE.width / safeScale;
|
||||
const worldPreviewHeight = PROJECT_COVER_VIEWPORT_SIZE.height / safeScale;
|
||||
const previewMinX = viewportCenterX - worldPreviewWidth / 2;
|
||||
const previewMinY = viewportCenterY - worldPreviewHeight / 2;
|
||||
const scale = Math.min(
|
||||
PROJECT_COVER_SIZE.width / worldPreviewWidth,
|
||||
PROJECT_COVER_SIZE.height / worldPreviewHeight,
|
||||
);
|
||||
const offsetX = -previewMinX * scale;
|
||||
const offsetY = -previewMinY * scale;
|
||||
|
||||
return (
|
||||
<span className="project-gallery__canvas-cover" aria-hidden="true">
|
||||
{coverLayers.map((layer) => (
|
||||
<span
|
||||
key={layer.layerId}
|
||||
className="project-gallery__canvas-cover-layer"
|
||||
style={{
|
||||
left: offsetX + layer.x * scale,
|
||||
top: offsetY + layer.y * scale,
|
||||
width: layer.width * scale,
|
||||
height: layer.height * scale,
|
||||
zIndex: layer.zIndex,
|
||||
}}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={layer.imageSrc}
|
||||
alt=""
|
||||
className="project-gallery__canvas-cover-image"
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatProjectUpdatedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
|
||||
@@ -185,6 +185,7 @@ import {
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
} from '../auth/AuthUiContext';
|
||||
import { PLATFORM_DESKTOP_LAYOUT_QUERY } from '../platform-entry/platformEntryResponsive';
|
||||
import {
|
||||
getUnifiedCreationSpec,
|
||||
type UnifiedCreationPlayId,
|
||||
@@ -2528,6 +2529,25 @@ function createAuthValue(
|
||||
};
|
||||
}
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
function mockDesktopPlatformLayout() {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === PLATFORM_DESKTOP_LAYOUT_QUERY,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function TestWrapper({
|
||||
withAuth = false,
|
||||
authValue,
|
||||
@@ -3779,6 +3799,11 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalMatchMedia,
|
||||
});
|
||||
});
|
||||
|
||||
test('create tab shows template tabs and embeds puzzle form by default', async () => {
|
||||
@@ -9087,18 +9112,63 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy
|
||||
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
|
||||
});
|
||||
|
||||
test('create tab does not render legacy gameplay creation entries', async () => {
|
||||
const user = userEvent.setup();
|
||||
test('creation home does not render legacy gameplay creation entries', async () => {
|
||||
mockDesktopPlatformLayout();
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
const { container } = render(<TestWrapper withAuth />);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
expect(
|
||||
await screen.findByRole('heading', {
|
||||
name: '陶泥儿 - 开启全民精品游戏创作',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(container.querySelector('.platform-desktop-topbar')).toBeTruthy();
|
||||
expect(container.querySelector('.platform-desktop-rail')).toBeTruthy();
|
||||
expect(screen.queryByText('选择创作类型')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(createBigFishCreationSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creation home community opens in place without switching to profile', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDesktopPlatformLayout();
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await screen.findByRole('heading', {
|
||||
name: '陶泥儿 - 开启全民精品游戏创作',
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '玩家社区' }));
|
||||
|
||||
expect(window.location.pathname).toBe('/creation');
|
||||
expect(window.location.search).toBe('');
|
||||
const dialog = await screen.findByRole('dialog', { name: '玩家社区' });
|
||||
expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
|
||||
expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
|
||||
expect(screen.queryByText('陶泥号')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation home desktop rail can return to platform tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDesktopPlatformLayout();
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
const { container } = render(<TestWrapper withAuth />);
|
||||
|
||||
await screen.findByRole('heading', {
|
||||
name: '陶泥儿 - 开启全民精品游戏创作',
|
||||
});
|
||||
const nav = container.querySelector('.platform-desktop-rail');
|
||||
expect(nav).toBeTruthy();
|
||||
|
||||
await user.click(within(nav as HTMLElement).getByRole('button', { name: '推荐' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('main', { name: '陶泥儿创作主页' })).toBeNull();
|
||||
});
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
|
||||
test('embedded puzzle form timeout exits busy state and shows a readable error', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -782,6 +782,8 @@ function ProfileHomeViewHarness({
|
||||
profileGenerationQueueStatus = null,
|
||||
profilePlayStats = null,
|
||||
isProfilePlayStatsOpen = false,
|
||||
initialProfilePopupPanel = null,
|
||||
onInitialProfilePopupPanelConsumed,
|
||||
}: {
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
profileDashboardOverrides?: Partial<
|
||||
@@ -793,6 +795,8 @@ function ProfileHomeViewHarness({
|
||||
profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus'];
|
||||
profilePlayStats?: ProfilePlayStatsResponse | null;
|
||||
isProfilePlayStatsOpen?: boolean;
|
||||
initialProfilePopupPanel?: RpgEntryHomeViewProps['initialProfilePopupPanel'];
|
||||
onInitialProfilePopupPanelConsumed?: RpgEntryHomeViewProps['onInitialProfilePopupPanelConsumed'];
|
||||
}) {
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
@@ -859,6 +863,8 @@ function ProfileHomeViewHarness({
|
||||
onRechargeSuccess={onRechargeSuccess}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
profileGenerationQueueStatus={profileGenerationQueueStatus}
|
||||
initialProfilePopupPanel={initialProfilePopupPanel}
|
||||
onInitialProfilePopupPanelConsumed={onInitialProfilePopupPanelConsumed}
|
||||
/>
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
@@ -875,6 +881,8 @@ function renderProfileView(
|
||||
profilePlayStats?: ProfilePlayStatsResponse | null;
|
||||
isProfilePlayStatsOpen?: boolean;
|
||||
profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus'];
|
||||
initialProfilePopupPanel?: RpgEntryHomeViewProps['initialProfilePopupPanel'];
|
||||
onInitialProfilePopupPanelConsumed?: RpgEntryHomeViewProps['onInitialProfilePopupPanelConsumed'];
|
||||
} = {},
|
||||
) {
|
||||
return render(
|
||||
@@ -888,6 +896,10 @@ function renderProfileView(
|
||||
}
|
||||
profilePlayStats={profileStatsOptions.profilePlayStats}
|
||||
isProfilePlayStatsOpen={profileStatsOptions.isProfilePlayStatsOpen}
|
||||
initialProfilePopupPanel={profileStatsOptions.initialProfilePopupPanel}
|
||||
onInitialProfilePopupPanelConsumed={
|
||||
profileStatsOptions.onInitialProfilePopupPanelConsumed
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -998,6 +1010,9 @@ function renderLoggedInHomeView(
|
||||
| 'draftTabContent'
|
||||
| 'myEntries'
|
||||
| 'isLoadingPlatform'
|
||||
| 'isDesktopLayout'
|
||||
| 'onOpenCreateTypePicker'
|
||||
| 'onOpenProjects'
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
@@ -1032,6 +1047,7 @@ function renderLoggedInHomeView(
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab={overrides.activeTab ?? 'saves'}
|
||||
isDesktopLayout={overrides.isDesktopLayout}
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
@@ -1050,10 +1066,11 @@ function renderLoggedInHomeView(
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenCreateTypePicker={overrides.onOpenCreateTypePicker ?? vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
onOpenProjects={overrides.onOpenProjects}
|
||||
hasUnreadDraftUpdate={overrides.hasUnreadDraftUpdate ?? false}
|
||||
draftTabContent={overrides.draftTabContent}
|
||||
/>
|
||||
@@ -2296,10 +2313,11 @@ test('profile recharge modal blocks tab navigation while virtual payment confirm
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '正在确认支付' })).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '创作' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
const nav = document.querySelector('.platform-bottom-nav');
|
||||
expect(nav).toBeTruthy();
|
||||
for (const navButton of within(nav as HTMLElement).getAllByRole('button')) {
|
||||
expect((navButton as HTMLButtonElement).disabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('profile recharge modal loads wechat js sdk before mini program payment bridge', async () => {
|
||||
@@ -3064,6 +3082,10 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
}),
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
within(shortcutRegion).queryByRole('button', { name: /项目/u }),
|
||||
).toBeNull();
|
||||
expect(shortcutRegion.textContent).not.toContain('画布项目');
|
||||
expect(
|
||||
within(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈与建议/u }),
|
||||
@@ -3324,6 +3346,61 @@ test('profile community shortcut shows reward subtitle and invited users', async
|
||||
});
|
||||
});
|
||||
|
||||
test('profile community popup can be opened from external creation entry', async () => {
|
||||
const onConsumed = vi.fn();
|
||||
|
||||
renderProfileView(
|
||||
vi.fn(),
|
||||
{},
|
||||
{},
|
||||
0,
|
||||
{
|
||||
initialProfilePopupPanel: 'community',
|
||||
onInitialProfilePopupPanelConsumed: onConsumed,
|
||||
},
|
||||
);
|
||||
|
||||
const communityDialog = await screen.findByRole('dialog', {
|
||||
name: '玩家社区',
|
||||
});
|
||||
|
||||
expect(onConsumed).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
within(communityDialog).getByRole('button', { name: '关闭玩家社区' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByAltText('玩家社区微信群二维码')).toBeTruthy();
|
||||
expect(screen.getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile community popup external open intent can be reused after consume', async () => {
|
||||
const onConsumed = vi.fn();
|
||||
const { rerender } = render(
|
||||
<ProfileHomeViewHarness
|
||||
initialProfilePopupPanel="community"
|
||||
onInitialProfilePopupPanelConsumed={onConsumed}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '玩家社区' })).toBeTruthy();
|
||||
expect(onConsumed).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<ProfileHomeViewHarness
|
||||
initialProfilePopupPanel={null}
|
||||
onInitialProfilePopupPanelConsumed={onConsumed}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<ProfileHomeViewHarness
|
||||
initialProfilePopupPanel="community"
|
||||
onInitialProfilePopupPanelConsumed={onConsumed}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '玩家社区' })).toBeTruthy();
|
||||
expect(onConsumed).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => {
|
||||
renderProfileView(vi.fn(), {});
|
||||
|
||||
@@ -3483,6 +3560,10 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).queryByRole('button', { name: /项目/u }),
|
||||
).toBeNull();
|
||||
expect(shortcutRegion.textContent).not.toContain('画布项目');
|
||||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||||
expect(dailyTask).toBeTruthy();
|
||||
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
|
||||
@@ -3549,21 +3630,24 @@ test('logged out bottom nav turns active recommend tab into next action', () =>
|
||||
|
||||
expect(buttons.map((button) => button.textContent)).toEqual([
|
||||
'下一个',
|
||||
'创作',
|
||||
'发现',
|
||||
'我的',
|
||||
]);
|
||||
expect(buttons[0]?.querySelector('.lucide-chevron-down')).toBeTruthy();
|
||||
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
|
||||
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||
expect(buttons[1]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||
expect(buttons[2]?.querySelector('.lucide-user-round')).toBeTruthy();
|
||||
expect(
|
||||
buttons[1]?.querySelector('.platform-bottom-nav__primary-action'),
|
||||
).toBeTruthy();
|
||||
within(nav as HTMLElement).queryByRole('button', { name: '创作' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', { name: '项目' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
buttons[0]?.querySelector('.platform-bottom-nav__active-mark'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logged in draft bottom tab shows unread marker', () => {
|
||||
test('logged in mobile bottom nav hides draft and project entries', () => {
|
||||
const { container } = renderLoggedInHomeView({
|
||||
hasUnreadDraftUpdate: true,
|
||||
draftTabContent: <div>草稿内容</div>,
|
||||
@@ -3571,11 +3655,74 @@ test('logged in draft bottom tab shows unread marker', () => {
|
||||
|
||||
const nav = container.querySelector('.platform-bottom-nav');
|
||||
expect(nav).toBeTruthy();
|
||||
const draftButton = within(nav as HTMLElement).getByRole('button', {
|
||||
name: '草稿,有新草稿',
|
||||
expect(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '推荐' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '发现' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '我的' }),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', {
|
||||
name: '草稿,有新草稿',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', { name: '创作' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', { name: '项目' }),
|
||||
).toBeNull();
|
||||
expect(nav?.querySelector('.platform-nav-unread-dot')).toBeNull();
|
||||
});
|
||||
|
||||
test('desktop nav shows creation and projects without draft entry', async () => {
|
||||
const { container } = renderLoggedInHomeView({
|
||||
activeTab: 'profile',
|
||||
isDesktopLayout: true,
|
||||
hasUnreadDraftUpdate: true,
|
||||
});
|
||||
|
||||
expect(draftButton.querySelector('.platform-nav-unread-dot')).toBeTruthy();
|
||||
const nav = container.querySelector('.platform-desktop-rail');
|
||||
expect(nav).toBeTruthy();
|
||||
expect(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '创作' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(nav as HTMLElement).getByRole('button', { name: '项目' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', {
|
||||
name: '草稿,有新草稿',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(nav as HTMLElement).queryByRole('button', { name: /^草稿$/u }),
|
||||
).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('desktop nav opens creation home and project gallery from dedicated entries', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenCreateTypePicker = vi.fn();
|
||||
const onOpenProjects = vi.fn();
|
||||
const { container } = renderLoggedInHomeView({
|
||||
activeTab: 'profile',
|
||||
isDesktopLayout: true,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenProjects,
|
||||
});
|
||||
|
||||
const nav = container.querySelector('.platform-desktop-rail');
|
||||
expect(nav).toBeTruthy();
|
||||
await user.click(within(nav as HTMLElement).getByRole('button', { name: '创作' }));
|
||||
await user.click(within(nav as HTMLElement).getByRole('button', { name: '项目' }));
|
||||
|
||||
expect(onOpenCreateTypePicker).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenProjects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('logged in saves tab shows loading state while fetching my entries', () => {
|
||||
|
||||
@@ -137,6 +137,7 @@ import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProf
|
||||
import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel';
|
||||
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
||||
import {
|
||||
type ProfilePopupPanel,
|
||||
type RechargePaymentResult,
|
||||
usePlatformProfileCenterController,
|
||||
} from '../platform-entry/usePlatformProfileCenterController';
|
||||
@@ -222,6 +223,7 @@ export type PlatformHomeTab =
|
||||
| 'create'
|
||||
| 'saves'
|
||||
| 'profile';
|
||||
type PlatformNavTab = PlatformHomeTab | 'projects';
|
||||
export interface RpgEntryHomeViewProps {
|
||||
activeTab: PlatformHomeTab;
|
||||
isDesktopLayout?: boolean;
|
||||
@@ -279,6 +281,8 @@ export interface RpgEntryHomeViewProps {
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
profileTaskRefreshKey?: number;
|
||||
profileGenerationQueueStatus?: ExternalGenerationQueueStatus | null;
|
||||
initialProfilePopupPanel?: ProfilePopupPanel | null;
|
||||
onInitialProfilePopupPanelConsumed?: () => void;
|
||||
createTabContent?: ReactNode;
|
||||
draftTabContent?: ReactNode;
|
||||
hasUnreadDraftUpdate?: boolean;
|
||||
@@ -306,6 +310,13 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
|
||||
'saves',
|
||||
'profile',
|
||||
];
|
||||
const PLATFORM_DESKTOP_NAV_TABS: PlatformNavTab[] = [
|
||||
'home',
|
||||
'category',
|
||||
'create',
|
||||
'projects',
|
||||
'profile',
|
||||
];
|
||||
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const AVATAR_OUTPUT_SIZE = 256;
|
||||
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
@@ -1887,6 +1898,10 @@ function DesktopTabButton({
|
||||
);
|
||||
}
|
||||
|
||||
function isPlatformHomeTab(tab: PlatformNavTab): tab is PlatformHomeTab {
|
||||
return tab !== 'projects';
|
||||
}
|
||||
|
||||
function PlatformTabPanel({
|
||||
tab,
|
||||
activeTab,
|
||||
@@ -2575,6 +2590,8 @@ export function RpgEntryHomeView({
|
||||
onRechargeSuccess,
|
||||
profileTaskRefreshKey = 0,
|
||||
profileGenerationQueueStatus = null,
|
||||
initialProfilePopupPanel = null,
|
||||
onInitialProfilePopupPanelConsumed,
|
||||
createTabContent,
|
||||
draftTabContent,
|
||||
hasUnreadDraftUpdate = false,
|
||||
@@ -2701,6 +2718,30 @@ export function RpgEntryHomeView({
|
||||
requestLogin: () => authUi?.openLoginModal(),
|
||||
currentUser,
|
||||
});
|
||||
|
||||
const openedInitialProfilePopupPanelRef = useRef<ProfilePopupPanel | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!initialProfilePopupPanel) {
|
||||
openedInitialProfilePopupPanelRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeTab !== 'profile' ||
|
||||
openedInitialProfilePopupPanelRef.current === initialProfilePopupPanel
|
||||
) {
|
||||
return;
|
||||
}
|
||||
openedInitialProfilePopupPanelRef.current = initialProfilePopupPanel;
|
||||
openProfilePopupPanel(initialProfilePopupPanel);
|
||||
onInitialProfilePopupPanelConsumed?.();
|
||||
}, [
|
||||
activeTab,
|
||||
initialProfilePopupPanel,
|
||||
onInitialProfilePopupPanelConsumed,
|
||||
openProfilePopupPanel,
|
||||
]);
|
||||
const edutainmentEntryEnabled = isEdutainmentEntryEnabled();
|
||||
const [fallbackDesktopLayout] = useState(getInitialPlatformDesktopLayout);
|
||||
const isDesktopLayout = isDesktopLayoutProp ?? fallbackDesktopLayout;
|
||||
@@ -2834,11 +2875,18 @@ export function RpgEntryHomeView({
|
||||
hasManualCategoryTagSelectionRef.current = true;
|
||||
setSelectedCategoryTag(tag);
|
||||
}, []);
|
||||
const visibleTabs = useMemo<PlatformHomeTab[]>(
|
||||
const visibleMobileTabs = useMemo<PlatformHomeTab[]>(
|
||||
() =>
|
||||
isAuthenticated
|
||||
? ['home', 'category', 'create', 'saves', 'profile']
|
||||
: ['home', 'create', 'category'],
|
||||
? ['home', 'category', 'profile']
|
||||
: ['home', 'category', 'profile'],
|
||||
[isAuthenticated],
|
||||
);
|
||||
const visibleDesktopNavTabs = useMemo<PlatformNavTab[]>(
|
||||
() =>
|
||||
isAuthenticated
|
||||
? PLATFORM_DESKTOP_NAV_TABS
|
||||
: ['home', 'category', 'create', 'projects', 'profile'],
|
||||
[isAuthenticated],
|
||||
);
|
||||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||||
@@ -2856,37 +2904,44 @@ export function RpgEntryHomeView({
|
||||
() => buildProfileTaskCardSummary(taskCenter),
|
||||
[taskCenter],
|
||||
);
|
||||
const tabIcons: Record<
|
||||
PlatformHomeTab,
|
||||
ComponentType<{ className?: string }>
|
||||
> = isAuthenticated
|
||||
? {
|
||||
home: Sparkles,
|
||||
category: Compass,
|
||||
create: Sparkles,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
}
|
||||
: {
|
||||
home: Gamepad2,
|
||||
category: Compass,
|
||||
create: Sparkles,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
};
|
||||
const tabIcons: Record<PlatformNavTab, ComponentType<{ className?: string }>> =
|
||||
isAuthenticated
|
||||
? {
|
||||
home: Sparkles,
|
||||
category: Compass,
|
||||
create: Sparkles,
|
||||
projects: FolderKanban,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
}
|
||||
: {
|
||||
home: Gamepad2,
|
||||
category: Compass,
|
||||
create: Sparkles,
|
||||
projects: FolderKanban,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
};
|
||||
const tabLabels = {
|
||||
home: '推荐',
|
||||
category: '发现',
|
||||
create: '创作',
|
||||
projects: '项目',
|
||||
saves: '草稿',
|
||||
profile: '我的',
|
||||
} as const;
|
||||
} as const satisfies Record<PlatformNavTab, string>;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleTabs.includes(activeTab)) {
|
||||
if (!visibleMobileTabs.includes(activeTab) && !isDesktopLayout) {
|
||||
onTabChange(isAuthenticated ? 'home' : 'category');
|
||||
}
|
||||
}, [activeTab, isAuthenticated, onTabChange, visibleTabs]);
|
||||
}, [
|
||||
activeTab,
|
||||
isAuthenticated,
|
||||
isDesktopLayout,
|
||||
onTabChange,
|
||||
visibleMobileTabs,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -4382,12 +4437,6 @@ export function RpgEntryHomeView({
|
||||
imageSrc={profileCommunityImage}
|
||||
onClick={() => openProfilePopupPanel('community')}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="项目"
|
||||
subLabel="画布项目"
|
||||
icon={FolderKanban}
|
||||
onClick={onOpenProjects}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="反馈与建议"
|
||||
subLabel="帮我们优化产品"
|
||||
@@ -4733,8 +4782,9 @@ export function RpgEntryHomeView({
|
||||
saves: savesContent,
|
||||
profile: profileContent,
|
||||
} satisfies Record<PlatformHomeTab, ReactNode>;
|
||||
const tabPanels = PLATFORM_HOME_TABS.filter((tab) =>
|
||||
visibleTabs.includes(tab),
|
||||
const tabPanels = PLATFORM_HOME_TABS.filter(
|
||||
(tab) =>
|
||||
isDesktopLayout || visibleMobileTabs.includes(tab) || tab === activeTab,
|
||||
).map((tab) => {
|
||||
const shouldMountPanel = tab === activeTab || visitedTabs.has(tab);
|
||||
|
||||
@@ -4919,9 +4969,9 @@ export function RpgEntryHomeView({
|
||||
|
||||
<div className="platform-mobile-bottom-dock min-w-0 shrink-0">
|
||||
<div
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
className={`platform-bottom-nav grid ${visibleMobileTabs.length === 5 ? 'grid-cols-5' : visibleMobileTabs.length === 4 ? 'grid-cols-4' : visibleMobileTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
{visibleTabs.map((tab) => (
|
||||
{visibleMobileTabs.map((tab) => (
|
||||
<PlatformTabButton
|
||||
key={tab}
|
||||
active={activeTab === tab}
|
||||
@@ -5092,19 +5142,27 @@ export function RpgEntryHomeView({
|
||||
|
||||
<div className="mt-5 flex min-h-0 gap-5">
|
||||
<aside className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3">
|
||||
{visibleTabs.map((tab) => (
|
||||
{visibleDesktopNavTabs.map((tab) => (
|
||||
<DesktopTabButton
|
||||
key={tab}
|
||||
active={activeTab === tab}
|
||||
active={isPlatformHomeTab(tab) && activeTab === tab}
|
||||
label={tabLabels[tab]}
|
||||
icon={tabIcons[tab]}
|
||||
emphasized={tab === 'create'}
|
||||
showDot={tab === 'saves' && hasUnreadDraftUpdate}
|
||||
showDot={false}
|
||||
disabled={isRechargePaymentConfirmationPending}
|
||||
onClick={() => {
|
||||
if (isRechargePaymentConfirmationPending) {
|
||||
return;
|
||||
}
|
||||
if (tab === 'create') {
|
||||
onOpenCreateTypePicker();
|
||||
return;
|
||||
}
|
||||
if (tab === 'projects') {
|
||||
onOpenProjects?.();
|
||||
return;
|
||||
}
|
||||
onTabChange(tab);
|
||||
}}
|
||||
/>
|
||||
|
||||
+374
@@ -3213,6 +3213,380 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
margin: 1rem 1.5rem 0;
|
||||
}
|
||||
|
||||
.creation-landing {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.creation-landing__hero,
|
||||
.creation-landing__section {
|
||||
width: min(980px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.creation-landing__hero {
|
||||
display: grid;
|
||||
gap: 1.35rem;
|
||||
padding: 3.2rem 0 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.creation-landing__hero-copy {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.82rem;
|
||||
}
|
||||
|
||||
.creation-landing__eyebrow {
|
||||
width: fit-content;
|
||||
color: #6b7280;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.creation-landing__hero h1 {
|
||||
margin: 0;
|
||||
max-width: 38rem;
|
||||
color: #111827;
|
||||
font-size: clamp(1.55rem, 3vw, 2.25rem);
|
||||
font-weight: 950;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.creation-landing__hero p {
|
||||
margin: 0;
|
||||
color: #4b5563;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.creation-landing__hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
padding-top: 0.08rem;
|
||||
}
|
||||
|
||||
.creation-landing__hero-board {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.creation-landing__hero-tile {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.55rem;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.creation-landing__hero-tile--main {
|
||||
left: 14%;
|
||||
top: 14%;
|
||||
width: 58%;
|
||||
aspect-ratio: 4 / 3;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(75, 181, 170, 0.24), transparent 52%),
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.creation-landing__hero-tile--side {
|
||||
right: 9%;
|
||||
top: 28%;
|
||||
width: 34%;
|
||||
aspect-ratio: 1;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(246, 171, 76, 0.24), transparent 56%),
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.creation-landing__hero-tile--small {
|
||||
left: 30%;
|
||||
bottom: 10%;
|
||||
width: 36%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(129, 140, 248, 0.2), transparent 56%),
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.creation-landing__section {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 0.95rem 0;
|
||||
}
|
||||
|
||||
.creation-landing__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.creation-landing__section-header h2 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 920;
|
||||
}
|
||||
|
||||
.creation-landing__link-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
padding: 0.48rem 0.82rem;
|
||||
color: #238a82;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 840;
|
||||
}
|
||||
|
||||
.creation-landing__feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.creation-landing__feature {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"icon title"
|
||||
"icon desc";
|
||||
gap: 0.22rem 0.72rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 0.85rem;
|
||||
background: #ffffff;
|
||||
padding: 0.85rem;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.creation-landing__feature-icon {
|
||||
display: grid;
|
||||
grid-area: icon;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
place-items: center;
|
||||
border-radius: 0.5rem;
|
||||
background: #e6f7f5;
|
||||
color: #238a82;
|
||||
}
|
||||
|
||||
.creation-landing__feature h3,
|
||||
.creation-landing__asset-card h3 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 880;
|
||||
}
|
||||
|
||||
.creation-landing__feature h3 {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.creation-landing__feature p {
|
||||
grid-area: desc;
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.creation-landing__project-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(10rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.creation-landing__project-card {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.65rem;
|
||||
background: #ffffff;
|
||||
padding: 0.7rem;
|
||||
color: #111827;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.creation-landing__project-card:hover,
|
||||
.creation-landing__link-button:hover {
|
||||
border-color: rgba(35, 138, 130, 0.32);
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.creation-landing__project-card--new {
|
||||
place-items: center;
|
||||
min-height: 12rem;
|
||||
color: #238a82;
|
||||
font-weight: 880;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.creation-landing__project-card--new span {
|
||||
display: grid;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #e6f7f5;
|
||||
}
|
||||
|
||||
.creation-landing__project-cover {
|
||||
border-radius: 0.45rem;
|
||||
}
|
||||
|
||||
.creation-landing__project-meta {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.creation-landing__project-meta strong,
|
||||
.creation-landing__project-meta small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.creation-landing__project-meta strong {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.creation-landing__project-meta small {
|
||||
color: #64748b;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.creation-landing__tabs {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.creation-landing__tab {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
padding: 0.48rem 0.8rem;
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.creation-landing__tab--active {
|
||||
border-color: rgba(35, 138, 130, 0.32);
|
||||
background: #e6f7f5;
|
||||
color: #176c66;
|
||||
}
|
||||
|
||||
.creation-landing__asset-waterfall {
|
||||
column-count: 3;
|
||||
column-gap: 0.92rem;
|
||||
}
|
||||
|
||||
.creation-landing__asset-card {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0 0 0.92rem;
|
||||
break-inside: avoid;
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: 0.78rem;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.creation-landing__asset-preview {
|
||||
display: grid;
|
||||
min-height: 9.5rem;
|
||||
max-height: 23rem;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
background: linear-gradient(180deg, #ffffff, #f8fafc);
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta p {
|
||||
display: -webkit-box;
|
||||
min-height: 2.6em;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta dt {
|
||||
color: #94a3b8;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.creation-landing__asset-meta dd {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 780;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.creation-landing__hero {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.creation-landing__feature-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.creation-landing__project-row {
|
||||
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||
}
|
||||
|
||||
.creation-landing__asset-waterfall {
|
||||
column-count: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.creation-landing__asset-waterfall {
|
||||
column-count: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-canvas-editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -31,12 +31,27 @@ describe('appPageRoutes', () => {
|
||||
expect(resolvePathForSelectionStage('image-editor')).toBe('/editor/canvas');
|
||||
});
|
||||
|
||||
it('preserves project id when navigating to an editor canvas project', () => {
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
|
||||
pushAppHistoryPath('/editor/canvas?projectid=project-from-route-test');
|
||||
|
||||
expect(window.location.pathname).toBe('/editor/canvas');
|
||||
expect(window.location.search).toBe('?projectid=project-from-route-test');
|
||||
});
|
||||
|
||||
it('resolves the project route', () => {
|
||||
expect(resolveSelectionStageFromPath('/project')).toBe('project');
|
||||
expect(resolveSelectionStageFromPath('/PROJECT/')).toBe('project');
|
||||
expect(resolvePathForSelectionStage('project')).toBe('/project');
|
||||
});
|
||||
|
||||
it('resolves the creation home route', () => {
|
||||
expect(resolveSelectionStageFromPath('/creation')).toBe('creation-home');
|
||||
expect(resolveSelectionStageFromPath('/CREATION/')).toBe('creation-home');
|
||||
expect(resolvePathForSelectionStage('creation-home')).toBe('/creation');
|
||||
});
|
||||
|
||||
it('resolves jump-hop creation, gallery and runtime routes', () => {
|
||||
expect(resolveSelectionStageFromPath('/creation/jump-hop')).toBe(
|
||||
'jump-hop-workspace',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work';
|
||||
|
||||
const STAGE_ROUTE_ENTRIES = [
|
||||
['platform', '/'],
|
||||
['creation-home', '/creation'],
|
||||
['project', '/project'],
|
||||
['image-editor', '/editor/canvas'],
|
||||
['profile-feedback', '/profile/feedback'],
|
||||
|
||||
Reference in New Issue
Block a user