1551 lines
58 KiB
TypeScript
1551 lines
58 KiB
TypeScript
import {
|
||
Archive,
|
||
ArrowRight,
|
||
Bell,
|
||
BookOpen,
|
||
Camera,
|
||
ChevronRight,
|
||
Clock3,
|
||
Coins,
|
||
Copy,
|
||
Crown,
|
||
House,
|
||
MessageCircle,
|
||
Pencil,
|
||
Search,
|
||
Settings,
|
||
Sparkles,
|
||
Ticket,
|
||
UserPlus,
|
||
UserRound,
|
||
} from 'lucide-react';
|
||
import { type ComponentType, useMemo } from 'react';
|
||
|
||
import type {
|
||
CustomWorldGalleryCard,
|
||
CustomWorldLibraryEntry,
|
||
ProfileDashboardCardKey,
|
||
ProfileDashboardSummary,
|
||
ProfileSaveArchiveSummary,
|
||
} from '../../../packages/shared/src/contracts/runtime';
|
||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||
import type { AuthUser } from '../../services/authService';
|
||
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
|
||
import type { CustomWorldProfile } from '../../types';
|
||
import { useAuthUi } from '../auth/AuthUiContext';
|
||
import { PlatformBrandLogo } from './PlatformBrandLogo';
|
||
import {
|
||
buildPlatformWorldTags,
|
||
describePlatformThemeLabel,
|
||
formatPlatformWorldTime,
|
||
type PlatformWorldCardLike,
|
||
resolvePlatformWorldCoverImage,
|
||
resolvePlatformWorldLeadPortrait,
|
||
} from './platformWorldPresentation';
|
||
|
||
export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
|
||
|
||
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
|
||
const HERO_SURFACE_CLASS =
|
||
'platform-surface platform-surface--hero platform-interactive-card';
|
||
const MOBILE_PAGE_STAGE_CLASS =
|
||
'platform-page-stage platform-remap-surface space-y-4 pb-2';
|
||
const DESKTOP_PAGE_STAGE_CLASS =
|
||
'platform-page-stage platform-remap-surface space-y-5 pb-4';
|
||
|
||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||
return (
|
||
<div className="mb-3">
|
||
<div className="text-[10px] font-semibold tracking-[0.26em] text-zinc-500">
|
||
{detail}
|
||
</div>
|
||
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||
{title}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyShelf({ text }: { text: string }) {
|
||
return (
|
||
<div
|
||
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`}
|
||
>
|
||
{text}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SaveArchivePreview({
|
||
entry,
|
||
label,
|
||
className,
|
||
}: {
|
||
entry: ProfileSaveArchiveSummary;
|
||
label: string;
|
||
className: string;
|
||
}) {
|
||
return (
|
||
<div
|
||
aria-hidden="true"
|
||
className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`}
|
||
>
|
||
{entry.coverImageSrc ? (
|
||
<img
|
||
src={entry.coverImageSrc}
|
||
alt=""
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_34%),linear-gradient(145deg,rgba(255,94,125,0.92),rgba(255,150,116,0.88))]" />
|
||
)}
|
||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
|
||
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
|
||
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88">
|
||
{label}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WorldCard({
|
||
entry,
|
||
badge,
|
||
metaLabel,
|
||
onClick,
|
||
className,
|
||
}: {
|
||
entry: PlatformWorldCardLike;
|
||
badge: string;
|
||
metaLabel: string;
|
||
onClick: () => void;
|
||
className?: string;
|
||
}) {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||
const tags = [
|
||
...new Set(
|
||
buildPlatformWorldTags(entry)
|
||
.map((tag) => tag.trim())
|
||
.filter(Boolean),
|
||
),
|
||
].slice(0, 3);
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[15.25rem] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
|
||
>
|
||
{coverImage ? (
|
||
<img
|
||
src={coverImage}
|
||
alt={entry.worldName}
|
||
className="absolute inset-0 h-full w-full object-cover opacity-40"
|
||
/>
|
||
) : null}
|
||
{leadPortrait ? (
|
||
<img
|
||
src={leadPortrait}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||
<div className="relative z-10 flex h-full flex-col">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<span className="platform-pill platform-pill--warm">{badge}</span>
|
||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||
{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, index) => (
|
||
<span
|
||
key={`world-tag-${index}-${tag || 'empty'}`}
|
||
className="platform-pill platform-pill--neutral px-2.5"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))
|
||
) : (
|
||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||
{describePlatformThemeLabel(entry.themeMode)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function CreationLibraryCard({
|
||
entry,
|
||
onClick,
|
||
}: {
|
||
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
|
||
onClick: () => void;
|
||
}) {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||
const statusLabel = entry.visibility === 'published' ? '已发布' : '草稿';
|
||
const metaLabel =
|
||
entry.visibility === 'published'
|
||
? formatPlatformWorldTime(entry.publishedAt)
|
||
: '仅自己可见';
|
||
const primaryTag =
|
||
buildPlatformWorldTags(entry)
|
||
.map((tag) => tag.trim())
|
||
.filter(Boolean)[0] ?? describePlatformThemeLabel(entry.themeMode);
|
||
const summaryText =
|
||
entry.summaryText || entry.subtitle || '继续补完这个世界的设定与游玩入口。';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="platform-surface platform-interactive-card relative flex min-h-[13rem] w-full min-w-0 flex-col overflow-hidden px-3 py-3 text-left sm:min-h-[14rem] sm:px-3.5 sm:py-3.5"
|
||
>
|
||
{coverImage ? (
|
||
<img
|
||
src={coverImage}
|
||
alt={entry.worldName}
|
||
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||
/>
|
||
) : null}
|
||
{leadPortrait ? (
|
||
<img
|
||
src={leadPortrait}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="absolute bottom-1.5 right-1.5 h-16 w-16 object-contain opacity-24 sm:h-20 sm:w-20"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||
<div className="relative z-10 flex h-full min-w-0 flex-col">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||
<span
|
||
className={`inline-flex items-center rounded-full border px-2 py-1 text-[10px] font-semibold tracking-[0.12em] ${
|
||
entry.visibility === 'published'
|
||
? 'border-emerald-300/25 bg-emerald-500/12 text-emerald-50'
|
||
: 'border-amber-300/25 bg-amber-500/12 text-amber-50'
|
||
}`}
|
||
>
|
||
{statusLabel}
|
||
</span>
|
||
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-zinc-300">
|
||
<span className="truncate">{metaLabel}</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-auto min-w-0">
|
||
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-white sm:text-[1.12rem]">
|
||
{entry.worldName}
|
||
</div>
|
||
{entry.subtitle ? (
|
||
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-zinc-300/84">
|
||
{entry.subtitle}
|
||
</div>
|
||
) : null}
|
||
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-zinc-200/88 sm:text-xs">
|
||
{summaryText}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-zinc-100/90">
|
||
<span className="truncate">{primaryTag}</span>
|
||
</span>
|
||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200">
|
||
<span>
|
||
{entry.visibility === 'published' ? '进入世界' : '继续创作'}
|
||
</span>
|
||
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function SaveArchiveCard({
|
||
entry,
|
||
onClick,
|
||
loading = false,
|
||
}: {
|
||
entry: ProfileSaveArchiveSummary;
|
||
onClick: () => void;
|
||
loading?: boolean;
|
||
}) {
|
||
const summaryText =
|
||
entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={loading}
|
||
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[13rem] w-full overflow-hidden p-3.5 text-left sm:min-h-[12.5rem] sm:p-4 ${loading ? 'opacity-80' : ''}`}
|
||
>
|
||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-deep)]" />
|
||
<div className="relative z-10 flex h-full w-full flex-col gap-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<span className="platform-pill platform-pill--cool">ARCHIVE</span>
|
||
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
|
||
</span>
|
||
</div>
|
||
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-white sm:text-xl">
|
||
{entry.worldName}
|
||
</div>
|
||
{entry.subtitle ? (
|
||
<div className="mt-1 line-clamp-1 break-words text-sm text-zinc-300">
|
||
{entry.subtitle}
|
||
</div>
|
||
) : null}
|
||
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-zinc-400 sm:text-sm">
|
||
{summaryText}
|
||
</div>
|
||
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
|
||
<span>{loading ? '正在恢复' : '继续游玩'}</span>
|
||
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
|
||
</div>
|
||
</div>
|
||
<SaveArchivePreview
|
||
entry={entry}
|
||
label={loading ? '恢复中' : '最近存档'}
|
||
className="h-[7.4rem] w-[5.6rem] sm:h-[8rem] sm:w-[6.4rem]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlatformTabButton({
|
||
active,
|
||
label,
|
||
icon: Icon,
|
||
onClick,
|
||
}: {
|
||
active: boolean;
|
||
label: string;
|
||
icon: ComponentType<{ className?: string }>;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`}
|
||
>
|
||
<span className="flex flex-col items-center justify-center gap-1">
|
||
<span className="platform-bottom-nav__icon-shell">
|
||
<Icon className="platform-bottom-nav__icon h-[1.05rem] w-[1.05rem]" />
|
||
</span>
|
||
<span className="platform-bottom-nav__label text-[11px] font-semibold tracking-[0.18em]">
|
||
{label}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function DesktopTabButton({
|
||
active,
|
||
label,
|
||
icon: Icon,
|
||
onClick,
|
||
}: {
|
||
active: boolean;
|
||
label: string;
|
||
icon: ComponentType<{ className?: string }>;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={`platform-desktop-rail__button ${active ? 'platform-desktop-rail__button--active' : ''}`}
|
||
>
|
||
<span className="platform-desktop-rail__icon-shell">
|
||
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
|
||
</span>
|
||
<span className="platform-desktop-rail__label text-[11px] font-semibold tracking-[0.2em]">
|
||
{label}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function DesktopTrendingItem({
|
||
entry,
|
||
rank,
|
||
onClick,
|
||
}: {
|
||
entry: CustomWorldGalleryCard;
|
||
rank: number;
|
||
onClick: () => void;
|
||
}) {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const tags = buildPlatformWorldTags(entry).filter(Boolean).slice(0, 2);
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="platform-desktop-trending-item flex items-center gap-4 px-4 py-4 text-left"
|
||
>
|
||
<div className="relative h-[5.5rem] w-[4.3rem] shrink-0 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-[rgba(255,255,255,0.66)]">
|
||
{coverImage ? (
|
||
<img
|
||
src={coverImage}
|
||
alt={entry.worldName}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.26))]" />
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
|
||
<span>{`${rank}`.padStart(2, '0')}</span>
|
||
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
|
||
</div>
|
||
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
|
||
{entry.worldName}
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-sm leading-6 text-zinc-300/86">
|
||
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{tags.length > 0 ? (
|
||
tags.map((tag, index) => (
|
||
<span
|
||
key={`${entry.profileId}-trend-tag-${index}-${tag}`}
|
||
className="platform-pill platform-pill--neutral px-2.5"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))
|
||
) : (
|
||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||
{describePlatformThemeLabel(entry.themeMode)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<ChevronRight className="h-4 w-4 shrink-0 text-zinc-500" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function formatSnapshotTime(value: string | null | undefined) {
|
||
if (!value) {
|
||
return '刚刚保存';
|
||
}
|
||
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return value;
|
||
}
|
||
|
||
return date.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function describeLoginMethod(loginMethod: AuthUser['loginMethod']) {
|
||
switch (loginMethod) {
|
||
case 'phone':
|
||
return '手机号';
|
||
case 'wechat':
|
||
return '微信';
|
||
default:
|
||
return '账号密码';
|
||
}
|
||
}
|
||
|
||
function describeBindingStatus(bindingStatus: AuthUser['bindingStatus']) {
|
||
return bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '正常';
|
||
}
|
||
|
||
function formatCompactPlayTime(playTimeMs: number) {
|
||
const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000));
|
||
const days = totalMinutes / 1440;
|
||
|
||
if (days >= 10) {
|
||
return `${Math.floor(days)}天`;
|
||
}
|
||
if (days >= 1) {
|
||
return `${days.toFixed(days >= 3 ? 0 : 1)}天`;
|
||
}
|
||
|
||
const hours = totalMinutes / 60;
|
||
if (hours >= 1) {
|
||
return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`;
|
||
}
|
||
|
||
return `${Math.max(0, totalMinutes)}分`;
|
||
}
|
||
|
||
function formatDashboardCount(value: number) {
|
||
const normalizedValue = Math.max(0, Math.round(value));
|
||
if (normalizedValue >= 100000000) {
|
||
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
|
||
}
|
||
if (normalizedValue >= 10000) {
|
||
return `${(normalizedValue / 10000).toFixed(1)}万`;
|
||
}
|
||
|
||
return normalizedValue.toLocaleString('zh-CN');
|
||
}
|
||
|
||
function formatDashboardUpdatedAt(value: string | null | undefined) {
|
||
if (!value) {
|
||
return '暂无更新记录';
|
||
}
|
||
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return value;
|
||
}
|
||
|
||
return date.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||
const raw =
|
||
user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() ||
|
||
user?.username.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() ||
|
||
'00000000';
|
||
|
||
return `SY-${raw.slice(-8).padStart(8, '0')}`;
|
||
}
|
||
|
||
function getUserAvatarLabel(user: AuthUser | null | undefined) {
|
||
return (user?.displayName || user?.username || '叙')
|
||
.slice(0, 1)
|
||
.toUpperCase();
|
||
}
|
||
|
||
function copyText(value: string) {
|
||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
||
return;
|
||
}
|
||
|
||
void navigator.clipboard.writeText(value);
|
||
}
|
||
|
||
function ProfileStatCard({
|
||
cardKey,
|
||
label,
|
||
value,
|
||
onClick,
|
||
icon,
|
||
}: {
|
||
cardKey: ProfileDashboardCardKey;
|
||
label: string;
|
||
value: string;
|
||
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
|
||
icon: ComponentType<{ className?: string }>;
|
||
}) {
|
||
const Icon = icon;
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||
>
|
||
<div className="flex items-center gap-2 text-zinc-400">
|
||
<Icon className="h-4 w-4" />
|
||
<span className="text-[11px] tracking-[0.16em]">{label}</span>
|
||
</div>
|
||
<div className="mt-3 text-lg font-black text-[var(--platform-text-strong)]">
|
||
{value}
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function ProfileStatCardSkeleton() {
|
||
return (
|
||
<div className="platform-subpanel rounded-[1.35rem] px-4 py-3">
|
||
<div className="h-4 w-20 animate-pulse rounded-full bg-white/10" />
|
||
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-white/12" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProfileShortcutButton({
|
||
label,
|
||
icon,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
icon: ComponentType<{ className?: string }>;
|
||
onClick?: (() => void) | null;
|
||
}) {
|
||
const Icon = icon;
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick ?? undefined}
|
||
className="platform-subpanel flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||
>
|
||
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
|
||
<Icon className="h-[1.125rem] w-[1.125rem]" />
|
||
</div>
|
||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
{label}
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function PlatformHomeView({
|
||
activeTab,
|
||
onTabChange,
|
||
hasSavedGame,
|
||
savedSnapshot,
|
||
saveEntries,
|
||
saveError,
|
||
featuredEntries,
|
||
latestEntries,
|
||
myEntries,
|
||
historyEntries,
|
||
profileDashboard,
|
||
isLoadingPlatform,
|
||
isLoadingDashboard,
|
||
isResumingSaveWorldKey,
|
||
platformError,
|
||
dashboardError,
|
||
onContinueGame,
|
||
onResumeSave,
|
||
onOpenCreateWorld,
|
||
onOpenCreateTypePicker,
|
||
onOpenGalleryDetail,
|
||
onOpenLibraryDetail,
|
||
onOpenProfileDashboardCard,
|
||
}: {
|
||
activeTab: PlatformHomeTab;
|
||
onTabChange: (tab: PlatformHomeTab) => void;
|
||
hasSavedGame: boolean;
|
||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||
saveEntries: ProfileSaveArchiveSummary[];
|
||
saveError: string | null;
|
||
featuredEntries: CustomWorldGalleryCard[];
|
||
latestEntries: CustomWorldGalleryCard[];
|
||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||
historyEntries: PlatformBrowseHistoryEntry[];
|
||
profileDashboard: ProfileDashboardSummary | null;
|
||
isLoadingPlatform: boolean;
|
||
isLoadingDashboard: boolean;
|
||
isResumingSaveWorldKey: string | null;
|
||
platformError: string | null;
|
||
dashboardError: string | null;
|
||
onContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
|
||
onOpenCreateWorld: () => void;
|
||
onOpenCreateTypePicker: () => void;
|
||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||
onOpenLibraryDetail: (
|
||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||
) => void;
|
||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||
}) {
|
||
const authUi = useAuthUi();
|
||
const isAuthenticated = Boolean(authUi?.user);
|
||
const featuredShelf = useMemo(
|
||
() => featuredEntries.slice(0, 6),
|
||
[featuredEntries],
|
||
);
|
||
const snapshotWorldName =
|
||
savedSnapshot?.gameState.customWorldProfile?.name ??
|
||
savedSnapshot?.gameState.currentScenePreset?.name ??
|
||
'继续冒险';
|
||
const snapshotCharacterName =
|
||
savedSnapshot?.gameState.playerCharacter?.title ??
|
||
savedSnapshot?.gameState.playerCharacter?.name ??
|
||
'旅人';
|
||
const snapshotDigest =
|
||
savedSnapshot?.gameState.storyEngineMemory?.continueGameDigest ??
|
||
savedSnapshot?.currentStory?.text ??
|
||
savedSnapshot?.gameState.customWorldProfile?.summary ??
|
||
'上一次冒险已经保存,可以从这里继续推进故事。';
|
||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||
const avatarLabel = getUserAvatarLabel(authUi?.user);
|
||
const remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0;
|
||
const totalPlayTime = formatCompactPlayTime(
|
||
profileDashboard?.totalPlayTimeMs ?? 0,
|
||
);
|
||
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
|
||
const tabIcons = {
|
||
home: House,
|
||
create: Sparkles,
|
||
saves: Archive,
|
||
profile: UserRound,
|
||
} as const;
|
||
const openUserSurface = () => {
|
||
if (authUi?.user) {
|
||
authUi.openAccountModal();
|
||
return;
|
||
}
|
||
authUi?.openLoginModal();
|
||
};
|
||
const desktopHeroEntry =
|
||
featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null;
|
||
const desktopHeroCover = desktopHeroEntry
|
||
? resolvePlatformWorldCoverImage(desktopHeroEntry)
|
||
: null;
|
||
const desktopHeroStripEntries = (
|
||
featuredShelf.length > 0 ? featuredShelf : latestEntries
|
||
).slice(0, 5);
|
||
const desktopTrendingEntries = latestEntries.slice(0, 3);
|
||
const desktopFeaturedGrid = featuredShelf.slice(0, 4);
|
||
const desktopReleaseGrid = latestEntries.slice(0, 6);
|
||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||
|
||
let content = (
|
||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (hasSavedGame) {
|
||
onContinueGame();
|
||
return;
|
||
}
|
||
|
||
onOpenCreateWorld();
|
||
}}
|
||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||
>
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,47,112,0.92),rgba(255,136,104,0.9))]" />
|
||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<span className="platform-pill platform-pill--warm">
|
||
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
|
||
</span>
|
||
<div className="platform-pill platform-pill--neutral px-3 text-[11px] tracking-[0.08em]">
|
||
{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} 的进度已保存,点这里回到上一次停下来的故事节点。`
|
||
: '从设定、角色到场景网络,先生成一版可玩的世界底稿,再继续精修和发布。'}
|
||
</div>
|
||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||
<span>{hasSavedGame ? '继续推进故事' : '进入创作工作台'}</span>
|
||
<ArrowRight className="h-4 w-4" />
|
||
</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="为你挑选" />
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在读取精选作品..." />
|
||
) : featuredShelf.length > 0 ? (
|
||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||
{featuredShelf.map((entry: CustomWorldGalleryCard) => (
|
||
<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: CustomWorldGalleryCard) => (
|
||
<WorldCard
|
||
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
|
||
entry={entry}
|
||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||
metaLabel={entry.authorDisplayName}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf text="公开广场暂时还没有新作品。" />
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
|
||
if (activeTab === 'create') {
|
||
content = (
|
||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||
<button
|
||
type="button"
|
||
onClick={onOpenCreateTypePicker}
|
||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
|
||
>
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.16),transparent_38%),radial-gradient(circle_at_right,rgba(255,201,172,0.18),transparent_30%),linear-gradient(180deg,rgba(255,90,141,0.88),rgba(255,144,105,0.88))]" />
|
||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||
<span className="platform-pill platform-pill--cool w-fit">
|
||
CREATE
|
||
</span>
|
||
<div>
|
||
<div className="text-3xl font-black text-white">开启新的创作</div>
|
||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||
</div>
|
||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||
<span>选择类型并继续</span>
|
||
<ArrowRight className="h-4 w-4" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
<section>
|
||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在读取你的作品..." />
|
||
) : myEntries.length > 0 ? (
|
||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
|
||
{myEntries.map(
|
||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||
<CreationLibraryCard
|
||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||
entry={entry}
|
||
onClick={() => onOpenLibraryDetail(entry)}
|
||
/>
|
||
),
|
||
)}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf
|
||
text={
|
||
isAuthenticated
|
||
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
|
||
: '登录后查看你的作品。'
|
||
}
|
||
/>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeTab === 'saves') {
|
||
content = (
|
||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||
{authUi?.user ? (
|
||
<>
|
||
{saveError ? (
|
||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||
{saveError}
|
||
</div>
|
||
) : null}
|
||
|
||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||
<SectionHeader title="全部存档" detail="最近更新时间排序" />
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在读取存档..." />
|
||
) : saveEntries.length > 0 ? (
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{saveEntries.map((entry) => (
|
||
<SaveArchiveCard
|
||
key={entry.worldKey}
|
||
entry={entry}
|
||
loading={isResumingSaveWorldKey === entry.worldKey}
|
||
onClick={() => onResumeSave(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf text="还没有可恢复的存档,去首页开始一段新的游玩吧。" />
|
||
)}
|
||
</section>
|
||
</>
|
||
) : (
|
||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
|
||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||
尚未登录
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => authUi?.openLoginModal()}
|
||
className="platform-button platform-button--primary mt-4"
|
||
>
|
||
登录
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (activeTab === 'profile') {
|
||
content = (
|
||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||
{authUi?.user ? (
|
||
<>
|
||
<section className="platform-profile-hero rounded-[1.8rem] p-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => authUi.openAccountModal()}
|
||
className="platform-profile-avatar relative h-16 w-16 shrink-0 rounded-[1.4rem]"
|
||
>
|
||
<span className="flex h-full w-full items-center justify-center text-2xl font-black">
|
||
{avatarLabel}
|
||
</span>
|
||
<span className="platform-profile-camera absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full">
|
||
<Camera className="h-3.5 w-3.5" />
|
||
</span>
|
||
</button>
|
||
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<div className="truncate text-xl font-black text-[var(--platform-text-strong)]">
|
||
{authUi.user.displayName}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => authUi.openAccountModal()}
|
||
className="platform-profile-icon-button flex h-7 w-7 items-center justify-center rounded-full"
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--platform-text-soft)]">
|
||
<span>叙世号 {publicUserCode}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => copyText(publicUserCode)}
|
||
className="platform-profile-chip flex items-center gap-1 rounded-full px-2 py-1"
|
||
>
|
||
<Copy className="h-3 w-3" />
|
||
复制
|
||
</button>
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||
<span className="platform-profile-chip rounded-full px-2.5 py-1">
|
||
{describeLoginMethod(authUi.user.loginMethod)}
|
||
</span>
|
||
<span className="platform-profile-chip rounded-full px-2.5 py-1">
|
||
{describeBindingStatus(authUi.user.bindingStatus)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||
>
|
||
<Crown className="h-4 w-4" />
|
||
<div>
|
||
<div className="text-xs font-bold">会员充值</div>
|
||
<div className="text-[10px] opacity-80">普通用户</div>
|
||
</div>
|
||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{isLoadingDashboard ? (
|
||
<>
|
||
<ProfileStatCardSkeleton />
|
||
<ProfileStatCardSkeleton />
|
||
<ProfileStatCardSkeleton />
|
||
</>
|
||
) : dashboardError ? (
|
||
<>
|
||
<ProfileStatCard
|
||
cardKey="wallet"
|
||
label="剩余叙世币"
|
||
value="暂不可用"
|
||
icon={Coins}
|
||
onClick={onOpenProfileDashboardCard}
|
||
/>
|
||
<ProfileStatCard
|
||
cardKey="playTime"
|
||
label="总游戏时长"
|
||
value="暂不可用"
|
||
icon={Clock3}
|
||
onClick={onOpenProfileDashboardCard}
|
||
/>
|
||
<ProfileStatCard
|
||
cardKey="playedWorks"
|
||
label="玩过作品"
|
||
value="暂不可用"
|
||
icon={BookOpen}
|
||
onClick={onOpenProfileDashboardCard}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<ProfileStatCard
|
||
cardKey="wallet"
|
||
label="剩余叙世币"
|
||
value={formatDashboardCount(remainingNarrativeCoins)}
|
||
icon={Coins}
|
||
onClick={onOpenProfileDashboardCard}
|
||
/>
|
||
<ProfileStatCard
|
||
cardKey="playTime"
|
||
label="总游戏时长"
|
||
value={totalPlayTime}
|
||
icon={Clock3}
|
||
onClick={onOpenProfileDashboardCard}
|
||
/>
|
||
<ProfileStatCard
|
||
cardKey="playedWorks"
|
||
label="玩过作品"
|
||
value={formatDashboardCount(playedWorkCount)}
|
||
icon={BookOpen}
|
||
onClick={onOpenProfileDashboardCard}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="mt-3 text-[11px] text-zinc-500">
|
||
{dashboardError
|
||
? dashboardError
|
||
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
|
||
</div>
|
||
</section>
|
||
|
||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||
<SectionHeader title="常用功能" detail="快捷入口" />
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<ProfileShortcutButton label="邀请好友" icon={UserPlus} />
|
||
<ProfileShortcutButton label="填邀请码" icon={Ticket} />
|
||
<ProfileShortcutButton label="玩家社区" icon={MessageCircle} />
|
||
</div>
|
||
</section>
|
||
|
||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||
<button
|
||
type="button"
|
||
onClick={() => authUi.openSettingsModal()}
|
||
className="platform-subpanel platform-interactive-card flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
|
||
<Settings className="h-[1.125rem] w-[1.125rem]" />
|
||
</div>
|
||
<div>
|
||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||
设置
|
||
</div>
|
||
<div className="text-xs text-zinc-400">主题与账号</div>
|
||
</div>
|
||
</div>
|
||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||
</button>
|
||
</section>
|
||
</>
|
||
) : (
|
||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
|
||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||
尚未登录
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => authUi?.openLoginModal()}
|
||
className="platform-button platform-button--primary mt-4"
|
||
>
|
||
登录
|
||
</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const desktopContent =
|
||
activeTab === 'home' ? (
|
||
<div className={DESKTOP_PAGE_STAGE_CLASS}>
|
||
{platformError ? (
|
||
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||
{platformError}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.55fr)_22rem]">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (hasSavedGame) {
|
||
onContinueGame();
|
||
return;
|
||
}
|
||
|
||
onOpenCreateWorld();
|
||
}}
|
||
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
|
||
>
|
||
{desktopHeroCover ? (
|
||
<img
|
||
src={desktopHeroCover}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="absolute inset-0 h-full w-full object-cover opacity-34"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[linear-gradient(115deg,rgba(255,31,111,0.94),rgba(255,109,104,0.8)_52%,rgba(255,164,124,0.9))]" />
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_78%_24%,rgba(255,208,178,0.18),transparent_20%)]" />
|
||
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<span className="platform-pill platform-pill--warm">
|
||
{hasSavedGame ? 'CONTINUE STORY' : 'CREATE WORLD'}
|
||
</span>
|
||
<span className="platform-pill platform-pill--neutral px-3">
|
||
{hasSavedGame ? '最近存档' : '创作入口'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="max-w-[35rem]">
|
||
<div className="text-5xl font-semibold leading-[1.08] text-white">
|
||
{hasSavedGame
|
||
? snapshotWorldName
|
||
: '把你的世界观直接变成可游玩的舞台'}
|
||
</div>
|
||
<div className="mt-4 text-base leading-8 text-zinc-200/86">
|
||
{hasSavedGame
|
||
? `${snapshotCharacterName} 的进度已经保存,桌面端可以直接从这里回到上一次停下来的关键节点。`
|
||
: '从设定、角色、世界结构到可玩流程,一次生成创作底稿,再继续精修并发布到平台广场。'}
|
||
</div>
|
||
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
|
||
<span>
|
||
{hasSavedGame ? '继续推进故事' : '进入创作工作台'}
|
||
</span>
|
||
<ArrowRight className="h-4 w-4" />
|
||
</div>
|
||
</div>
|
||
|
||
{desktopHeroStripEntries.length > 0 ? (
|
||
<div className="grid gap-3 sm:grid-cols-5">
|
||
{desktopHeroStripEntries.map((entry, index) => {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
return (
|
||
<div
|
||
key={`${entry.ownerUserId}:${entry.profileId}:hero-strip`}
|
||
className="platform-subpanel overflow-hidden rounded-[1.15rem]"
|
||
>
|
||
<div className="relative aspect-[1.35/1] overflow-hidden">
|
||
{coverImage ? (
|
||
<img
|
||
src={coverImage}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.34))]" />
|
||
</div>
|
||
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-zinc-300/82">
|
||
<span className="text-zinc-500">
|
||
{`${index + 1}`.padStart(2, '0')}
|
||
</span>
|
||
<span className="line-clamp-1">
|
||
{entry.worldName}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</button>
|
||
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<div className="mb-4 flex items-start justify-between gap-3">
|
||
<SectionHeader title="趋势关注" detail="TRENDING NOW" />
|
||
<span className="platform-pill platform-pill--neutral px-3">
|
||
LIVE
|
||
</span>
|
||
</div>
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在整理趋势作品..." />
|
||
) : desktopTrendingEntries.length > 0 ? (
|
||
<div className="space-y-3">
|
||
{desktopTrendingEntries.map((entry, index) => (
|
||
<DesktopTrendingItem
|
||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-trend`}
|
||
entry={entry}
|
||
rank={index + 1}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf text="公开广场暂时还没有趋势作品。" />
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]">
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader title="精选推荐" detail="CURATED WORLDS" />
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在读取精选作品..." />
|
||
) : desktopFeaturedGrid.length > 0 ? (
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
{desktopFeaturedGrid.map((entry) => (
|
||
<WorldCard
|
||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-featured`}
|
||
entry={entry}
|
||
badge="推荐"
|
||
metaLabel={describePlatformThemeLabel(entry.themeMode)}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
className="h-[16rem] w-full min-w-0"
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
|
||
)}
|
||
</section>
|
||
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader
|
||
title={hasSavedGame ? '继续进度' : '创作入口'}
|
||
detail="QUICK ACCESS"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (hasSavedGame) {
|
||
onContinueGame();
|
||
return;
|
||
}
|
||
|
||
onOpenCreateWorld();
|
||
}}
|
||
className="platform-surface platform-surface--soft platform-interactive-card relative block w-full overflow-hidden px-5 py-5 text-left"
|
||
>
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,164,198,0.18),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(255,202,176,0.18),transparent_32%)]" />
|
||
<div className="relative z-10">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="platform-pill platform-pill--cool">
|
||
{hasSavedGame ? 'SAVE POINT' : 'START HERE'}
|
||
</span>
|
||
<ArrowRight className="h-4 w-4 text-zinc-400" />
|
||
</div>
|
||
<div className="mt-4 text-2xl font-semibold text-white">
|
||
{hasSavedGame ? snapshotWorldName : '从这里开启新的创作'}
|
||
</div>
|
||
<div className="mt-2 text-sm leading-7 text-zinc-300/84">
|
||
{hasSavedGame
|
||
? `当前角色:${snapshotCharacterName}`
|
||
: '快速进入自定义世界创作,继续补齐设定、角色与核心冲突。'}
|
||
</div>
|
||
<div className="mt-3 line-clamp-3 text-sm leading-6 text-zinc-400">
|
||
{hasSavedGame
|
||
? snapshotDigest
|
||
: '先生成一版可玩的世界底稿,再继续编辑并发布。'}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
<div className="mt-5">
|
||
<div className="text-[10px] font-semibold tracking-[0.24em] text-zinc-500">
|
||
{desktopLibraryPreview.length > 0
|
||
? '最近作品'
|
||
: historyEntries.length > 0
|
||
? '最近浏览'
|
||
: isAuthenticated
|
||
? '创作状态'
|
||
: '账户状态'}
|
||
</div>
|
||
|
||
{desktopLibraryPreview.length > 0 ? (
|
||
<div className="mt-3 space-y-3">
|
||
{desktopLibraryPreview.map((entry) => (
|
||
<button
|
||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-mine`}
|
||
type="button"
|
||
onClick={() => onOpenLibraryDetail(entry)}
|
||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="line-clamp-1 text-base font-semibold text-white">
|
||
{entry.worldName}
|
||
</div>
|
||
<div className="mt-1 text-sm text-zinc-400">
|
||
{entry.visibility === 'published'
|
||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||
: '草稿待完善'}
|
||
</div>
|
||
</div>
|
||
<span className="platform-pill platform-pill--neutral px-3">
|
||
{entry.visibility === 'published' ? '已发布' : '草稿'}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : historyEntries.length > 0 ? (
|
||
<div className="mt-3 space-y-3">
|
||
{historyEntries.slice(0, 2).map((entry) => (
|
||
<button
|
||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
|
||
type="button"
|
||
onClick={() =>
|
||
onOpenGalleryDetail({
|
||
ownerUserId: entry.ownerUserId,
|
||
profileId: entry.profileId,
|
||
visibility: 'published',
|
||
publishedAt: entry.visitedAt,
|
||
updatedAt: entry.visitedAt,
|
||
worldName: entry.worldName,
|
||
subtitle: entry.subtitle,
|
||
summaryText: entry.summaryText,
|
||
coverImageSrc: entry.coverImageSrc,
|
||
themeMode: entry.themeMode,
|
||
authorDisplayName: entry.authorDisplayName,
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
})
|
||
}
|
||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="line-clamp-1 text-base font-semibold text-white">
|
||
{entry.worldName}
|
||
</div>
|
||
<div className="mt-1 text-sm text-zinc-400">
|
||
作者:{entry.authorDisplayName}
|
||
</div>
|
||
</div>
|
||
<span className="platform-pill platform-pill--neutral px-3">
|
||
浏览
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="platform-subpanel mt-3 rounded-[1.35rem] px-4 py-4 text-sm leading-6 text-[var(--platform-text-base)]">
|
||
{isAuthenticated
|
||
? '创建一个草稿后,这里会出现你最近保存的作品。'
|
||
: '登录后可同步你的创作、游玩进度与平台资料。'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader title="最新发布" detail="PLAYER SQUARE" />
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在读取最新发布..." />
|
||
) : desktopReleaseGrid.length > 0 ? (
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
{desktopReleaseGrid.map((entry) => (
|
||
<WorldCard
|
||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-latest`}
|
||
entry={entry}
|
||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||
metaLabel={entry.authorDisplayName}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
className="h-[17rem] w-full min-w-0"
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf text="公开广场暂时还没有新作品。" />
|
||
)}
|
||
</section>
|
||
</div>
|
||
) : (
|
||
content
|
||
);
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 flex-col">
|
||
<div className="flex h-full min-h-0 flex-col lg:hidden">
|
||
<div className="mb-4">
|
||
<PlatformBrandLogo />
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||
{content}
|
||
</div>
|
||
|
||
<div
|
||
className="mt-4 border-t pt-3"
|
||
style={{
|
||
borderColor: 'var(--platform-line-soft)',
|
||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
|
||
}}
|
||
>
|
||
<div className="platform-bottom-nav grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] px-1 py-1">
|
||
<PlatformTabButton
|
||
active={activeTab === 'home'}
|
||
label="首页"
|
||
icon={tabIcons.home}
|
||
onClick={() => onTabChange('home')}
|
||
/>
|
||
<PlatformTabButton
|
||
active={activeTab === 'create'}
|
||
label="创作"
|
||
icon={tabIcons.create}
|
||
onClick={() => onTabChange('create')}
|
||
/>
|
||
<PlatformTabButton
|
||
active={activeTab === 'saves'}
|
||
label="存档"
|
||
icon={tabIcons.saves}
|
||
onClick={() => onTabChange('saves')}
|
||
/>
|
||
<PlatformTabButton
|
||
active={activeTab === 'profile'}
|
||
label="我的"
|
||
icon={tabIcons.profile}
|
||
onClick={() => onTabChange('profile')}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hidden h-full min-h-0 lg:flex lg:flex-col">
|
||
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
|
||
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||
<PlatformBrandLogo className="shrink-0" decorative />
|
||
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400">
|
||
<Search className="h-4 w-4 shrink-0" />
|
||
<span className="truncate text-sm">
|
||
搜索世界、角色、主题或灵感
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={openUserSurface}
|
||
className="platform-icon-button"
|
||
aria-label="通知与账户"
|
||
>
|
||
<Bell className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={openUserSurface}
|
||
className="platform-desktop-search flex items-center gap-3 px-3 py-2.5 text-left"
|
||
>
|
||
<span
|
||
className="flex h-11 w-11 items-center justify-center rounded-full text-base font-black text-white"
|
||
style={{
|
||
background: 'var(--platform-profile-avatar-fill)',
|
||
boxShadow: 'var(--platform-profile-avatar-shadow)',
|
||
}}
|
||
>
|
||
{avatarLabel}
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
{authUi?.user?.displayName || '进入账户'}
|
||
</span>
|
||
<span className="block truncate text-xs text-zinc-400">
|
||
{authUi?.user ? publicUserCode : '登录后同步创作与进度'}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<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">
|
||
<DesktopTabButton
|
||
active={activeTab === 'home'}
|
||
label="首页"
|
||
icon={tabIcons.home}
|
||
onClick={() => onTabChange('home')}
|
||
/>
|
||
<DesktopTabButton
|
||
active={activeTab === 'create'}
|
||
label="创作"
|
||
icon={tabIcons.create}
|
||
onClick={() => onTabChange('create')}
|
||
/>
|
||
<DesktopTabButton
|
||
active={activeTab === 'saves'}
|
||
label="存档"
|
||
icon={tabIcons.saves}
|
||
onClick={() => onTabChange('saves')}
|
||
/>
|
||
<DesktopTabButton
|
||
active={activeTab === 'profile'}
|
||
label="我的"
|
||
icon={tabIcons.profile}
|
||
onClick={() => onTabChange('profile')}
|
||
/>
|
||
</aside>
|
||
|
||
<div className="platform-desktop-scroll flex-1">
|
||
{desktopContent}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|