Files
Genarrative/src/components/rpg-entry/RpgEntryHomeView.tsx
kdletters f54c3ee936 收口个人中心状态弹层与扫码组件
新增 PlatformStatusDialog 统一支付结果与确认中状态弹层
新增 PlatformProfileQrScannerModal 统一个人中心扫码面板
RpgEntryHomeView 改用共享组件并删除内联支付与扫码实现
更新 PlatformUiKit 收口文档与团队决策记录
2026-06-10 20:24:09 +08:00

5247 lines
177 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowRight,
BookOpen,
Camera,
ChevronDown,
ChevronRight,
Clock3,
Coins,
Compass,
Crown,
Gamepad2,
GitFork,
Heart,
LogIn,
MessageCircle,
Palette,
Pencil,
ScanLine,
Search,
Settings,
Share2,
SlidersHorizontal,
Sparkles,
Star,
ThumbsUp,
Ticket,
UserRound,
} from 'lucide-react';
import {
type ComponentType,
type CSSProperties,
type PointerEvent,
type ReactNode,
type RefObject,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
import profileCoinsImage from '../../../media/profile/_Image (4).png';
import profileGiftImage from '../../../media/profile/_Image (6).png';
import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
import profileMascotImage from '../../../media/profile/_Image (9).png';
import profilePointImage from '../../../media/profile/_Image.png';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type {
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
isGeneratedLegacyPath,
resolveAssetReadUrl,
} from '../../services/assetReadUrlService';
import type { AuthUser } from '../../services/authService';
import {
getPublicAuthUserByCode,
getPublicAuthUserById,
updateAuthProfile,
} from '../../services/authService';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
type LegalDocumentId,
} from '../common/legalDocuments';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../common/RuntimeResourcePendingMarker';
import { SquareImageCropModal } from '../common/SquareImageCropModal';
import { UnifiedModal } from '../common/UnifiedModal';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
type SquareImageCropRect,
} from '../common/squareImageCropModel';
import {
useCopyFeedback,
} from '../common/useCopyFeedback';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import {
canExposePublicWork,
EDUTAINMENT_WORK_TAG,
filterEdutainmentPublicWorks,
filterGeneralPublicWorks,
findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import {
ProfileLegalSection,
ProfileSettingsRow,
ProfileShortcutButton,
ProfileStatCard,
ProfileStatCardSkeleton,
} from '../platform-entry/PlatformProfilePrimitives';
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import { PlatformProfileTaskCenterModal } from '../platform-entry/PlatformProfileTaskCenterModal';
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import {
type RechargePaymentResult,
usePlatformProfileCenterController,
} from '../platform-entry/usePlatformProfileCenterController';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
buildProfileDashboardPresentation,
formatSnapshotTime,
} from './rpgEntryProfileDashboardPresentation';
import { buildProfileTaskCardSummary } from './rpgEntryProfileTaskViewModel';
import {
buildPlatformRankingEntries,
buildPlatformRecommendFeedEntries,
buildPublicCategoryGroups,
buildPublicGalleryCardKey,
dedupePlatformPublicGalleryEntries,
DEFAULT_PLATFORM_CATEGORY_KIND_FILTER,
DEFAULT_PLATFORM_CATEGORY_SORT_MODE,
DEFAULT_PLATFORM_RANKING_TAB,
filterPlatformWorkSearchResults,
filterTodayPublishedEntries,
getAllPlatformPublicEntries,
getNextPlatformCategorySortMode,
getPlatformCategoryKindFilterOption,
getPlatformCategoryPrimaryMetric,
getPlatformCategorySortOption,
getPlatformPublicEntries,
getPlatformRankingMetric,
getPlatformRankingTabConfig,
getPlatformSearchableWorkIds,
getPlatformWorldLikeCount,
getPlatformWorldPlayCount,
getPlatformWorldRemixCount,
isExactPublicWorkCodeSearch,
matchesPlatformCategoryKindFilter,
PLATFORM_CATEGORY_KIND_FILTERS,
PLATFORM_CATEGORY_SORT_OPTIONS,
PLATFORM_RANKING_TABS,
type PlatformCategoryKindFilter,
type PlatformCategorySortMode,
type PlatformRankingMetric,
type PlatformRankingTab,
selectPlatformRecommendFeedWindow,
sortPlatformCategoryEntries,
} from './rpgEntryPublicGalleryViewModel';
import {
buildRecommendSwipeRailClassName,
clampRecommendDragOffset,
hasRecommendDragStarted,
RECOMMEND_ENTRY_COMMIT_ANIMATION_MS,
type RecommendSwipeDirection,
resolveRecommendCommitOffset,
resolveRecommendDragCommitDirection,
shouldAnimateRecommendSwipe,
} from './rpgEntryRecommendSwipeDeckModel';
import {
buildPlatformWorldDisplayTags,
describePlatformPublicWorkKind,
describePlatformThemeLabel,
formatPlatformCompactCount,
formatPlatformPublicAuthorAvatarLabel,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag,
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
isPuzzleGalleryEntry,
isVisualNovelGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformPublicWorkAuthorLookup,
resolvePlatformPublicWorkAuthorLookup,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldFallbackCoverImage,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
export type PlatformHomeTab =
| 'home'
| 'category'
| 'create'
| 'saves'
| 'profile';
export interface RpgEntryHomeViewProps {
activeTab: PlatformHomeTab;
isDesktopLayout?: boolean;
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;
onOpenChildMotionDemo?: () => void;
onOpenBabyLoveDrawing?: () => void;
onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void;
recommendRuntimeContent?: ReactNode;
activeRecommendEntryKey?: string | null;
isStartingRecommendEntry?: boolean;
isRecommendRuntimeReady?: boolean;
recommendRuntimeError?: string | null;
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onShareRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onRemixRecommendEntry?: (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;
onOpenFeedback?: () => void;
onRechargeSuccess?: () => void | Promise<void>;
profileTaskRefreshKey?: number;
createTabContent?: ReactNode;
draftTabContent?: ReactNode;
hasUnreadDraftUpdate?: boolean;
}
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 MOBILE_PROFILE_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-4 pb-2';
const MOBILE_RECOMMEND_PAGE_STAGE_CLASS =
'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2';
const MOBILE_DISCOVER_PAGE_STAGE_CLASS =
'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_DISCOVER_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-5 pb-4';
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 RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS = 520;
const RECOMMEND_RUNTIME_RESOURCE_IDLE_MS = 80;
const RECOMMEND_RUNTIME_READY_FRAME_COUNT = 2;
type RecommendResolvedCoverUrlMap = ReadonlyMap<string, string>;
function resolveRecommendDisplayCoverImage(
imageSrc: string,
fallbackSrc: string,
resolvedCoverUrls?: RecommendResolvedCoverUrlMap,
) {
const normalizedImageSrc = imageSrc.trim();
const normalizedFallbackSrc = fallbackSrc.trim();
if (!normalizedImageSrc) {
return (
resolvedCoverUrls?.get(normalizedFallbackSrc) ?? normalizedFallbackSrc
);
}
const resolvedImageSrc = resolvedCoverUrls?.get(normalizedImageSrc);
if (resolvedImageSrc) {
return resolvedImageSrc;
}
if (isGeneratedLegacyPath(normalizedImageSrc)) {
return (
resolvedCoverUrls?.get(normalizedFallbackSrc) ?? normalizedFallbackSrc
);
}
return normalizedImageSrc;
}
function resolveRecommendCardCoverImage(entry: PlatformPublicGalleryCard) {
const cardCoverSlide = resolvePlatformWorldCoverSlides(entry)[0] ?? null;
return (
cardCoverSlide?.imageSrc.trim() || resolvePlatformWorldCoverImage(entry)
);
}
function collectRecommendCoverPreloadUrls(
entries: PlatformPublicGalleryCard[],
) {
const urls = new Set<string>();
entries.forEach((entry) => {
resolvePlatformWorldCoverSlides(entry).forEach((slide) => {
const slideImageSrc = slide.imageSrc.trim();
if (slideImageSrc) {
urls.add(slideImageSrc);
}
});
[
resolveRecommendCardCoverImage(entry),
resolvePlatformWorldCoverImage(entry),
resolvePlatformWorldFallbackCoverImage(entry),
]
.map((url) => url.trim())
.filter(Boolean)
.forEach((url) => urls.add(url));
});
return [...urls];
}
function useResolvedRecommendCoverImages(
entries: PlatformPublicGalleryCard[],
): RecommendResolvedCoverUrlMap {
const preloadUrls = useMemo(
() => collectRecommendCoverPreloadUrls(entries),
[entries],
);
const preloadKey = preloadUrls.join('\n');
const [resolvedCoverUrls, setResolvedCoverUrls] = useState<
Map<string, string>
>(() => new Map());
useEffect(() => {
let cancelled = false;
const cleanupCallbacks: Array<() => void> = [];
const preloadCoverImage = (
imageSrc: string,
onLoaded?: (loadedImageSrc: string) => void,
) => {
if (!imageSrc || typeof Image === 'undefined') {
onLoaded?.(imageSrc);
return;
}
const image = new Image();
const cleanupImage = () => {
image.onload = null;
image.onerror = null;
};
const finishImageLoad = () => {
if (cancelled) {
return;
}
cleanupImage();
onLoaded?.(imageSrc);
};
const finishImageError = () => {
if (cancelled) {
return;
}
cleanupImage();
};
image.decoding = 'async';
image.onload = finishImageLoad;
image.onerror = finishImageError;
image.src = imageSrc;
if (image.complete) {
finishImageLoad();
}
cleanupCallbacks.push(cleanupImage);
};
setResolvedCoverUrls((currentUrls) => {
const nextUrls = new Map<string, string>();
preloadUrls.forEach((url) => {
const cachedUrl = currentUrls.get(url);
if (cachedUrl) {
nextUrls.set(url, cachedUrl);
return;
}
if (!isGeneratedLegacyPath(url)) {
nextUrls.set(url, url);
}
});
return nextUrls;
});
preloadUrls.forEach((url) => {
if (!isGeneratedLegacyPath(url)) {
preloadCoverImage(url);
return;
}
void resolveAssetReadUrl(url)
.then((resolvedUrl) => {
if (cancelled || !resolvedUrl) {
return;
}
preloadCoverImage(resolvedUrl, (loadedUrl) => {
if (cancelled) {
return;
}
setResolvedCoverUrls((currentUrls) => {
if (currentUrls.get(url) === loadedUrl) {
return currentUrls;
}
const nextUrls = new Map(currentUrls);
nextUrls.set(url, loadedUrl);
return nextUrls;
});
});
})
.catch(() => undefined);
});
return () => {
cancelled = true;
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
};
}, [preloadKey, preloadUrls]);
return resolvedCoverUrls;
}
function scheduleRecommendRuntimeReady(
signal: AbortSignal,
onReady: () => void,
) {
if (signal.aborted) {
return null;
}
let animationFrameId: number | null = null;
let remainingFrameCount = RECOMMEND_RUNTIME_READY_FRAME_COUNT;
const tick = () => {
animationFrameId = null;
if (signal.aborted) {
return;
}
remainingFrameCount -= 1;
if (remainingFrameCount <= 0) {
onReady();
return;
}
animationFrameId = window.requestAnimationFrame(tick);
};
animationFrameId = window.requestAnimationFrame(tick);
return () => {
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
}
};
}
function getRecommendRuntimeImageSource(image: HTMLImageElement) {
return (
image.currentSrc ||
image.getAttribute('src') ||
image.getAttribute('srcset') ||
''
).trim();
}
function getRecommendRuntimeMediaSource(media: HTMLMediaElement) {
return (media.currentSrc || media.getAttribute('src') || '').trim();
}
function collectRecommendRuntimeBackgroundUrls(root: HTMLElement) {
const urls = new Set<string>();
root.querySelectorAll<HTMLElement>('[style]').forEach((element) => {
const backgroundImage = element.style.backgroundImage;
if (!backgroundImage || backgroundImage === 'none') {
return;
}
const pattern = /url\((?:"([^"]*)"|'([^']*)'|([^)]*))\)/giu;
let match: RegExpExecArray | null;
while ((match = pattern.exec(backgroundImage))) {
const url = (match[1] ?? match[2] ?? match[3] ?? '').trim();
if (url) {
urls.add(url);
}
}
});
return urls;
}
function readyRecommendRuntime(
root: HTMLElement | null,
signal: AbortSignal,
): Promise<boolean> {
if (!root || signal.aborted) {
return Promise.resolve(false);
}
const runtimeRoot = root;
return new Promise((resolve) => {
let scanFrameId: number | null = null;
let readyIdleTimeoutId: number | null = null;
let pendingRecheckTimeoutId: number | null = null;
let readyFrameCleanup: (() => void) | null = null;
let settled = false;
const cleanupCallbacks: Array<() => void> = [];
const pendingImageListeners = new Map<
HTMLImageElement,
{ src: string; cleanup: () => void }
>();
const pendingMediaListeners = new Map<
HTMLMediaElement,
{ src: string; cleanup: () => void }
>();
const settledImageSources = new WeakMap<HTMLImageElement, string>();
const settledMediaSources = new WeakMap<HTMLMediaElement, string>();
const loadedBackgroundUrls = new Set<string>();
const pendingBackgroundPreloads = new Map<string, () => void>();
const cancelReadySchedule = () => {
if (readyIdleTimeoutId !== null) {
window.clearTimeout(readyIdleTimeoutId);
readyIdleTimeoutId = null;
}
if (readyFrameCleanup) {
readyFrameCleanup();
readyFrameCleanup = null;
}
};
const cancelPendingRecheck = () => {
if (pendingRecheckTimeoutId !== null) {
window.clearTimeout(pendingRecheckTimeoutId);
pendingRecheckTimeoutId = null;
}
};
const finish = (value: boolean) => {
if (settled) {
return;
}
settled = true;
cancelReadySchedule();
cancelPendingRecheck();
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
if (scanFrameId !== null) {
window.cancelAnimationFrame(scanFrameId);
}
resolve(value);
};
const abort = () => finish(false);
signal.addEventListener('abort', abort, { once: true });
cleanupCallbacks.push(() => signal.removeEventListener('abort', abort));
cleanupCallbacks.push(() => {
pendingImageListeners.forEach(({ cleanup }) => cleanup());
pendingImageListeners.clear();
pendingMediaListeners.forEach(({ cleanup }) => cleanup());
pendingMediaListeners.clear();
pendingBackgroundPreloads.forEach((cleanup) => cleanup());
pendingBackgroundPreloads.clear();
});
const scheduleScan = () => {
if (settled) {
return;
}
cancelReadySchedule();
cancelPendingRecheck();
if (scanFrameId !== null) {
return;
}
scanFrameId = window.requestAnimationFrame(() => {
scanFrameId = null;
scanResources();
});
};
const scheduleReady = () => {
cancelReadySchedule();
readyIdleTimeoutId = window.setTimeout(() => {
readyIdleTimeoutId = null;
readyFrameCleanup = scheduleRecommendRuntimeReady(signal, () =>
finish(true),
);
if (readyFrameCleanup === null) {
finish(false);
}
}, RECOMMEND_RUNTIME_RESOURCE_IDLE_MS);
};
const preloadBackgroundUrl = (url: string) => {
if (loadedBackgroundUrls.has(url) || pendingBackgroundPreloads.has(url)) {
return;
}
if (typeof Image === 'undefined') {
loadedBackgroundUrls.add(url);
return;
}
const image = new Image();
const cleanup = () => {
image.onload = null;
image.onerror = null;
};
const markReady = () => {
cleanup();
pendingBackgroundPreloads.delete(url);
loadedBackgroundUrls.add(url);
scheduleScan();
};
image.decoding = 'async';
image.onload = markReady;
image.onerror = markReady;
pendingBackgroundPreloads.set(url, cleanup);
image.src = url;
if (image.complete) {
markReady();
}
};
function scanResources() {
if (signal.aborted) {
finish(false);
return;
}
const currentImages = new Set(
Array.from(runtimeRoot.querySelectorAll<HTMLImageElement>('img')),
);
const currentMedia = new Set(
Array.from(
runtimeRoot.querySelectorAll<HTMLMediaElement>('audio,video'),
),
);
pendingImageListeners.forEach((entry, image) => {
const currentSrc = getRecommendRuntimeImageSource(image);
if (
!currentImages.has(image) ||
currentSrc !== entry.src ||
!currentSrc
) {
entry.cleanup();
pendingImageListeners.delete(image);
}
});
pendingMediaListeners.forEach((entry, media) => {
const currentSrc = getRecommendRuntimeMediaSource(media);
if (
!currentMedia.has(media) ||
currentSrc !== entry.src ||
!currentSrc
) {
entry.cleanup();
pendingMediaListeners.delete(media);
}
});
currentImages.forEach((image) => {
const imageSrc = getRecommendRuntimeImageSource(image);
const settledImageSrc = settledImageSources.get(image);
if (settledImageSrc && settledImageSrc !== imageSrc) {
settledImageSources.delete(image);
}
if (!imageSrc || image.complete) {
if (imageSrc && image.complete) {
settledImageSources.set(image, imageSrc);
}
return;
}
if (settledImageSources.get(image) === imageSrc) {
return;
}
const existingEntry = pendingImageListeners.get(image);
if (existingEntry?.src === imageSrc) {
return;
}
existingEntry?.cleanup();
const markImageReady = () => {
const activeEntry = pendingImageListeners.get(image);
if (activeEntry?.src !== imageSrc) {
return;
}
settledImageSources.set(image, imageSrc);
activeEntry.cleanup();
pendingImageListeners.delete(image);
scheduleScan();
};
const cleanupImageListeners = () => {
image.removeEventListener('load', markImageReady);
image.removeEventListener('error', markImageReady);
};
image.addEventListener('load', markImageReady, { once: true });
image.addEventListener('error', markImageReady, { once: true });
pendingImageListeners.set(image, {
src: imageSrc,
cleanup: cleanupImageListeners,
});
if (image.complete) {
markImageReady();
}
});
currentMedia.forEach((media) => {
const mediaSrc = getRecommendRuntimeMediaSource(media);
const settledMediaSrc = settledMediaSources.get(media);
const mediaReadyThreshold =
typeof HTMLMediaElement !== 'undefined'
? HTMLMediaElement.HAVE_CURRENT_DATA
: 2;
if (settledMediaSrc && settledMediaSrc !== mediaSrc) {
settledMediaSources.delete(media);
}
if (
!mediaSrc ||
media.readyState >= mediaReadyThreshold ||
media.error
) {
if (mediaSrc && media.readyState >= mediaReadyThreshold) {
settledMediaSources.set(media, mediaSrc);
}
return;
}
if (settledMediaSources.get(media) === mediaSrc) {
return;
}
const existingEntry = pendingMediaListeners.get(media);
if (existingEntry?.src === mediaSrc) {
return;
}
existingEntry?.cleanup();
const markMediaReady = () => {
const activeEntry = pendingMediaListeners.get(media);
if (activeEntry?.src !== mediaSrc) {
return;
}
settledMediaSources.set(media, mediaSrc);
activeEntry.cleanup();
pendingMediaListeners.delete(media);
scheduleScan();
};
const cleanupMediaListeners = () => {
media.removeEventListener('loadeddata', markMediaReady);
media.removeEventListener('canplaythrough', markMediaReady);
media.removeEventListener('error', markMediaReady);
};
media.addEventListener('loadeddata', markMediaReady, { once: true });
media.addEventListener('canplaythrough', markMediaReady, {
once: true,
});
media.addEventListener('error', markMediaReady, { once: true });
pendingMediaListeners.set(media, {
src: mediaSrc,
cleanup: cleanupMediaListeners,
});
if (media.readyState >= mediaReadyThreshold || media.error) {
markMediaReady();
}
});
const currentBackgroundUrls =
collectRecommendRuntimeBackgroundUrls(runtimeRoot);
pendingBackgroundPreloads.forEach((cleanup, url) => {
if (!currentBackgroundUrls.has(url)) {
cleanup();
pendingBackgroundPreloads.delete(url);
}
});
currentBackgroundUrls.forEach((url) => preloadBackgroundUrl(url));
const hasPendingResourceMarker = Boolean(
runtimeRoot.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR),
);
if (
hasPendingResourceMarker ||
pendingImageListeners.size > 0 ||
pendingMediaListeners.size > 0 ||
pendingBackgroundPreloads.size > 0
) {
cancelReadySchedule();
if (pendingRecheckTimeoutId === null) {
pendingRecheckTimeoutId = window.setTimeout(() => {
pendingRecheckTimeoutId = null;
scheduleScan();
}, RECOMMEND_RUNTIME_RESOURCE_IDLE_MS);
}
return;
}
cancelPendingRecheck();
scheduleReady();
}
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(scheduleScan);
observer.observe(runtimeRoot, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
'src',
'srcset',
'style',
'data-runtime-resource-pending',
],
});
cleanupCallbacks.push(() => observer.disconnect());
}
scanResources();
});
}
type DiscoverChannel =
| 'recommend'
| 'today'
| 'category'
| 'ranking'
| 'edutainment';
const DISCOVER_CHANNELS: Array<{
id: DiscoverChannel;
label: string;
}> = [
{ id: 'recommend', label: '推荐' },
{ id: 'today', label: '今日' },
{ id: 'category', label: '分类' },
{ id: 'ranking', label: '排行' },
];
const EDUTAINMENT_DISCOVER_CHANNEL = {
id: 'edutainment',
label: EDUTAINMENT_WORK_TAG,
} as const;
const BABY_LOVE_DRAWING_DEFAULT_CARD = {
title: '宝贝爱画',
subtitle: '空白画板',
summary: '挥动小手画一张画。',
};
const CHILD_MOTION_DEMO_DEFAULT_CARD = {
title: '热身关卡',
subtitle: '动作识别热身',
summary: '站位、招手和左右手活动。',
};
function ResolvedAssetBackdrop({
src,
fallbackSrc,
alt,
className,
ariaHidden = false,
}: {
src?: string | null;
fallbackSrc?: string | null;
alt: string;
className: string;
ariaHidden?: boolean;
}) {
return (
<ResolvedAssetImage
src={src}
fallbackSrc={fallbackSrc}
alt={alt}
aria-hidden={ariaHidden}
className={className}
/>
);
}
function PlatformWorkCoverArtwork({
entry,
imageSrc,
fallbackSrc,
alt,
className,
}: {
entry: PlatformPublicGalleryCard;
imageSrc?: string | null;
fallbackSrc?: string | null;
alt: string;
className: string;
}) {
if (isBarkBattleGalleryEntry(entry)) {
return (
<CustomWorldCoverArtwork
imageSrc={imageSrc}
fallbackImageSrc={fallbackSrc}
title={entry.worldName}
fallbackLabel="封面"
renderMode={entry.coverRenderMode}
characterImageSrcs={entry.coverCharacterImageSrcs}
className={className}
/>
);
}
return (
<ResolvedAssetBackdrop
src={imageSrc}
fallbackSrc={fallbackSrc}
alt={alt}
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 TopbarWalletShortcutButton({
variant,
balanceLabel,
onClick,
}: {
variant: 'mobile' | 'desktop';
balanceLabel: string;
onClick: () => void;
}) {
const isMobile = variant === 'mobile';
return (
<PlatformActionButton
tone="accentSoft"
shape="pill"
size="xs"
onClick={onClick}
aria-label={balanceLabel}
className={[
'shrink-0 [--platform-action-accent:#b65f2c]',
isMobile
? 'platform-mobile-create-wallet-chip !gap-1.5 !px-2.5 !py-1.5'
: 'platform-desktop-create-wallet-chip platform-desktop-search !px-3 !py-2.5',
]
.filter(Boolean)
.join(' ')}
>
<PlatformIconBadge
icon={<Coins className="h-3.5 w-3.5" />}
size="xs"
tone="neutral"
className={
isMobile
? '!h-6 !w-6 !bg-[#ffe0ab] !text-[#cf7b34]'
: '!bg-[#ffe0ab] !text-[#cf7b34]'
}
/>
<span>{balanceLabel}</span>
</PlatformActionButton>
);
}
function WorldCard({
entry,
onClick,
className,
authorAvatarUrl,
authorSummary,
feedCardKey,
enableCoverCarousel = false,
isCoverCarouselActive = false,
variant = 'standard',
}: {
entry: PlatformPublicGalleryCard;
onClick: () => void;
className?: string;
authorAvatarUrl?: string | null;
authorSummary?: PublicUserSummary | null;
feedCardKey?: string;
enableCoverCarousel?: boolean;
isCoverCarouselActive?: boolean;
variant?: 'standard' | 'immersive';
}) {
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
const fallbackAssetCoverImage = resolvePlatformWorldFallbackCoverImage(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 = describePlatformPublicWorkKind(entry);
const authorName = resolvePlatformWorkAuthorDisplayName(entry, authorSummary);
const authorAvatarLabel = formatPlatformPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const cardLabel = `${entry.worldName}${typeLabel}${formatPlatformCompactCount(playCount)}游玩,${formatPlatformCompactCount(remixCount)}改造,${formatPlatformCompactCount(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 ${variant === 'immersive' ? 'platform-public-work-card--immersive' : ''} 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 ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
fallbackSrc={fallbackAssetCoverImage}
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>{formatPlatformCompactCount(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) => (
<PlatformPillBadge
key={`world-tag-${index}-${tag || 'empty'}`}
tone="neutral"
size="xxs"
className="max-w-full px-2.5 tracking-[0.18em]"
>
<span className="truncate">{tag}</span>
</PlatformPillBadge>
))
) : (
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-2.5 tracking-[0.18em]"
>
{describePlatformPublicWorkKind(entry)}
</PlatformPillBadge>
)}
</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 ? (
<PlatformActionButton
tone="danger"
size="xs"
shape="pill"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={isDeleting}
className="absolute right-2 top-2 z-20 min-h-0 px-2.5 py-1 text-[10px] disabled:cursor-not-allowed disabled:opacity-60"
>
{isDeleting ? '删除中' : '删除'}
</PlatformActionButton>
) : 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">
<PlatformPillBadge
tone={
entry.visibility === 'published' ? 'darkEmerald' : 'darkAmber'
}
size="xxs"
className="px-2 font-semibold tracking-[0.12em]"
>
{statusLabel}
</PlatformPillBadge>
<PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="min-w-0 max-w-full px-2 font-medium text-[var(--platform-text-base)]"
>
<span className="truncate">{metaLabel}</span>
</PlatformPillBadge>
</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">
<PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="min-w-0 max-w-full px-2 font-semibold tracking-[0.1em] text-[color:color-mix(in_srgb,var(--platform-text-strong)_90%,transparent)]"
>
<span className="truncate">{primaryTag}</span>
</PlatformPillBadge>
<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 RecommendRuntimePreviewCard({
entry,
position,
resolvedCoverUrls,
}: {
entry: PlatformPublicGalleryCard;
position?: 'previous' | 'next' | 'cover';
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
}) {
const rawCoverImage = resolveRecommendCardCoverImage(entry);
const rawFallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const resolvedCoverImage = resolveRecommendDisplayCoverImage(
rawCoverImage,
rawFallbackCoverImage,
resolvedCoverUrls,
);
const fallbackCoverImage =
resolvedCoverUrls?.get(rawFallbackCoverImage) ?? rawFallbackCoverImage;
const previewKey = `${buildPublicGalleryCardKey(entry)}:${position ?? 'preview'}`;
const shouldLockCoverImage = position === 'cover';
const [lockedCoverImage, setLockedCoverImage] = useState({
key: previewKey,
imageSrc: resolvedCoverImage,
fallbackSrc: fallbackCoverImage,
});
useEffect(() => {
setLockedCoverImage((currentValue) => {
if (shouldLockCoverImage) {
return currentValue;
}
return {
key: previewKey,
imageSrc: resolvedCoverImage,
fallbackSrc: fallbackCoverImage,
};
});
}, [
fallbackCoverImage,
previewKey,
resolvedCoverImage,
shouldLockCoverImage,
]);
const coverImage = shouldLockCoverImage
? lockedCoverImage.imageSrc
: resolvedCoverImage;
const displayFallbackCoverImage = shouldLockCoverImage
? lockedCoverImage.imageSrc
: fallbackCoverImage;
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePlatformPublicWorkKind(entry);
const previewClassName = `platform-recommend-runtime-preview ${
position === 'cover' ? 'platform-recommend-runtime-preview--cover' : ''
}`;
return (
<div
className={previewClassName}
aria-hidden="true"
data-preview-position={position}
>
{coverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
fallbackSrc={displayFallbackCoverImage}
alt=""
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.08),rgba(0,0,0,0.42))]" />
<div className="platform-recommend-runtime-preview__body">
<span className="platform-public-work-card__kind">{typeLabel}</span>
<span className="platform-recommend-runtime-preview__title">
{displayName}
</span>
</div>
</div>
);
}
function RecommendRuntimeCover({
entry,
className = '',
resolvedCoverUrls,
}: {
entry: PlatformPublicGalleryCard;
className?: string;
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
}) {
return (
<div
className={`platform-recommend-runtime-cover ${className}`}
aria-hidden="true"
>
<RecommendRuntimePreviewCard
key={buildPublicGalleryCardKey(entry)}
entry={entry}
position="cover"
resolvedCoverUrls={resolvedCoverUrls}
/>
<div className="platform-recommend-runtime-loading" />
</div>
);
}
function RecommendRuntimeReadyProbe({
rootRef,
onReady,
}: {
rootRef: RefObject<HTMLDivElement | null>;
onReady: () => void;
}) {
useEffect(() => {
const abortController = new AbortController();
void readyRecommendRuntime(rootRef.current, abortController.signal).then(
(ready) => {
if (ready) {
onReady();
}
},
);
return () => abortController.abort();
}, [onReady, rootRef]);
return null;
}
function RecommendRuntimeVisual({
entry,
runtimeContent,
isStarting,
isRuntimeReady,
resolvedCoverUrls,
}: {
entry: PlatformPublicGalleryCard;
runtimeContent?: ReactNode;
isStarting: boolean;
isRuntimeReady: boolean;
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
}) {
const [isRuntimeMounted, setIsRuntimeMounted] = useState(false);
const [isCoverMinVisible, setIsCoverMinVisible] = useState(true);
const activeEntryKey = buildPublicGalleryCardKey(entry);
const previousEntryKeyRef = useRef(activeEntryKey);
const runtimeVisibilityRef = useRef({
hasRuntimeContent: Boolean(runtimeContent),
isRuntimeMounted: false,
isRuntimeReady,
isStarting,
});
const runtimeViewportRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
runtimeVisibilityRef.current = {
hasRuntimeContent: Boolean(runtimeContent),
isRuntimeMounted,
isRuntimeReady,
isStarting,
};
}, [isRuntimeMounted, isRuntimeReady, isStarting, runtimeContent]);
useEffect(() => {
const currentRuntimeVisibility = runtimeVisibilityRef.current;
if (
previousEntryKeyRef.current !== activeEntryKey &&
currentRuntimeVisibility.hasRuntimeContent &&
currentRuntimeVisibility.isRuntimeMounted &&
currentRuntimeVisibility.isRuntimeReady &&
!currentRuntimeVisibility.isStarting
) {
setIsCoverMinVisible(false);
return undefined;
}
setIsCoverMinVisible(true);
const timeoutId = window.setTimeout(() => {
setIsCoverMinVisible(false);
}, RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS);
return () => window.clearTimeout(timeoutId);
}, [activeEntryKey]);
useEffect(() => {
if (previousEntryKeyRef.current === activeEntryKey) {
return;
}
previousEntryKeyRef.current = activeEntryKey;
setIsRuntimeMounted((currentValue) => {
// 中文注释:拼图推荐流“下一关”会在同一个 run 内切到相似作品;
// 此时只更新作品信息和分享基准,不应重显封面造成运行态闪跳。
if (currentValue && !isStarting && isRuntimeReady) {
return currentValue;
}
return false;
});
}, [activeEntryKey, isRuntimeReady, isStarting]);
const handleRuntimeReady = useCallback(() => {
if (!isStarting && isRuntimeReady) {
setIsRuntimeMounted(true);
}
}, [isRuntimeReady, isStarting]);
const shouldShowCover =
isCoverMinVisible ||
!runtimeContent ||
isStarting ||
!isRuntimeReady ||
!isRuntimeMounted;
return (
<div className="platform-recommend-runtime-visual">
{runtimeContent ? (
<Suspense fallback={null}>
<div
ref={runtimeViewportRef}
className="platform-recommend-runtime-viewport"
aria-hidden={shouldShowCover}
>
{runtimeContent}
<RecommendRuntimeReadyProbe
key={activeEntryKey}
rootRef={runtimeViewportRef}
onReady={handleRuntimeReady}
/>
</div>
</Suspense>
) : null}
<RecommendRuntimeCover
entry={entry}
resolvedCoverUrls={resolvedCoverUrls}
className={
shouldShowCover ? '' : 'platform-recommend-runtime-cover--hidden'
}
/>
</div>
);
}
function RecommendSwipeCard({
entry,
authorAvatarUrl,
authorSummary,
isActive,
visual,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
onLike,
onShare,
onRemix,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
authorSummary?: PublicUserSummary | null;
isActive: boolean;
visual: ReactNode;
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
}) {
return (
<div
className={`platform-recommend-swipe-card ${isActive ? 'platform-recommend-swipe-card--active' : 'platform-recommend-swipe-card--preview'}`}
data-active={isActive ? 'true' : 'false'}
>
<div className="platform-recommend-swipe-card__visual">{visual}</div>
<div
className="platform-recommend-swipe-card__meta"
data-recommend-swipe-zone={isActive ? 'true' : 'false'}
>
<RecommendRuntimeMeta
entry={entry}
authorAvatarUrl={authorAvatarUrl}
authorSummary={authorSummary}
isActive={isActive}
onDragPointerDown={onDragPointerDown}
onDragPointerMove={onDragPointerMove}
onDragPointerUp={onDragPointerUp}
onDragPointerCancel={onDragPointerCancel}
onLike={onLike}
onShare={onShare}
onRemix={onRemix}
/>
</div>
</div>
);
}
function RecommendRuntimeMeta({
entry,
authorAvatarUrl,
authorSummary,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
onLike,
onShare,
onRemix,
isActive = true,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
authorSummary?: PublicUserSummary | null;
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
isActive?: boolean;
}) {
const likeCount = getPlatformWorldLikeCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const authorName = resolvePlatformWorkAuthorDisplayName(entry, authorSummary);
const authorAvatarLabel = formatPlatformPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const stopActionPointer = (event: PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
return (
<section
className={`platform-recommend-work-meta ${
isActive
? 'platform-recommend-work-meta--active'
: 'platform-recommend-work-meta--preview'
}`}
aria-label={`${entry.worldName} 作品信息`}
aria-hidden={!isActive}
data-active={isActive ? 'true' : 'false'}
onPointerDown={(event) => {
onDragPointerDown?.(event);
}}
onPointerMove={onDragPointerMove}
onPointerUp={onDragPointerUp}
onPointerCancel={onDragPointerCancel}
>
<div className="platform-recommend-work-meta__row">
<div className="platform-recommend-work-meta__identity">
<span
className="platform-recommend-work-meta__avatar"
aria-hidden="true"
>
{normalizedAuthorAvatarUrl ? (
<img
src={normalizedAuthorAvatarUrl}
alt=""
className="h-full w-full rounded-full object-cover"
/>
) : (
authorAvatarLabel
)}
</span>
<span className="platform-recommend-work-meta__text">
<span className="platform-recommend-work-meta__author">
{authorName}
</span>
<span className="platform-recommend-work-meta__title">
{displayName}
</span>
</span>
</div>
<div className="platform-recommend-work-meta__actions">
<PlatformIconButton
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--like"
label={`点赞 ${formatPlatformCompactCount(likeCount)}`}
title="点赞"
icon={<ThumbsUp className="h-5 w-5" aria-hidden="true" />}
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onLike?.();
}}
disabled={!isActive || !onLike}
/>
<span
className="platform-recommend-work-meta__like-count"
aria-label={`${formatPlatformCompactCount(likeCount)} 个赞`}
>
{formatPlatformCompactCount(likeCount)}
</span>
<PlatformIconButton
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon"
label="分享"
title="分享"
icon={<Share2 className="h-5 w-5" aria-hidden="true" />}
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onShare?.();
}}
disabled={!isActive || !onShare}
/>
<PlatformIconButton
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--remix"
label={`改造 ${formatPlatformCompactCount(remixCount)}`}
title="改造"
icon={<GitFork className="h-5 w-5" aria-hidden="true" />}
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onRemix?.();
}}
disabled={!isActive || !onRemix}
/>
</div>
</div>
</section>
);
}
function PlatformTabButton({
active,
label,
icon: Icon,
onClick,
emphasized = false,
showDot = false,
disabled = false,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
showDot?: boolean;
disabled?: boolean;
}) {
const ariaLabel = showDot ? `${label},有新草稿` : label;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''} disabled:cursor-not-allowed disabled:opacity-55`}
>
<span className="platform-bottom-nav__button-content">
<span
className={`platform-bottom-nav__icon-shell ${emphasized ? 'platform-bottom-nav__primary-action' : ''}`}
>
<Icon className="platform-bottom-nav__icon" />
{showDot ? (
<span aria-hidden="true" className="platform-nav-unread-dot" />
) : null}
</span>
<span className="platform-bottom-nav__label">{label}</span>
{active ? (
<span
aria-hidden="true"
className="platform-bottom-nav__active-mark"
/>
) : null}
</span>
</button>
);
}
function DesktopTabButton({
active,
label,
icon: Icon,
onClick,
emphasized = false,
showDot = false,
disabled = false,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
showDot?: boolean;
disabled?: boolean;
}) {
const ariaLabel = showDot ? `${label},有新草稿` : label;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''} disabled:cursor-not-allowed disabled:opacity-55`}
>
<span className="platform-desktop-rail__icon-shell">
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
{showDot ? (
<span aria-hidden="true" className="platform-nav-unread-dot" />
) : null}
</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 ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={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">
{describePlatformPublicWorkKind(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) => (
<PlatformPillBadge
key={`${entry.profileId}-trend-tag-${index}-${tag}`}
tone="neutral"
size="xxs"
className="px-2.5 tracking-[0.18em]"
>
{tag}
</PlatformPillBadge>
))
) : (
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-2.5 tracking-[0.18em]"
>
{isBigFishGalleryEntry(entry)
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: isEdutainmentGalleryEntry(entry)
? entry.templateName
: describePlatformPublicWorkKind(entry)}
</PlatformPillBadge>
)}
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
function PlatformRankingItem({
entry,
rank,
metric,
onClick,
}: {
entry: PlatformPublicGalleryCard;
rank: number;
metric: PlatformRankingMetric;
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 2);
const actionLabel =
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(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 ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={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)]">
{formatPlatformCompactCount(metric.value)}
</span>
<span>{metric.label}</span>
<span>·</span>
<span>{describePlatformPublicWorkKind(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 = [
describePlatformPublicWorkKind(entry),
...tags.filter((tag) => tag !== categoryTag),
].slice(0, 3);
const actionLabel =
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(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 ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={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>{formatPlatformCompactCount(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 PlatformCategoryFilterDialog({
kindFilter,
sortMode,
resultCount,
onKindFilterChange,
onSortModeChange,
onReset,
onClose,
}: {
kindFilter: PlatformCategoryKindFilter;
sortMode: PlatformCategorySortMode;
resultCount: number;
onKindFilterChange: (filter: PlatformCategoryKindFilter) => void;
onSortModeChange: (mode: PlatformCategorySortMode) => void;
onReset: () => void;
onClose: () => void;
}) {
return (
<UnifiedModal
open
title="分类筛选"
description={`${resultCount} 个作品`}
onClose={onClose}
closeOnBackdrop
closeOnEscape={false}
closeLabel="关闭分类筛选"
portal={false}
size="sm"
zIndexClassName="z-[90]"
overlayClassName="platform-modal-backdrop !px-3 !py-4"
panelClassName="platform-category-filter-dialog relative !rounded-[1.35rem]"
headerClassName="border-b-0 px-4 py-4"
titleClassName="text-base font-black"
descriptionClassName="mt-0.5 text-xs font-semibold text-[var(--platform-text-soft)]"
bodyClassName="grid gap-4 !px-4 !pb-4 !pt-0 sm:!px-4 sm:!pb-4 sm:!pt-0"
>
<div className="grid gap-2">
<div className="text-xs font-black text-[var(--platform-text-soft)]">
</div>
<div className="platform-category-filter-dialog__options">
{PLATFORM_CATEGORY_KIND_FILTERS.map((option) => {
const active = option.id === kindFilter;
return (
<button
key={option.id}
type="button"
onClick={() => onKindFilterChange(option.id)}
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
>
{option.label}
</button>
);
})}
</div>
</div>
<div className="grid gap-2">
<div className="text-xs font-black text-[var(--platform-text-soft)]">
</div>
<div className="platform-category-filter-dialog__options">
{PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => {
const active = option.id === sortMode;
return (
<button
key={option.id}
type="button"
onClick={() => onSortModeChange(option.id)}
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
>
{option.label}
</button>
);
})}
</div>
</div>
<div className="platform-category-filter-dialog__actions">
<button
type="button"
onClick={onReset}
className="platform-category-filter-dialog__action platform-category-filter-dialog__action--secondary"
>
</button>
<button
type="button"
onClick={onClose}
className="platform-category-filter-dialog__action platform-category-filter-dialog__action--primary"
>
</button>
</div>
</UnifiedModal>
);
}
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} 个作品`} />
<PlatformIconButton
onClick={onClear}
label="清空搜索"
title="清空搜索"
icon={<span aria-hidden="true">×</span>}
/>
</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>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="shrink-0 px-3 tracking-[0.18em]"
>
{describePlatformPublicWorkKind(entry)}
</PlatformPillBadge>
</button>
);
})}
</div>
) : (
<PlatformEmptyState></PlatformEmptyState>
)}
</section>
);
}
async function getPublicWorkAuthorSummary(
authorLookup: PlatformPublicWorkAuthorLookup,
): Promise<PublicUserSummary | null> {
if (authorLookup.source === 'publicUserCode') {
return getPublicAuthUserByCode(authorLookup.value);
}
if (authorLookup.source === 'ownerUserId') {
return getPublicAuthUserById(authorLookup.value);
}
return null;
}
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() || '00000000';
return `SY-${raw.slice(-8).padStart(8, '0')}`;
}
function getUserAvatarLabel(user: AuthUser | null | undefined) {
return (user?.displayName || '叙').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;
});
}
const PROFILE_MODAL_OVERLAY_CLASS =
'platform-modal-backdrop !items-center !justify-center !px-4 !py-6';
function ProfileNicknameModal({
value,
error,
isSaving,
onChange,
onClose,
onSubmit,
}: {
value: string;
error: string | null;
isSaving: boolean;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}) {
return (
<PlatformProfileModalShell
title="修改昵称"
onClose={onClose}
closeLabel="关闭昵称修改"
closeVariant="profileCompact"
panelClassName="platform-remap-surface !max-w-sm rounded-[1.4rem]"
bodyClassName="px-5 py-5"
>
<label className="block">
<span className="sr-only"></span>
<PlatformTextField
autoFocus
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit();
}
}}
maxLength={20}
surface="editorDark"
size="lg"
density="roomy"
className="rounded-2xl border-white/12 bg-white/10 text-[var(--platform-text-strong)] focus:border-[var(--platform-surface-hover-border)]"
placeholder="输入新昵称"
/>
</label>
{error ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
className="mt-3 rounded-2xl border-rose-400/25 text-rose-600"
>
{error}
</PlatformStatusMessage>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<PlatformActionButton tone="secondary" onClick={onClose}>
</PlatformActionButton>
<PlatformActionButton onClick={onSubmit} disabled={isSaving}>
{isSaving ? '保存中' : '保存'}
</PlatformActionButton>
</div>
</PlatformProfileModalShell>
);
}
function RechargePaymentResultModal({
result,
onClose,
}: {
result: RechargePaymentResult;
onClose: () => void;
}) {
const status =
result.kind === 'success'
? 'success'
: result.kind === 'pending'
? 'loading'
: result.kind === 'cancel'
? 'cancel'
: 'error';
return (
<PlatformStatusDialog
status={status}
title={result.title}
description={result.message}
onClose={onClose}
action={{ label: '知道了', onClick: onClose }}
zIndexClassName="z-[90]"
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
/>
);
}
function RechargePaymentConfirmationMask({ orderId }: { orderId: string }) {
return (
<PlatformStatusDialog
status="confirming"
title="正在确认支付"
description={`订单 ${orderId} 正在同步到账状态,请先停留在当前页面。`}
onClose={() => undefined}
closeDisabled
zIndexClassName="z-[95]"
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
/>
);
}
export function RpgEntryHomeView({
activeTab,
isDesktopLayout: isDesktopLayoutProp,
onTabChange,
saveEntries,
saveError,
featuredEntries,
latestEntries,
myEntries,
historyEntries,
profileDashboard,
isLoadingPlatform,
isLoadingDashboard,
isResumingSaveWorldKey,
platformError,
dashboardError,
onResumeSave,
onOpenCreateTypePicker,
onOpenGalleryDetail,
onOpenChildMotionDemo,
onOpenBabyLoveDrawing,
onOpenRecommendGalleryDetail,
recommendRuntimeContent,
activeRecommendEntryKey = null,
isStartingRecommendEntry = false,
isRecommendRuntimeReady = false,
recommendRuntimeError = null,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
onLikeRecommendEntry,
onShareRecommendEntry,
onRemixRecommendEntry,
onOpenLibraryDetail,
onDeleteLibraryEntry,
deletingLibraryEntryId = null,
onSearchPublicCode,
isSearchingPublicCode = false,
onOpenProfileDashboardCard,
profilePlayStats = null,
isProfilePlayStatsOpen = false,
isProfilePlayStatsLoading = false,
profilePlayStatsError = null,
onCloseProfilePlayStats,
onOpenPlayedWork,
onOpenFeedback,
onRechargeSuccess,
profileTaskRefreshKey = 0,
createTabContent,
draftTabContent,
hasUnreadDraftUpdate = false,
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const showRechargeEntry = shouldShowRechargeEntry();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState('');
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [qrScannerError, setQrScannerError] = useState<string | null>(null);
const [qrScannerResult, setQrScannerResult] = useState<string | null>(null);
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const [categoryKindFilter, setCategoryKindFilter] =
useState<PlatformCategoryKindFilter>(DEFAULT_PLATFORM_CATEGORY_KIND_FILTER);
const [categorySortMode, setCategorySortMode] =
useState<PlatformCategorySortMode>(DEFAULT_PLATFORM_CATEGORY_SORT_MODE);
const [isCategoryFilterPanelOpen, setIsCategoryFilterPanelOpen] =
useState(false);
const [discoverChannel, setDiscoverChannel] =
useState<DiscoverChannel>('recommend');
const mobileDiscoverFeedRef = useRef<HTMLElement | null>(null);
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
>(null);
const hasManualCategoryTagSelectionRef = useRef(false);
const pendingPublicAuthorKeysRef = useRef<Set<string>>(new Set());
const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState<
Record<string, PublicUserSummary | null>
>({});
const [activeRankingTab, setActiveRankingTab] = useState<PlatformRankingTab>(
DEFAULT_PLATFORM_RANKING_TAB,
);
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
() => new Set([activeTab]),
);
const { copyState: profileCopyState, copyText: copyProfileText } =
useCopyFeedback();
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | 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 [avatarCrop, setAvatarCrop] = useState<SquareImageCropRect>({
x: 0,
y: 0,
size: 1,
});
const [avatarError, setAvatarError] = useState<string | null>(null);
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const currentUser = authUi?.user ?? null;
const isAuthenticated = Boolean(currentUser);
const {
activeRechargeTab,
buyRechargeProduct,
claimTaskReward,
claimingTaskId,
closeProfilePopupPanel,
confirmNativeWechatPayment,
copyInviteInfo,
inviteCopyState,
isLoadingRechargeCenter,
isLoadingReferral,
isLoadingTaskCenter,
isLoadingWalletLedger,
isRechargeOpen,
isRewardCodeOpen,
isSubmittingReferralRedeem,
isSubmittingRewardCode,
isTaskCenterOpen,
isWalletLedgerOpen,
loadRechargeCenter,
loadTaskCenter,
loadWalletLedger,
nativeWechatPayment,
openProfilePopupPanel,
openRechargeOrRewardCodeModal,
openRewardCodeModal,
openTaskCenterPanel,
openWalletLedgerPanel,
profilePopupPanel,
rechargeCenter,
rechargeError,
rechargePaymentResult,
referralCenter,
referralError,
referralRedeemCode,
referralSuccess,
rewardCodeError,
rewardCodeInput,
rewardCodeSuccess,
setActiveRechargeTab,
setIsRechargeOpen,
setIsRewardCodeOpen,
setIsTaskCenterOpen,
setIsWalletLedgerOpen,
setRechargePaymentResult,
setReferralRedeemCode,
setRewardCodeInput,
submittingRechargeProductId,
submitReferralRedeemCode,
submitRewardCode,
taskCenter,
taskCenterError,
taskClaimSuccess,
walletLedger,
walletLedgerError,
wechatRechargeOrderConfirmationState,
} = usePlatformProfileCenterController({
activeTab,
isAuthenticated,
showRechargeEntry,
profileTaskRefreshKey,
onRechargeSuccess,
requestLogin: () => authUi?.openLoginModal(),
currentUser,
});
const edutainmentEntryEnabled = isEdutainmentEntryEnabled();
const [fallbackDesktopLayout] = useState(getInitialPlatformDesktopLayout);
const isDesktopLayout = isDesktopLayoutProp ?? fallbackDesktopLayout;
const openRecommendGalleryDetail =
onOpenRecommendGalleryDetail ?? onOpenGalleryDetail;
const generalFeaturedEntries = useMemo(
() => filterGeneralPublicWorks(featuredEntries),
[featuredEntries],
);
const featuredShelf = useMemo(
() => generalFeaturedEntries.slice(0, 6),
[generalFeaturedEntries],
);
const generalLatestEntries = useMemo(
() => filterGeneralPublicWorks(latestEntries),
[latestEntries],
);
const allEdutainmentEntries = useMemo(
() => filterEdutainmentPublicWorks([...featuredEntries, ...latestEntries]),
[featuredEntries, latestEntries],
);
const edutainmentEntries = useMemo(
() => (edutainmentEntryEnabled ? allEdutainmentEntries : []),
[allEdutainmentEntries, edutainmentEntryEnabled],
);
const visibleDiscoverChannels = useMemo(
() =>
edutainmentEntryEnabled
? [...DISCOVER_CHANNELS, EDUTAINMENT_DISCOVER_CHANNEL]
: DISCOVER_CHANNELS,
[edutainmentEntryEnabled],
);
const categoryGroups = useMemo(
() =>
buildPublicCategoryGroups(generalFeaturedEntries, generalLatestEntries),
[generalFeaturedEntries, generalLatestEntries],
);
const publicEntries = useMemo(
() =>
getPlatformPublicEntries(generalFeaturedEntries, generalLatestEntries),
[generalFeaturedEntries, generalLatestEntries],
);
const allPublicEntries = useMemo(
() => getAllPlatformPublicEntries(featuredEntries, latestEntries),
[featuredEntries, latestEntries],
);
const visibleHistoryEntries = useMemo(
() =>
historyEntries.filter((entry) => {
const matchedPublicWork = findPublicWorkForHistoryEntry(
entry,
allPublicEntries,
);
return !matchedPublicWork || canExposePublicWork(matchedPublicWork);
}),
[allPublicEntries, historyEntries],
);
const workSearchResults = useMemo(
() =>
filterPlatformWorkSearchResults(publicEntries, activeWorkSearchKeyword),
[activeWorkSearchKeyword, publicEntries],
);
const getPublicEntryAuthorAvatarUrl = useCallback(
(entry: PlatformPublicGalleryCard) => {
const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry);
if (!authorLookup) {
return null;
}
return (
publicAuthorSummariesByKey[authorLookup.key]?.avatarUrl?.trim() || null
);
},
[publicAuthorSummariesByKey],
);
const getPublicEntryAuthorSummary = useCallback(
(entry: PlatformPublicGalleryCard) => {
const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry);
if (!authorLookup) {
return null;
}
return publicAuthorSummariesByKey[authorLookup.key] ?? null;
},
[publicAuthorSummariesByKey],
);
const activeCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ??
categoryGroups[0] ??
null;
const activeCategoryEntries = useMemo(() => {
if (!activeCategoryGroup) {
return [];
}
return sortPlatformCategoryEntries(
activeCategoryGroup.entries.filter((entry) =>
matchesPlatformCategoryKindFilter(entry, categoryKindFilter),
),
categorySortMode,
);
}, [activeCategoryGroup, categoryKindFilter, categorySortMode]);
const activeCategoryRawCount = activeCategoryGroup?.entries.length ?? 0;
const activeCategoryFilterLabel =
getPlatformCategoryKindFilterOption(categoryKindFilter).label;
const activeCategorySortLabel =
getPlatformCategorySortOption(categorySortMode).label;
const activeCategoryFilterCount = activeCategoryEntries.length;
const categoryFilterApplied =
categoryKindFilter !== DEFAULT_PLATFORM_CATEGORY_KIND_FILTER;
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 activeLegalDocument = activeLegalDocumentId
? getLegalDocument(activeLegalDocumentId)
: null;
const profileDashboardPresentation = useMemo(
() => buildProfileDashboardPresentation(profileDashboard),
[profileDashboard],
);
const remainingNarrativeCoins = profileDashboardPresentation.walletBalance;
const profileTaskCardSummary = useMemo(
() => buildProfileTaskCardSummary(taskCenter),
[taskCenter],
);
const tabIcons: Record<
PlatformHomeTab,
ComponentType<{ className?: string }>
> = isAuthenticated
? {
home: Sparkles,
category: Compass,
create: Sparkles,
saves: Pencil,
profile: UserRound,
}
: {
home: Gamepad2,
category: Compass,
create: Sparkles,
saves: Pencil,
profile: UserRound,
};
const tabLabels = {
home: '推荐',
category: '发现',
create: '创作',
saves: '草稿',
profile: '我的',
} as const;
useEffect(() => {
if (!visibleTabs.includes(activeTab)) {
onTabChange(isAuthenticated ? 'home' : 'category');
}
}, [activeTab, isAuthenticated, onTabChange, visibleTabs]);
useEffect(() => {
if (
!visibleDiscoverChannels.some((channel) => channel.id === discoverChannel)
) {
setDiscoverChannel('recommend');
}
}, [discoverChannel, visibleDiscoverChannels]);
useEffect(() => {
setVisitedTabs((currentTabs) => {
if (currentTabs.has(activeTab)) {
return currentTabs;
}
const nextTabs = new Set(currentTabs);
nextTabs.add(activeTab);
return nextTabs;
});
}, [activeTab]);
useEffect(() => {
if (categoryGroups.length === 0) {
setSelectedCategoryTag(null);
hasManualCategoryTagSelectionRef.current = false;
return;
}
const firstCategoryGroup = categoryGroups[0];
const selectedCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null;
if (
firstCategoryGroup &&
(!selectedCategoryGroup ||
(!hasManualCategoryTagSelectionRef.current &&
firstCategoryGroup.tag !== selectedCategoryGroup.tag))
) {
setSelectedCategoryTag(firstCategoryGroup.tag);
}
if (
selectedCategoryTag &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
) {
hasManualCategoryTagSelectionRef.current = false;
}
}, [categoryGroups, selectedCategoryTag]);
useEffect(() => {
const authorLookupsByKey = new Map<
string,
PlatformPublicWorkAuthorLookup
>();
publicEntries.forEach((entry) => {
const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry);
if (authorLookup) {
authorLookupsByKey.set(authorLookup.key, authorLookup);
}
});
const missingAuthorLookups = Array.from(authorLookupsByKey.values()).filter(
(authorLookup) =>
!(authorLookup.key in publicAuthorSummariesByKey) &&
!pendingPublicAuthorKeysRef.current.has(authorLookup.key),
);
if (missingAuthorLookups.length === 0) {
return undefined;
}
let cancelled = false;
missingAuthorLookups.forEach((authorLookup) => {
pendingPublicAuthorKeysRef.current.add(authorLookup.key);
});
// 中文注释:头像来自公开用户摘要,失败时缓存空值,避免首页滚动时反复打公开用户接口。
void Promise.all(
missingAuthorLookups.map(async (authorLookup) => {
try {
const author = await getPublicWorkAuthorSummary(authorLookup);
return [authorLookup.key, author] as const;
} catch {
return [authorLookup.key, null] as const;
} finally {
pendingPublicAuthorKeysRef.current.delete(authorLookup.key);
}
}),
).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 copyProfilePublicUserCode = () => {
void copyProfileText(publicUserCode);
};
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);
setAvatarSource(source);
setAvatarImageSize(imageSize);
setAvatarCrop(buildCenteredSquareImageCropRect(imageSize));
})
.catch((error: unknown) => {
setAvatarError(
error instanceof Error ? error.message : '头像图片读取失败',
);
});
};
const updateAvatarCrop = useCallback(
(nextCrop: SquareImageCropRect) => {
if (!avatarImageSize) {
return;
}
setAvatarCrop(clampSquareImageCropRect(avatarImageSize, nextCrop));
},
[avatarImageSize],
);
const submitAvatar = () => {
if (
!avatarSource ||
!avatarImageSize ||
avatarCrop.size <= 0 ||
isSavingAvatar
) {
return;
}
setIsSavingAvatar(true);
setAvatarError(null);
void cropAvatarImage({
source: avatarSource,
cropX: avatarCrop.x,
cropY: avatarCrop.y,
cropSize: avatarCrop.size,
})
.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 openQrScannerPanel = () => {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setQrScannerError(null);
setQrScannerResult(null);
setIsQrScannerOpen(true);
};
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,
);
const hiddenEdutainmentMatches = filterPlatformWorkSearchResults(
allEdutainmentEntries,
trimmedKeyword,
);
if (
matchedEntries.length > 0 &&
isExactPublicWorkCodeSearch(matchedEntries, trimmedKeyword) &&
onSearchPublicCode &&
!isSearchingPublicCode
) {
setActiveWorkSearchKeyword('');
void onSearchPublicCode(trimmedKeyword);
return;
}
if (matchedEntries.length > 0) {
setActiveWorkSearchKeyword(trimmedKeyword);
return;
}
if (hiddenEdutainmentMatches.length > 0) {
setActiveWorkSearchKeyword(trimmedKeyword);
return;
}
setActiveWorkSearchKeyword('');
if (!onSearchPublicCode || isSearchingPublicCode) {
return;
}
void onSearchPublicCode(trimmedKeyword);
};
const submitDesktopSearch = () => {
submitWorkSearch(desktopSearchKeyword);
};
const submitMobileSearch = () => {
submitWorkSearch(mobileSearchKeyword);
};
const cycleCategorySortMode = () => {
setCategorySortMode(getNextPlatformCategorySortMode(categorySortMode));
};
const desktopHeroEntry =
featuredShelf[0] ?? generalLatestEntries[0] ?? myEntries[0] ?? null;
const desktopHeroCover = desktopHeroEntry
? resolvePlatformWorldCoverImage(desktopHeroEntry)
: null;
const desktopHeroStripEntries = (
featuredShelf.length > 0 ? featuredShelf : generalLatestEntries
).slice(0, 5);
// 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。
const recommendedFeedEntries = useMemo(
() =>
buildPlatformRecommendFeedEntries(featuredShelf, generalLatestEntries),
[featuredShelf, generalLatestEntries],
);
const desktopRecommendEntries = recommendedFeedEntries;
const desktopTodayEntries = useMemo(
() => filterTodayPublishedEntries(generalLatestEntries),
[generalLatestEntries],
);
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const resolvedRecommendCoverUrls = useResolvedRecommendCoverImages(
recommendedFeedEntries,
);
const discoverFeedEntries = useMemo(() => {
const sourceEntries =
discoverChannel === 'recommend'
? recommendedFeedEntries
: filterTodayPublishedEntries(generalLatestEntries);
return dedupePlatformPublicGalleryEntries(sourceEntries);
}, [discoverChannel, generalLatestEntries, recommendedFeedEntries]);
const edutainmentFeedEntries = useMemo(
() => dedupePlatformPublicGalleryEntries(edutainmentEntries),
[edutainmentEntries],
);
const mobileFeedCarouselEnabled =
!isDesktopLayout &&
activeTab === 'category' &&
(discoverChannel === 'recommend' || discoverChannel === 'today');
useEffect(() => {
if (!mobileFeedCarouselEnabled) {
setMobileCenteredCardKey(null);
return undefined;
}
const feedElement = mobileDiscoverFeedRef.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);
};
}, [
discoverChannel,
discoverFeedEntries,
activeTab,
mobileFeedCarouselEnabled,
]);
const activeRankingConfig = getPlatformRankingTabConfig(activeRankingTab);
const rankingEntries = useMemo(
() =>
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
[activeRankingTab, publicEntries],
);
const recommendFeedWindow = useMemo(
() =>
selectPlatformRecommendFeedWindow(
recommendedFeedEntries,
activeRecommendEntryKey,
),
[activeRecommendEntryKey, recommendedFeedEntries],
);
const activeRecommendEntry = recommendFeedWindow.activeEntry;
const previousRecommendEntry = recommendFeedWindow.previousEntry;
const nextRecommendEntry = recommendFeedWindow.nextEntry;
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<RecommendSwipeDirection | null>(null);
const activeRecommendEntryKeyForSelection =
recommendFeedWindow.activeEntryKey;
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
pointerId: number;
startY: number;
dragging: boolean;
} | null>(null);
const commitRecommendDrag = useCallback(
(direction: RecommendSwipeDirection) => {
if (recommendDragCommitDirection) {
return;
}
setRecommendDragCommitDirection(direction);
const panelHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
setRecommendDragOffsetY(
resolveRecommendCommitOffset(
direction,
panelHeight,
window.innerHeight,
),
);
window.setTimeout(() => {
if (direction === 1) {
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
} else {
onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection);
}
setRecommendDragOffsetY(0);
setRecommendDragCommitDirection(null);
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
},
[
activeRecommendEntryKeyForSelection,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
recommendDragCommitDirection,
],
);
const beginRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
if (
recommendDragCommitDirection ||
!activeRecommendEntry ||
recommendedFeedEntries.length <= 1
) {
return;
}
recommendDragStartRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
dragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[
activeRecommendEntry,
recommendDragCommitDirection,
recommendedFeedEntries.length,
],
);
const moveRecommendDrag = useCallback((event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (!drag) {
return;
}
const deltaY = event.clientY - drag.startY;
drag.dragging = drag.dragging || hasRecommendDragStarted(deltaY);
if (!drag.dragging) {
return;
}
event.preventDefault();
const cardHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
setRecommendDragOffsetY(clampRecommendDragOffset(deltaY, cardHeight));
}, []);
const endRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (!drag) {
return;
}
event.currentTarget.releasePointerCapture?.(drag.pointerId);
recommendDragStartRef.current = null;
const deltaY = event.clientY - drag.startY;
const commitDirection = resolveRecommendDragCommitDirection(deltaY);
if (!commitDirection) {
setRecommendDragOffsetY(0);
return;
}
commitRecommendDrag(commitDirection);
},
[commitRecommendDrag],
);
const cancelRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (drag) {
event.currentTarget.releasePointerCapture?.(drag.pointerId);
}
recommendDragStartRef.current = null;
setRecommendDragOffsetY(0);
},
[],
);
const recommendRailStyle = {
transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`,
} satisfies CSSProperties;
const recommendRailClassName = buildRecommendSwipeRailClassName({
offsetY: recommendDragOffsetY,
commitDirection: recommendDragCommitDirection,
});
const selectNextRecommendEntry = useCallback(() => {
if (
shouldAnimateRecommendSwipe({
isAuthenticated,
hasActiveEntry: Boolean(activeRecommendEntry),
entryCount: recommendedFeedEntries.length,
})
) {
commitRecommendDrag(1);
return;
}
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
}, [
activeRecommendEntry,
activeRecommendEntryKeyForSelection,
commitRecommendDrag,
isAuthenticated,
onSelectNextRecommendEntry,
recommendedFeedEntries.length,
]);
const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
openRecommendGalleryDetail(leadPublicEntry);
return;
}
onTabChange('category');
};
const mobileRankingPanel: ReactNode = (
<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 ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : 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}
metric={getPlatformRankingMetric(entry, activeRankingTab)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState>{activeRankingConfig.emptyText}</PlatformEmptyState>
)}
</section>
);
const mobileRecommendContent: ReactNode = (
<div
className={`${MOBILE_RECOMMEND_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-2xl"
>
{platformError}
</PlatformStatusMessage>
) : null}
{isLoadingPlatform ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">
...
</div>
</section>
) : recommendRuntimeError ? (
<section className="platform-recommend-runtime-panel">
<button
type="button"
onClick={() =>
activeRecommendEntry
? openRecommendGalleryDetail(activeRecommendEntry)
: undefined
}
className="platform-recommend-runtime-state platform-recommend-runtime-state--button"
>
{recommendRuntimeError}
</button>
</section>
) : activeRecommendEntry ? (
<div
ref={recommendCardStageRef}
className="platform-recommend-swipe-stage"
>
<div
className={`platform-recommend-swipe-rail ${recommendRailClassName}`}
style={recommendRailStyle}
>
{previousRecommendEntry ? (
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--previous">
<RecommendSwipeCard
entry={previousRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
previousRecommendEntry,
)}
authorSummary={getPublicEntryAuthorSummary(
previousRecommendEntry,
)}
isActive={false}
visual={
<RecommendRuntimePreviewCard
entry={previousRecommendEntry}
position="previous"
resolvedCoverUrls={resolvedRecommendCoverUrls}
/>
}
/>
</div>
) : null}
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--current">
<RecommendSwipeCard
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
activeRecommendEntry,
)}
authorSummary={getPublicEntryAuthorSummary(
activeRecommendEntry,
)}
isActive
visual={
<RecommendRuntimeVisual
entry={activeRecommendEntry}
runtimeContent={recommendRuntimeContent}
isStarting={isStartingRecommendEntry}
isRuntimeReady={isRecommendRuntimeReady}
resolvedCoverUrls={resolvedRecommendCoverUrls}
/>
}
onDragPointerDown={beginRecommendDrag}
onDragPointerMove={moveRecommendDrag}
onDragPointerUp={endRecommendDrag}
onDragPointerCancel={cancelRecommendDrag}
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
onShare={() => onShareRecommendEntry?.(activeRecommendEntry)}
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
/>
</div>
{nextRecommendEntry ? (
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--next">
<RecommendSwipeCard
entry={nextRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
nextRecommendEntry,
)}
authorSummary={getPublicEntryAuthorSummary(
nextRecommendEntry,
)}
isActive={false}
visual={
<RecommendRuntimePreviewCard
entry={nextRecommendEntry}
position="next"
resolvedCoverUrls={resolvedRecommendCoverUrls}
/>
}
/>
</div>
) : null}
</div>
</div>
) : (
<section className="platform-recommend-runtime-panel">
<PlatformEmptyState>
广
</PlatformEmptyState>
</section>
)}
</div>
);
const mobileDiscoverContent: ReactNode = (
<div
className={`${MOBILE_DISCOVER_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">
{visibleDiscoverChannels.map((channel) => {
const active = discoverChannel === channel.id;
return (
<button
key={channel.id}
type="button"
onClick={() => setDiscoverChannel(channel.id)}
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
>
{channel.label}
</button>
);
})}
</div>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-2xl"
>
{platformError}
</PlatformStatusMessage>
) : null}
{discoverChannel === 'ranking' ? (
mobileRankingPanel
) : discoverChannel === 'category' ? (
<section className="platform-category-list-panel">
{isLoadingPlatform ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : categoryGroups.length > 0 && activeCategoryGroup ? (
<>
<div className="platform-category-filter-row">
<button
type="button"
onClick={() => setIsCategoryFilterPanelOpen(true)}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>
{categoryFilterApplied
? activeCategoryFilterLabel
: '筛选'}
</span>
<span className="platform-category-filter-button__count">
{activeCategoryFilterCount}
</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={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
</button>
);
})}
</div>
</div>
<button
type="button"
onClick={cycleCategorySortMode}
className="platform-category-sort-button"
>
<span>{activeCategorySortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
{activeCategoryEntries.length > 0 ? (
<div className="platform-category-game-list">
{activeCategoryEntries.map((entry) => (
<PlatformCategoryGameItem
key={`${buildPublicGalleryCardKey(entry)}:mobile-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
entry={entry}
categoryTag={activeCategoryGroup.tag}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState>
</PlatformEmptyState>
)}
</>
) : (
<PlatformEmptyState>
广
</PlatformEmptyState>
)}
</section>
) : discoverChannel === 'edutainment' ? (
<section className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : edutainmentFeedEntries.length > 0 ||
onOpenChildMotionDemo ||
onOpenBabyLoveDrawing ? (
<div className="grid min-w-0 gap-3">
{edutainmentFeedEntries.map((entry) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-edutainment`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
feedCardKey={cardKey}
/>
);
})}
{onOpenChildMotionDemo ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenChildMotionDemo}
>
<span className="platform-edutainment-level-card__icon">
<Camera className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{CHILD_MOTION_DEMO_DEFAULT_CARD.title}</strong>
<span>{CHILD_MOTION_DEMO_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{CHILD_MOTION_DEMO_DEFAULT_CARD.summary}
</span>
</button>
) : null}
{onOpenBabyLoveDrawing ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenBabyLoveDrawing}
>
<span className="platform-edutainment-level-card__icon">
<Palette className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{BABY_LOVE_DRAWING_DEFAULT_CARD.title}</strong>
<span>{BABY_LOVE_DRAWING_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{BABY_LOVE_DRAWING_DEFAULT_CARD.summary}
</span>
</button>
) : null}
</div>
) : (
<PlatformEmptyState>
</PlatformEmptyState>
)}
</section>
) : (
<section
ref={mobileDiscoverFeedRef}
className="platform-mobile-home-feed"
>
{isLoadingPlatform ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : discoverFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-3">
{discoverFeedEntries.map(
(entry: PlatformPublicGalleryCard) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-feed:${discoverChannel}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
feedCardKey={cardKey}
enableCoverCarousel={mobileFeedCarouselEnabled}
isCoverCarouselActive={
mobileCenteredCardKey === cardKey
}
/>
);
},
)}
</div>
) : (
<PlatformEmptyState>
广
</PlatformEmptyState>
)}
</section>
)}
</>
)}
</div>
);
const desktopDiscoverContent: ReactNode = (
<div className={DESKTOP_DISCOVER_PAGE_STAGE_CLASS}>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{visibleDiscoverChannels.map((channel) => {
const active = discoverChannel === channel.id;
return (
<button
key={`desktop-${channel.id}`}
type="button"
onClick={() => setDiscoverChannel(channel.id)}
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
>
{channel.label}
</button>
);
})}
</div>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-[1.5rem]"
>
{platformError}
</PlatformStatusMessage>
) : null}
{discoverChannel === 'ranking' ? (
mobileRankingPanel
) : discoverChannel === 'category' ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
{isLoadingPlatform ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : activeCategoryGroup && activeCategoryRawCount > 0 ? (
<>
<div className="mb-4 flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => setIsCategoryFilterPanelOpen(true)}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>
{categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
</span>
<span className="platform-category-filter-button__count">
{activeCategoryFilterCount}
</span>
</button>
<div className="flex min-w-0 flex-1 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-discover-category`}
type="button"
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
</button>
);
})}
</div>
<button
type="button"
onClick={cycleCategorySortMode}
className="platform-category-sort-button shrink-0"
>
<span>{activeCategorySortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
{desktopCategoryGrid.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-3">
{desktopCategoryGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState></PlatformEmptyState>
)}
</>
) : (
<PlatformEmptyState></PlatformEmptyState>
)}
</section>
) : discoverChannel === 'edutainment' ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title={EDUTAINMENT_WORK_TAG} detail="EDUTAINMENT" />
{isLoadingPlatform ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : edutainmentFeedEntries.length > 0 ||
onOpenChildMotionDemo ||
onOpenBabyLoveDrawing ? (
<div className="grid gap-4 xl:grid-cols-3">
{edutainmentFeedEntries.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-edutainment`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
{onOpenChildMotionDemo ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenChildMotionDemo}
>
<span className="platform-edutainment-level-card__icon">
<Camera className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{CHILD_MOTION_DEMO_DEFAULT_CARD.title}</strong>
<span>{CHILD_MOTION_DEMO_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{CHILD_MOTION_DEMO_DEFAULT_CARD.summary}
</span>
</button>
) : null}
{onOpenBabyLoveDrawing ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenBabyLoveDrawing}
>
<span className="platform-edutainment-level-card__icon">
<Palette className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{BABY_LOVE_DRAWING_DEFAULT_CARD.title}</strong>
<span>{BABY_LOVE_DRAWING_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{BABY_LOVE_DRAWING_DEFAULT_CARD.summary}
</span>
</button>
) : null}
</div>
) : (
<PlatformEmptyState></PlatformEmptyState>
)}
</section>
) : (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={discoverChannel === 'today' ? '今日游戏' : '推荐'}
detail={discoverChannel === 'today' ? 'TODAY GAMES' : 'RECOMMENDED'}
/>
{isLoadingPlatform ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : discoverFeedEntries.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-3">
{discoverFeedEntries.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-feed:${discoverChannel}`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState>
广
</PlatformEmptyState>
)}
</section>
)}
</div>
);
const categoryContent: ReactNode = isDesktopLayout
? desktopDiscoverContent
: mobileDiscoverContent;
const fallbackCreateStartContent: ReactNode = (
<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">
<PlatformPillBadge
tone="cool"
size="xxs"
className="w-fit tracking-[0.18em]"
>
CREATE
</PlatformPillBadge>
<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>
</div>
);
const fallbackDraftContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<section>
<SectionHeader title="我的创作" detail="草稿与已发布" />
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-2xl"
>
{platformError}
</PlatformStatusMessage>
) : null}
{isLoadingPlatform ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : 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>
) : (
<PlatformEmptyState>
{isAuthenticated
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
: '登录后查看你的作品。'}
</PlatformEmptyState>
)}
</section>
</div>
);
const createContent: ReactNode =
createTabContent ?? fallbackCreateStartContent;
const savesContent: ReactNode = (
<>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-2xl"
>
{platformError}
</PlatformStatusMessage>
) : null}
{draftTabContent ?? fallbackDraftContent}
</>
);
const profileContent: ReactNode = (
<div className={`${MOBILE_PROFILE_PAGE_STAGE_CLASS} platform-profile-page`}>
{authUi?.user ? (
<>
<section className="platform-profile-header">
<img
src={profileStillLifeImage}
alt=""
className="platform-profile-scene-decor"
/>
<div className="platform-profile-header__identity">
<div className="platform-profile-header__identity-row flex min-w-0 items-center gap-4">
<button
type="button"
onClick={openAvatarPicker}
className="platform-profile-avatar relative h-[5.15rem] w-[5.15rem] shrink-0 rounded-full"
aria-label="上传头像"
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full rounded-full object-cover"
/>
) : (
<img
src={profileMascotImage}
alt=""
className="h-full w-full rounded-full object-cover"
/>
)}
<span className="platform-profile-camera absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full">
<Camera className="h-3.5 w-3.5" />
</span>
</button>
<input
ref={avatarFileInputRef}
type="file"
aria-label="上传头像"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(event) =>
handleAvatarFileChange(event.target.files?.[0] ?? null)
}
/>
<div className="platform-profile-header__text min-w-0">
<div className="flex items-center gap-2">
<div className="platform-profile-header__name truncate text-[18px] font-black leading-tight text-[var(--platform-text-strong)]">
{authUi.user.displayName}
</div>
<PlatformIconButton
label="修改昵称"
icon={<Pencil className="h-3.5 w-3.5" />}
onClick={openNicknameModal}
className="platform-profile-edit-button"
/>
</div>
<div className="platform-profile-header__code mt-2 flex flex-wrap items-center gap-2 text-[12px] text-[var(--platform-text-base)]">
<span> {publicUserCode}</span>
<CopyFeedbackButton
state={profileCopyState}
onClick={copyProfilePublicUserCode}
className="platform-profile-copy-button"
idleLabel="复制"
showIcon={false}
/>
</div>
</div>
</div>
</div>
</section>
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-membership-card"
aria-label="查看权益"
>
<span className="platform-profile-membership-card__badge">
<Crown className="platform-profile-membership-card__crown" />
</span>
<span className="min-w-0 flex-1">
<span className="platform-profile-membership-card__title block text-[16px] font-black leading-tight text-white">
</span>
<span className="platform-profile-membership-card__subtitle mt-1.5 block text-[12px] font-medium text-white/92">
</span>
</span>
<span className="platform-profile-membership-card__action">
</span>
</button>
<section
className="platform-profile-stats-panel"
aria-label="我的数据"
>
<div className="platform-profile-stats-grid grid grid-cols-3 gap-3">
{isLoadingDashboard ? (
<>
<ProfileStatCardSkeleton />
<ProfileStatCardSkeleton />
<ProfileStatCardSkeleton />
</>
) : dashboardError ? (
<>
<ProfileStatCard
cardKey="wallet"
label="泥点余额"
value="暂不可用"
icon={Coins}
imageSrc={profilePointImage}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="累计游玩"
value="暂不可用"
icon={Clock3}
imageSrc={profileClockImage}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="已玩游戏"
value="暂不可用"
icon={BookOpen}
imageSrc={profileGamepadImage}
onClick={onOpenProfileDashboardCard}
/>
</>
) : (
<>
<ProfileStatCard
cardKey="wallet"
label="泥点余额"
value={profileDashboardPresentation.walletBalanceLabel}
icon={Coins}
imageSrc={profilePointImage}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="累计游戏时长"
value={profileDashboardPresentation.totalPlayTimeLabel}
icon={Clock3}
imageSrc={profileClockImage}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="已玩游戏数量"
value={profileDashboardPresentation.playedWorkCountLabel}
icon={BookOpen}
imageSrc={profileGamepadImage}
onClick={onOpenProfileDashboardCard}
/>
</>
)}
</div>
</section>
<button
type="button"
onClick={openTaskCenterPanel}
className="platform-profile-daily-task-card"
>
<span className="min-w-0 flex-1">
<span className="platform-profile-daily-task-card__title block text-[15px] font-black text-[var(--platform-text-strong)]">
</span>
<span className="platform-profile-daily-task-card__desc mt-2 block text-[12px] font-medium text-[var(--platform-text-base)]">
{' '}
<span className="text-[#c45b2a]">
{profileTaskCardSummary.rewardPoints}
</span>{' '}
</span>
<span className="platform-profile-daily-task-card__progress mt-3 flex items-center gap-3">
<span className="platform-profile-daily-task-card__progress-value text-[13px] font-semibold text-[#dc3f0e]">
{profileTaskCardSummary.progressCount} /{' '}
{profileTaskCardSummary.threshold}
</span>
<span className="platform-profile-daily-task-card__track">
<span
className="platform-profile-daily-task-card__bar"
style={{
width: `${profileTaskCardSummary.progressPercent}%`,
}}
/>
</span>
</span>
</span>
<img
src={profileMascotImage}
alt=""
className="platform-profile-daily-task-card__mascot"
/>
</button>
<section
className="platform-profile-shortcut-panel"
aria-label="常用功能"
>
<div className="platform-profile-shortcut-grid grid w-full !grid-cols-4">
<ProfileShortcutButton
label="泥点充值"
subLabel="充值泥点"
icon={Coins}
imageSrc={profileCoinsImage}
onClick={openRechargeOrRewardCodeModal}
/>
<ProfileShortcutButton
label="兑换码"
subLabel="领取福利"
icon={Ticket}
imageSrc={profileGiftImage}
onClick={openRewardCodeModal}
/>
<ProfileShortcutButton
label="玩家社区"
subLabel="交流心得"
icon={MessageCircle}
imageSrc={profileCommunityImage}
onClick={() => openProfilePopupPanel('community')}
/>
<ProfileShortcutButton
label="反馈与建议"
subLabel="帮我们优化产品"
icon={MessageCircle}
imageSrc={profileFeedbackImage}
onClick={onOpenFeedback}
/>
</div>
</section>
<section
className="platform-profile-settings-panel"
aria-label="设置入口"
>
<ProfileSettingsRow
label="通用设置"
icon={Settings}
onClick={() => authUi.openSettingsModal()}
/>
</section>
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
</>
) : (
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<PlatformSubpanel as="div" radius="lg" padding="md">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<PlatformActionButton
className="mt-4"
onClick={() => authUi?.openLoginModal()}
>
</PlatformActionButton>
</PlatformSubpanel>
</section>
)}
</div>
);
const desktopHomeContent: ReactNode = (
<div className={DESKTOP_PAGE_STAGE_CLASS}>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-[1.5rem]"
>
{platformError}
</PlatformStatusMessage>
) : 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 ? (
desktopHeroEntry ? (
<PlatformWorkCoverArtwork
entry={desktopHeroEntry}
imageSrc={desktopHeroCover}
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
) : (
<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">
<PlatformPillBadge
tone="warning"
size="xxs"
className="tracking-[0.18em]"
>
</PlatformPillBadge>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
{leadPublicEntry
? describePlatformPublicWorkKind(leadPublicEntry)
: '作品'}
</PlatformPillBadge>
</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 ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
alt=""
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" />
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
TODAY
</PlatformPillBadge>
</div>
{isLoadingPlatform ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : 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={() => openRecommendGalleryDetail(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState></PlatformEmptyState>
)}
</section>
</div>
<div
className={`grid gap-5 ${desktopLibraryPreview.length > 0 || visibleHistoryEntries.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 ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : desktopFeaturedGrid.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-2">
{desktopFeaturedGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState></PlatformEmptyState>
)}
</section>
{desktopLibraryPreview.length > 0 ||
visibleHistoryEntries.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>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
{entry.visibility === 'published'
? '已发布'
: '草稿'}
</PlatformPillBadge>
</button>
);
})}
</div>
) : (
<div className="mt-3 space-y-3">
{visibleHistoryEntries.slice(0, 2).map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
type="button"
onClick={() =>
openRecommendGalleryDetail({
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>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
</PlatformPillBadge>
</button>
);
})}
</div>
)}
</div>
</section>
) : null}
</div>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
{isLoadingPlatform ? (
<PlatformEmptyState>...</PlatformEmptyState>
) : activeCategoryGroup && activeCategoryRawCount > 0 ? (
<>
<div className="mb-4 flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => setIsCategoryFilterPanelOpen(true)}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>
{categoryFilterApplied
? activeCategoryFilterLabel
: '筛选'}
</span>
<span className="platform-category-filter-button__count">
{activeCategoryFilterCount}
</span>
</button>
<div className="flex min-w-0 flex-1 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={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
</button>
);
})}
</div>
<button
type="button"
onClick={cycleCategorySortMode}
className="platform-category-sort-button shrink-0"
>
<span>{activeCategorySortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
{desktopCategoryGrid.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-3">
{desktopCategoryGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState></PlatformEmptyState>
)}
</>
) : (
<PlatformEmptyState></PlatformEmptyState>
)}
</section>
</>
)}
</div>
);
const tabContentById = {
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
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 ? (
<SquareImageCropModal
source={avatarSource}
imageSize={avatarImageSize}
cropRect={avatarCrop}
titleId="profile-avatar-crop-title"
labels={{
title: '裁剪头像',
close: '关闭头像裁剪',
editor: '头像裁剪操作区',
previewAlt: '头像裁剪预览',
cancel: '取消',
submit: '上传',
saving: '上传中',
}}
error={avatarError}
isSaving={isSavingAvatar}
onCropRectChange={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 ? (
<PlatformProfileRewardCodeRedeemModal
value={rewardCodeInput}
isSubmitting={isSubmittingRewardCode}
error={rewardCodeError}
success={rewardCodeSuccess}
onChange={setRewardCodeInput}
onSubmit={submitRewardCode}
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null;
const rechargeModal: ReactNode = isRechargeOpen ? (
<PlatformProfileRechargeModal
center={rechargeCenter}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
submittingProductId={submittingRechargeProductId}
nativePayment={nativeWechatPayment}
activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct}
onConfirmNativePayment={confirmNativeWechatPayment}
/>
) : null;
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (
<RechargePaymentResultModal
result={rechargePaymentResult}
onClose={() => setRechargePaymentResult(null)}
/>
) : null;
const rechargePaymentConfirmationMask: ReactNode =
wechatRechargeOrderConfirmationState ? (
<RechargePaymentConfirmationMask
orderId={wechatRechargeOrderConfirmationState.orderId}
/>
) : null;
const isRechargePaymentConfirmationPending = Boolean(
wechatRechargeOrderConfirmationState,
);
const categoryFilterDialog: ReactNode = isCategoryFilterPanelOpen ? (
<PlatformCategoryFilterDialog
kindFilter={categoryKindFilter}
sortMode={categorySortMode}
resultCount={activeCategoryFilterCount}
onKindFilterChange={setCategoryKindFilter}
onSortModeChange={setCategorySortMode}
onReset={() => {
setCategoryKindFilter('all');
setCategorySortMode('composite');
}}
onClose={() => setIsCategoryFilterPanelOpen(false)}
/>
) : null;
const qrScannerModal: ReactNode = isQrScannerOpen ? (
<PlatformProfileQrScannerModal
error={qrScannerError}
result={qrScannerResult}
onClose={() => {
setIsQrScannerOpen(false);
setQrScannerError(null);
setQrScannerResult(null);
}}
onError={setQrScannerError}
onResult={(value) => {
setQrScannerError(null);
setQrScannerResult(value);
}}
/>
) : null;
if (!isDesktopLayout) {
const isMobileRecommendTab = activeTab === 'home';
return (
<>
<div
inert={isRechargePaymentConfirmationPending ? true : undefined}
className={`platform-mobile-entry-shell ${isMobileRecommendTab ? 'platform-mobile-entry-shell--recommend' : ''} flex h-full min-h-0 min-w-0 flex-col overflow-hidden`}
>
{!isMobileRecommendTab ? (
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{isAuthenticated && activeTab === 'profile' ? (
<div className="flex items-center gap-2.5">
<PlatformIconButton
label="扫码"
icon={<ScanLine className="h-5 w-5" />}
onClick={openQrScannerPanel}
className="platform-profile-header__icon-button"
/>
<PlatformIconButton
label="打开设置"
icon={<Settings className="h-5 w-5" />}
onClick={() => authUi?.openSettingsModal()}
className="platform-profile-header__icon-button"
/>
</div>
) : isAuthenticated &&
(activeTab === 'create' || activeTab === 'saves') ? (
<TopbarWalletShortcutButton
variant="mobile"
balanceLabel={
profileDashboardPresentation.walletBalanceWithUnitLabel
}
onClick={openRechargeOrRewardCodeModal}
/>
) : !isAuthenticated ? (
<PlatformActionButton
size="xs"
className="shrink-0"
onClick={openUserSurface}
>
<LogIn className="h-3.5 w-3.5" />
</PlatformActionButton>
) : null}
</div>
) : null}
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
<div className="platform-mobile-bottom-dock min-w-0 shrink-0">
<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={
activeTab === 'home' && tab === 'home'
? '下一个'
: tabLabels[tab]
}
icon={
activeTab === 'home' && tab === 'home'
? ChevronDown
: tabIcons[tab]
}
emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
disabled={isRechargePaymentConfirmationPending}
onClick={() => {
if (isRechargePaymentConfirmationPending) {
return;
}
if (activeTab === 'home' && tab === 'home') {
selectNextRecommendEntry();
return;
}
onTabChange(tab);
}}
/>
))}
</div>
</div>
{profilePopupPanel ? (
<PlatformProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
isLoading={isLoadingReferral}
isSubmittingRedeem={isSubmittingReferralRedeem}
redeemCode={referralRedeemCode}
copyInviteState={inviteCopyState}
error={referralError}
success={referralSuccess}
onClose={closeProfilePopupPanel}
onCopyInvite={copyInviteInfo}
onRedeemCodeChange={setReferralRedeemCode}
onSubmitRedeemCode={submitReferralRedeemCode}
/>
) : null}
{rewardCodeModal}
{rechargeModal}
{rechargePaymentResultModal}
{qrScannerModal}
{categoryFilterDialog}
{isTaskCenterOpen ? (
<PlatformProfileTaskCenterModal
center={taskCenter}
isLoading={isLoadingTaskCenter}
error={taskCenterError}
success={taskClaimSuccess}
claimingTaskId={claimingTaskId}
fallbackBalance={remainingNarrativeCoins}
onClose={() => setIsTaskCenterOpen(false)}
onRetry={loadTaskCenter}
onClaim={claimTaskReward}
/>
) : null}
{isProfilePlayStatsOpen ? (
<PlatformProfilePlayedWorksModal
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
onResumeSave={onResumeSave}
/>
) : null}
{isWalletLedgerOpen ? (
<PlatformProfileWalletLedgerModal
ledger={walletLedger}
fallbackBalance={remainingNarrativeCoins}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
<LegalDocumentModal
document={activeLegalDocument}
open={Boolean(activeLegalDocument)}
platformTheme={authUi?.platformTheme}
onClose={() => setActiveLegalDocumentId(null)}
/>
{profileEditModals}
</div>
{rechargePaymentConfirmationMask}
</>
);
}
return (
<>
<div
inert={isRechargePaymentConfirmationPending ? true : undefined}
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">
{isAuthenticated &&
(activeTab === 'create' || activeTab === 'saves') ? (
<TopbarWalletShortcutButton
variant="desktop"
balanceLabel={
profileDashboardPresentation.walletBalanceWithUnitLabel
}
onClick={openRechargeOrRewardCodeModal}
/>
) : null}
<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'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
disabled={isRechargePaymentConfirmationPending}
onClick={() => {
if (isRechargePaymentConfirmationPending) {
return;
}
onTabChange(tab);
}}
/>
))}
</aside>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
</div>
</div>
</div>
</div>
{rewardCodeModal}
{rechargeModal}
{rechargePaymentResultModal}
{categoryFilterDialog}
{isTaskCenterOpen ? (
<PlatformProfileTaskCenterModal
center={taskCenter}
isLoading={isLoadingTaskCenter}
error={taskCenterError}
success={taskClaimSuccess}
claimingTaskId={claimingTaskId}
fallbackBalance={remainingNarrativeCoins}
onClose={() => setIsTaskCenterOpen(false)}
onRetry={loadTaskCenter}
onClaim={claimTaskReward}
/>
) : null}
{profilePopupPanel ? (
<PlatformProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
isLoading={isLoadingReferral}
isSubmittingRedeem={isSubmittingReferralRedeem}
redeemCode={referralRedeemCode}
copyInviteState={inviteCopyState}
error={referralError}
success={referralSuccess}
onClose={closeProfilePopupPanel}
onCopyInvite={copyInviteInfo}
onRedeemCodeChange={setReferralRedeemCode}
onSubmitRedeemCode={submitReferralRedeemCode}
/>
) : null}
{isProfilePlayStatsOpen ? (
<PlatformProfilePlayedWorksModal
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
onResumeSave={onResumeSave}
/>
) : null}
{isWalletLedgerOpen ? (
<PlatformProfileWalletLedgerModal
ledger={walletLedger}
fallbackBalance={remainingNarrativeCoins}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
<LegalDocumentModal
document={activeLegalDocument}
open={Boolean(activeLegalDocument)}
platformTheme={authUi?.platformTheme}
onClose={() => setActiveLegalDocumentId(null)}
/>
{profileEditModals}
{rechargePaymentConfirmationMask}
</>
);
}
export const PlatformHomeView = RpgEntryHomeView;