4582 lines
154 KiB
TypeScript
4582 lines
154 KiB
TypeScript
import {
|
||
Archive,
|
||
ArrowRight,
|
||
Bell,
|
||
BookOpen,
|
||
Camera,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Clock3,
|
||
Coins,
|
||
Copy,
|
||
Gamepad2,
|
||
Heart,
|
||
House,
|
||
LogIn,
|
||
MessageCircle,
|
||
Pencil,
|
||
Search,
|
||
Settings,
|
||
SlidersHorizontal,
|
||
Sparkles,
|
||
Star,
|
||
Ticket,
|
||
Trophy,
|
||
UserPlus,
|
||
UserRound,
|
||
} from 'lucide-react';
|
||
import {
|
||
type ComponentType,
|
||
type PointerEvent,
|
||
type ReactNode,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import communityQqQrImage from '../../../media/social-media-group/qq.png';
|
||
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
|
||
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
||
import type {
|
||
CustomWorldLibraryEntry,
|
||
PlatformBrowseHistoryEntry,
|
||
ProfileDashboardCardKey,
|
||
ProfileDashboardSummary,
|
||
ProfilePlayedWorkSummary,
|
||
ProfilePlayStatsResponse,
|
||
ProfileReferralInviteCenterResponse,
|
||
ProfileSaveArchiveSummary,
|
||
ProfileTaskCenterResponse,
|
||
ProfileTaskItem,
|
||
ProfileWalletLedgerResponse,
|
||
RedeemProfileRewardCodeResponse,
|
||
} from '../../../packages/shared/src/contracts/runtime';
|
||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||
import type { AuthUser } from '../../services/authService';
|
||
import {
|
||
getPublicAuthUserByCode,
|
||
getPublicAuthUserById,
|
||
updateAuthProfile,
|
||
} from '../../services/authService';
|
||
import { copyTextToClipboard } from '../../services/clipboard';
|
||
import {
|
||
getRpgProfileReferralInviteCenter,
|
||
getRpgProfileTasks,
|
||
getRpgProfileWalletLedger,
|
||
claimRpgProfileTaskReward,
|
||
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 {
|
||
buildPlatformWorldDisplayTags,
|
||
describePlatformThemeLabel,
|
||
formatPlatformWorkDisplayName,
|
||
formatPlatformWorkDisplayTag,
|
||
formatPlatformWorldTime,
|
||
isBigFishGalleryEntry,
|
||
isMatch3DGalleryEntry,
|
||
isPuzzleGalleryEntry,
|
||
isSquareHoleGalleryEntry,
|
||
type PlatformPublicGalleryCard,
|
||
type PlatformWorldCardLike,
|
||
resolvePlatformWorldCoverImage,
|
||
resolvePlatformWorldCoverSlides,
|
||
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<boolean | 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',
|
||
];
|
||
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||
const AVATAR_OUTPUT_SIZE = 256;
|
||
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
|
||
const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
|
||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||
type MobileHomeChannel = 'recommend' | 'today' | 'category';
|
||
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
|
||
|
||
const COMMUNITY_QR_CODES = [
|
||
{
|
||
label: '微信群',
|
||
src: communityWechatQrImage,
|
||
alt: '玩家社区微信群二维码',
|
||
},
|
||
{
|
||
label: 'QQ群',
|
||
src: communityQqQrImage,
|
||
alt: '玩家社区 QQ 群二维码',
|
||
},
|
||
] as const;
|
||
|
||
const MOBILE_HOME_CHANNELS: Array<{
|
||
id: MobileHomeChannel;
|
||
label: string;
|
||
}> = [
|
||
{ id: 'recommend', label: '推荐' },
|
||
{ id: 'today', label: '今日游戏' },
|
||
{ id: 'category', label: '游戏分类' },
|
||
];
|
||
|
||
const PLATFORM_RANKING_TABS: Array<{
|
||
id: PlatformRankingTab;
|
||
label: string;
|
||
metricLabel: string;
|
||
emptyText: string;
|
||
}> = [
|
||
{
|
||
id: 'hot',
|
||
label: '热门榜',
|
||
metricLabel: '游玩',
|
||
emptyText: '公开广场暂时还没有热门作品。',
|
||
},
|
||
{
|
||
id: 'remix',
|
||
label: '改造榜',
|
||
metricLabel: '改造',
|
||
emptyText: '公开广场暂时还没有改造作品。',
|
||
},
|
||
{
|
||
id: 'new',
|
||
label: '新品榜',
|
||
metricLabel: '近7日',
|
||
emptyText: '近 7 日暂时还没有新品。',
|
||
},
|
||
{
|
||
id: 'like',
|
||
label: '点赞榜',
|
||
metricLabel: '点赞',
|
||
emptyText: '公开广场暂时还没有点赞作品。',
|
||
},
|
||
];
|
||
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="搜索作品号、名称、作者、描述"
|
||
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,
|
||
className,
|
||
}: {
|
||
entry: ProfileSaveArchiveSummary;
|
||
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>
|
||
);
|
||
}
|
||
|
||
function WorldCard({
|
||
entry,
|
||
onClick,
|
||
className,
|
||
authorAvatarUrl,
|
||
feedCardKey,
|
||
enableCoverCarousel = false,
|
||
isCoverCarouselActive = false,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
onClick: () => void;
|
||
className?: string;
|
||
authorAvatarUrl?: string | null;
|
||
feedCardKey?: string;
|
||
enableCoverCarousel?: boolean;
|
||
isCoverCarouselActive?: boolean;
|
||
}) {
|
||
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
|
||
const coverSlides = useMemo(() => {
|
||
if (!enableCoverCarousel) {
|
||
return fallbackCoverImage
|
||
? [
|
||
{
|
||
id: 'cover',
|
||
imageSrc: fallbackCoverImage,
|
||
label: entry.worldName,
|
||
},
|
||
]
|
||
: [];
|
||
}
|
||
|
||
return resolvePlatformWorldCoverSlides(entry);
|
||
}, [enableCoverCarousel, entry, fallbackCoverImage]);
|
||
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
|
||
const visibleCoverIndex = isCoverCarouselActive ? activeCoverIndex : 0;
|
||
const activeCoverSlide =
|
||
coverSlides[visibleCoverIndex] ?? coverSlides[0] ?? null;
|
||
const coverImage = activeCoverSlide?.imageSrc ?? '';
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const tags = buildPlatformWorldDisplayTags(entry, 3);
|
||
const playCount = getPlatformWorldPlayCount(entry);
|
||
const remixCount = getPlatformWorldRemixCount(entry);
|
||
const likeCount = getPlatformWorldLikeCount(entry);
|
||
const typeLabel = describePublicGalleryCardKind(entry);
|
||
const authorName = entry.authorDisplayName.trim() || '玩家';
|
||
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
|
||
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
|
||
const cardLabel = `${entry.worldName},${typeLabel},${formatCompactCount(playCount)}游玩,${formatCompactCount(remixCount)}改造,${formatCompactCount(likeCount)}点赞`;
|
||
const coverStats = [
|
||
{
|
||
label: '游玩',
|
||
value: playCount,
|
||
icon: Gamepad2,
|
||
},
|
||
{
|
||
label: '改造',
|
||
value: remixCount,
|
||
icon: Pencil,
|
||
},
|
||
{
|
||
label: '点赞',
|
||
value: likeCount,
|
||
icon: Heart,
|
||
},
|
||
];
|
||
|
||
useEffect(() => {
|
||
setActiveCoverIndex(0);
|
||
}, [entry.ownerUserId, entry.profileId, coverSlides.length]);
|
||
|
||
useEffect(() => {
|
||
if (!isCoverCarouselActive) {
|
||
setActiveCoverIndex(0);
|
||
}
|
||
}, [isCoverCarouselActive]);
|
||
|
||
useEffect(() => {
|
||
if (!isCoverCarouselActive || coverSlides.length <= 1) {
|
||
return undefined;
|
||
}
|
||
|
||
const timerId = window.setInterval(() => {
|
||
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
|
||
}, PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS);
|
||
|
||
return () => {
|
||
window.clearInterval(timerId);
|
||
};
|
||
}, [coverSlides.length, isCoverCarouselActive]);
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
aria-label={cardLabel}
|
||
data-mobile-feed-card-key={feedCardKey}
|
||
className={`platform-public-work-card platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
|
||
>
|
||
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
|
||
{coverImage ? (
|
||
<ResolvedAssetBackdrop
|
||
src={coverImage}
|
||
alt={entry.worldName}
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||
)}
|
||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.02),rgba(0,0,0,0.18))]" />
|
||
<div className="platform-public-work-card__cover-stats">
|
||
{coverStats.map(({ label, value, icon: Icon }) => (
|
||
<span key={label} className="platform-public-work-card__cover-stat">
|
||
<Icon
|
||
className={`h-3.5 w-3.5 ${label === '点赞' ? 'fill-current' : ''}`}
|
||
aria-hidden="true"
|
||
/>
|
||
<span>{formatCompactCount(value)}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="platform-public-work-card__body flex min-h-[7.25rem] flex-col gap-2 px-3.5 py-3">
|
||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
|
||
{displayName}
|
||
</div>
|
||
<div className="mt-1 flex min-w-0 items-center gap-1.5">
|
||
<span
|
||
aria-hidden="true"
|
||
className="platform-public-work-card__author-avatar"
|
||
>
|
||
{normalizedAuthorAvatarUrl ? (
|
||
<img
|
||
src={normalizedAuthorAvatarUrl}
|
||
alt=""
|
||
className="platform-public-work-card__author-avatar-image"
|
||
/>
|
||
) : (
|
||
authorAvatarLabel
|
||
)}
|
||
</span>
|
||
<span className="line-clamp-1 break-words text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
||
{authorName}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span className="platform-public-work-card__kind shrink-0">
|
||
{typeLabel}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="line-clamp-2 break-words text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)]">
|
||
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
|
||
</div>
|
||
|
||
<div className="mt-auto flex min-w-0 flex-wrap gap-1.5">
|
||
{tags.length > 0 ? (
|
||
tags.map((tag, index) => (
|
||
<span
|
||
key={`world-tag-${index}-${tag || 'empty'}`}
|
||
className="platform-pill platform-pill--neutral max-w-full px-2.5"
|
||
>
|
||
<span className="truncate">{tag}</span>
|
||
</span>
|
||
))
|
||
) : (
|
||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||
{describePublicGalleryCardKind(entry)}
|
||
</span>
|
||
)}
|
||
</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 =
|
||
buildPlatformWorldDisplayTags(entry, 1)[0] ??
|
||
formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
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]">
|
||
{displayName}
|
||
</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 || '继续推进上一次保存的故事。';
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
|
||
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 justify-end gap-2">
|
||
<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.35rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl">
|
||
{displayName}
|
||
</div>
|
||
{entry.subtitle ? (
|
||
<div className="mt-1 line-clamp-2 break-words text-sm font-semibold 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}
|
||
className="aspect-square w-[6.5rem] self-start sm:w-[7.5rem]"
|
||
/>
|
||
</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 displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const tags = buildPlatformWorldDisplayTags(entry, 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)]">
|
||
{displayName}
|
||
</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)
|
||
? '拼图'
|
||
: describePublicGalleryCardKind(entry)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlatformRankingItem({
|
||
entry,
|
||
rank,
|
||
metricLabel,
|
||
metricValue,
|
||
onClick,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
rank: number;
|
||
metricLabel: string;
|
||
metricValue: number;
|
||
onClick: () => void;
|
||
}) {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const tags = buildPlatformWorldDisplayTags(entry, 2);
|
||
const actionLabel = isPuzzleGalleryEntry(entry) ? '试玩' : '进入';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="platform-ranking-item w-full text-left"
|
||
>
|
||
<div className="platform-ranking-item__rank">{rank}</div>
|
||
<div className="platform-ranking-item__cover">
|
||
{coverImage ? (
|
||
<ResolvedAssetBackdrop
|
||
src={coverImage}
|
||
alt={entry.worldName}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="h-full w-full bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.24),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||
)}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
|
||
{displayName}
|
||
</div>
|
||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||
<span className="text-[var(--platform-warm-text)]">
|
||
{formatCompactCount(metricValue)}
|
||
</span>
|
||
<span>{metricLabel}</span>
|
||
<span>·</span>
|
||
<span>{describePublicGalleryCardKind(entry)}</span>
|
||
</div>
|
||
<div className="platform-ranking-item__tags">
|
||
{tags.map((tag, index) => (
|
||
<span key={`${entry.profileId}-ranking-tag-${index}-${tag}`}>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<span className="platform-ranking-item__action">{actionLabel}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlatformCategoryGameItem({
|
||
entry,
|
||
categoryTag,
|
||
onClick,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
categoryTag: string;
|
||
onClick: () => void;
|
||
}) {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const tags = buildPlatformWorldDisplayTags(entry, 2);
|
||
const metric = getPlatformCategoryPrimaryMetric(entry);
|
||
const metaParts = [
|
||
describePublicGalleryCardKind(entry),
|
||
...tags.filter((tag) => tag !== categoryTag),
|
||
].slice(0, 3);
|
||
const actionLabel = isPuzzleGalleryEntry(entry) ? '试玩' : '进入';
|
||
const summaryText =
|
||
entry.summaryText || entry.subtitle || `${displayName} 正在等待摘要。`;
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
aria-label={`${entry.worldName},${actionLabel}`}
|
||
className="platform-category-game-item"
|
||
>
|
||
<div className="platform-category-game-item__cover">
|
||
{coverImage ? (
|
||
<ResolvedAssetBackdrop
|
||
src={coverImage}
|
||
alt={entry.worldName}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="h-full w-full bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.26),transparent_32%),linear-gradient(135deg,rgba(255,118,117,0.44),rgba(89,164,255,0.36))]" />
|
||
)}
|
||
</div>
|
||
|
||
<div className="platform-category-game-item__body">
|
||
<div className="platform-category-game-item__title-row">
|
||
<span className="platform-category-game-item__title">
|
||
{displayName}
|
||
</span>
|
||
<span className="platform-category-game-item__badge">公开</span>
|
||
</div>
|
||
|
||
<div className="platform-category-game-item__meta">
|
||
<span className="platform-category-game-item__metric">
|
||
<Star className="h-3.5 w-3.5 fill-current" />
|
||
<span>{formatCompactCount(metric.value)}</span>
|
||
</span>
|
||
<span>{metric.label}</span>
|
||
{metaParts.length > 0 ? <span>{metaParts.join(' · ')}</span> : null}
|
||
</div>
|
||
|
||
<div className="platform-category-game-item__summary">
|
||
{summaryText}
|
||
</div>
|
||
</div>
|
||
|
||
<span className="platform-category-game-item__action">{actionLabel}</span>
|
||
</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 = buildPlatformWorldDisplayTags(entry, 3);
|
||
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 getPlatformPublicEntries(
|
||
featuredEntries: PlatformPublicGalleryCard[],
|
||
latestEntries: PlatformPublicGalleryCard[],
|
||
) {
|
||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||
|
||
[...featuredEntries, ...latestEntries].forEach((entry) => {
|
||
entryMap.set(buildPublicGalleryCardKey(entry), entry);
|
||
});
|
||
|
||
return Array.from(entryMap.values());
|
||
}
|
||
|
||
function normalizePlatformSearchText(value: string | null | undefined) {
|
||
return (value ?? '').trim().toLocaleLowerCase('zh-CN');
|
||
}
|
||
|
||
function normalizePlatformCompactSearchText(value: string | null | undefined) {
|
||
return normalizePlatformSearchText(value).replace(/[\s_-]+/gu, '');
|
||
}
|
||
|
||
function getPlatformSearchableWorkIds(entry: PlatformPublicGalleryCard) {
|
||
const ids = [entry.publicWorkCode, entry.profileId];
|
||
if ('workId' in entry) {
|
||
ids.push(entry.workId);
|
||
}
|
||
|
||
return ids.filter((value): value is string => Boolean(value?.trim()));
|
||
}
|
||
|
||
function buildPlatformWorkSearchText(entry: PlatformPublicGalleryCard) {
|
||
return [
|
||
...getPlatformSearchableWorkIds(entry),
|
||
entry.worldName,
|
||
entry.authorDisplayName,
|
||
entry.summaryText,
|
||
entry.subtitle,
|
||
].join(' ');
|
||
}
|
||
|
||
function matchesPlatformWorkSearch(
|
||
entry: PlatformPublicGalleryCard,
|
||
keyword: string,
|
||
) {
|
||
const normalizedKeyword = normalizePlatformSearchText(keyword);
|
||
const compactKeyword = normalizePlatformCompactSearchText(keyword);
|
||
if (!normalizedKeyword) {
|
||
return false;
|
||
}
|
||
|
||
const normalizedSearchText = normalizePlatformSearchText(
|
||
buildPlatformWorkSearchText(entry),
|
||
);
|
||
if (normalizedSearchText.includes(normalizedKeyword)) {
|
||
return true;
|
||
}
|
||
|
||
return (
|
||
Boolean(compactKeyword) &&
|
||
normalizePlatformCompactSearchText(
|
||
buildPlatformWorkSearchText(entry),
|
||
).includes(compactKeyword)
|
||
);
|
||
}
|
||
|
||
function filterPlatformWorkSearchResults(
|
||
entries: PlatformPublicGalleryCard[],
|
||
keyword: string,
|
||
) {
|
||
return entries
|
||
.filter((entry) => matchesPlatformWorkSearch(entry, keyword))
|
||
.sort((left, right) => {
|
||
const leftCode = getPlatformSearchableWorkIds(left)[0] ?? '';
|
||
const rightCode = getPlatformSearchableWorkIds(right)[0] ?? '';
|
||
const normalizedKeyword = normalizePlatformSearchText(keyword);
|
||
const leftNameStarts = normalizePlatformSearchText(
|
||
left.worldName,
|
||
).startsWith(normalizedKeyword);
|
||
const rightNameStarts = normalizePlatformSearchText(
|
||
right.worldName,
|
||
).startsWith(normalizedKeyword);
|
||
if (leftNameStarts !== rightNameStarts) {
|
||
return leftNameStarts ? -1 : 1;
|
||
}
|
||
|
||
const leftCodeStarts = normalizePlatformCompactSearchText(
|
||
leftCode,
|
||
).startsWith(normalizePlatformCompactSearchText(keyword));
|
||
const rightCodeStarts = normalizePlatformCompactSearchText(
|
||
rightCode,
|
||
).startsWith(normalizePlatformCompactSearchText(keyword));
|
||
if (leftCodeStarts !== rightCodeStarts) {
|
||
return leftCodeStarts ? -1 : 1;
|
||
}
|
||
|
||
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
|
||
});
|
||
}
|
||
|
||
function isExactPublicWorkCodeSearch(
|
||
entries: PlatformPublicGalleryCard[],
|
||
keyword: string,
|
||
) {
|
||
const normalizedKeyword = normalizePlatformSearchText(keyword);
|
||
return entries.some(
|
||
(entry) =>
|
||
Boolean(entry.publicWorkCode?.trim()) &&
|
||
normalizePlatformSearchText(entry.publicWorkCode) === normalizedKeyword,
|
||
);
|
||
}
|
||
|
||
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||
const kind = isBigFishGalleryEntry(entry)
|
||
? 'big-fish'
|
||
: isPuzzleGalleryEntry(entry)
|
||
? 'puzzle'
|
||
: isMatch3DGalleryEntry(entry)
|
||
? 'match3d'
|
||
: isSquareHoleGalleryEntry(entry)
|
||
? 'square-hole'
|
||
: 'rpg';
|
||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||
}
|
||
|
||
function PlatformWorkSearchResults({
|
||
keyword,
|
||
entries,
|
||
onOpen,
|
||
onClear,
|
||
}: {
|
||
keyword: string;
|
||
entries: PlatformPublicGalleryCard[];
|
||
onOpen: (entry: PlatformPublicGalleryCard) => void;
|
||
onClear: () => void;
|
||
}) {
|
||
const trimmedKeyword = keyword.trim();
|
||
if (!trimmedKeyword) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5 sm:px-5 sm:py-4`}>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<SectionHeader title="搜索结果" detail={`${entries.length} 个作品`} />
|
||
<button
|
||
type="button"
|
||
onClick={onClear}
|
||
className="platform-icon-button"
|
||
aria-label="清空搜索"
|
||
title="清空搜索"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
{entries.length > 0 ? (
|
||
<div className="grid min-w-0 gap-2.5">
|
||
{entries.slice(0, 12).map((entry) => {
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const workCode = getPlatformSearchableWorkIds(entry)[0] ?? '';
|
||
const summaryText =
|
||
entry.summaryText || entry.subtitle || '等待补充世界摘要。';
|
||
|
||
return (
|
||
<button
|
||
key={`${buildPublicGalleryCardKey(entry)}:search-result`}
|
||
type="button"
|
||
onClick={() => onOpen(entry)}
|
||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="line-clamp-1 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
{displayName}
|
||
</div>
|
||
<div className="mt-1 line-clamp-1 text-xs text-[var(--platform-text-soft)]">
|
||
{entry.authorDisplayName}
|
||
{workCode ? ` · ${workCode}` : ''}
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||
{summaryText}
|
||
</div>
|
||
</div>
|
||
<span className="platform-pill platform-pill--neutral shrink-0 px-3">
|
||
{describePublicGalleryCardKind(entry)}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf text="没有匹配的公开作品。" />
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function buildPublicWorkAuthorLookupKey(entry: PlatformPublicGalleryCard) {
|
||
if ('authorPublicUserCode' in entry) {
|
||
const authorPublicUserCode = entry.authorPublicUserCode?.trim();
|
||
if (authorPublicUserCode) {
|
||
return `code:${authorPublicUserCode}`;
|
||
}
|
||
}
|
||
|
||
const ownerUserId = entry.ownerUserId.trim();
|
||
return ownerUserId ? `id:${ownerUserId}` : null;
|
||
}
|
||
|
||
async function getPublicWorkAuthorSummary(
|
||
authorLookupKey: string,
|
||
): Promise<PublicUserSummary | null> {
|
||
if (authorLookupKey.startsWith('code:')) {
|
||
return getPublicAuthUserByCode(authorLookupKey.slice('code:'.length));
|
||
}
|
||
|
||
if (authorLookupKey.startsWith('id:')) {
|
||
return getPublicAuthUserById(authorLookupKey.slice('id:'.length));
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||
const kind = isBigFishGalleryEntry(entry)
|
||
? '大鱼'
|
||
: isPuzzleGalleryEntry(entry)
|
||
? '拼图'
|
||
: isMatch3DGalleryEntry(entry)
|
||
? '抓鹅'
|
||
: isSquareHoleGalleryEntry(entry)
|
||
? '方洞'
|
||
: describePlatformThemeLabel(entry.themeMode);
|
||
return formatPlatformWorkDisplayTag(kind);
|
||
}
|
||
|
||
function getPublicAuthorAvatarLabel(authorDisplayName: string) {
|
||
return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩';
|
||
}
|
||
|
||
function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
|
||
return Math.max(0, Math.round(entry.likeCount ?? 0));
|
||
}
|
||
|
||
function getPlatformWorldPlayCount(entry: PlatformWorldCardLike) {
|
||
return Math.max(
|
||
0,
|
||
Math.round(('playCount' in entry && entry.playCount) || 0),
|
||
);
|
||
}
|
||
|
||
function getPlatformWorldRemixCount(entry: PlatformWorldCardLike) {
|
||
return Math.max(
|
||
0,
|
||
Math.round(('remixCount' in entry && entry.remixCount) || 0),
|
||
);
|
||
}
|
||
|
||
function getPlatformWorldRecentPlayCount(entry: PlatformWorldCardLike) {
|
||
return Math.max(
|
||
0,
|
||
Math.round(('recentPlayCount7d' in entry && entry.recentPlayCount7d) || 0),
|
||
);
|
||
}
|
||
|
||
function getPlatformWorldTimestamp(entry: PlatformWorldCardLike) {
|
||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||
return parsePlatformEntryTimestamp(rawTime);
|
||
}
|
||
|
||
// 首页“今日游戏”只看作品首次发布时间,按玩家浏览器本地自然日判断。
|
||
function parsePlatformEntryTimestamp(value: string | null | undefined) {
|
||
if (!value) {
|
||
return 0;
|
||
}
|
||
|
||
const normalized = value.trim();
|
||
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
|
||
if (numericTimestamp?.[1]) {
|
||
const rawTimestamp = Number(numericTimestamp[1]);
|
||
if (Number.isFinite(rawTimestamp)) {
|
||
const absoluteTimestamp = Math.abs(rawTimestamp);
|
||
const timestampMs =
|
||
absoluteTimestamp >= 1_000_000_000_000_000
|
||
? rawTimestamp / 1000
|
||
: absoluteTimestamp >= 1_000_000_000_000
|
||
? rawTimestamp
|
||
: absoluteTimestamp >= 1_000_000_000
|
||
? rawTimestamp * 1000
|
||
: Number.NaN;
|
||
return Number.isNaN(timestampMs) ? 0 : timestampMs;
|
||
}
|
||
}
|
||
|
||
const timestamp = new Date(normalized).getTime();
|
||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||
}
|
||
|
||
function isSameLocalCalendarDay(left: Date, right: Date) {
|
||
return (
|
||
left.getFullYear() === right.getFullYear() &&
|
||
left.getMonth() === right.getMonth() &&
|
||
left.getDate() === right.getDate()
|
||
);
|
||
}
|
||
|
||
function isPublishedToday(entry: PlatformPublicGalleryCard, now = new Date()) {
|
||
const publishedAtTimestamp = parsePlatformEntryTimestamp(entry.publishedAt);
|
||
if (publishedAtTimestamp <= 0) {
|
||
return false;
|
||
}
|
||
|
||
const publishedAt = new Date(publishedAtTimestamp);
|
||
return isSameLocalCalendarDay(publishedAt, now);
|
||
}
|
||
|
||
function filterTodayPublishedEntries(entries: PlatformPublicGalleryCard[]) {
|
||
const now = new Date();
|
||
return entries.filter((entry) => isPublishedToday(entry, now));
|
||
}
|
||
|
||
function sortEntriesByMetric(
|
||
entries: PlatformPublicGalleryCard[],
|
||
getMetric: (entry: PlatformPublicGalleryCard) => number,
|
||
) {
|
||
return [...entries].sort((left, right) => {
|
||
const metricDiff = getMetric(right) - getMetric(left);
|
||
if (metricDiff !== 0) {
|
||
return metricDiff;
|
||
}
|
||
|
||
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
|
||
});
|
||
}
|
||
|
||
function buildPlatformRankingEntries(
|
||
entries: PlatformPublicGalleryCard[],
|
||
tab: PlatformRankingTab,
|
||
) {
|
||
if (tab === 'hot') {
|
||
return sortEntriesByMetric(entries, getPlatformWorldPlayCount);
|
||
}
|
||
|
||
if (tab === 'remix') {
|
||
return sortEntriesByMetric(entries, getPlatformWorldRemixCount);
|
||
}
|
||
|
||
if (tab === 'like') {
|
||
return sortEntriesByMetric(entries, getPlatformWorldLikeCount);
|
||
}
|
||
|
||
return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount);
|
||
}
|
||
|
||
function getPlatformRankingMetricValue(
|
||
entry: PlatformPublicGalleryCard,
|
||
tab: PlatformRankingTab,
|
||
) {
|
||
if (tab === 'remix') {
|
||
return getPlatformWorldRemixCount(entry);
|
||
}
|
||
|
||
if (tab === 'like') {
|
||
return getPlatformWorldLikeCount(entry);
|
||
}
|
||
|
||
if (tab === 'new') {
|
||
return getPlatformWorldRecentPlayCount(entry);
|
||
}
|
||
|
||
return getPlatformWorldPlayCount(entry);
|
||
}
|
||
|
||
function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) {
|
||
// 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。
|
||
return (
|
||
getPlatformWorldPlayCount(entry) +
|
||
getPlatformWorldRemixCount(entry) +
|
||
getPlatformWorldLikeCount(entry) +
|
||
getPlatformWorldRecentPlayCount(entry)
|
||
);
|
||
}
|
||
|
||
function getPlatformCategoryPrimaryMetric(entry: PlatformPublicGalleryCard) {
|
||
const likeCount = getPlatformWorldLikeCount(entry);
|
||
if (likeCount > 0) {
|
||
return { label: '点赞', value: likeCount };
|
||
}
|
||
|
||
const recentPlayCount = getPlatformWorldRecentPlayCount(entry);
|
||
if (recentPlayCount > 0) {
|
||
return { label: '近7日', value: recentPlayCount };
|
||
}
|
||
|
||
return { label: '游玩', value: getPlatformWorldPlayCount(entry) };
|
||
}
|
||
|
||
function formatCompactCount(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}`;
|
||
}
|
||
|
||
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 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 formatTotalPlayTimeHours(playTimeMs: number) {
|
||
const roundedHours = Math.max(0, Math.round(playTimeMs / 360000) / 10);
|
||
|
||
return `${roundedHours.toLocaleString('zh-CN', {
|
||
maximumFractionDigits: 1,
|
||
})}小时`;
|
||
}
|
||
|
||
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 isWithinProfileInviteRedeemWindow(
|
||
createdAt: string | null | undefined,
|
||
) {
|
||
if (!createdAt) {
|
||
return false;
|
||
}
|
||
|
||
const createdTime = new Date(createdAt).getTime();
|
||
if (Number.isNaN(createdTime)) {
|
||
return false;
|
||
}
|
||
|
||
return Date.now() - createdTime <= PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS;
|
||
}
|
||
|
||
function formatPlayedWorkType(value: string | null | undefined) {
|
||
const normalizedValue = (value ?? '').toLowerCase();
|
||
if (normalizedValue === 'puzzle') {
|
||
return '拼图';
|
||
}
|
||
if (normalizedValue === 'match3d' || normalizedValue === 'match_3d') {
|
||
return '抓鹅';
|
||
}
|
||
if (normalizedValue === 'square-hole' || normalizedValue === 'square_hole') {
|
||
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 validateProfileDisplayName(value: string) {
|
||
const normalized = value.trim();
|
||
if (!normalized) {
|
||
return '请输入昵称';
|
||
}
|
||
const length = Array.from(normalized).length;
|
||
if (length < 2 || length > 20) {
|
||
return '昵称需要 2 到 20 位';
|
||
}
|
||
if (!/^[\u4e00-\u9fffa-zA-Z0-9_]+$/u.test(normalized)) {
|
||
return '昵称仅支持中文、英文、数字和下划线';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function readImageIntrinsicSize(src: string) {
|
||
return new Promise<{ width: number; height: number }>((resolve, reject) => {
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
resolve({
|
||
width: image.naturalWidth,
|
||
height: image.naturalHeight,
|
||
});
|
||
};
|
||
image.onerror = () => reject(new Error('图片读取失败'));
|
||
image.src = src;
|
||
});
|
||
}
|
||
|
||
function loadAvatarFile(file: File) {
|
||
return new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
if (typeof reader.result !== 'string') {
|
||
reject(new Error('图片读取失败'));
|
||
return;
|
||
}
|
||
resolve(reader.result);
|
||
};
|
||
reader.onerror = () => reject(new Error('图片读取失败'));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function cropAvatarImage(params: {
|
||
source: string;
|
||
cropX: number;
|
||
cropY: number;
|
||
cropSize: number;
|
||
}) {
|
||
return new Promise<string>((resolve, reject) => {
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = AVATAR_OUTPUT_SIZE;
|
||
canvas.height = AVATAR_OUTPUT_SIZE;
|
||
const context = canvas.getContext('2d');
|
||
if (!context) {
|
||
reject(new Error('头像裁剪失败'));
|
||
return;
|
||
}
|
||
|
||
context.drawImage(
|
||
image,
|
||
params.cropX,
|
||
params.cropY,
|
||
params.cropSize,
|
||
params.cropSize,
|
||
0,
|
||
0,
|
||
AVATAR_OUTPUT_SIZE,
|
||
AVATAR_OUTPUT_SIZE,
|
||
);
|
||
resolve(canvas.toDataURL('image/png'));
|
||
};
|
||
image.onerror = () => reject(new Error('头像裁剪失败'));
|
||
image.src = params.source;
|
||
});
|
||
}
|
||
|
||
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,
|
||
subLabel,
|
||
icon,
|
||
onClick,
|
||
}: {
|
||
label: string;
|
||
subLabel?: ReactNode;
|
||
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>
|
||
{subLabel ? (
|
||
<div className="flex min-h-4 items-center justify-center gap-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
||
{subLabel}
|
||
</div>
|
||
) : null}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function ProfileReferralUserAvatar({
|
||
name,
|
||
avatarUrl,
|
||
}: {
|
||
name: string;
|
||
avatarUrl: string | null;
|
||
}) {
|
||
const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
|
||
|
||
return (
|
||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#ff4056] text-xs font-black text-white">
|
||
{avatarUrl ? (
|
||
<img
|
||
src={avatarUrl}
|
||
alt=""
|
||
className="h-full w-full object-cover"
|
||
loading="lazy"
|
||
decoding="async"
|
||
/>
|
||
) : (
|
||
avatarLabel
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ProfileNicknameModal({
|
||
value,
|
||
error,
|
||
isSaving,
|
||
onChange,
|
||
onClose,
|
||
onSubmit,
|
||
}: {
|
||
value: string;
|
||
error: string | null;
|
||
isSaving: boolean;
|
||
onChange: (value: string) => void;
|
||
onClose: () => void;
|
||
onSubmit: () => void;
|
||
}) {
|
||
return (
|
||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="profile-nickname-title"
|
||
className="platform-modal-shell platform-remap-surface 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 id="profile-nickname-title" className="text-base font-black">
|
||
修改昵称
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="关闭昵称修改"
|
||
onClick={onClose}
|
||
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="px-5 py-5">
|
||
<label className="block">
|
||
<span className="sr-only">新昵称</span>
|
||
<input
|
||
autoFocus
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
onSubmit();
|
||
}
|
||
}}
|
||
maxLength={20}
|
||
className="w-full rounded-2xl border border-white/12 bg-white/10 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)]"
|
||
placeholder="输入新昵称"
|
||
/>
|
||
</label>
|
||
{error ? (
|
||
<div className="mt-3 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="platform-button platform-button--secondary justify-center"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={isSaving}
|
||
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{isSaving ? '保存中' : '保存'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProfileAvatarCropModal({
|
||
source,
|
||
imageSize,
|
||
scale,
|
||
cropX,
|
||
cropY,
|
||
error,
|
||
isSaving,
|
||
onScaleChange,
|
||
onCropChange,
|
||
onClose,
|
||
onSubmit,
|
||
}: {
|
||
source: string;
|
||
imageSize: { width: number; height: number };
|
||
scale: number;
|
||
cropX: number;
|
||
cropY: number;
|
||
error: string | null;
|
||
isSaving: boolean;
|
||
onScaleChange: (value: number) => void;
|
||
onCropChange: (nextCrop: { x: number; y: number }) => void;
|
||
onClose: () => void;
|
||
onSubmit: () => void;
|
||
}) {
|
||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||
const dragStartRef = useRef<{
|
||
pointerId: number;
|
||
clientX: number;
|
||
clientY: number;
|
||
cropX: number;
|
||
cropY: number;
|
||
} | null>(null);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const cropSize = Math.min(imageSize.width, imageSize.height) / scale;
|
||
const maxCropX = Math.max(0, imageSize.width - cropSize);
|
||
const maxCropY = Math.max(0, imageSize.height - cropSize);
|
||
const backgroundSize = `${(imageSize.width / cropSize) * 100}% ${(imageSize.height / cropSize) * 100}%`;
|
||
const backgroundPosition = `${maxCropX > 0 ? (cropX / maxCropX) * 100 : 50}% ${maxCropY > 0 ? (cropY / maxCropY) * 100 : 50}%`;
|
||
const updateDragCrop = (event: PointerEvent<HTMLDivElement>) => {
|
||
const dragStart = dragStartRef.current;
|
||
const preview = previewRef.current;
|
||
if (!dragStart || !preview || event.pointerId !== dragStart.pointerId) {
|
||
return;
|
||
}
|
||
|
||
const rect = preview.getBoundingClientRect();
|
||
const sourcePixelsPerPreviewPixel = cropSize / Math.max(1, rect.width);
|
||
onCropChange({
|
||
x:
|
||
dragStart.cropX -
|
||
(event.clientX - dragStart.clientX) * sourcePixelsPerPreviewPixel,
|
||
y:
|
||
dragStart.cropY -
|
||
(event.clientY - dragStart.clientY) * sourcePixelsPerPreviewPixel,
|
||
});
|
||
};
|
||
const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
|
||
if (dragStartRef.current?.pointerId === event.pointerId) {
|
||
dragStartRef.current = null;
|
||
setIsDragging(false);
|
||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="profile-avatar-crop-title"
|
||
className="platform-modal-shell platform-remap-surface 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 id="profile-avatar-crop-title" className="text-base font-black">
|
||
裁剪头像
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="关闭头像裁剪"
|
||
onClick={onClose}
|
||
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="px-5 py-5">
|
||
<div
|
||
ref={previewRef}
|
||
className="mx-auto aspect-square w-full max-w-[16rem] overflow-hidden rounded-[1.4rem] border border-white/12 bg-cover bg-center"
|
||
style={{
|
||
backgroundImage: `url("${source}")`,
|
||
backgroundSize,
|
||
backgroundPosition,
|
||
cursor: isDragging ? 'grabbing' : 'grab',
|
||
touchAction: 'none',
|
||
}}
|
||
role="img"
|
||
aria-label="头像裁剪预览"
|
||
onPointerDown={(event) => {
|
||
dragStartRef.current = {
|
||
pointerId: event.pointerId,
|
||
clientX: event.clientX,
|
||
clientY: event.clientY,
|
||
cropX,
|
||
cropY,
|
||
};
|
||
setIsDragging(true);
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
}}
|
||
onPointerMove={updateDragCrop}
|
||
onPointerUp={stopDragging}
|
||
onPointerCancel={stopDragging}
|
||
/>
|
||
<div className="mt-5 space-y-4">
|
||
<label className="block">
|
||
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
|
||
缩放
|
||
</span>
|
||
<input
|
||
type="range"
|
||
min="1"
|
||
max="3"
|
||
step="0.01"
|
||
value={scale}
|
||
onChange={(event) => onScaleChange(Number(event.target.value))}
|
||
className="w-full"
|
||
/>
|
||
</label>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<label className="block">
|
||
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
|
||
横向
|
||
</span>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={maxCropX}
|
||
step="1"
|
||
value={Math.min(cropX, maxCropX)}
|
||
onChange={(event) =>
|
||
onCropChange({ x: Number(event.target.value), y: cropY })
|
||
}
|
||
className="w-full"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
|
||
纵向
|
||
</span>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={maxCropY}
|
||
step="1"
|
||
value={Math.min(cropY, maxCropY)}
|
||
onChange={(event) =>
|
||
onCropChange({ x: cropX, y: Number(event.target.value) })
|
||
}
|
||
className="w-full"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
{error ? (
|
||
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="platform-button platform-button--secondary justify-center"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={isSaving}
|
||
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{isSaving ? '上传中' : '上传'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
|
||
new_user_registration_reward: '注册赠送',
|
||
points_recharge: '光点充值',
|
||
invite_inviter_reward: '邀请奖励',
|
||
invite_invitee_reward: '填写邀请码奖励',
|
||
snapshot_sync: '账户同步',
|
||
asset_operation_consume: '资产操作消耗',
|
||
asset_operation_refund: '资产操作退回',
|
||
redeem_code_reward: '兑换码奖励',
|
||
daily_task_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>
|
||
);
|
||
}
|
||
|
||
const PROFILE_TASK_STATUS_LABELS: Record<ProfileTaskItem['status'], string> = {
|
||
incomplete: '未完成',
|
||
claimable: '可领取',
|
||
claimed: '已领取',
|
||
disabled: '已停用',
|
||
};
|
||
|
||
function ProfileTaskCenterModal({
|
||
center,
|
||
isLoading,
|
||
error,
|
||
success,
|
||
claimingTaskId,
|
||
fallbackBalance,
|
||
onClose,
|
||
onRetry,
|
||
onClaim,
|
||
}: {
|
||
center: ProfileTaskCenterResponse | null;
|
||
isLoading: boolean;
|
||
error: string | null;
|
||
success: string | null;
|
||
claimingTaskId: string | null;
|
||
fallbackBalance: number;
|
||
onClose: () => void;
|
||
onRetry: () => void;
|
||
onClaim: (taskId: string) => void;
|
||
}) {
|
||
const tasks = center?.tasks ?? [];
|
||
const walletBalance = center?.walletBalance ?? fallbackBalance;
|
||
|
||
return (
|
||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||
<div className="platform-recharge-modal w-full max-w-md overflow-hidden rounded-[1.4rem]">
|
||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||
<div>
|
||
<div className="text-base font-black">每日任务</div>
|
||
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||
{walletBalance}光点
|
||
</div>
|
||
</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">
|
||
{error ? (
|
||
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
|
||
<div>{error}</div>
|
||
<button
|
||
type="button"
|
||
onClick={onRetry}
|
||
className="platform-primary-button mt-3 rounded-2xl px-4 py-2 text-xs font-black"
|
||
>
|
||
重新加载
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{success ? (
|
||
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
|
||
{success}
|
||
</div>
|
||
) : null}
|
||
{isLoading ? (
|
||
<div className="space-y-3">
|
||
{Array.from({ length: 2 }).map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className="h-20 animate-pulse rounded-2xl bg-white/10"
|
||
/>
|
||
))}
|
||
</div>
|
||
) : tasks.length === 0 ? (
|
||
<div className="platform-subpanel rounded-2xl px-4 py-8 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
|
||
暂无任务
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{tasks.map((task) => {
|
||
const isClaimable = task.status === 'claimable';
|
||
const isClaiming = claimingTaskId === task.taskId;
|
||
const progressLabel = `${Math.min(task.progressCount, task.threshold)}/${task.threshold}`;
|
||
|
||
return (
|
||
<div
|
||
key={task.taskId}
|
||
className="platform-subpanel rounded-2xl px-4 py-4"
|
||
>
|
||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-base font-black text-[var(--platform-text-strong)]">
|
||
{task.title}
|
||
</div>
|
||
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||
{progressLabel}
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||
+{task.rewardPoints}
|
||
</div>
|
||
<div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
||
{PROFILE_TASK_STATUS_LABELS[task.status]}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={!isClaimable || Boolean(claimingTaskId)}
|
||
onClick={() => onClaim(task.taskId)}
|
||
className="platform-primary-button mt-3 w-full rounded-2xl px-4 py-2.5 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{isClaiming
|
||
? '领取中'
|
||
: task.status === 'claimed'
|
||
? '已领取'
|
||
: isClaimable
|
||
? '领取'
|
||
: '未完成'}
|
||
</button>
|
||
</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-[80] 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,
|
||
isLoading,
|
||
isSubmittingRedeem,
|
||
redeemCode,
|
||
error,
|
||
success,
|
||
onClose,
|
||
onCopyInvite,
|
||
onRedeemCodeChange,
|
||
onSubmitRedeemCode,
|
||
}: {
|
||
panel: ProfilePopupPanel;
|
||
center: ProfileReferralInviteCenterResponse | null;
|
||
isLoading: boolean;
|
||
isSubmittingRedeem: boolean;
|
||
redeemCode: string;
|
||
error: string | null;
|
||
success: string | null;
|
||
onClose: () => void;
|
||
onCopyInvite: () => void;
|
||
onRedeemCodeChange: (value: string) => void;
|
||
onSubmitRedeemCode: () => void;
|
||
}) {
|
||
const title =
|
||
panel === 'invite'
|
||
? '邀请好友'
|
||
: panel === 'redeem'
|
||
? '填邀请码'
|
||
: '玩家社区';
|
||
const normalizedRedeemCode = redeemCode
|
||
.trim()
|
||
.replace(/[^0-9a-z]/gi, '')
|
||
.toUpperCase();
|
||
|
||
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">
|
||
{COMMUNITY_QR_CODES.map((qrCode) => (
|
||
<div
|
||
key={qrCode.label}
|
||
className="rounded-xl border border-zinc-200 bg-zinc-50 p-2.5 text-center"
|
||
>
|
||
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-200 bg-white p-1.5">
|
||
<img
|
||
src={qrCode.src}
|
||
alt={qrCode.alt}
|
||
className="h-full w-full object-contain"
|
||
loading="lazy"
|
||
decoding="async"
|
||
/>
|
||
</div>
|
||
<div className="mt-2 text-sm font-bold text-zinc-700">
|
||
{qrCode.label}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : panel === 'redeem' ? (
|
||
isLoading ? (
|
||
<div className="mt-5 space-y-3">
|
||
<div className="h-12 animate-pulse rounded-xl bg-zinc-100" />
|
||
<div className="h-11 animate-pulse rounded-xl bg-zinc-100" />
|
||
</div>
|
||
) : center?.hasRedeemedCode ? (
|
||
<div className="mt-5 rounded-xl bg-zinc-50 px-4 py-5 text-center text-sm font-semibold text-zinc-600">
|
||
已填写邀请码
|
||
</div>
|
||
) : (
|
||
<form
|
||
className="mt-5 space-y-3"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
onSubmitRedeemCode();
|
||
}}
|
||
>
|
||
<input
|
||
value={redeemCode}
|
||
onChange={(event) => onRedeemCodeChange(event.target.value)}
|
||
className="w-full rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-center text-base font-black uppercase tracking-[0.16em] text-zinc-950 outline-none focus:border-[#ff4056]"
|
||
placeholder="邀请码"
|
||
aria-label="邀请码"
|
||
autoComplete="off"
|
||
autoFocus
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmittingRedeem || !normalizedRedeemCode}
|
||
className="flex w-full items-center justify-center rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{isSubmittingRedeem ? '提交中' : '提交'}
|
||
</button>
|
||
</form>
|
||
)
|
||
) : 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>
|
||
) : (
|
||
<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>
|
||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-3 text-sm font-semibold leading-6 text-amber-900">
|
||
<div>
|
||
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}光点。`}
|
||
</div>
|
||
<div>每日最多获得十次邀请奖励。</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="rounded-xl bg-zinc-50 px-3.5 py-3">
|
||
<div className="text-xs font-black text-zinc-900">
|
||
成功邀请
|
||
</div>
|
||
{center?.invitedUsers?.length ? (
|
||
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
|
||
{center.invitedUsers.map((user) => (
|
||
<div
|
||
key={`${user.userId}-${user.boundAt}`}
|
||
className="flex items-center gap-3 rounded-lg bg-white px-2.5 py-2"
|
||
>
|
||
<ProfileReferralUserAvatar
|
||
name={user.displayName}
|
||
avatarUrl={user.avatarUrl}
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-bold text-zinc-900">
|
||
{user.displayName || '玩家'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="mt-3 rounded-lg bg-white px-3 py-3 text-center text-xs font-semibold text-zinc-500">
|
||
暂无成功邀请
|
||
</div>
|
||
)}
|
||
</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}
|
||
{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>
|
||
{formatTotalPlayTimeHours(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 [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = 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 [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false);
|
||
const [taskCenter, setTaskCenter] = useState<ProfileTaskCenterResponse | null>(
|
||
null,
|
||
);
|
||
const [taskCenterError, setTaskCenterError] = useState<string | null>(null);
|
||
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
|
||
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
|
||
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
|
||
const [profilePopupPanel, setProfilePopupPanel] =
|
||
useState<ProfilePopupPanel | null>(null);
|
||
const [referralCenter, setReferralCenter] =
|
||
useState<ProfileReferralInviteCenterResponse | null>(null);
|
||
const [isLoadingReferral, setIsLoadingReferral] = useState(false);
|
||
const [isReferralCenterInitialized, setIsReferralCenterInitialized] =
|
||
useState(false);
|
||
const [referralRedeemCode, setReferralRedeemCode] = useState('');
|
||
const [isSubmittingReferralRedeem, setIsSubmittingReferralRedeem] =
|
||
useState(false);
|
||
const [referralError, setReferralError] = useState<string | null>(null);
|
||
const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
|
||
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [mobileHomeChannel, setMobileHomeChannel] =
|
||
useState<MobileHomeChannel>('recommend');
|
||
const mobileFeedRef = useRef<HTMLElement | null>(null);
|
||
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
|
||
string | null
|
||
>(null);
|
||
const pendingPublicAuthorKeysRef = useRef<Set<string>>(new Set());
|
||
const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState<
|
||
Record<string, PublicUserSummary | null>
|
||
>({});
|
||
const [activeRankingTab, setActiveRankingTab] =
|
||
useState<PlatformRankingTab>('hot');
|
||
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
|
||
() => new Set([activeTab]),
|
||
);
|
||
const [profileCopyState, setProfileCopyState] = useState<
|
||
'idle' | 'copied' | 'failed'
|
||
>('idle');
|
||
const profileCopyResetTimerRef = useRef<number | null>(null);
|
||
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
|
||
const [nicknameInput, setNicknameInput] = useState('');
|
||
const [nicknameError, setNicknameError] = useState<string | null>(null);
|
||
const [isSavingNickname, setIsSavingNickname] = useState(false);
|
||
const [avatarSource, setAvatarSource] = useState<string | null>(null);
|
||
const [avatarImageSize, setAvatarImageSize] = useState<{
|
||
width: number;
|
||
height: number;
|
||
} | null>(null);
|
||
const [avatarScale, setAvatarScale] = useState(1);
|
||
const [avatarCrop, setAvatarCrop] = useState({ x: 0, y: 0 });
|
||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
|
||
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 publicEntries = useMemo(
|
||
() => getPlatformPublicEntries(featuredEntries, latestEntries),
|
||
[featuredEntries, latestEntries],
|
||
);
|
||
const workSearchResults = useMemo(
|
||
() =>
|
||
filterPlatformWorkSearchResults(publicEntries, activeWorkSearchKeyword),
|
||
[activeWorkSearchKeyword, publicEntries],
|
||
);
|
||
const getPublicEntryAuthorAvatarUrl = useCallback(
|
||
(entry: PlatformPublicGalleryCard) => {
|
||
const authorLookupKey = buildPublicWorkAuthorLookupKey(entry);
|
||
if (!authorLookupKey) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
publicAuthorSummariesByKey[authorLookupKey]?.avatarUrl?.trim() || null
|
||
);
|
||
},
|
||
[publicAuthorSummariesByKey],
|
||
);
|
||
const activeCategoryGroup =
|
||
categoryGroups.find((group) => group.tag === selectedCategoryTag) ??
|
||
categoryGroups[0] ??
|
||
null;
|
||
const activeCategoryEntries = useMemo(() => {
|
||
if (!activeCategoryGroup) {
|
||
return [];
|
||
}
|
||
|
||
return [...activeCategoryGroup.entries].sort((left, right) => {
|
||
const scoreDiff =
|
||
getPlatformCategoryCompositeScore(right) -
|
||
getPlatformCategoryCompositeScore(left);
|
||
if (scoreDiff !== 0) {
|
||
return scoreDiff;
|
||
}
|
||
|
||
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
|
||
});
|
||
}, [activeCategoryGroup]);
|
||
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 avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
|
||
const avatarCropSize = avatarImageSize
|
||
? Math.min(avatarImageSize.width, avatarImageSize.height) / avatarScale
|
||
: 0;
|
||
const remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0;
|
||
const totalPlayTime = formatTotalPlayTimeHours(
|
||
profileDashboard?.totalPlayTimeMs ?? 0,
|
||
);
|
||
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
|
||
const canShowReferralRedeemShortcut =
|
||
isAuthenticated &&
|
||
isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) &&
|
||
isReferralCenterInitialized &&
|
||
Boolean(referralCenter) &&
|
||
referralCenter?.hasRedeemedCode !== true;
|
||
const tabIcons = {
|
||
home: House,
|
||
category: Trophy,
|
||
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 (profileCopyResetTimerRef.current !== null) {
|
||
window.clearTimeout(profileCopyResetTimerRef.current);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
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]);
|
||
|
||
useEffect(() => {
|
||
const missingAuthorKeys = [
|
||
...new Set(
|
||
publicEntries
|
||
.map(buildPublicWorkAuthorLookupKey)
|
||
.filter((key): key is string => Boolean(key)),
|
||
),
|
||
].filter(
|
||
(key) =>
|
||
!(key in publicAuthorSummariesByKey) &&
|
||
!pendingPublicAuthorKeysRef.current.has(key),
|
||
);
|
||
|
||
if (missingAuthorKeys.length === 0) {
|
||
return undefined;
|
||
}
|
||
|
||
let cancelled = false;
|
||
missingAuthorKeys.forEach((key) => {
|
||
pendingPublicAuthorKeysRef.current.add(key);
|
||
});
|
||
|
||
// 中文注释:头像来自公开用户摘要,失败时缓存空值,避免首页滚动时反复打公开用户接口。
|
||
void Promise.all(
|
||
missingAuthorKeys.map(async (authorLookupKey) => {
|
||
try {
|
||
const author = await getPublicWorkAuthorSummary(authorLookupKey);
|
||
return [authorLookupKey, author] as const;
|
||
} catch {
|
||
return [authorLookupKey, null] as const;
|
||
} finally {
|
||
pendingPublicAuthorKeysRef.current.delete(authorLookupKey);
|
||
}
|
||
}),
|
||
).then((results) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
setPublicAuthorSummariesByKey((currentSummaries) => {
|
||
let changed = false;
|
||
const nextSummaries = { ...currentSummaries };
|
||
|
||
results.forEach(([authorLookupKey, author]) => {
|
||
if (authorLookupKey in nextSummaries) {
|
||
return;
|
||
}
|
||
|
||
nextSummaries[authorLookupKey] = author;
|
||
changed = true;
|
||
});
|
||
|
||
return changed ? nextSummaries : currentSummaries;
|
||
});
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [publicAuthorSummariesByKey, publicEntries]);
|
||
const openUserSurface = () => {
|
||
if (authUi?.user) {
|
||
authUi.openAccountModal();
|
||
return;
|
||
}
|
||
authUi?.openLoginModal();
|
||
};
|
||
const scheduleProfileCopyStateReset = () => {
|
||
if (profileCopyResetTimerRef.current !== null) {
|
||
window.clearTimeout(profileCopyResetTimerRef.current);
|
||
}
|
||
profileCopyResetTimerRef.current = window.setTimeout(() => {
|
||
profileCopyResetTimerRef.current = null;
|
||
setProfileCopyState('idle');
|
||
}, 1400);
|
||
};
|
||
const copyProfilePublicUserCode = () => {
|
||
void copyTextToClipboard(publicUserCode).then((copied) => {
|
||
setProfileCopyState(copied ? 'copied' : 'failed');
|
||
scheduleProfileCopyStateReset();
|
||
});
|
||
};
|
||
const openNicknameModal = () => {
|
||
const user = authUi?.user;
|
||
if (!user) {
|
||
authUi?.openLoginModal();
|
||
return;
|
||
}
|
||
|
||
setNicknameInput(user.displayName);
|
||
setNicknameError(null);
|
||
setIsNicknameModalOpen(true);
|
||
};
|
||
const submitNickname = () => {
|
||
if (!authUi?.user || isSavingNickname) {
|
||
return;
|
||
}
|
||
|
||
const validationError = validateProfileDisplayName(nicknameInput);
|
||
if (validationError) {
|
||
setNicknameError(validationError);
|
||
return;
|
||
}
|
||
|
||
const nextName = nicknameInput.trim();
|
||
setIsSavingNickname(true);
|
||
setNicknameError(null);
|
||
void updateAuthProfile({ displayName: nextName })
|
||
.then((nextUser) => {
|
||
authUi.setCurrentUser(nextUser);
|
||
setIsNicknameModalOpen(false);
|
||
})
|
||
.catch((error: unknown) => {
|
||
setNicknameError(
|
||
error instanceof Error ? error.message : '昵称保存失败',
|
||
);
|
||
})
|
||
.finally(() => setIsSavingNickname(false));
|
||
};
|
||
const openAvatarPicker = () => {
|
||
if (!authUi?.user) {
|
||
authUi?.openLoginModal();
|
||
return;
|
||
}
|
||
|
||
setAvatarError(null);
|
||
avatarFileInputRef.current?.click();
|
||
};
|
||
const handleAvatarFileChange = (file: File | null) => {
|
||
if (avatarFileInputRef.current) {
|
||
avatarFileInputRef.current.value = '';
|
||
}
|
||
if (!file) {
|
||
return;
|
||
}
|
||
if (!AVATAR_ALLOWED_TYPES.has(file.type)) {
|
||
setAvatarError('头像仅支持 jpg、png、webp');
|
||
return;
|
||
}
|
||
if (file.size > AVATAR_MAX_FILE_SIZE) {
|
||
setAvatarError('头像图片不能超过 5MB');
|
||
return;
|
||
}
|
||
|
||
setAvatarError(null);
|
||
void loadAvatarFile(file)
|
||
.then(async (source) => {
|
||
const imageSize = await readImageIntrinsicSize(source);
|
||
const cropSize = Math.min(imageSize.width, imageSize.height);
|
||
setAvatarSource(source);
|
||
setAvatarImageSize(imageSize);
|
||
setAvatarScale(1);
|
||
setAvatarCrop({
|
||
x: Math.max(0, (imageSize.width - cropSize) / 2),
|
||
y: Math.max(0, (imageSize.height - cropSize) / 2),
|
||
});
|
||
})
|
||
.catch((error: unknown) => {
|
||
setAvatarError(
|
||
error instanceof Error ? error.message : '头像图片读取失败',
|
||
);
|
||
});
|
||
};
|
||
const updateAvatarScale = useCallback(
|
||
(nextScale: number) => {
|
||
if (!avatarImageSize) {
|
||
return;
|
||
}
|
||
|
||
const normalizedScale = Math.min(3, Math.max(1, nextScale));
|
||
const nextCropSize =
|
||
Math.min(avatarImageSize.width, avatarImageSize.height) /
|
||
normalizedScale;
|
||
setAvatarScale(normalizedScale);
|
||
setAvatarCrop((current) => ({
|
||
x: Math.min(
|
||
current.x,
|
||
Math.max(0, avatarImageSize.width - nextCropSize),
|
||
),
|
||
y: Math.min(
|
||
current.y,
|
||
Math.max(0, avatarImageSize.height - nextCropSize),
|
||
),
|
||
}));
|
||
},
|
||
[avatarImageSize],
|
||
);
|
||
const updateAvatarCrop = useCallback(
|
||
(nextCrop: { x: number; y: number }) => {
|
||
if (!avatarImageSize || avatarCropSize <= 0) {
|
||
return;
|
||
}
|
||
|
||
setAvatarCrop({
|
||
x: Math.min(
|
||
Math.max(0, nextCrop.x),
|
||
Math.max(0, avatarImageSize.width - avatarCropSize),
|
||
),
|
||
y: Math.min(
|
||
Math.max(0, nextCrop.y),
|
||
Math.max(0, avatarImageSize.height - avatarCropSize),
|
||
),
|
||
});
|
||
},
|
||
[avatarCropSize, avatarImageSize],
|
||
);
|
||
const submitAvatar = () => {
|
||
if (
|
||
!avatarSource ||
|
||
!avatarImageSize ||
|
||
avatarCropSize <= 0 ||
|
||
isSavingAvatar
|
||
) {
|
||
return;
|
||
}
|
||
|
||
setIsSavingAvatar(true);
|
||
setAvatarError(null);
|
||
void cropAvatarImage({
|
||
source: avatarSource,
|
||
cropX: avatarCrop.x,
|
||
cropY: avatarCrop.y,
|
||
cropSize: avatarCropSize,
|
||
})
|
||
.then((avatarDataUrl) => updateAuthProfile({ avatarDataUrl }))
|
||
.then((nextUser) => {
|
||
authUi?.setCurrentUser(nextUser);
|
||
setAvatarSource(null);
|
||
setAvatarImageSize(null);
|
||
})
|
||
.catch((error: unknown) => {
|
||
setAvatarError(error instanceof Error ? error.message : '头像上传失败');
|
||
})
|
||
.finally(() => setIsSavingAvatar(false));
|
||
};
|
||
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 loadTaskCenter = () => {
|
||
setTaskCenterError(null);
|
||
setIsLoadingTaskCenter(true);
|
||
void getRpgProfileTasks()
|
||
.then(setTaskCenter)
|
||
.catch((error: unknown) => {
|
||
setTaskCenter(null);
|
||
setTaskCenterError(
|
||
error instanceof Error ? error.message : '读取每日任务失败',
|
||
);
|
||
})
|
||
.finally(() => setIsLoadingTaskCenter(false));
|
||
};
|
||
const openTaskCenterPanel = () => {
|
||
setIsTaskCenterOpen(true);
|
||
setTaskClaimSuccess(null);
|
||
loadTaskCenter();
|
||
};
|
||
const loadReferralCenter = useCallback(() => {
|
||
setIsLoadingReferral(true);
|
||
setIsReferralCenterInitialized(false);
|
||
void getRpgProfileReferralInviteCenter()
|
||
.then(setReferralCenter)
|
||
.catch((error: unknown) => {
|
||
setReferralCenter(null);
|
||
setReferralError(
|
||
error instanceof Error ? error.message : '读取邀请码失败',
|
||
);
|
||
})
|
||
.finally(() => {
|
||
setIsReferralCenterInitialized(true);
|
||
setIsLoadingReferral(false);
|
||
});
|
||
}, []);
|
||
useEffect(() => {
|
||
if (
|
||
activeTab !== 'profile' ||
|
||
!isAuthenticated ||
|
||
!isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt)
|
||
) {
|
||
setIsReferralCenterInitialized(false);
|
||
setReferralCenter(null);
|
||
return;
|
||
}
|
||
|
||
loadReferralCenter();
|
||
}, [activeTab, authUi?.user?.createdAt, isAuthenticated, loadReferralCenter]);
|
||
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
|
||
setProfilePopupPanel(panel);
|
||
setReferralError(null);
|
||
setReferralSuccess(null);
|
||
if (panel === 'redeem') {
|
||
setReferralRedeemCode('');
|
||
}
|
||
if (panel === 'community') {
|
||
return;
|
||
}
|
||
|
||
if (!isReferralCenterInitialized && !isLoadingReferral) {
|
||
loadReferralCenter();
|
||
}
|
||
};
|
||
const copyInviteInfo = () => {
|
||
if (!referralCenter?.inviteCode) {
|
||
return;
|
||
}
|
||
|
||
const inviteUrl =
|
||
typeof window === 'undefined'
|
||
? referralCenter.inviteLinkPath
|
||
: new URL(referralCenter.inviteLinkPath, window.location.origin).href;
|
||
void copyTextToClipboard(`${referralCenter.inviteCode} ${inviteUrl}`).then(
|
||
(copied) => {
|
||
setReferralSuccess(copied ? '已复制' : '复制失败');
|
||
},
|
||
);
|
||
};
|
||
const submitReferralRedeemCode = () => {
|
||
const inviteCode = referralRedeemCode
|
||
.trim()
|
||
.replace(/[^0-9a-z]/gi, '')
|
||
.toUpperCase();
|
||
if (isSubmittingReferralRedeem || !inviteCode) {
|
||
return;
|
||
}
|
||
|
||
setIsSubmittingReferralRedeem(true);
|
||
setReferralError(null);
|
||
setReferralSuccess(null);
|
||
void redeemRpgProfileReferralInviteCode(inviteCode)
|
||
.then((response) => {
|
||
setReferralCenter(response.center);
|
||
setReferralRedeemCode('');
|
||
setReferralSuccess('已填写');
|
||
void onRechargeSuccess?.();
|
||
})
|
||
.catch((error: unknown) => {
|
||
setReferralError(
|
||
error instanceof Error ? error.message : '填写邀请码失败',
|
||
);
|
||
})
|
||
.finally(() => setIsSubmittingReferralRedeem(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 claimTaskReward = (taskId: string) => {
|
||
if (claimingTaskId) {
|
||
return;
|
||
}
|
||
|
||
setClaimingTaskId(taskId);
|
||
setTaskCenterError(null);
|
||
setTaskClaimSuccess(null);
|
||
void claimRpgProfileTaskReward(taskId)
|
||
.then((response) => {
|
||
setTaskCenter(response.center);
|
||
setTaskClaimSuccess(`已领取 ${response.rewardPoints} 光点`);
|
||
void onRechargeSuccess?.();
|
||
})
|
||
.catch((error: unknown) => {
|
||
setTaskCenterError(
|
||
error instanceof Error ? error.message : '领取任务奖励失败',
|
||
);
|
||
})
|
||
.finally(() => setClaimingTaskId(null));
|
||
};
|
||
const clearWorkSearch = () => {
|
||
setActiveWorkSearchKeyword('');
|
||
setDesktopSearchKeyword('');
|
||
setMobileSearchKeyword('');
|
||
};
|
||
const updateDesktopSearchKeyword = (value: string) => {
|
||
setDesktopSearchKeyword(value);
|
||
if (!value.trim()) {
|
||
setActiveWorkSearchKeyword('');
|
||
}
|
||
};
|
||
const updateMobileSearchKeyword = (value: string) => {
|
||
setMobileSearchKeyword(value);
|
||
if (!value.trim()) {
|
||
setActiveWorkSearchKeyword('');
|
||
}
|
||
};
|
||
const submitWorkSearch = (keyword: string) => {
|
||
const trimmedKeyword = keyword.trim();
|
||
if (!trimmedKeyword) {
|
||
return;
|
||
}
|
||
|
||
// 中文注释:优先使用首页已经聚合好的公开作品读模型做模糊命中;
|
||
// 无本地命中时继续走既有编号直达兜底,避免破坏深链搜索。
|
||
const matchedEntries = filterPlatformWorkSearchResults(
|
||
publicEntries,
|
||
trimmedKeyword,
|
||
);
|
||
if (
|
||
matchedEntries.length > 0 &&
|
||
isExactPublicWorkCodeSearch(matchedEntries, trimmedKeyword) &&
|
||
onSearchPublicCode &&
|
||
!isSearchingPublicCode
|
||
) {
|
||
setActiveWorkSearchKeyword('');
|
||
void onSearchPublicCode(trimmedKeyword);
|
||
return;
|
||
}
|
||
|
||
if (matchedEntries.length > 0) {
|
||
setActiveWorkSearchKeyword(trimmedKeyword);
|
||
return;
|
||
}
|
||
|
||
setActiveWorkSearchKeyword('');
|
||
if (!onSearchPublicCode || isSearchingPublicCode) {
|
||
return;
|
||
}
|
||
|
||
void onSearchPublicCode(trimmedKeyword);
|
||
};
|
||
const submitDesktopSearch = () => {
|
||
submitWorkSearch(desktopSearchKeyword);
|
||
};
|
||
const submitMobileSearch = () => {
|
||
submitWorkSearch(mobileSearchKeyword);
|
||
};
|
||
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 desktopRecommendEntries = useMemo(() => {
|
||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||
[...featuredShelf, ...latestEntries].forEach((entry) => {
|
||
entryMap.set(buildPublicGalleryCardKey(entry), entry);
|
||
});
|
||
|
||
return Array.from(entryMap.values());
|
||
}, [featuredShelf, latestEntries]);
|
||
const desktopTodayEntries = useMemo(
|
||
() => filterTodayPublishedEntries(latestEntries),
|
||
[latestEntries],
|
||
);
|
||
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
|
||
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
|
||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||
const mobileFeedEntries = useMemo(() => {
|
||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||
const sourceEntries =
|
||
mobileHomeChannel === 'recommend'
|
||
? [...featuredShelf, ...latestEntries]
|
||
: filterTodayPublishedEntries(latestEntries);
|
||
|
||
sourceEntries.forEach((entry) => {
|
||
entryMap.set(buildPublicGalleryCardKey(entry), entry);
|
||
});
|
||
|
||
return Array.from(entryMap.values());
|
||
}, [featuredShelf, latestEntries, mobileHomeChannel]);
|
||
const mobileFeedCarouselEnabled =
|
||
!isDesktopLayout && activeTab === 'home' && mobileHomeChannel !== 'category';
|
||
useEffect(() => {
|
||
if (!mobileFeedCarouselEnabled) {
|
||
setMobileCenteredCardKey(null);
|
||
return undefined;
|
||
}
|
||
|
||
const feedElement = mobileFeedRef.current;
|
||
const scrollElement = feedElement?.closest('.platform-tab-panel');
|
||
if (!feedElement || !scrollElement) {
|
||
setMobileCenteredCardKey(null);
|
||
return undefined;
|
||
}
|
||
|
||
let frameId: number | null = null;
|
||
const updateCenteredCard = () => {
|
||
frameId = null;
|
||
const cards = Array.from(
|
||
feedElement.querySelectorAll<HTMLElement>('[data-mobile-feed-card-key]'),
|
||
);
|
||
const viewportRect = scrollElement.getBoundingClientRect();
|
||
const viewportCenterY =
|
||
viewportRect.top + Math.max(0, viewportRect.height) / 2;
|
||
let closestKey: string | null = null;
|
||
let closestDistance = Number.POSITIVE_INFINITY;
|
||
|
||
cards.forEach((card) => {
|
||
const cardKey = card.dataset.mobileFeedCardKey;
|
||
if (!cardKey) {
|
||
return;
|
||
}
|
||
|
||
const cardRect = card.getBoundingClientRect();
|
||
if (
|
||
cardRect.bottom <= viewportRect.top ||
|
||
cardRect.top >= viewportRect.bottom
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const cardCenterY = cardRect.top + cardRect.height / 2;
|
||
const distance = Math.abs(cardCenterY - viewportCenterY);
|
||
if (distance < closestDistance) {
|
||
closestDistance = distance;
|
||
closestKey = cardKey;
|
||
}
|
||
});
|
||
|
||
setMobileCenteredCardKey((current) =>
|
||
current === closestKey ? current : closestKey,
|
||
);
|
||
};
|
||
const scheduleUpdate = () => {
|
||
if (frameId !== null) {
|
||
return;
|
||
}
|
||
|
||
frameId =
|
||
typeof window.requestAnimationFrame === 'function'
|
||
? window.requestAnimationFrame(updateCenteredCard)
|
||
: window.setTimeout(updateCenteredCard, 0);
|
||
};
|
||
|
||
scheduleUpdate();
|
||
scrollElement.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||
window.addEventListener('resize', scheduleUpdate);
|
||
|
||
return () => {
|
||
if (frameId !== null) {
|
||
if (typeof window.cancelAnimationFrame === 'function') {
|
||
window.cancelAnimationFrame(frameId);
|
||
} else {
|
||
window.clearTimeout(frameId);
|
||
}
|
||
}
|
||
scrollElement.removeEventListener('scroll', scheduleUpdate);
|
||
window.removeEventListener('resize', scheduleUpdate);
|
||
};
|
||
}, [mobileFeedCarouselEnabled, mobileFeedEntries, mobileHomeChannel]);
|
||
const activeRankingConfig = PLATFORM_RANKING_TABS.find(
|
||
(tab) => tab.id === activeRankingTab,
|
||
) as (typeof PLATFORM_RANKING_TABS)[number];
|
||
const rankingEntries = useMemo(
|
||
() =>
|
||
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
|
||
[activeRankingTab, publicEntries],
|
||
);
|
||
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={updateMobileSearchKeyword}
|
||
onSubmit={submitMobileSearch}
|
||
isSearching={isSearchingPublicCode}
|
||
/>
|
||
|
||
{activeWorkSearchKeyword.trim() ? (
|
||
<PlatformWorkSearchResults
|
||
keyword={activeWorkSearchKeyword}
|
||
entries={workSearchResults}
|
||
onOpen={onOpenGalleryDetail}
|
||
onClear={clearWorkSearch}
|
||
/>
|
||
) : (
|
||
<>
|
||
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||
{MOBILE_HOME_CHANNELS.map((channel) => {
|
||
const active = mobileHomeChannel === channel.id;
|
||
return (
|
||
<button
|
||
key={channel.id}
|
||
type="button"
|
||
onClick={() => setMobileHomeChannel(channel.id)}
|
||
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
|
||
>
|
||
{channel.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{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}
|
||
|
||
{mobileHomeChannel === 'category' ? (
|
||
<section className="platform-category-list-panel">
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在读取公开作品..." />
|
||
) : categoryGroups.length > 0 && activeCategoryGroup ? (
|
||
<>
|
||
<div className="platform-category-filter-row">
|
||
<button
|
||
type="button"
|
||
className="platform-category-filter-button"
|
||
>
|
||
<SlidersHorizontal className="h-4 w-4" />
|
||
<span>筛选</span>
|
||
<span className="platform-category-filter-button__count">
|
||
{activeCategoryGroup.entries.length}
|
||
</span>
|
||
</button>
|
||
<span className="platform-category-filter-divider" />
|
||
<div className="platform-category-chip-scroll scrollbar-hide">
|
||
{categoryGroups.map((group) => {
|
||
const active = group.tag === activeCategoryGroup.tag;
|
||
|
||
return (
|
||
<button
|
||
key={group.tag}
|
||
type="button"
|
||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||
className={`platform-category-chip ${active ? 'platform-category-chip--active' : ''}`}
|
||
>
|
||
{group.tag}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<button type="button" className="platform-category-sort-button">
|
||
<span>按综合排序</span>
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
</button>
|
||
|
||
<div className="platform-category-game-list">
|
||
{activeCategoryEntries.map((entry) => (
|
||
<PlatformCategoryGameItem
|
||
key={`${buildPublicGalleryCardKey(entry)}:mobile-category:${activeCategoryGroup.tag}`}
|
||
entry={entry}
|
||
categoryTag={activeCategoryGroup.tag}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<EmptyShelf text="公开广场暂时还没有可分类的作品。" />
|
||
)}
|
||
</section>
|
||
) : (
|
||
<section ref={mobileFeedRef} className="platform-mobile-home-feed">
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在读取公开作品..." />
|
||
) : mobileFeedEntries.length > 0 ? (
|
||
<div className="grid min-w-0 gap-3">
|
||
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => {
|
||
const cardKey = buildPublicGalleryCardKey(entry);
|
||
|
||
return (
|
||
<WorldCard
|
||
key={`${cardKey}:mobile-feed:${mobileHomeChannel}`}
|
||
entry={entry}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
className="w-full"
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||
feedCardKey={cardKey}
|
||
enableCoverCarousel={mobileFeedCarouselEnabled}
|
||
isCoverCarouselActive={mobileCenteredCardKey === cardKey}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
||
)}
|
||
</section>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const categoryContent: ReactNode = (
|
||
<div className={categoryPageClass}>
|
||
<section
|
||
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
|
||
>
|
||
<div
|
||
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
|
||
role="tablist"
|
||
aria-label="作品排行"
|
||
>
|
||
{PLATFORM_RANKING_TABS.map((tab) => {
|
||
const active = tab.id === activeRankingTab;
|
||
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={() => setActiveRankingTab(tab.id)}
|
||
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在读取公开作品..." />
|
||
) : rankingEntries.length > 0 ? (
|
||
<div className="mt-3 grid min-w-0 gap-2.5">
|
||
{rankingEntries.map((entry, index) => (
|
||
<PlatformRankingItem
|
||
key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`}
|
||
entry={entry}
|
||
rank={index + 1}
|
||
metricLabel={activeRankingConfig.metricLabel}
|
||
metricValue={getPlatformRankingMetricValue(
|
||
entry,
|
||
activeRankingTab,
|
||
)}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf text={activeRankingConfig.emptyText} />
|
||
)}
|
||
</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={openAvatarPicker}
|
||
className="platform-profile-avatar relative h-16 w-16 shrink-0 rounded-[1.4rem]"
|
||
aria-label="上传头像"
|
||
>
|
||
{avatarUrl ? (
|
||
<img
|
||
src={avatarUrl}
|
||
alt=""
|
||
className="h-full w-full rounded-[1.4rem] object-cover"
|
||
/>
|
||
) : (
|
||
<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>
|
||
<input
|
||
ref={avatarFileInputRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
className="hidden"
|
||
onChange={(event) =>
|
||
handleAvatarFileChange(event.target.files?.[0] ?? null)
|
||
}
|
||
/>
|
||
|
||
<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={openNicknameModal}
|
||
className="platform-profile-icon-button flex h-7 w-7 items-center justify-center rounded-full"
|
||
aria-label="修改昵称"
|
||
>
|
||
<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={copyProfilePublicUserCode}
|
||
className="platform-profile-chip flex items-center gap-1 rounded-full px-2 py-1"
|
||
>
|
||
<Copy className="h-3 w-3" />
|
||
{profileCopyState === 'copied'
|
||
? '已复制'
|
||
: profileCopyState === 'failed'
|
||
? '复制失败'
|
||
: '复制'}
|
||
</button>
|
||
</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`}
|
||
aria-label="常用功能"
|
||
>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<ProfileShortcutButton
|
||
label="每日任务"
|
||
subLabel={
|
||
<>
|
||
<span>领10</span>
|
||
<Coins className="h-3 w-3" />
|
||
</>
|
||
}
|
||
icon={Star}
|
||
onClick={openTaskCenterPanel}
|
||
/>
|
||
<ProfileShortcutButton
|
||
label="邀请好友"
|
||
subLabel={
|
||
<>
|
||
<span>双方得30</span>
|
||
<Coins className="h-3 w-3" />
|
||
</>
|
||
}
|
||
icon={UserPlus}
|
||
onClick={() => openProfilePopupPanel('invite')}
|
||
/>
|
||
{canShowReferralRedeemShortcut ? (
|
||
<ProfileShortcutButton
|
||
label="填邀请码"
|
||
subLabel="新用户奖励"
|
||
icon={Ticket}
|
||
onClick={() => openProfilePopupPanel('redeem')}
|
||
/>
|
||
) : null}
|
||
<ProfileShortcutButton
|
||
label="玩家社区"
|
||
subLabel="每日领福利"
|
||
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}
|
||
|
||
{activeWorkSearchKeyword.trim() ? (
|
||
<PlatformWorkSearchResults
|
||
keyword={activeWorkSearchKeyword}
|
||
entries={workSearchResults}
|
||
onOpen={onOpenGalleryDetail}
|
||
onClear={clearWorkSearch}
|
||
/>
|
||
) : (
|
||
<>
|
||
<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);
|
||
const displayName = formatPlatformWorkDisplayName(
|
||
entry.worldName,
|
||
);
|
||
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">{displayName}</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="TODAY GAMES" />
|
||
<span className="platform-pill platform-pill--neutral px-3">
|
||
TODAY
|
||
</span>
|
||
</div>
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在读取今日游戏..." />
|
||
) : desktopTodayEntries.length > 0 ? (
|
||
<div className="space-y-3">
|
||
{desktopTodayEntries.slice(0, 3).map((entry, index) => (
|
||
<DesktopTrendingItem
|
||
key={`${buildPublicGalleryCardKey(entry)}:desktop-today`}
|
||
entry={entry}
|
||
rank={index + 1}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf text="今天暂时还没有新游戏。" />
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
<div
|
||
className={`grid gap-5 ${desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
|
||
>
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader title="推荐" detail="RECOMMENDED" />
|
||
{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}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
className="w-full min-w-0"
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyShelf text="暂时还没有推荐作品。" />
|
||
)}
|
||
</section>
|
||
|
||
{desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? (
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader
|
||
title={desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
|
||
detail="QUICK ACCESS"
|
||
/>
|
||
|
||
<div>
|
||
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
|
||
{desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
|
||
</div>
|
||
|
||
{desktopLibraryPreview.length > 0 ? (
|
||
<div className="mt-3 space-y-3">
|
||
{desktopLibraryPreview.map((entry) => {
|
||
const displayName = formatPlatformWorkDisplayName(
|
||
entry.worldName,
|
||
);
|
||
|
||
return (
|
||
<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)]">
|
||
{displayName}
|
||
</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>
|
||
) : (
|
||
<div className="mt-3 space-y-3">
|
||
{historyEntries.slice(0, 2).map((entry) => {
|
||
const displayName = formatPlatformWorkDisplayName(
|
||
entry.worldName,
|
||
);
|
||
|
||
return (
|
||
<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,
|
||
likeCount: 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)]">
|
||
{displayName}
|
||
</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>
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
|
||
{isLoadingPlatform ? (
|
||
<EmptyShelf text="正在读取作品分类..." />
|
||
) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? (
|
||
<>
|
||
<div className="mb-4 flex min-w-0 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||
{categoryGroups.map((group) => {
|
||
const active = group.tag === activeCategoryGroup.tag;
|
||
|
||
return (
|
||
<button
|
||
key={`${group.tag}:desktop-category`}
|
||
type="button"
|
||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||
>
|
||
{group.tag}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
{desktopCategoryGrid.map((entry) => (
|
||
<WorldCard
|
||
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}`}
|
||
entry={entry}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
className="w-full min-w-0"
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||
/>
|
||
))}
|
||
</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>
|
||
);
|
||
});
|
||
const profileEditModals: ReactNode = (
|
||
<>
|
||
{isNicknameModalOpen ? (
|
||
<ProfileNicknameModal
|
||
value={nicknameInput}
|
||
error={nicknameError}
|
||
isSaving={isSavingNickname}
|
||
onChange={(value) => {
|
||
setNicknameInput(value);
|
||
setNicknameError(null);
|
||
}}
|
||
onClose={() => setIsNicknameModalOpen(false)}
|
||
onSubmit={submitNickname}
|
||
/>
|
||
) : null}
|
||
{avatarSource && avatarImageSize ? (
|
||
<ProfileAvatarCropModal
|
||
source={avatarSource}
|
||
imageSize={avatarImageSize}
|
||
scale={avatarScale}
|
||
cropX={avatarCrop.x}
|
||
cropY={avatarCrop.y}
|
||
error={avatarError}
|
||
isSaving={isSavingAvatar}
|
||
onScaleChange={updateAvatarScale}
|
||
onCropChange={updateAvatarCrop}
|
||
onClose={() => {
|
||
setAvatarSource(null);
|
||
setAvatarImageSize(null);
|
||
setAvatarError(null);
|
||
}}
|
||
onSubmit={submitAvatar}
|
||
/>
|
||
) : null}
|
||
{avatarError && !avatarSource ? (
|
||
<div className="pointer-events-none fixed left-1/2 top-5 z-[90] w-[min(92vw,22rem)] -translate-x-1/2 rounded-2xl border border-rose-400/25 bg-white px-4 py-3 text-center text-sm font-semibold text-rose-600 shadow-2xl">
|
||
{avatarError}
|
||
</div>
|
||
) : null}
|
||
</>
|
||
);
|
||
const rewardCodeModal: ReactNode = isRewardCodeOpen ? (
|
||
<RewardCodeRedeemModal
|
||
value={rewardCodeInput}
|
||
isSubmitting={isSubmittingRewardCode}
|
||
error={rewardCodeError}
|
||
success={rewardCodeSuccess}
|
||
onChange={setRewardCodeInput}
|
||
onSubmit={submitRewardCode}
|
||
onClose={() => setIsRewardCodeOpen(false)}
|
||
/>
|
||
) : null;
|
||
|
||
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="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0 border-t pt-2"
|
||
style={{
|
||
borderColor: 'var(--platform-line-soft)',
|
||
}}
|
||
>
|
||
<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}
|
||
isLoading={isLoadingReferral}
|
||
isSubmittingRedeem={isSubmittingReferralRedeem}
|
||
redeemCode={referralRedeemCode}
|
||
error={referralError}
|
||
success={referralSuccess}
|
||
onClose={() => setProfilePopupPanel(null)}
|
||
onCopyInvite={copyInviteInfo}
|
||
onRedeemCodeChange={setReferralRedeemCode}
|
||
onSubmitRedeemCode={submitReferralRedeemCode}
|
||
/>
|
||
) : null}
|
||
{rewardCodeModal}
|
||
{isTaskCenterOpen ? (
|
||
<ProfileTaskCenterModal
|
||
center={taskCenter}
|
||
isLoading={isLoadingTaskCenter}
|
||
error={taskCenterError}
|
||
success={taskClaimSuccess}
|
||
claimingTaskId={claimingTaskId}
|
||
fallbackBalance={remainingNarrativeCoins}
|
||
onClose={() => setIsTaskCenterOpen(false)}
|
||
onRetry={loadTaskCenter}
|
||
onClaim={claimTaskReward}
|
||
/>
|
||
) : 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}
|
||
{profileEditModals}
|
||
</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={updateDesktopSearchKeyword}
|
||
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 overflow-hidden rounded-full text-base font-black text-white"
|
||
style={{
|
||
background: 'var(--platform-profile-avatar-fill)',
|
||
boxShadow: 'var(--platform-profile-avatar-shadow)',
|
||
}}
|
||
>
|
||
{avatarUrl ? (
|
||
<img
|
||
src={avatarUrl}
|
||
alt=""
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
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>
|
||
{rewardCodeModal}
|
||
{isTaskCenterOpen ? (
|
||
<ProfileTaskCenterModal
|
||
center={taskCenter}
|
||
isLoading={isLoadingTaskCenter}
|
||
error={taskCenterError}
|
||
success={taskClaimSuccess}
|
||
claimingTaskId={claimingTaskId}
|
||
fallbackBalance={remainingNarrativeCoins}
|
||
onClose={() => setIsTaskCenterOpen(false)}
|
||
onRetry={loadTaskCenter}
|
||
onClaim={claimTaskReward}
|
||
/>
|
||
) : null}
|
||
{profilePopupPanel ? (
|
||
<ProfileReferralModal
|
||
panel={profilePopupPanel}
|
||
center={referralCenter}
|
||
isLoading={isLoadingReferral}
|
||
isSubmittingRedeem={isSubmittingReferralRedeem}
|
||
redeemCode={referralRedeemCode}
|
||
error={referralError}
|
||
success={referralSuccess}
|
||
onClose={() => setProfilePopupPanel(null)}
|
||
onCopyInvite={copyInviteInfo}
|
||
onRedeemCodeChange={setReferralRedeemCode}
|
||
onSubmitRedeemCode={submitReferralRedeemCode}
|
||
/>
|
||
) : 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}
|
||
{profileEditModals}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const PlatformHomeView = RpgEntryHomeView;
|