This commit is contained in:
356
src/components/game-shell/PlatformHomeView.tsx
Normal file
356
src/components/game-shell/PlatformHomeView.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorldTime,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './platformWorldPresentation';
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
detail,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
title: string;
|
||||
detail: string;
|
||||
actionLabel?: string;
|
||||
onAction?: (() => void) | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
{detail}
|
||||
</div>
|
||||
<div className="mt-1 text-base font-bold text-white">{title}</div>
|
||||
</div>
|
||||
{actionLabel && onAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
className="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyShelf({
|
||||
text,
|
||||
}: {
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel rounded-[1.35rem] text-sm leading-6 text-zinc-300"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorldCard({
|
||||
entry,
|
||||
badge,
|
||||
metaLabel,
|
||||
onClick,
|
||||
}: {
|
||||
entry: PlatformWorldCardLike;
|
||||
badge: string;
|
||||
metaLabel: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const tags = buildPlatformWorldTags(entry);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="pixel-nine-slice pixel-pressable relative flex h-[15rem] w-[15.25rem] shrink-0 flex-col overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 14 })}
|
||||
>
|
||||
{coverImage ? (
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-40"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<img
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.9))]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-100">
|
||||
{badge}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||
{metaLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="line-clamp-1 text-xl font-black text-white">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-zinc-300/85">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-2 text-xs leading-5 text-zinc-200/90">
|
||||
{entry.summaryText || '等待补充世界摘要。'}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||
{describePlatformThemeLabel(entry.themeMode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformHomeView({
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
featuredEntries,
|
||||
latestEntries,
|
||||
myEntries,
|
||||
isLoadingPlatform,
|
||||
platformError,
|
||||
onContinueGame,
|
||||
onRefresh,
|
||||
onOpenCreateWorld,
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
}: {
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
featuredEntries: CustomWorldGalleryCard[];
|
||||
latestEntries: CustomWorldGalleryCard[];
|
||||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
isLoadingPlatform: boolean;
|
||||
platformError: string | null;
|
||||
onContinueGame: () => void;
|
||||
onRefresh: () => void;
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||
onOpenLibraryDetail: (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const snapshotWorldName =
|
||||
savedSnapshot?.gameState.customWorldProfile?.name ??
|
||||
savedSnapshot?.gameState.currentScenePreset?.name ??
|
||||
'继续冒险';
|
||||
const snapshotCharacterName =
|
||||
savedSnapshot?.gameState.playerCharacter?.title ??
|
||||
savedSnapshot?.gameState.playerCharacter?.name ??
|
||||
'旅人';
|
||||
const featuredShelf = featuredEntries.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-amber-300/20 bg-amber-500/10">
|
||||
<PixelIcon src={CHROME_ICONS.refreshOptions} className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
GENARRATIVE PLATFORM
|
||||
</div>
|
||||
<div className="truncate text-lg font-black text-white">
|
||||
自定义世界广场
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="rounded-full border border-white/10 bg-black/25 px-3 py-2 text-[11px] text-zinc-200 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
{authUi?.user ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="rounded-full border border-white/10 bg-black/25 px-3 py-2 text-[11px] text-zinc-100 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
{authUi.user.displayName}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||
<div className="space-y-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(250,204,21,0.16),transparent_36%),linear-gradient(135deg,rgba(15,23,42,0.78),rgba(8,10,14,0.95))]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-amber-100">
|
||||
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
|
||||
</span>
|
||||
<div className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] text-zinc-100">
|
||||
{hasSavedGame ? '继续冒险' : '创建世界'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
{hasSavedGame ? snapshotWorldName : '把第一页变成你的作品页'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
{hasSavedGame
|
||||
? `${snapshotCharacterName} 的上一次冒险已保存在云端,点这里直接回到故事现场。`
|
||||
: '从设定、角色到场景网络,一次生成一部可游玩的自定义 RPG,再决定是否发布到广场。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="精选推荐"
|
||||
detail="为你挑选"
|
||||
actionLabel="看看最新"
|
||||
onAction={onRefresh}
|
||||
/>
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取精选作品..." />
|
||||
) : featuredShelf.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{featuredShelf.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:featured`}
|
||||
entry={entry}
|
||||
badge="推荐"
|
||||
metaLabel={describePlatformThemeLabel(entry.themeMode)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="最新发布" detail="玩家广场" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取最新发布..." />
|
||||
) : latestEntries.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{latestEntries.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有新作品。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="我的作品" detail="草稿与已发布" />
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateWorld}
|
||||
className="pixel-nine-slice pixel-pressable relative min-h-[13rem] overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.92))]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/20 bg-sky-500/10">
|
||||
<PixelIcon src={CHROME_ICONS.refreshOptions} className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="text-2xl font-black text-white">
|
||||
创建新世界
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
新建一个只属于你的世界设定,生成后先进入草稿库,再决定要不要发布。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{myEntries.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
metaLabel={entry.visibility === 'published' ? formatPlatformWorldTime(entry.publishedAt) : '仅自己可见'}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isLoadingPlatform && myEntries.length === 0 ? (
|
||||
<div className="mt-3">
|
||||
<EmptyShelf text="你还没有保存任何自定义世界,先创建一个草稿开始吧。" />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user