3d8e0211db
Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/159 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
1662 lines
52 KiB
TypeScript
1662 lines
52 KiB
TypeScript
import { Film, Image as ImageIcon, Music2, Pause, Play } from 'lucide-react';
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||
import { ApiClientError } from '../../services/apiClient';
|
||
import {
|
||
createEditorProject,
|
||
type EditorProjectResourceListPage,
|
||
type EditorProjectResourceSnapshot,
|
||
type EditorProjectSnapshot,
|
||
type EditorPublicShowcaseResourceSnapshot,
|
||
type EditorShowcaseCampaignSnapshot,
|
||
listEditorProjects,
|
||
listPublicEditorProjectResources,
|
||
toggleEditorShowcaseAssetLike,
|
||
} from '../../services/image-editor/editorProjectClient';
|
||
import { useAuthUi } from '../auth/AuthUiContext';
|
||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||
import { UnifiedModal } from '../common/UnifiedModal';
|
||
import { resolveProjectCoverSnapshotResource } from '../project/ProjectCanvasCover';
|
||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||
import { ResolvedAssetVideo } from '../ResolvedAssetVideo';
|
||
import {
|
||
buildCreationShowcaseItems,
|
||
SHOWCASE_TABS,
|
||
type ShowcaseAssetItem,
|
||
type ShowcaseAssetPreview,
|
||
type ShowcaseTabId,
|
||
} from './creationShowcaseModel';
|
||
import {
|
||
applySequentialShowcaseMasonry,
|
||
resetSequentialShowcaseMasonry,
|
||
} from './showcaseMasonryLayout';
|
||
|
||
type CreationLandingViewProps = {
|
||
onOpenProject: (
|
||
projectId: string,
|
||
options?: { guide?: boolean; tool?: CreationFeatureTool },
|
||
) => void;
|
||
onOpenProjects: () => void;
|
||
onOpenCommunity?: () => void;
|
||
searchKeyword?: string;
|
||
};
|
||
|
||
type CreationFeatureTool =
|
||
| 'character-spec'
|
||
| 'icon-spec'
|
||
| 'ui-design'
|
||
| 'background-music'
|
||
| 'image'
|
||
| 'publication-cover'
|
||
| 'scene';
|
||
|
||
type CreationFeatureItem = {
|
||
title: string;
|
||
description: string;
|
||
iconSrc: string;
|
||
action: { kind: 'tool'; tool: CreationFeatureTool } | { kind: 'pending' };
|
||
};
|
||
|
||
const CREATION_HOME_ASSETS = {
|
||
heroCreate: '/creation-home/hero-create.png',
|
||
heroCommunity: '/creation-home/hero-community.png',
|
||
projectNew: '/creation-home/project-new.png',
|
||
} as const;
|
||
|
||
const SHOWCASE_LIKE_ICON_SRC = '/creation-home/showcase-like-thumb.png';
|
||
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)';
|
||
|
||
function usePrefersReducedMotion() {
|
||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
|
||
typeof window.matchMedia === 'function'
|
||
? window.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches
|
||
: false,
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (typeof window.matchMedia !== 'function') {
|
||
return undefined;
|
||
}
|
||
const mediaQuery = window.matchMedia(REDUCED_MOTION_MEDIA_QUERY);
|
||
const handleChange = (event: MediaQueryListEvent) => {
|
||
setPrefersReducedMotion(event.matches);
|
||
};
|
||
setPrefersReducedMotion(mediaQuery.matches);
|
||
mediaQuery.addEventListener('change', handleChange);
|
||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||
}, []);
|
||
|
||
return prefersReducedMotion;
|
||
}
|
||
|
||
const CREATION_FEATURES: CreationFeatureItem[] = [
|
||
{
|
||
title: '游戏视觉规范',
|
||
description: '轻松约束多类素材视觉一致性',
|
||
iconSrc: '/creation-home/feature-visual-spec.png',
|
||
action: { kind: 'tool', tool: 'icon-spec' },
|
||
},
|
||
{
|
||
title: '游戏角色',
|
||
description: '整套高完成度2D/3D角色素材及动画',
|
||
iconSrc: '/creation-home/feature-character.png',
|
||
action: { kind: 'tool', tool: 'character-spec' },
|
||
},
|
||
{
|
||
title: '游戏UI',
|
||
description: '轻松生成高一致性的UI素材',
|
||
iconSrc: '/creation-home/feature-ui.png',
|
||
action: { kind: 'tool', tool: 'ui-design' },
|
||
},
|
||
{
|
||
title: '游戏音乐',
|
||
description: '轻松制作游戏背景音乐、音效',
|
||
iconSrc: '/creation-home/feature-music.png',
|
||
action: { kind: 'tool', tool: 'background-music' },
|
||
},
|
||
{
|
||
title: '游戏特效',
|
||
description: '低成本生成精品游戏特效',
|
||
iconSrc: '/creation-home/feature-effects.png',
|
||
action: { kind: 'pending' },
|
||
},
|
||
{
|
||
title: '游戏场景',
|
||
description: '轻松生成各类游戏场景素材',
|
||
iconSrc: '/creation-home/feature-scene.png',
|
||
action: { kind: 'tool', tool: 'scene' },
|
||
},
|
||
{
|
||
title: '游戏美宣',
|
||
description: '轻松生成游戏上架所需图片和视频',
|
||
iconSrc: '/creation-home/feature-marketing.png',
|
||
action: { kind: 'tool', tool: 'publication-cover' },
|
||
},
|
||
{
|
||
title: '游戏功能编码',
|
||
description: '自然语言驱动游戏生成',
|
||
iconSrc: '/creation-home/feature-coding.png',
|
||
action: { kind: 'pending' },
|
||
},
|
||
{
|
||
title: '一键导出',
|
||
description: '支持导出为微抖小游戏、Godot、Unity、UE工程',
|
||
iconSrc: '/creation-home/feature-export.png',
|
||
action: { kind: 'pending' },
|
||
},
|
||
];
|
||
|
||
function isUnauthorizedError(error: unknown) {
|
||
return error instanceof ApiClientError && error.status === 401;
|
||
}
|
||
|
||
function normalizeShowcaseResourcePage(
|
||
page: EditorProjectResourceListPage | EditorProjectResourceSnapshot[],
|
||
): EditorProjectResourceListPage {
|
||
return Array.isArray(page)
|
||
? {
|
||
resources: page.map((resource) => ({
|
||
...resource,
|
||
viewerLiked:
|
||
(resource as Partial<EditorPublicShowcaseResourceSnapshot>)
|
||
.viewerLiked === true,
|
||
})),
|
||
nextCursor: null,
|
||
campaign: null,
|
||
}
|
||
: page;
|
||
}
|
||
|
||
function mergeShowcaseResources(
|
||
current: EditorPublicShowcaseResourceSnapshot[],
|
||
incoming: EditorPublicShowcaseResourceSnapshot[],
|
||
) {
|
||
const seenResourceIds = new Set(
|
||
current.map((resource) => resource.resourceId),
|
||
);
|
||
return [
|
||
...current,
|
||
...incoming.filter((resource) => {
|
||
if (seenResourceIds.has(resource.resourceId)) {
|
||
return false;
|
||
}
|
||
seenResourceIds.add(resource.resourceId);
|
||
return true;
|
||
}),
|
||
];
|
||
}
|
||
|
||
function formatProjectUpdatedAt(value: string) {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return '';
|
||
}
|
||
return new Intl.DateTimeFormat('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
}).format(date);
|
||
}
|
||
|
||
function getPreviewAspectRatio(preview: ShowcaseAssetPreview) {
|
||
return preview.width && preview.height
|
||
? `${Math.max(1, preview.width)} / ${Math.max(1, preview.height)}`
|
||
: undefined;
|
||
}
|
||
|
||
function useSequentialShowcaseMasonry(
|
||
itemOrderKey: string,
|
||
expectedItemCount: number,
|
||
) {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
useLayoutEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container) {
|
||
return;
|
||
}
|
||
|
||
const view = container.ownerDocument.defaultView;
|
||
let animationFrame: number | null = null;
|
||
const layout = () => {
|
||
animationFrame = null;
|
||
applySequentialShowcaseMasonry(container, { expectedItemCount });
|
||
};
|
||
const scheduleLayout = () => {
|
||
if (animationFrame !== null) {
|
||
return;
|
||
}
|
||
if (view?.requestAnimationFrame) {
|
||
animationFrame = view.requestAnimationFrame(layout);
|
||
return;
|
||
}
|
||
layout();
|
||
};
|
||
|
||
layout();
|
||
const ResizeObserverConstructor = globalThis.ResizeObserver;
|
||
const resizeObserver = ResizeObserverConstructor
|
||
? new ResizeObserverConstructor(scheduleLayout)
|
||
: null;
|
||
resizeObserver?.observe(container);
|
||
for (const card of container.querySelectorAll<HTMLElement>(
|
||
':scope > .creation-landing__asset-card',
|
||
)) {
|
||
resizeObserver?.observe(card);
|
||
}
|
||
|
||
return () => {
|
||
if (animationFrame !== null && view?.cancelAnimationFrame) {
|
||
view.cancelAnimationFrame(animationFrame);
|
||
}
|
||
resizeObserver?.disconnect();
|
||
resetSequentialShowcaseMasonry(container);
|
||
};
|
||
}, [expectedItemCount, itemOrderKey]);
|
||
|
||
return containerRef;
|
||
}
|
||
|
||
function getPreviewIcon(preview: ShowcaseAssetPreview) {
|
||
if (preview.mediaType === 'audio') {
|
||
return <Music2 aria-hidden="true" />;
|
||
}
|
||
if (preview.mediaType === 'video' || preview.mediaType === 'image-sequence') {
|
||
return <Film aria-hidden="true" />;
|
||
}
|
||
return <ImageIcon aria-hidden="true" />;
|
||
}
|
||
|
||
function ShowcaseAudioPreview({
|
||
preview,
|
||
controls = false,
|
||
}: {
|
||
preview: ShowcaseAssetPreview;
|
||
controls?: boolean;
|
||
}) {
|
||
const { resolvedUrl } = useResolvedAssetReadUrl(preview.src, {
|
||
objectKey: preview.objectKey,
|
||
});
|
||
const coverSrc =
|
||
preview.coverSrc?.trim() || '/creation-home/audio-asset-cover.png';
|
||
|
||
return (
|
||
<div className="creation-landing__asset-audio-preview">
|
||
<img
|
||
alt={preview.label}
|
||
className="creation-landing__asset-audio-cover"
|
||
draggable={false}
|
||
src={coverSrc}
|
||
/>
|
||
{controls && resolvedUrl ? (
|
||
<audio controls preload="metadata" src={resolvedUrl} />
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ShowcasePreview({
|
||
preview,
|
||
variant = 'card',
|
||
sequencePlaying = false,
|
||
}: {
|
||
preview: ShowcaseAssetPreview;
|
||
variant?: 'card' | 'modal' | 'thumb';
|
||
sequencePlaying?: boolean;
|
||
}) {
|
||
if (preview.mediaType === 'audio') {
|
||
return (
|
||
<ShowcaseAudioPreview preview={preview} controls={variant === 'modal'} />
|
||
);
|
||
}
|
||
if (preview.mediaType === 'video') {
|
||
return (
|
||
<ShowcaseVideoPreview
|
||
preview={preview}
|
||
controls={variant === 'modal'}
|
||
variant={variant}
|
||
/>
|
||
);
|
||
}
|
||
if (preview.mediaType === 'image-sequence') {
|
||
return (
|
||
<ShowcaseImageSequencePreview
|
||
preview={preview}
|
||
variant={variant}
|
||
playing={sequencePlaying}
|
||
/>
|
||
);
|
||
}
|
||
return <ShowcaseImagePreview preview={preview} variant={variant} />;
|
||
}
|
||
|
||
function ShowcaseImageSequenceFrame({
|
||
preview,
|
||
frame,
|
||
frameKey,
|
||
visible,
|
||
loaded,
|
||
retryGeneration,
|
||
onReady,
|
||
onFailed,
|
||
}: {
|
||
preview: ShowcaseAssetPreview;
|
||
frame: NonNullable<ShowcaseAssetPreview['imageSequenceFrames']>[number];
|
||
frameKey: string;
|
||
visible: boolean;
|
||
loaded: boolean;
|
||
retryGeneration: number;
|
||
onReady: (frameKey: string) => void;
|
||
onFailed: (frameKey: string) => void;
|
||
}) {
|
||
const failureReportedRef = useRef(false);
|
||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
|
||
frame.imageSrc,
|
||
{
|
||
objectKey: frame.objectKey,
|
||
refreshKey: `${preview.id}:${frameKey}:${retryGeneration}`,
|
||
},
|
||
);
|
||
const reportFailure = useCallback(() => {
|
||
if (failureReportedRef.current) {
|
||
return;
|
||
}
|
||
failureReportedRef.current = true;
|
||
onFailed(frameKey);
|
||
}, [frameKey, onFailed]);
|
||
|
||
useEffect(() => {
|
||
if (shouldResolve && !isResolving && !resolvedUrl) {
|
||
reportFailure();
|
||
}
|
||
}, [isResolving, reportFailure, resolvedUrl, shouldResolve]);
|
||
|
||
if (!resolvedUrl) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<img
|
||
src={resolvedUrl}
|
||
alt={visible ? `角色动作预览:${preview.label}` : ''}
|
||
aria-hidden={visible ? undefined : true}
|
||
className="creation-landing__image-sequence-frame"
|
||
decoding="async"
|
||
style={{
|
||
opacity: visible && loaded ? 1 : 0,
|
||
transition: 'none',
|
||
}}
|
||
onLoad={() => onReady(frameKey)}
|
||
onError={reportFailure}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ShowcaseImageSequencePreview({
|
||
preview,
|
||
variant,
|
||
playing,
|
||
}: {
|
||
preview: ShowcaseAssetPreview;
|
||
variant: 'card' | 'modal' | 'thumb';
|
||
playing: boolean;
|
||
}) {
|
||
const frames = useMemo(
|
||
() => preview.imageSequenceFrames ?? [],
|
||
[preview.imageSequenceFrames],
|
||
);
|
||
const durationMs = preview.imageSequenceDurationMs ?? 0;
|
||
const shouldMountSequence = variant === 'modal' || playing;
|
||
const frameItems = useMemo(
|
||
() =>
|
||
frames.map((frame, index) => ({
|
||
frame,
|
||
index,
|
||
key: [preview.id, frame.objectKey ?? '', frame.imageSrc, index].join(
|
||
':',
|
||
),
|
||
})),
|
||
[frames, preview.id],
|
||
);
|
||
const frameSequenceKey = frameItems.map((item) => item.key).join('|');
|
||
const firstFrameKey = frameItems[0]?.key ?? '';
|
||
const [frameIndex, setFrameIndex] = useState(0);
|
||
const [isPlaying, setIsPlaying] = useState(playing);
|
||
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
|
||
() => new Set(),
|
||
);
|
||
const [failedFrameKeys, setFailedFrameKeys] = useState<ReadonlySet<string>>(
|
||
() => new Set(),
|
||
);
|
||
const [retryGeneration, setRetryGeneration] = useState(0);
|
||
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
|
||
const currentFrameItem =
|
||
frameItems[Math.min(frameIndex, frameItems.length - 1)];
|
||
const currentFrameKey = currentFrameItem?.key ?? '';
|
||
const mountedFrameKeys = new Set<string>();
|
||
if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) {
|
||
mountedFrameKeys.add(visibleFrameKey);
|
||
}
|
||
for (
|
||
let offset = 0;
|
||
offset < frameItems.length && mountedFrameKeys.size < 3;
|
||
offset += 1
|
||
) {
|
||
const item = frameItems[(frameIndex + offset) % frameItems.length];
|
||
if (item && !failedFrameKeys.has(item.key)) {
|
||
mountedFrameKeys.add(item.key);
|
||
}
|
||
}
|
||
const availableFrameCount = frameItems.length - failedFrameKeys.size;
|
||
|
||
const handleFrameReady = useCallback((frameKey: string) => {
|
||
setLoadedFrameKeys((currentKeys) => {
|
||
if (currentKeys.has(frameKey)) {
|
||
return currentKeys;
|
||
}
|
||
const nextKeys = new Set(currentKeys);
|
||
nextKeys.add(frameKey);
|
||
return nextKeys;
|
||
});
|
||
}, []);
|
||
|
||
const handleFrameFailed = useCallback((frameKey: string) => {
|
||
setFailedFrameKeys((currentKeys) => {
|
||
if (currentKeys.has(frameKey)) {
|
||
return currentKeys;
|
||
}
|
||
const nextKeys = new Set(currentKeys);
|
||
nextKeys.add(frameKey);
|
||
return nextKeys;
|
||
});
|
||
}, []);
|
||
|
||
const retryFailedFrames = useCallback(() => {
|
||
setFrameIndex(0);
|
||
setIsPlaying(playing);
|
||
setLoadedFrameKeys(new Set());
|
||
setFailedFrameKeys(new Set());
|
||
setVisibleFrameKey(firstFrameKey);
|
||
setRetryGeneration((current) => current + 1);
|
||
}, [firstFrameKey, playing]);
|
||
|
||
useEffect(() => {
|
||
setFrameIndex(0);
|
||
setLoadedFrameKeys(new Set());
|
||
setFailedFrameKeys(new Set());
|
||
setVisibleFrameKey(firstFrameKey);
|
||
setIsPlaying(playing);
|
||
}, [firstFrameKey, frameSequenceKey, playing]);
|
||
|
||
useEffect(() => {
|
||
if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) {
|
||
return;
|
||
}
|
||
const nextIndex = findNextShowcaseFrameIndex(
|
||
frameItems,
|
||
failedFrameKeys,
|
||
frameIndex,
|
||
);
|
||
if (nextIndex === null) {
|
||
setVisibleFrameKey('');
|
||
setIsPlaying(false);
|
||
return;
|
||
}
|
||
setFrameIndex(nextIndex);
|
||
}, [currentFrameKey, failedFrameKeys, frameIndex, frameItems]);
|
||
|
||
useEffect(() => {
|
||
if (!currentFrameKey) {
|
||
setVisibleFrameKey('');
|
||
return;
|
||
}
|
||
if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) {
|
||
return;
|
||
}
|
||
setVisibleFrameKey(currentFrameKey);
|
||
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
|
||
|
||
useEffect(() => {
|
||
if (!isPlaying || availableFrameCount < 2 || durationMs <= 0) {
|
||
return undefined;
|
||
}
|
||
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
|
||
const timer = window.setInterval(() => {
|
||
setFrameIndex(
|
||
(currentIndex) =>
|
||
findNextShowcaseFrameIndex(
|
||
frameItems,
|
||
failedFrameKeys,
|
||
currentIndex,
|
||
) ?? currentIndex,
|
||
);
|
||
}, frameIntervalMs);
|
||
return () => window.clearInterval(timer);
|
||
}, [
|
||
availableFrameCount,
|
||
durationMs,
|
||
failedFrameKeys,
|
||
frameItems,
|
||
frames.length,
|
||
isPlaying,
|
||
]);
|
||
|
||
if (!shouldMountSequence) {
|
||
return <ShowcaseImagePreview preview={preview} variant={variant} />;
|
||
}
|
||
|
||
const mediaClassName =
|
||
variant === 'modal'
|
||
? 'creation-landing__showcase-media'
|
||
: 'creation-landing__asset-preview-media';
|
||
|
||
return (
|
||
<div
|
||
aria-label={`角色动作预览:${preview.label}`}
|
||
className={`${mediaClassName} creation-landing__image-sequence`}
|
||
>
|
||
{frameItems
|
||
.filter((item) => mountedFrameKeys.has(item.key))
|
||
.map((item) => (
|
||
<ShowcaseImageSequenceFrame
|
||
key={`${item.key}:${retryGeneration}`}
|
||
preview={preview}
|
||
frame={item.frame}
|
||
frameKey={item.key}
|
||
visible={item.key === visibleFrameKey}
|
||
loaded={loadedFrameKeys.has(item.key)}
|
||
retryGeneration={retryGeneration}
|
||
onReady={handleFrameReady}
|
||
onFailed={handleFrameFailed}
|
||
/>
|
||
))}
|
||
{variant === 'modal' ? (
|
||
<div className="creation-landing__image-sequence-controls">
|
||
<button
|
||
type="button"
|
||
aria-label={isPlaying ? '暂停角色动作' : '播放角色动作'}
|
||
onClick={() => setIsPlaying((current) => !current)}
|
||
>
|
||
{isPlaying ? (
|
||
<Pause size={16} aria-hidden="true" />
|
||
) : (
|
||
<Play size={16} aria-hidden="true" />
|
||
)}
|
||
</button>
|
||
<span>{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`}</span>
|
||
{failedFrameKeys.size ? (
|
||
<>
|
||
<span aria-live="polite">{`${failedFrameKeys.size} 帧加载失败`}</span>
|
||
<button type="button" onClick={retryFailedFrames}>
|
||
重试失败帧
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : availableFrameCount === 0 ? (
|
||
<span
|
||
aria-live="polite"
|
||
className="creation-landing__image-sequence-error"
|
||
>
|
||
角色动作加载失败,打开预览可重试
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function findNextShowcaseFrameIndex(
|
||
frameItems: ReadonlyArray<{ key: string }>,
|
||
failedFrameKeys: ReadonlySet<string>,
|
||
currentIndex: number,
|
||
) {
|
||
for (let offset = 1; offset <= frameItems.length; offset += 1) {
|
||
const nextIndex = (currentIndex + offset) % frameItems.length;
|
||
const item = frameItems[nextIndex];
|
||
if (item && !failedFrameKeys.has(item.key)) {
|
||
return nextIndex;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function ShowcaseImagePreview({
|
||
preview,
|
||
variant,
|
||
}: {
|
||
preview: ShowcaseAssetPreview;
|
||
variant: 'card' | 'modal' | 'thumb';
|
||
}) {
|
||
return (
|
||
<ResolvedAssetImage
|
||
src={preview.src}
|
||
objectKey={preview.objectKey}
|
||
alt={preview.label}
|
||
className={
|
||
variant === 'modal'
|
||
? 'creation-landing__showcase-media'
|
||
: 'creation-landing__asset-preview-media'
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ShowcaseVideoPreview({
|
||
preview,
|
||
controls,
|
||
variant,
|
||
}: {
|
||
preview: ShowcaseAssetPreview;
|
||
controls: boolean;
|
||
variant: 'card' | 'modal' | 'thumb';
|
||
}) {
|
||
return (
|
||
<ResolvedAssetVideo
|
||
src={preview.src}
|
||
objectKey={preview.objectKey}
|
||
className={
|
||
variant === 'modal'
|
||
? 'creation-landing__showcase-media'
|
||
: 'creation-landing__asset-preview-media'
|
||
}
|
||
controls={controls}
|
||
muted={!controls}
|
||
loop={!controls}
|
||
playsInline
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ShowcaseBundlePreview({
|
||
item,
|
||
playSequences,
|
||
}: {
|
||
item: ShowcaseAssetItem;
|
||
playSequences: boolean;
|
||
}) {
|
||
const visiblePreviews = item.previews.slice(0, 4);
|
||
const primaryPreview = visiblePreviews[0];
|
||
const previewClassName = [
|
||
'creation-landing__asset-preview',
|
||
item.previews.length > 1 ? 'creation-landing__asset-preview--bundle' : '',
|
||
item.campaign ? 'creation-landing__asset-preview--campaign' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
|
||
if (!primaryPreview) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={previewClassName}
|
||
style={{
|
||
aspectRatio: getPreviewAspectRatio(primaryPreview),
|
||
}}
|
||
>
|
||
{visiblePreviews.map((preview) => (
|
||
<span
|
||
key={preview.id}
|
||
className="creation-landing__asset-preview-tile"
|
||
aria-label={preview.label}
|
||
>
|
||
<ShowcasePreview preview={preview} sequencePlaying={playSequences} />
|
||
</span>
|
||
))}
|
||
{item.previews.length > visiblePreviews.length ? (
|
||
<span className="creation-landing__asset-preview-more">
|
||
+{item.previews.length - visiblePreviews.length}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CreationShowcaseModal({
|
||
item,
|
||
activeIndex,
|
||
playSequences,
|
||
onSelectIndex,
|
||
onClose,
|
||
}: {
|
||
item: ShowcaseAssetItem | null;
|
||
activeIndex: number;
|
||
playSequences: boolean;
|
||
onSelectIndex: (index: number) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const safeActiveIndex = item
|
||
? Math.min(Math.max(activeIndex, 0), item.previews.length - 1)
|
||
: 0;
|
||
const activePreview = item?.previews[safeActiveIndex] ?? null;
|
||
|
||
return (
|
||
<UnifiedModal
|
||
open={Boolean(item && activePreview)}
|
||
title={item?.label ?? '素材预览'}
|
||
ariaLabel={item?.label ?? '素材预览'}
|
||
onClose={onClose}
|
||
showHeader={false}
|
||
showCloseButton={false}
|
||
size="fullscreen"
|
||
// 素材舞台是独立黑底视觉;避免平台主题 remap 把它改成普通白底工具弹窗。
|
||
portalTheme="none"
|
||
zIndexClassName="z-[120]"
|
||
overlayClassName="creation-landing__showcase-modal-overlay"
|
||
panelClassName="creation-landing__showcase-modal-panel"
|
||
bodyClassName="creation-landing__showcase-modal-body"
|
||
closeOnBackdrop
|
||
>
|
||
{item && activePreview ? (
|
||
<div className="creation-landing__showcase-modal">
|
||
<PlatformModalCloseButton
|
||
label="关闭素材预览"
|
||
variant="floating"
|
||
className="creation-landing__showcase-modal-close"
|
||
onClick={onClose}
|
||
/>
|
||
<div className="creation-landing__showcase-modal-stage">
|
||
<div className="creation-landing__showcase-current">
|
||
<ShowcasePreview
|
||
preview={activePreview}
|
||
variant="modal"
|
||
sequencePlaying={playSequences}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="creation-landing__showcase-dock">
|
||
<div className="creation-landing__showcase-dock-meta">
|
||
<h3>{item.label}</h3>
|
||
<p>{item.prompt}</p>
|
||
<dl>
|
||
<div>
|
||
<dt>作者</dt>
|
||
<dd>{item.author}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>生成总成本</dt>
|
||
<dd>{item.cost}</dd>
|
||
</div>
|
||
{typeof item.likeCount === 'number' ? (
|
||
<div>
|
||
<dt>赞</dt>
|
||
<dd>{item.likeCount}</dd>
|
||
</div>
|
||
) : null}
|
||
</dl>
|
||
</div>
|
||
<div
|
||
className="creation-landing__showcase-thumbs"
|
||
aria-label="素材缩略图导航"
|
||
>
|
||
{item.previews.map((preview, index) => (
|
||
<button
|
||
key={preview.id}
|
||
type="button"
|
||
className={
|
||
index === safeActiveIndex
|
||
? 'creation-landing__showcase-thumb creation-landing__showcase-thumb--active'
|
||
: 'creation-landing__showcase-thumb'
|
||
}
|
||
aria-label={`查看${preview.label}`}
|
||
aria-current={index === safeActiveIndex ? 'true' : undefined}
|
||
onClick={() => onSelectIndex(index)}
|
||
>
|
||
<span className="creation-landing__showcase-thumb-media">
|
||
{preview.mediaType === 'image' ||
|
||
preview.mediaType === 'audio' ||
|
||
preview.mediaType === 'image-sequence' ? (
|
||
<ShowcasePreview preview={preview} variant="thumb" />
|
||
) : (
|
||
getPreviewIcon(preview)
|
||
)}
|
||
</span>
|
||
<span>{preview.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</UnifiedModal>
|
||
);
|
||
}
|
||
|
||
export function CreationLandingView({
|
||
onOpenProject,
|
||
onOpenProjects,
|
||
onOpenCommunity,
|
||
searchKeyword = '',
|
||
}: CreationLandingViewProps) {
|
||
const prefersReducedMotion = usePrefersReducedMotion();
|
||
const authUi = useAuthUi();
|
||
const isAuthenticated = Boolean(authUi?.user);
|
||
const showcaseViewerUserId = authUi?.user?.id.trim() || null;
|
||
const canLoadShowcaseViewer =
|
||
!showcaseViewerUserId || Boolean(authUi?.canAccessProtectedData);
|
||
const showcaseViewerKey = showcaseViewerUserId
|
||
? `user:${showcaseViewerUserId}`
|
||
: 'anonymous';
|
||
const showcaseRequestScope = `${showcaseViewerKey}:${canLoadShowcaseViewer ? 'ready' : 'recovering'}`;
|
||
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
|
||
const [recentProjects, setRecentProjects] = useState<EditorProjectSnapshot[]>(
|
||
[],
|
||
);
|
||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||
const [isLoadingShowcase, setIsLoadingShowcase] = useState(false);
|
||
const [isLoadingMoreShowcase, setIsLoadingMoreShowcase] = useState(false);
|
||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||
const [projectError, setProjectError] = useState<string | null>(null);
|
||
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
|
||
EditorPublicShowcaseResourceSnapshot[]
|
||
>([]);
|
||
const [showcaseCampaign, setShowcaseCampaign] =
|
||
useState<EditorShowcaseCampaignSnapshot | null>(null);
|
||
const [showcaseNextCursor, setShowcaseNextCursor] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [assetError, setAssetError] = useState<string | null>(null);
|
||
const [showcaseLoadMoreError, setShowcaseLoadMoreError] = useState<
|
||
string | null
|
||
>(null);
|
||
const [activeShowcaseTab, setActiveShowcaseTab] =
|
||
useState<ShowcaseTabId>('all');
|
||
const [selectedShowcaseItem, setSelectedShowcaseItem] =
|
||
useState<ShowcaseAssetItem | null>(null);
|
||
const [hoveredShowcaseSequenceCardId, setHoveredShowcaseSequenceCardId] =
|
||
useState<string | null>(null);
|
||
const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] =
|
||
useState<string | null>(null);
|
||
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
|
||
const [pendingLikeShowcaseIds, setPendingLikeShowcaseIds] = useState<
|
||
Set<string>
|
||
>(() => new Set());
|
||
const [showcaseLikeError, setShowcaseLikeError] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [isPendingFeatureDialogOpen, setIsPendingFeatureDialogOpen] =
|
||
useState(false);
|
||
const showcaseLoadMoreRef = useRef<HTMLDivElement | null>(null);
|
||
const showcaseLoadingMoreCursorRef = useRef<string | null>(null);
|
||
const showcaseFailedLoadMoreCursorRef = useRef<string | null>(null);
|
||
const showcaseRequestStateRef = useRef({
|
||
scope: showcaseRequestScope,
|
||
generation: 0,
|
||
});
|
||
|
||
const refreshRecentProjects = useCallback(() => {
|
||
if (!isAuthenticated) {
|
||
setRecentProjects([]);
|
||
setProjectError(null);
|
||
return;
|
||
}
|
||
setIsLoadingProjects(true);
|
||
setProjectError(null);
|
||
listEditorProjects()
|
||
.then((projects) => {
|
||
const sortedProjects = [...projects].sort(
|
||
(left, right) =>
|
||
new Date(right.updatedAt).getTime() -
|
||
new Date(left.updatedAt).getTime(),
|
||
);
|
||
setRecentProjects(sortedProjects.slice(0, 4));
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (isUnauthorizedError(error)) {
|
||
authUi?.openLoginModal(refreshRecentProjects);
|
||
return;
|
||
}
|
||
const message =
|
||
error instanceof Error ? error.message : '读取最近项目失败';
|
||
setProjectError(message);
|
||
})
|
||
.finally(() => setIsLoadingProjects(false));
|
||
}, [authUi, isAuthenticated]);
|
||
|
||
useEffect(() => {
|
||
refreshRecentProjects();
|
||
}, [refreshRecentProjects]);
|
||
|
||
useEffect(() => {
|
||
const requestState = {
|
||
scope: showcaseRequestScope,
|
||
generation: showcaseRequestStateRef.current.generation + 1,
|
||
};
|
||
showcaseRequestStateRef.current = requestState;
|
||
const isCurrentRequest = () =>
|
||
showcaseRequestStateRef.current.scope === requestState.scope &&
|
||
showcaseRequestStateRef.current.generation === requestState.generation;
|
||
const invalidateRequest = () => {
|
||
if (isCurrentRequest()) {
|
||
showcaseRequestStateRef.current = {
|
||
scope: requestState.scope,
|
||
generation: requestState.generation + 1,
|
||
};
|
||
}
|
||
};
|
||
|
||
setIsLoadingShowcase(true);
|
||
setIsLoadingMoreShowcase(false);
|
||
showcaseLoadingMoreCursorRef.current = null;
|
||
showcaseFailedLoadMoreCursorRef.current = null;
|
||
setProjectShowcaseResources([]);
|
||
setShowcaseCampaign(null);
|
||
setShowcaseNextCursor(null);
|
||
setAssetError(null);
|
||
setShowcaseLoadMoreError(null);
|
||
setShowcaseLikeError(null);
|
||
setPendingLikeShowcaseIds(new Set());
|
||
setSelectedShowcaseItem(null);
|
||
if (!canLoadShowcaseViewer) {
|
||
return invalidateRequest;
|
||
}
|
||
|
||
listPublicEditorProjectResources({
|
||
viewer: showcaseViewerUserId ? 'authenticated' : 'anonymous',
|
||
})
|
||
.then((page) => {
|
||
if (!isCurrentRequest()) {
|
||
return;
|
||
}
|
||
const normalizedPage = normalizeShowcaseResourcePage(page);
|
||
setProjectShowcaseResources(normalizedPage.resources);
|
||
setShowcaseCampaign(normalizedPage.campaign ?? null);
|
||
setShowcaseNextCursor(normalizedPage.nextCursor);
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (!isCurrentRequest()) {
|
||
return;
|
||
}
|
||
setProjectShowcaseResources([]);
|
||
setShowcaseCampaign(null);
|
||
setShowcaseNextCursor(null);
|
||
setShowcaseLoadMoreError(null);
|
||
setAssetError(
|
||
error instanceof Error ? error.message : '读取陶泥儿精选失败',
|
||
);
|
||
})
|
||
.finally(() => {
|
||
if (isCurrentRequest()) {
|
||
setIsLoadingShowcase(false);
|
||
}
|
||
});
|
||
return invalidateRequest;
|
||
}, [canLoadShowcaseViewer, showcaseRequestScope, showcaseViewerUserId]);
|
||
|
||
const loadMoreShowcaseResources = useCallback(
|
||
(options: { force?: boolean } = {}) => {
|
||
const cursor = showcaseNextCursor;
|
||
if (!cursor || isLoadingShowcase || isLoadingMoreShowcase) {
|
||
return;
|
||
}
|
||
if (showcaseLoadingMoreCursorRef.current === cursor) {
|
||
return;
|
||
}
|
||
if (
|
||
!options.force &&
|
||
showcaseFailedLoadMoreCursorRef.current === cursor
|
||
) {
|
||
return;
|
||
}
|
||
showcaseLoadingMoreCursorRef.current = cursor;
|
||
const requestState = { ...showcaseRequestStateRef.current };
|
||
const isCurrentRequest = () =>
|
||
showcaseRequestStateRef.current.scope === requestState.scope &&
|
||
showcaseRequestStateRef.current.generation === requestState.generation;
|
||
setIsLoadingMoreShowcase(true);
|
||
setShowcaseLoadMoreError(null);
|
||
listPublicEditorProjectResources({
|
||
cursor,
|
||
viewer: showcaseViewerUserId ? 'authenticated' : 'anonymous',
|
||
})
|
||
.then((page) => {
|
||
if (!isCurrentRequest()) {
|
||
return;
|
||
}
|
||
const normalizedPage = normalizeShowcaseResourcePage(page);
|
||
setProjectShowcaseResources((current) =>
|
||
mergeShowcaseResources(current, normalizedPage.resources),
|
||
);
|
||
setShowcaseCampaign((current) => normalizedPage.campaign ?? current);
|
||
setShowcaseNextCursor(normalizedPage.nextCursor);
|
||
showcaseFailedLoadMoreCursorRef.current = null;
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (!isCurrentRequest()) {
|
||
return;
|
||
}
|
||
showcaseFailedLoadMoreCursorRef.current = cursor;
|
||
setShowcaseLoadMoreError(
|
||
error instanceof Error ? error.message : '读取陶泥儿精选失败',
|
||
);
|
||
})
|
||
.finally(() => {
|
||
if (!isCurrentRequest()) {
|
||
return;
|
||
}
|
||
if (showcaseLoadingMoreCursorRef.current === cursor) {
|
||
showcaseLoadingMoreCursorRef.current = null;
|
||
}
|
||
setIsLoadingMoreShowcase(false);
|
||
});
|
||
},
|
||
[
|
||
isLoadingMoreShowcase,
|
||
isLoadingShowcase,
|
||
showcaseNextCursor,
|
||
showcaseViewerUserId,
|
||
],
|
||
);
|
||
|
||
useEffect(() => {
|
||
const element = showcaseLoadMoreRef.current;
|
||
if (
|
||
!element ||
|
||
!showcaseNextCursor ||
|
||
isLoadingShowcase ||
|
||
isLoadingMoreShowcase
|
||
) {
|
||
return;
|
||
}
|
||
if (showcaseFailedLoadMoreCursorRef.current === showcaseNextCursor) {
|
||
return;
|
||
}
|
||
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
|
||
loadMoreShowcaseResources();
|
||
return;
|
||
}
|
||
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (entries.some((entry) => entry.isIntersecting)) {
|
||
loadMoreShowcaseResources();
|
||
}
|
||
},
|
||
{ rootMargin: '520px 0px', threshold: 0 },
|
||
);
|
||
observer.observe(element);
|
||
return () => observer.disconnect();
|
||
}, [
|
||
isLoadingMoreShowcase,
|
||
isLoadingShowcase,
|
||
loadMoreShowcaseResources,
|
||
showcaseNextCursor,
|
||
]);
|
||
|
||
const createProject = useCallback(
|
||
(tool?: CreationFeatureTool) => {
|
||
if (authUi && !authUi.user) {
|
||
authUi.openLoginModal(() => createProject(tool));
|
||
return;
|
||
}
|
||
setIsCreatingProject(true);
|
||
setProjectError(null);
|
||
createEditorProject()
|
||
.then((project) =>
|
||
onOpenProject(project.projectId, tool ? { tool } : { guide: true }),
|
||
)
|
||
.catch((error: unknown) => {
|
||
if (isUnauthorizedError(error)) {
|
||
authUi?.openLoginModal(() => createProject(tool));
|
||
return;
|
||
}
|
||
setProjectError(
|
||
error instanceof Error ? error.message : '创建项目失败',
|
||
);
|
||
})
|
||
.finally(() => setIsCreatingProject(false));
|
||
},
|
||
[authUi, onOpenProject],
|
||
);
|
||
|
||
const handleFeatureClick = useCallback(
|
||
(feature: CreationFeatureItem) => {
|
||
if (feature.action.kind === 'pending') {
|
||
setIsPendingFeatureDialogOpen(true);
|
||
return;
|
||
}
|
||
createProject(feature.action.tool);
|
||
},
|
||
[createProject],
|
||
);
|
||
|
||
const updateShowcaseViewerState = useCallback(
|
||
(showcaseId: string, likeCount: number, viewerLiked: boolean) => {
|
||
setProjectShowcaseResources((current) =>
|
||
current.map((resource) =>
|
||
resource.showcaseId === showcaseId ||
|
||
resource.resourceId === showcaseId
|
||
? { ...resource, likeCount, viewerLiked }
|
||
: resource,
|
||
),
|
||
);
|
||
setSelectedShowcaseItem((current) =>
|
||
current?.showcaseId === showcaseId
|
||
? { ...current, likeCount, viewerLiked }
|
||
: current,
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const handleToggleShowcaseLike = useCallback(
|
||
(item: ShowcaseAssetItem) => {
|
||
const showcaseId = item.showcaseId?.trim();
|
||
if (!showcaseId) {
|
||
return;
|
||
}
|
||
if (!authUi?.user) {
|
||
authUi?.openLoginModal();
|
||
return;
|
||
}
|
||
if (pendingLikeShowcaseIds.has(showcaseId)) {
|
||
return;
|
||
}
|
||
|
||
const nextLiked = item.viewerLiked !== true;
|
||
const requestState = { ...showcaseRequestStateRef.current };
|
||
const requestViewerKey = showcaseViewerKey;
|
||
const isCurrentRequest = () =>
|
||
showcaseRequestStateRef.current.scope === requestState.scope &&
|
||
showcaseRequestStateRef.current.generation ===
|
||
requestState.generation &&
|
||
requestViewerKey === showcaseViewerKey;
|
||
setShowcaseLikeError(null);
|
||
setPendingLikeShowcaseIds((current) => new Set(current).add(showcaseId));
|
||
|
||
toggleEditorShowcaseAssetLike(showcaseId, nextLiked)
|
||
.then((record) => {
|
||
if (!isCurrentRequest()) {
|
||
return;
|
||
}
|
||
updateShowcaseViewerState(
|
||
showcaseId,
|
||
record.likeCount ?? item.likeCount ?? 0,
|
||
record.viewerLiked,
|
||
);
|
||
})
|
||
.catch(() => {
|
||
if (isCurrentRequest()) {
|
||
setShowcaseLikeError(
|
||
nextLiked ? '点赞失败,请重试' : '取消点赞失败,请重试',
|
||
);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!isCurrentRequest()) {
|
||
return;
|
||
}
|
||
setPendingLikeShowcaseIds((current) => {
|
||
const next = new Set(current);
|
||
next.delete(showcaseId);
|
||
return next;
|
||
});
|
||
});
|
||
},
|
||
[
|
||
authUi,
|
||
pendingLikeShowcaseIds,
|
||
showcaseViewerKey,
|
||
updateShowcaseViewerState,
|
||
],
|
||
);
|
||
|
||
const showcaseItems = useMemo(
|
||
() =>
|
||
buildCreationShowcaseItems({
|
||
activeTab: activeShowcaseTab,
|
||
projectResources: projectShowcaseResources,
|
||
campaign: showcaseCampaign,
|
||
fallbackAuthorName: null,
|
||
}),
|
||
[activeShowcaseTab, projectShowcaseResources, showcaseCampaign],
|
||
);
|
||
const visibleShowcaseItems = useMemo(() => {
|
||
if (!normalizedSearchKeyword) {
|
||
return showcaseItems;
|
||
}
|
||
return showcaseItems.filter((item) =>
|
||
[item.label, item.author, item.prompt].some((value) =>
|
||
value.toLocaleLowerCase().includes(normalizedSearchKeyword),
|
||
),
|
||
);
|
||
}, [normalizedSearchKeyword, showcaseItems]);
|
||
const showcaseMasonryOrderKey = JSON.stringify(
|
||
visibleShowcaseItems.map((item) => item.id),
|
||
);
|
||
const showcaseMasonryRef = useSequentialShowcaseMasonry(
|
||
showcaseMasonryOrderKey,
|
||
visibleShowcaseItems.length,
|
||
);
|
||
const visibleRecentProjects = useMemo(() => {
|
||
if (!normalizedSearchKeyword) {
|
||
return recentProjects;
|
||
}
|
||
return recentProjects.filter((project) =>
|
||
project.title.toLocaleLowerCase().includes(normalizedSearchKeyword),
|
||
);
|
||
}, [normalizedSearchKeyword, recentProjects]);
|
||
|
||
const isShowcaseLoading = isLoadingShowcase;
|
||
const showcaseEmptyText = '暂无素材';
|
||
const renderShowcaseLoadMoreSentinel = () =>
|
||
showcaseNextCursor || isLoadingMoreShowcase || showcaseLoadMoreError ? (
|
||
<div
|
||
ref={showcaseLoadMoreRef}
|
||
className="creation-landing__asset-load-more"
|
||
aria-live="polite"
|
||
>
|
||
{isLoadingMoreShowcase ? '正在读取更多素材' : null}
|
||
{!isLoadingMoreShowcase && showcaseLoadMoreError ? (
|
||
<button
|
||
type="button"
|
||
className="creation-landing__asset-load-more-button"
|
||
onClick={() => loadMoreShowcaseResources({ force: true })}
|
||
>
|
||
读取更多素材失败,点击重试
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : null;
|
||
|
||
const renderAssetShowcase = () => {
|
||
if (assetError) {
|
||
return (
|
||
<PlatformStatusMessage tone="error" surface="platform" role="alert">
|
||
{assetError}
|
||
</PlatformStatusMessage>
|
||
);
|
||
}
|
||
if (isShowcaseLoading) {
|
||
return (
|
||
<PlatformEmptyState surface="subpanel" size="inline">
|
||
正在读取素材
|
||
</PlatformEmptyState>
|
||
);
|
||
}
|
||
if (visibleShowcaseItems.length === 0) {
|
||
return (
|
||
<>
|
||
<PlatformEmptyState surface="subpanel" size="inline">
|
||
{showcaseNextCursor || isLoadingMoreShowcase
|
||
? '正在读取更多素材'
|
||
: normalizedSearchKeyword
|
||
? '没有匹配素材'
|
||
: showcaseEmptyText}
|
||
</PlatformEmptyState>
|
||
{renderShowcaseLoadMoreSentinel()}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{showcaseLikeError ? (
|
||
<PlatformStatusMessage tone="error" surface="platform" role="alert">
|
||
{showcaseLikeError}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
<div
|
||
ref={showcaseMasonryRef}
|
||
className="creation-landing__asset-waterfall"
|
||
aria-label="陶泥儿精选素材列表"
|
||
role="list"
|
||
>
|
||
{visibleShowcaseItems.map((item) => {
|
||
const showcaseId = item.showcaseId?.trim();
|
||
const isLiked = item.viewerLiked === true;
|
||
const isLikePending = Boolean(
|
||
showcaseId && pendingLikeShowcaseIds.has(showcaseId),
|
||
);
|
||
return (
|
||
<article
|
||
key={item.id}
|
||
className="creation-landing__asset-card"
|
||
role="listitem"
|
||
>
|
||
<button
|
||
type="button"
|
||
className="creation-landing__asset-card-open"
|
||
onPointerEnter={() =>
|
||
setHoveredShowcaseSequenceCardId(item.id)
|
||
}
|
||
onPointerLeave={() =>
|
||
setHoveredShowcaseSequenceCardId((current) =>
|
||
current === item.id ? null : current,
|
||
)
|
||
}
|
||
onFocus={() => setFocusedShowcaseSequenceCardId(item.id)}
|
||
onBlur={() =>
|
||
setFocusedShowcaseSequenceCardId((current) =>
|
||
current === item.id ? null : current,
|
||
)
|
||
}
|
||
onClick={() => {
|
||
setSelectedShowcaseItem(item);
|
||
setSelectedShowcaseIndex(0);
|
||
}}
|
||
>
|
||
<ShowcaseBundlePreview
|
||
item={item}
|
||
playSequences={
|
||
!prefersReducedMotion &&
|
||
(hoveredShowcaseSequenceCardId === item.id ||
|
||
focusedShowcaseSequenceCardId === item.id) &&
|
||
!selectedShowcaseItem
|
||
}
|
||
/>
|
||
<div className="creation-landing__asset-meta">
|
||
<h3>{item.label}</h3>
|
||
<p>{item.prompt}</p>
|
||
<dl>
|
||
<div>
|
||
<dt>作者</dt>
|
||
<dd>{item.author}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>生成总成本</dt>
|
||
<dd>{item.cost}</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
</button>
|
||
{showcaseId ? (
|
||
<button
|
||
type="button"
|
||
className={
|
||
isLiked
|
||
? 'creation-landing__asset-like-button creation-landing__asset-like-button--active'
|
||
: 'creation-landing__asset-like-button'
|
||
}
|
||
aria-label={`${isLiked ? '取消点赞' : '点赞'}:${item.label}`}
|
||
aria-busy={isLikePending}
|
||
disabled={isLikePending}
|
||
onClick={() => handleToggleShowcaseLike(item)}
|
||
>
|
||
<img
|
||
src={SHOWCASE_LIKE_ICON_SRC}
|
||
alt=""
|
||
aria-hidden="true"
|
||
/>
|
||
<span>{item.likeCount ?? 0}</span>
|
||
</button>
|
||
) : null}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
{renderShowcaseLoadMoreSentinel()}
|
||
</>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<main className="creation-landing" aria-label="陶泥儿创作主页">
|
||
<section className="creation-landing__hero">
|
||
<div className="creation-landing__hero-copy">
|
||
<span className="creation-landing__eyebrow">
|
||
陶泥儿 Genarrative|游戏美术 AI 创作工具
|
||
</span>
|
||
<h1>陶泥儿 · 开启全民精品游戏创作</h1>
|
||
<div className="creation-landing__hero-detail">
|
||
<p className="creation-landing__hero-subtitle">
|
||
面向个人创作者的游戏美术 AI 工作台。
|
||
<br />
|
||
用美术 Agent 与无限画布,快速制作角色、场景、UI 与宣发素材。
|
||
</p>
|
||
<p className="creation-landing__hero-benefit">
|
||
登录即送 100 泥点,可以免费制作 50 个素材
|
||
</p>
|
||
</div>
|
||
<div className="creation-landing__hero-actions">
|
||
<PlatformActionButton
|
||
size="md"
|
||
shape="pill"
|
||
className="creation-landing__hero-action creation-landing__hero-action--primary"
|
||
onClick={() => createProject()}
|
||
disabled={isCreatingProject}
|
||
>
|
||
<img
|
||
src={CREATION_HOME_ASSETS.heroCreate}
|
||
alt=""
|
||
aria-hidden="true"
|
||
draggable={false}
|
||
className="creation-landing__button-art"
|
||
/>
|
||
开始创作
|
||
</PlatformActionButton>
|
||
{onOpenCommunity ? (
|
||
<PlatformActionButton
|
||
tone="secondary"
|
||
size="md"
|
||
shape="pill"
|
||
className="creation-landing__hero-action creation-landing__hero-action--secondary"
|
||
onClick={onOpenCommunity}
|
||
>
|
||
<img
|
||
src={CREATION_HOME_ASSETS.heroCommunity}
|
||
alt=""
|
||
aria-hidden="true"
|
||
draggable={false}
|
||
className="creation-landing__button-art"
|
||
/>
|
||
玩家社区
|
||
</PlatformActionButton>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="creation-landing__hero-board" aria-hidden="true">
|
||
<span className="creation-landing__hero-tile creation-landing__hero-tile--main" />
|
||
<span className="creation-landing__hero-tile creation-landing__hero-tile--side" />
|
||
<span className="creation-landing__hero-tile creation-landing__hero-tile--small" />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="creation-landing__section">
|
||
<div className="creation-landing__section-header">
|
||
<div>
|
||
<h2>游戏美术 AI 创作工具</h2>
|
||
</div>
|
||
</div>
|
||
<div className="creation-landing__feature-grid">
|
||
{CREATION_FEATURES.map((feature) => (
|
||
<button
|
||
key={feature.title}
|
||
type="button"
|
||
className="creation-landing__feature"
|
||
onClick={() => handleFeatureClick(feature)}
|
||
disabled={isCreatingProject && feature.action.kind === 'tool'}
|
||
>
|
||
<span className="creation-landing__feature-figure">
|
||
<span className="creation-landing__feature-icon">
|
||
<img
|
||
src={feature.iconSrc}
|
||
alt=""
|
||
aria-hidden="true"
|
||
loading="lazy"
|
||
draggable={false}
|
||
className="creation-landing__feature-art"
|
||
/>
|
||
</span>
|
||
</span>
|
||
<span className="creation-landing__feature-copy">
|
||
<h3>{feature.title}</h3>
|
||
<p>{feature.description}</p>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{isAuthenticated ? (
|
||
<section className="creation-landing__section">
|
||
<div className="creation-landing__section-header">
|
||
<div>
|
||
<h2>最近项目</h2>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="creation-landing__link-button"
|
||
onClick={onOpenProjects}
|
||
>
|
||
查看全部
|
||
</button>
|
||
</div>
|
||
{projectError ? (
|
||
<PlatformStatusMessage tone="error" surface="platform" role="alert">
|
||
{projectError}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
<div className="creation-landing__project-row">
|
||
<button
|
||
type="button"
|
||
className="creation-landing__project-card creation-landing__project-card--new"
|
||
onClick={() => createProject()}
|
||
disabled={isCreatingProject}
|
||
>
|
||
<span>
|
||
<img
|
||
src={CREATION_HOME_ASSETS.projectNew}
|
||
alt=""
|
||
aria-hidden="true"
|
||
draggable={false}
|
||
className="creation-landing__project-new-art"
|
||
/>
|
||
</span>
|
||
新建项目
|
||
</button>
|
||
{isLoadingProjects ? (
|
||
<PlatformEmptyState surface="subpanel" size="inline">
|
||
正在读取项目
|
||
</PlatformEmptyState>
|
||
) : (
|
||
visibleRecentProjects.map((project) => {
|
||
const coverSnapshot =
|
||
resolveProjectCoverSnapshotResource(project);
|
||
return (
|
||
<button
|
||
key={project.projectId}
|
||
type="button"
|
||
className="creation-landing__project-card"
|
||
onClick={() => onOpenProject(project.projectId)}
|
||
>
|
||
<PlatformMediaFrame
|
||
src={coverSnapshot?.imageSrc ?? null}
|
||
objectKey={coverSnapshot?.objectKey ?? null}
|
||
alt=""
|
||
fallbackLabel="项目"
|
||
aspect="standard"
|
||
surface="bright"
|
||
refreshKey={
|
||
coverSnapshot?.updatedAt ??
|
||
coverSnapshot?.createdAt ??
|
||
coverSnapshot?.resourceId ??
|
||
null
|
||
}
|
||
className="creation-landing__project-cover"
|
||
/>
|
||
<span className="creation-landing__project-meta">
|
||
<strong>{project.title}</strong>
|
||
<small>{formatProjectUpdatedAt(project.updatedAt)}</small>
|
||
</span>
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<section className="creation-landing__section">
|
||
<div className="creation-landing__section-header">
|
||
<div>
|
||
<h2>陶泥儿精选</h2>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="creation-landing__tabs"
|
||
role="tablist"
|
||
aria-label="用户素材分类"
|
||
>
|
||
{SHOWCASE_TABS.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={activeShowcaseTab === tab.id}
|
||
className={
|
||
activeShowcaseTab === tab.id
|
||
? 'creation-landing__tab creation-landing__tab--active'
|
||
: 'creation-landing__tab'
|
||
}
|
||
onClick={() => setActiveShowcaseTab(tab.id)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{renderAssetShowcase()}
|
||
</section>
|
||
<CreationShowcaseModal
|
||
item={selectedShowcaseItem}
|
||
activeIndex={selectedShowcaseIndex}
|
||
playSequences={!prefersReducedMotion}
|
||
onSelectIndex={setSelectedShowcaseIndex}
|
||
onClose={() => setSelectedShowcaseItem(null)}
|
||
/>
|
||
<UnifiedModal
|
||
open={isPendingFeatureDialogOpen}
|
||
title="抱歉"
|
||
onClose={() => setIsPendingFeatureDialogOpen(false)}
|
||
showHeader={false}
|
||
showCloseButton={false}
|
||
size="sm"
|
||
portalTheme="light"
|
||
overlayClassName="platform-mobile-home-welcome-overlay !items-center !p-4"
|
||
panelClassName="platform-remap-surface platform-mobile-home-welcome-dialog"
|
||
bodyClassName="platform-mobile-home-welcome-dialog__body"
|
||
footerClassName="platform-mobile-home-welcome-dialog__footer"
|
||
footer={
|
||
<PlatformActionButton
|
||
onClick={() => setIsPendingFeatureDialogOpen(false)}
|
||
size="md"
|
||
shape="pill"
|
||
fullWidth
|
||
className="min-h-11"
|
||
>
|
||
期待一下
|
||
</PlatformActionButton>
|
||
}
|
||
>
|
||
<img
|
||
src="/branding/taonier-product-ip.png"
|
||
alt=""
|
||
className="platform-mobile-home-welcome-dialog__icon"
|
||
/>
|
||
<h2 className="platform-mobile-home-welcome-dialog__title">抱歉</h2>
|
||
<p className="platform-mobile-home-welcome-dialog__copy">
|
||
功能还在调试中
|
||
<br />
|
||
暂未开放
|
||
</p>
|
||
</UnifiedModal>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export default CreationLandingView;
|