Files
Genarrative/src/components/rpg-entry/RpgEntryHomeView.tsx
kdletters 2b66b883d5
Some checks failed
CI / verify (pull_request) Has been cancelled
Merge latest master into user play stats branch
2026-04-28 15:57:11 +08:00

2581 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Archive,
ArrowRight,
Bell,
BookOpen,
Camera,
ChevronRight,
Clock3,
Coins,
Copy,
House,
LogIn,
MessageCircle,
Pencil,
Search,
Settings,
Sparkles,
Tags,
Ticket,
UserPlus,
UserRound,
} from 'lucide-react';
import {
type ComponentType,
type ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
import type {
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary,
ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import {
getRpgProfileReferralInviteCenter,
getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
buildPlatformWorldTags,
describePlatformThemeLabel,
formatPlatformWorldTime,
isBigFishGalleryEntry,
isPuzzleGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformWorldCoverImage,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
export type PlatformHomeTab =
| 'home'
| 'category'
| 'create'
| 'saves'
| 'profile';
export interface RpgEntryHomeViewProps {
activeTab: PlatformHomeTab;
onTabChange: (tab: PlatformHomeTab) => void;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
featuredEntries: PlatformPublicGalleryCard[];
latestEntries: PlatformPublicGalleryCard[];
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: PlatformPublicGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
onDeleteLibraryEntry?: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
deletingLibraryEntryId?: string | null;
onSearchPublicCode?: (keyword: string) => void | Promise<void>;
isSearchingPublicCode?: boolean;
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
profilePlayStats?: ProfilePlayStatsResponse | null;
isProfilePlayStatsOpen?: boolean;
isProfilePlayStatsLoading?: boolean;
profilePlayStatsError?: string | null;
onCloseProfilePlayStats?: () => void;
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
onRechargeSuccess?: () => void | Promise<void>;
createTabContent?: ReactNode;
}
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
const HERO_SURFACE_CLASS =
'platform-surface platform-surface--hero platform-interactive-card min-w-0';
const MOBILE_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
const DESKTOP_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'home',
'category',
'create',
'saves',
'profile',
];
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(DESKTOP_LAYOUT_QUERY).matches;
});
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return;
}
const mediaQuery = window.matchMedia(DESKTOP_LAYOUT_QUERY);
const updateLayout = (event?: MediaQueryListEvent) => {
setIsDesktopLayout(event?.matches ?? mediaQuery.matches);
};
updateLayout();
// 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', updateLayout);
return () => mediaQuery.removeEventListener('change', updateLayout);
}
mediaQuery.addListener(updateLayout);
return () => mediaQuery.removeListener(updateLayout);
}, []);
return isDesktopLayout;
}
function ResolvedAssetBackdrop({
src,
alt,
className,
ariaHidden = false,
}: {
src?: string | null;
alt: string;
className: string;
ariaHidden?: boolean;
}) {
return (
<ResolvedAssetImage
src={src}
alt={alt}
aria-hidden={ariaHidden}
className={className}
/>
);
}
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
<div className="mb-3">
<div className="text-[10px] font-semibold tracking-[0.26em] text-[var(--platform-text-soft)]">
{detail}
</div>
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
</div>
);
}
function PublicCodeSearchBar({
value,
onChange,
onSubmit,
isSearching,
className,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
isSearching: boolean;
className?: string;
}) {
return (
<div
className={`platform-desktop-search flex min-w-0 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)] ${className ?? ''}`}
>
<Search className="h-4 w-4 shrink-0" />
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit();
}
}}
placeholder="输入 SY / CW / BF / PZ 编号"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
type="button"
onClick={onSubmit}
disabled={!value.trim() || isSearching}
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
>
{isSearching ? '搜索中' : '搜索'}
</button>
</div>
);
}
function EmptyShelf({ text }: { text: string }) {
return (
<div
className={`${PANEL_SURFACE_CLASS} min-w-0 rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-[var(--platform-text-base)]`}
>
{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 ? (
<ResolvedAssetBackdrop
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-[var(--platform-text-strong)]">
{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-[min(15.25rem,78vw)] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-40"
/>
) : null}
{leadPortrait ? (
<ResolvedAssetImage
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 max-w-[8.5rem] truncate">
{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-[var(--platform-text-strong)]">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-[color:color-mix(in_srgb,var(--platform-text-base)_85%,transparent)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-2 text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_90%,transparent)]">
{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">
{describePublicGalleryCardKind(entry)}
</span>
)}
</div>
</div>
</div>
</button>
);
}
function CreationLibraryCard({
entry,
onClick,
onDelete,
isDeleting = false,
}: {
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
onClick: () => void;
onDelete?: () => void;
isDeleting?: boolean;
}) {
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 ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-38"
/>
) : null}
{leadPortrait ? (
<ResolvedAssetImage
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)]" />
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={isDeleting}
className="platform-button platform-button--danger absolute right-2 top-2 z-20 min-h-0 rounded-full px-2.5 py-1 text-[10px] disabled:cursor-not-allowed disabled:opacity-60"
>
{isDeleting ? '删除中' : '删除'}
</button>
) : null}
<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-[var(--platform-text-base)]">
<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-[var(--platform-text-strong)] sm:text-[1.12rem]">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)] 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-[color:color-mix(in_srgb,var(--platform-text-strong)_90%,transparent)]">
<span className="truncate">{primaryTag}</span>
</span>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-[var(--platform-text-base)]">
<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-[var(--platform-text-base)]">
{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-[var(--platform-text-strong)] sm:text-xl">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-sm text-[var(--platform-text-base)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-[var(--platform-text-soft)] 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,
emphasized = false,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
>
<span className="platform-bottom-nav__button-content">
<span className="platform-bottom-nav__icon-shell">
<Icon className="platform-bottom-nav__icon" />
</span>
<span className="platform-bottom-nav__label">{label}</span>
</span>
</button>
);
}
function DesktopTabButton({
active,
label,
icon: Icon,
onClick,
emphasized = false,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${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 PlatformTabPanel({
tab,
activeTab,
children,
}: {
tab: PlatformHomeTab;
activeTab: PlatformHomeTab;
children: ReactNode;
}) {
const active = activeTab === tab;
// 主 Tab 保持挂载,只切换可见性,避免重页面卸载重建造成首帧闪烁。
return (
<section
id={`platform-tab-panel-${tab}`}
aria-hidden={!active}
className={`platform-tab-panel ${active ? 'platform-tab-panel--active' : 'platform-tab-panel--hidden'}`}
>
{children}
</section>
);
}
function DesktopTrendingItem({
entry,
rank,
onClick,
}: {
entry: PlatformPublicGalleryCard;
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 ? (
<ResolvedAssetBackdrop
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-[var(--platform-text-soft)]">
<span>{`${rank}`.padStart(2, '0')}</span>
<span className="truncate">
{describePublicGalleryCardKind(entry)}
</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-[color:color-mix(in_srgb,var(--platform-text-base)_86%,transparent)]">
{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">
{isBigFishGalleryEntry(entry)
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: describePlatformThemeLabel(entry.themeMode)}
</span>
)}
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
function buildPublicCategoryGroups(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
const publicEntryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredEntries, ...latestEntries].forEach((entry) => {
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
});
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
Array.from(publicEntryMap.values()).forEach((entry) => {
const tags = buildPlatformWorldTags(entry)
.map((tag) => tag.trim())
.filter(Boolean);
const normalizedTags = tags.length > 0 ? tags : ['回响'];
normalizedTags.forEach((tag) => {
const entries = categoryMap.get(tag) ?? [];
entries.push(entry);
categoryMap.set(tag, entries);
});
});
return Array.from(categoryMap.entries())
.map(([tag, entries]) => ({ tag, entries }))
.sort((left, right) => {
if (right.entries.length !== left.entries.length) {
return right.entries.length - left.entries.length;
}
return left.tag.localeCompare(right.tag, 'zh-CN');
});
}
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
return isBigFishGalleryEntry(entry)
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: describePlatformThemeLabel(entry.themeMode);
}
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 formatPlayedWorkType(value: string | null | undefined) {
const normalizedValue = (value ?? '').toLowerCase();
if (normalizedValue === 'puzzle') {
return '拼图';
}
if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') {
return '大鱼';
}
return 'RPG';
}
function formatPlayedWorkId(work: ProfilePlayedWorkSummary) {
return work.profileId?.trim() || work.worldKey;
}
function buildPublicUserCode(user: AuthUser | null | undefined) {
if (user?.publicUserCode?.trim()) {
return user.publicUserCode.trim();
}
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-[var(--platform-text-soft)]">
<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-[var(--platform-subpanel-border)]" />
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
</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>
);
}
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
points_recharge: '叙世币充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回',
redeem_code_reward: '兑换码奖励',
};
function formatWalletLedgerAmount(amountDelta: number) {
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
}
function WalletLedgerModal({
ledger,
fallbackBalance,
isLoading,
error,
onClose,
onRetry,
}: {
ledger: ProfileWalletLedgerResponse | null;
fallbackBalance: number;
isLoading: boolean;
error: string | null;
onClose: () => void;
onRetry: () => void;
}) {
const entries = ledger?.entries ?? [];
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[30rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
aria-label="关闭叙世币账单"
>
×
</button>
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
LEDGER
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span>{balance}</span>
</div>
</div>
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-3 text-sm text-rose-700">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-full bg-[#ff4056] px-4 py-2 text-xs font-black text-white"
>
</button>
</div>
) : isLoading ? (
<div className="mt-5 space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="h-16 animate-pulse rounded-xl bg-zinc-100"
/>
))}
</div>
) : entries.length === 0 ? (
<div className="mt-5 rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm font-semibold text-zinc-500">
</div>
) : (
<div className="mt-5 space-y-2.5">
{entries.map((entry) => {
const isIncome = entry.amountDelta > 0;
const label =
WALLET_LEDGER_SOURCE_LABELS[entry.sourceType] ??
entry.sourceType;
return (
<div
key={entry.id}
className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200 bg-white px-3 py-3 shadow-sm"
>
<div className="min-w-0">
<div className="truncate text-sm font-black text-zinc-900">
{label}
</div>
<div className="mt-1 text-xs font-semibold text-zinc-500">
{formatPlatformWorldTime(entry.createdAt)}
</div>
</div>
<div className="shrink-0 text-right">
<div
className={`text-base font-black ${
isIncome ? 'text-emerald-600' : 'text-rose-500'
}`}
>
{formatWalletLedgerAmount(entry.amountDelta)}
</div>
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
{entry.balanceAfter}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function RewardCodeRedeemModal({
value,
isSubmitting,
error,
success,
onChange,
onSubmit,
onClose,
}: {
value: string;
isSubmitting: boolean;
error: string | null;
success: string | null;
onChange: (value: string) => void;
onSubmit: () => void;
onClose: () => void;
}) {
return (
<div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-black"></div>
<button
type="button"
aria-label="关闭兑换码"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="space-y-3 px-5 py-5">
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSubmit();
}
}}
className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal"
placeholder="输入兑换码"
autoFocus
/>
<button
type="button"
onClick={onSubmit}
disabled={isSubmitting || !value.trim()}
className="platform-primary-button w-full rounded-2xl px-4 py-3 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? '兑换中' : '兑换'}
</button>
{error ? (
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
{error}
</div>
) : null}
{success ? (
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
{success}
</div>
) : null}
</div>
</div>
</div>
);
}
function ProfileReferralModal({
panel,
center,
inviteCodeInput,
isLoading,
isSubmitting,
error,
success,
onClose,
onInputChange,
onCopyInvite,
onSubmitRedeem,
}: {
panel: ProfilePopupPanel;
center: ProfileReferralInviteCenterResponse | null;
inviteCodeInput: string;
isLoading: boolean;
isSubmitting: boolean;
error: string | null;
success: string | null;
onClose: () => void;
onInputChange: (value: string) => void;
onCopyInvite: () => void;
onSubmitRedeem: () => void;
}) {
const title =
panel === 'invite'
? '邀请好友'
: panel === 'redeem'
? '填邀请码'
: '玩家社区';
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
<div className="relative w-full max-w-[24rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]"
aria-label={`关闭${title}`}
>
×
</button>
<div className="px-5 pb-5 pt-4">
<div className="text-center text-xl font-black">{title}</div>
{panel === 'community' ? (
<div className="mt-5 grid grid-cols-2 gap-3">
{['微信群', 'QQ群'].map((label) => (
<div
key={label}
className="rounded-xl border border-zinc-200 bg-zinc-50 p-3 text-center"
>
<div className="aspect-square rounded-lg border border-dashed border-zinc-300 bg-white" />
<div className="mt-2 text-sm font-bold text-zinc-700">
{label}
</div>
</div>
))}
</div>
) : isLoading ? (
<div className="mt-5 space-y-3">
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
</div>
) : panel === 'invite' ? (
<div className="mt-5 space-y-3">
<div className="rounded-xl bg-zinc-50 px-4 py-4 text-center">
<div className="text-[11px] font-bold text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
{center?.inviteCode ?? '--------'}
</div>
</div>
<button
type="button"
onClick={onCopyInvite}
disabled={!center?.inviteCode}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:opacity-60"
>
<Copy className="h-4 w-4" />
</button>
<div className="grid grid-cols-3 gap-2 text-center text-xs text-zinc-500">
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-black text-zinc-900">
{center?.invitedCount ?? 0}
</div>
</div>
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-black text-zinc-900">
{center?.rewardedInviteCount ?? 0}
</div>
</div>
<div className="rounded-lg bg-zinc-50 px-2 py-2">
<div className="font-black text-zinc-900">
{center?.todayInviterRewardRemaining ?? 0}
</div>
</div>
</div>
</div>
) : (
<div className="mt-5 space-y-3">
{center?.hasRedeemedCode ? (
<div className="rounded-xl bg-emerald-50 px-4 py-4 text-center text-sm font-bold text-emerald-700">
</div>
) : (
<>
<input
value={inviteCodeInput}
onChange={(event) => onInputChange(event.target.value)}
placeholder="输入邀请码"
className="w-full rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-center text-base font-black tracking-[0.14em] outline-none focus:border-[#ff4056]"
/>
<button
type="button"
onClick={onSubmitRedeem}
disabled={isSubmitting || !inviteCodeInput.trim()}
className="w-full rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:opacity-60"
>
{isSubmitting ? '提交中' : '确认填写'}
</button>
</>
)}
</div>
)}
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{error}
</div>
) : null}
{success ? (
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
{success}
</div>
) : null}
</div>
</div>
</div>
);
}
function ProfilePlayedWorksModal({
stats,
isLoading,
error,
onClose,
onOpenWork,
}: {
stats: ProfilePlayStatsResponse | null;
isLoading: boolean;
error: string | null;
onClose: () => void;
onOpenWork?: (work: ProfilePlayedWorkSummary) => void;
}) {
const playedWorks = stats?.playedWorks ?? [];
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[34rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
aria-label="关闭玩过作品"
>
×
</button>
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
PLAYED
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-rose-50 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />
<span>{formatCompactPlayTime(stats?.totalPlayTimeMs ?? 0)}</span>
</div>
</div>
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{error}
</div>
) : null}
{isLoading ? (
<div className="mt-5 space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-20 animate-pulse rounded-xl bg-zinc-100"
/>
))}
</div>
) : playedWorks.length > 0 ? (
<div className="mt-5 space-y-3">
{playedWorks.map((work) => (
<button
type="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-1 text-base font-black text-zinc-950">
{work.worldTitle}
</div>
{work.worldSubtitle ? (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{work.worldSubtitle}
</div>
) : null}
</div>
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
{formatPlayedWorkType(work.worldType)}
</span>
</div>
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
<span className="truncate">
{formatPlayedWorkId(work)}
</span>
<span className="truncate">
{formatSnapshotTime(work.lastPlayedAt)}
</span>
<span className="truncate">
{formatCompactPlayTime(work.lastObservedPlayTimeMs)}
</span>
</div>
</button>
))}
</div>
) : (
<div className="mt-5 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
</div>
)}
</div>
</div>
</div>
);
}
export function RpgEntryHomeView({
activeTab,
onTabChange,
saveEntries,
saveError,
featuredEntries,
latestEntries,
myEntries,
historyEntries,
profileDashboard,
isLoadingPlatform,
isLoadingDashboard,
isResumingSaveWorldKey,
platformError,
dashboardError,
onResumeSave,
onOpenCreateTypePicker,
onOpenGalleryDetail,
onOpenLibraryDetail,
onDeleteLibraryEntry,
deletingLibraryEntryId = null,
onSearchPublicCode,
isSearchingPublicCode = false,
onOpenProfileDashboardCard,
profilePlayStats = null,
isProfilePlayStatsOpen = false,
isProfilePlayStatsLoading = false,
profilePlayStatsError = null,
onCloseProfilePlayStats,
onOpenPlayedWork,
onRechargeSuccess,
createTabContent,
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
const [rewardCodeInput, setRewardCodeInput] = useState('');
const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false);
const [rewardCodeError, setRewardCodeError] = useState<string | null>(null);
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
null,
);
const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
const [walletLedger, setWalletLedger] =
useState<ProfileWalletLedgerResponse | null>(null);
const [walletLedgerError, setWalletLedgerError] = useState<string | null>(
null,
);
const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false);
const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] =
useState<ProfileReferralInviteCenterResponse | null>(null);
const [isLoadingReferral, setIsLoadingReferral] = useState(false);
const [isSubmittingReferral, setIsSubmittingReferral] = useState(false);
const [referralError, setReferralError] = useState<string | null>(null);
const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
const [inviteCodeInput, setInviteCodeInput] = useState('');
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
() => new Set([activeTab]),
);
const isAuthenticated = Boolean(authUi?.user);
const isDesktopLayout = usePlatformDesktopLayout();
const featuredShelf = useMemo(
() => featuredEntries.slice(0, 6),
[featuredEntries],
);
const categoryGroups = useMemo(
() => buildPublicCategoryGroups(featuredEntries, latestEntries),
[featuredEntries, latestEntries],
);
const activeCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ??
categoryGroups[0] ??
null;
const visibleTabs = useMemo<PlatformHomeTab[]>(
() =>
isAuthenticated
? ['home', 'category', 'create', 'saves', 'profile']
: ['home', 'create', 'category'],
[isAuthenticated],
);
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,
category: Tags,
create: Sparkles,
saves: Archive,
profile: UserRound,
} as const;
const tabLabels = {
home: '首页',
category: '分类',
create: '创作',
saves: '存档',
profile: '我的',
} as const;
useEffect(() => {
if (!visibleTabs.includes(activeTab)) {
onTabChange('home');
}
}, [activeTab, onTabChange, visibleTabs]);
useEffect(() => {
setVisitedTabs((currentTabs) => {
if (currentTabs.has(activeTab)) {
return currentTabs;
}
const nextTabs = new Set(currentTabs);
nextTabs.add(activeTab);
return nextTabs;
});
}, [activeTab]);
useEffect(() => {
if (categoryGroups.length === 0) {
setSelectedCategoryTag(null);
return;
}
const firstCategoryGroup = categoryGroups[0];
if (
firstCategoryGroup &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
) {
setSelectedCategoryTag(firstCategoryGroup.tag);
}
}, [categoryGroups, selectedCategoryTag]);
const openUserSurface = () => {
if (authUi?.user) {
authUi.openAccountModal();
return;
}
authUi?.openLoginModal();
};
const loadWalletLedger = () => {
setWalletLedgerError(null);
setIsLoadingWalletLedger(true);
void getRpgProfileWalletLedger()
.then(setWalletLedger)
.catch((error: unknown) => {
setWalletLedger(null);
setWalletLedgerError(
error instanceof Error ? error.message : '读取叙世币账单失败',
);
})
.finally(() => setIsLoadingWalletLedger(false));
};
const openWalletLedgerPanel = () => {
setIsWalletLedgerOpen(true);
loadWalletLedger();
};
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
setProfilePopupPanel(panel);
setReferralError(null);
setReferralSuccess(null);
if (panel === 'community') {
return;
}
setIsLoadingReferral(true);
void getRpgProfileReferralInviteCenter()
.then(setReferralCenter)
.catch((error: unknown) => {
setReferralCenter(null);
setReferralError(
error instanceof Error ? error.message : '读取邀请码失败',
);
})
.finally(() => setIsLoadingReferral(false));
};
const copyInviteInfo = () => {
if (!referralCenter?.inviteCode) {
return;
}
const inviteUrl =
typeof window === 'undefined'
? referralCenter.inviteLinkPath
: new URL(referralCenter.inviteLinkPath, window.location.origin).href;
copyText(`${referralCenter.inviteCode} ${inviteUrl}`);
setReferralSuccess('已复制');
};
const submitReferralInviteCode = () => {
if (isSubmittingReferral || !inviteCodeInput.trim()) {
return;
}
setIsSubmittingReferral(true);
setReferralError(null);
setReferralSuccess(null);
void redeemRpgProfileReferralInviteCode(inviteCodeInput)
.then((response: RedeemProfileReferralInviteCodeResponse) => {
setReferralCenter(response.center);
setInviteCodeInput('');
setReferralSuccess(
response.inviteeRewardGranted ? '已获得30叙世币' : '填写成功',
);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setReferralError(
error instanceof Error ? error.message : '填写邀请码失败',
);
})
.finally(() => setIsSubmittingReferral(false));
};
const openRewardCodeModal = () => {
setIsRewardCodeOpen(true);
setRewardCodeError(null);
setRewardCodeSuccess(null);
};
const submitRewardCode = () => {
if (isSubmittingRewardCode || !rewardCodeInput.trim()) {
return;
}
setIsSubmittingRewardCode(true);
setRewardCodeError(null);
setRewardCodeSuccess(null);
void redeemRpgProfileRewardCode(rewardCodeInput)
.then((response: RedeemProfileRewardCodeResponse) => {
setRewardCodeInput('');
setRewardCodeSuccess(`已到账 ${response.amountGranted} 叙世币`);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setRewardCodeError(error instanceof Error ? error.message : '兑换失败');
})
.finally(() => setIsSubmittingRewardCode(false));
};
const submitDesktopSearch = () => {
const keyword = desktopSearchKeyword.trim();
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
return;
}
void onSearchPublicCode(keyword);
};
const submitMobileSearch = () => {
const keyword = mobileSearchKeyword.trim();
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
return;
}
void onSearchPublicCode(keyword);
};
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);
const categoryPageClass = isDesktopLayout
? DESKTOP_PAGE_STAGE_CLASS
: MOBILE_PAGE_STAGE_CLASS;
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
onOpenGalleryDetail(leadPublicEntry);
return;
}
onTabChange('category');
};
const mobileHomeContent: ReactNode = (
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
<PublicCodeSearchBar
value={mobileSearchKeyword}
onChange={setMobileSearchKeyword}
onSubmit={submitMobileSearch}
isSearching={isSearchingPublicCode}
/>
<button
type="button"
onClick={openLeadPublicEntry}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
>
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<span className="platform-pill platform-pill--warm shrink-0">
</span>
<div className="platform-mobile-hero-secondary platform-pill platform-pill--neutral max-w-full px-3 text-[11px] tracking-[0.08em]">
{leadPublicEntry
? describePublicGalleryCardKind(leadPublicEntry)
: '作品广场'}
</div>
</div>
<div className="min-w-0">
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
</div>
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
{leadPublicEntry?.summaryText ||
leadPublicEntry?.subtitle ||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
</div>
<div className="mt-4 flex min-w-0 items-center gap-2 text-sm font-semibold text-white/90">
<span className="min-w-0 break-all"></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-700">
{platformError}
</div>
) : null}
<section>
<SectionHeader title="精选推荐" detail="为你挑选" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取精选作品..." />
) : featuredShelf.length > 0 ? (
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
{featuredShelf.map((entry: PlatformPublicGalleryCard) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:featured`}
entry={entry}
badge="推荐"
metaLabel={describePublicGalleryCardKind(entry)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有精选作品。" />
)}
</section>
<section>
<SectionHeader title="最新发布" detail="玩家广场" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取最新发布..." />
) : latestEntries.length > 0 ? (
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
{latestEntries.map((entry: PlatformPublicGalleryCard) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:latest`}
entry={entry}
badge={describePublicGalleryCardKind(entry)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有新作品。" />
)}
</section>
</div>
);
const categoryContent: ReactNode = (
<div className={categoryPageClass}>
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<SectionHeader title="分类" detail="按标签浏览" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : categoryGroups.length > 0 && activeCategoryGroup ? (
<>
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide">
{categoryGroups.map((group) => {
const active = group.tag === activeCategoryGroup.tag;
return (
<button
key={group.tag}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
className={`platform-pill shrink-0 px-3 py-1.5 ${active ? 'platform-pill--warm' : 'platform-pill--neutral'}`}
>
{group.tag} · {group.entries.length}
</button>
);
})}
</div>
<div className="mt-4 grid grid-cols-2 gap-2.5 sm:gap-3 lg:grid-cols-3 xl:grid-cols-4">
{activeCategoryGroup.entries.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:category:${activeCategoryGroup.tag}`}
entry={entry}
badge={activeCategoryGroup.tag}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[15rem] w-full min-w-0 sm:h-[16rem]"
/>
))}
</div>
</>
) : (
<EmptyShelf text="公开广场暂时还没有可分类的作品。" />
)}
</section>
</div>
);
const createContent: ReactNode = createTabContent ?? (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<button
type="button"
onClick={onOpenCreateTypePicker}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
>
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<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 className="min-w-0">
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
</div>
<div className="mt-2 max-w-[28rem] break-all 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)}
onDelete={
onDeleteLibraryEntry
? () => onDeleteLibraryEntry(entry)
: undefined
}
isDeleting={deletingLibraryEntryId === entry.profileId}
/>
),
)}
</div>
) : (
<EmptyShelf
text={
isAuthenticated
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
: '登录后查看你的作品。'
}
/>
)}
</section>
</div>
);
const savesContent: ReactNode = (
<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-700">
{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>
);
const profileContent: ReactNode = (
<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"
onClick={openRewardCodeModal}
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
>
<Ticket 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={openWalletLedgerPanel}
/>
<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={openWalletLedgerPanel}
/>
<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-[var(--platform-text-soft)]">
{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}
onClick={() => openProfilePopupPanel('invite')}
/>
<ProfileShortcutButton
label="填邀请码"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
<ProfileShortcutButton
label="玩家社区"
icon={MessageCircle}
onClick={() => openProfilePopupPanel('community')}
/>
</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-[var(--platform-text-soft)]">
</div>
</div>
</div>
<ChevronRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
</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 desktopHomeContent: ReactNode = (
<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-700">
{platformError}
</div>
) : null}
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.55fr)_22rem]">
<button
type="button"
onClick={openLeadPublicEntry}
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
>
{desktopHeroCover ? (
<ResolvedAssetBackdrop
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-[var(--platform-hero-overlay-strong)]" />
<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"></span>
<span className="platform-pill platform-pill--neutral px-3">
{leadPublicEntry
? describePublicGalleryCardKind(leadPublicEntry)
: '作品广场'}
</span>
</div>
<div className="max-w-[35rem]">
<div className="text-5xl font-semibold leading-[1.08] text-white">
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
</div>
<div className="mt-4 text-base leading-8 text-zinc-200/86">
{leadPublicEntry?.summaryText ||
leadPublicEntry?.subtitle ||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
</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></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 ? (
<ResolvedAssetBackdrop
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-[color:color-mix(in_srgb,var(--platform-text-base)_82%,transparent)]">
<span className="text-[var(--platform-text-soft)]">
{`${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={`${buildPublicGalleryCardKey(entry)}: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={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
entry={entry}
badge="推荐"
metaLabel={describePublicGalleryCardKind(entry)}
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={
desktopLibraryPreview.length > 0
? '最近作品'
: historyEntries.length > 0
? '最近浏览'
: '作品广场'
}
detail="QUICK ACCESS"
/>
<div>
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
{desktopLibraryPreview.length > 0
? '最近作品'
: historyEntries.length > 0
? '最近浏览'
: '公开作品'}
</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-[var(--platform-text-strong)]">
{entry.worldName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{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,
publicWorkCode: null,
authorPublicUserCode: null,
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-[var(--platform-text-strong)]">
{entry.worldName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{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)]">
广
</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={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
entry={entry}
badge={describePublicGalleryCardKind(entry)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[17rem] w-full min-w-0"
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有新作品。" />
)}
</section>
</div>
);
const tabContentById = {
home: isDesktopLayout ? desktopHomeContent : mobileHomeContent,
category: categoryContent,
create: createContent,
saves: savesContent,
profile: profileContent,
} satisfies Record<PlatformHomeTab, ReactNode>;
const tabPanels = PLATFORM_HOME_TABS.filter((tab) =>
visibleTabs.includes(tab),
).map((tab) => {
const shouldMountPanel = tab === activeTab || visitedTabs.has(tab);
return (
<PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}>
{shouldMountPanel ? tabContentById[tab] : null}
</PlatformTabPanel>
);
});
if (!isDesktopLayout) {
return (
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{!isAuthenticated ? (
<button
type="button"
onClick={openUserSurface}
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
>
<LogIn className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
<div
className="mt-3 min-w-0 shrink-0 border-t pt-2"
style={{
borderColor: 'var(--platform-line-soft)',
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
}}
>
<div
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
{visibleTabs.map((tab) => (
<PlatformTabButton
key={tab}
active={activeTab === tab}
label={tabLabels[tab]}
icon={tabIcons[tab]}
emphasized={tab === 'create'}
onClick={() => onTabChange(tab)}
/>
))}
</div>
</div>
{profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
inviteCodeInput={inviteCodeInput}
isLoading={isLoadingReferral}
isSubmitting={isSubmittingReferral}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onInputChange={setInviteCodeInput}
onCopyInvite={copyInviteInfo}
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
{isProfilePlayStatsOpen ? (
<ProfilePlayedWorksModal
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
/>
) : null}
{isWalletLedgerOpen ? (
<WalletLedgerModal
ledger={walletLedger}
fallbackBalance={remainingNarrativeCoins}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-full min-h-0 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">
<RpgEntryBrandLogo className="shrink-0" decorative />
<PublicCodeSearchBar
value={desktopSearchKeyword}
onChange={setDesktopSearchKeyword}
onSubmit={submitDesktopSearch}
isSearching={
!onSearchPublicCode || Boolean(isSearchingPublicCode)
}
className="max-w-[34rem] flex-1"
/>
</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-[var(--platform-text-soft)]">
{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">
{visibleTabs.map((tab) => (
<DesktopTabButton
key={tab}
active={activeTab === tab}
label={tabLabels[tab]}
icon={tabIcons[tab]}
emphasized={tab === 'create'}
onClick={() => onTabChange(tab)}
/>
))}
</aside>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
</div>
</div>
</div>
{isRewardCodeOpen ? (
<RewardCodeRedeemModal
value={rewardCodeInput}
isSubmitting={isSubmittingRewardCode}
error={rewardCodeError}
success={rewardCodeSuccess}
onChange={setRewardCodeInput}
onSubmit={submitRewardCode}
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null}
{profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
inviteCodeInput={inviteCodeInput}
isLoading={isLoadingReferral}
isSubmitting={isSubmittingReferral}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onInputChange={setInviteCodeInput}
onCopyInvite={copyInviteInfo}
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
{isProfilePlayStatsOpen ? (
<ProfilePlayedWorksModal
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
/>
) : null}
{isWalletLedgerOpen ? (
<WalletLedgerModal
ledger={walletLedger}
fallbackBalance={remainingNarrativeCoins}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
</div>
);
}
export const PlatformHomeView = RpgEntryHomeView;