Files
Genarrative/src/components/rpg-entry/RpgEntryHomeView.tsx
T

5244 lines
174 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,
FolderKanban,
Gamepad2,
GitFork,
Heart,
KeyRound,
LogIn,
MessageCircle,
Palette,
Pencil,
ScanLine,
Search,
Settings,
Share2,
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 { PlatformMudPointWalletEntry } from '@/packages/shared/src/components/PlatformMudPointWalletEntry.tsx';
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 {
canUseNativeHostCapability,
type HostFileImportImageResult,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
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 { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFilterToolbar } from '../common/PlatformFilterToolbar';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import {
PlatformOptionSegment,
PlatformUnderlineTabRail,
} from '../common/PlatformSegmentedTabPresets';
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 {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
type SquareImageCropRect,
} from '../common/squareImageCropModel';
import { UnifiedModal } from '../common/UnifiedModal';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import {
canExposePublicWork,
EDUTAINMENT_WORK_TAG,
filterEdutainmentPublicWorks,
filterGeneralPublicWorks,
findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel';
import { PlatformProfileApiKeysModal } from '../platform-entry/PlatformProfileApiKeysModal';
import { PlatformProfileGenerationQueueCard } from '../platform-entry/PlatformProfileGenerationQueueCard';
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
import {
ProfileLegalSection,
ProfileSettingsRow,
ProfileShortcutButton,
ProfileStatCard,
ProfileStatCardSkeleton,
} from '../platform-entry/PlatformProfilePrimitives';
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 { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
import {
PlatformRechargePaymentConfirmationMask,
PlatformRechargePaymentResultDialog,
} from '../platform-entry/PlatformRechargePaymentStatusDialogs';
import {
type ProfilePopupPanel,
usePlatformProfileCenterController,
} from '../platform-entry/usePlatformProfileCenterController';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import { buildProfileDashboardPresentation } from './rpgEntryProfileDashboardPresentation';
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';
type PlatformNavTab = PlatformHomeTab | 'projects';
export interface RpgEntryHomeViewProps {
activeTab: PlatformHomeTab;
activeDesktopNavTab?: PlatformNavTab;
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;
onOpenProjects?: () => void;
onRechargeSuccess?: () => void | Promise<void>;
profileTaskRefreshKey?: number;
profileGenerationQueueStatus?: ExternalGenerationQueueStatus | null;
initialProfilePopupPanel?: ProfilePopupPanel | null;
onInitialProfilePopupPanelConsumed?: () => void;
createTabContent?: ReactNode;
draftTabContent?: ReactNode;
hasUnreadDraftUpdate?: boolean;
allowHiddenActiveTab?: 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 PLATFORM_DESKTOP_NAV_TABS: PlatformNavTab[] = [
'home',
'category',
'create',
'projects',
'profile',
];
const GUEST_DESKTOP_NAV_TABS: PlatformNavTab[] = [
'home',
'category',
'create',
'projects',
'profile',
];
const HIDDEN_DESKTOP_CREATION_NAV_TABS = new Set<PlatformNavTab>([
'home',
'category',
]);
const CREATION_HOME_NAV_ICON_SRC: Record<PlatformNavTab, string> = {
home: '/creation-home/nav-recommend.png',
category: '/creation-home/nav-discover.png',
create: '/creation-home/nav-create.png',
projects: '/creation-home/nav-projects.png',
saves: '/creation-home/nav-projects.png',
profile: '/creation-home/nav-profile.png',
};
const CREATION_HOME_TOPBAR_ASSETS = {
search: '/creation-home/topbar-search.png',
} as const;
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>
<h2 className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
{title}
</h2>
</div>
);
}
function HomeSeoIntro({
visuallyHidden = false,
}: {
visuallyHidden?: boolean;
}) {
return (
<header
className={visuallyHidden ? 'sr-only' : 'mb-2 min-w-0 shrink-0 px-1'}
>
<h1 className="text-sm font-semibold leading-5 text-[var(--platform-text-strong)]">
游戏美术AI工作台,让个人创作者更快做出角色、场景与宣发素材
</h1>
<p className="mt-0.5 text-xs leading-5 text-[var(--platform-text-soft)]">
在陶泥儿 Genarrative,用美术 Agent
快速生成与迭代素材,再在无限画布中整理角色、场景和宣发灵感。
</p>
</header>
);
}
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 ?? ''}`}
>
<img
src={CREATION_HOME_TOPBAR_ASSETS.search}
alt=""
aria-hidden="true"
draggable={false}
className="creation-home-topbar-art creation-home-topbar-art--search"
/>
<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)]"
/>
<PlatformActionButton
onClick={onSubmit}
disabled={!value.trim() || isSearching}
tone="ghost"
size="xxs"
shape="pill"
className="shrink-0"
>
{isSearching ? '搜索中' : '搜索'}
</PlatformActionButton>
</div>
);
}
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,
creationHomeIconSrc,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
showDot?: boolean;
disabled?: boolean;
creationHomeIconSrc?: string;
}) {
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">
{creationHomeIconSrc ? (
<img
src={creationHomeIconSrc}
alt=""
aria-hidden="true"
draggable={false}
className="creation-home-nav-art"
/>
) : null}
<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 (
<PlatformNavigableListItem
onClick={onClick}
align="start"
className="platform-desktop-trending-item px-4 py-4"
leading={
<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>
}
trailing={
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
}
>
<div className="min-w-0">
<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>
</PlatformNavigableListItem>
);
}
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 (
<PlatformNavigableListItem
onClick={onClick}
align="center"
className="platform-ranking-item"
leadingClassName="flex items-center gap-3"
leading={
<>
<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>
</>
}
trailing={
<span className="platform-ranking-item__action">{actionLabel}</span>
}
>
<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>
</PlatformNavigableListItem>
);
}
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 (
<PlatformNavigableListItem
onClick={onClick}
aria-label={`${entry.worldName}${actionLabel}`}
align="center"
className="platform-category-game-item"
bodyClassName="platform-category-game-item__body"
leading={
<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>
}
trailing={
<span className="platform-category-game-item__action">
{actionLabel}
</span>
}
>
<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>
</PlatformNavigableListItem>
);
}
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;
}) {
const kindFilterTabs = useMemo(
() =>
PLATFORM_CATEGORY_KIND_FILTERS.map((option) => ({
id: option.id,
label: option.label,
})),
[],
);
const sortModeTabs = useMemo(
() =>
PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => ({
id: option.id,
label: option.label,
})),
[],
);
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>
<PlatformOptionSegment
items={kindFilterTabs}
activeId={kindFilter}
onChange={onKindFilterChange}
variant="categoryFilter"
/>
</div>
<div className="grid gap-2">
<div className="text-xs font-black text-[var(--platform-text-soft)]">
排序
</div>
<PlatformOptionSegment
items={sortModeTabs}
activeId={sortMode}
onChange={onSortModeChange}
variant="categoryFilter"
/>
</div>
<div className="platform-category-filter-dialog__actions">
<PlatformActionButton
onClick={onReset}
tone="secondary"
shape="pill"
fullWidth
className="platform-category-filter-dialog__action !min-h-[2.55rem]"
>
重置
</PlatformActionButton>
<PlatformActionButton
onClick={onClose}
shape="pill"
fullWidth
className="platform-category-filter-dialog__action !min-h-[2.55rem]"
>
完成
</PlatformActionButton>
</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 (
<PlatformNavigableListItem
key={`${buildPublicGalleryCardKey(entry)}:search-result`}
onClick={() => onOpen(entry)}
className="platform-desktop-trending-item px-4 py-3"
trailing={
<PlatformPillBadge
tone="neutral"
size="xxs"
className="shrink-0 px-3 tracking-[0.18em]"
>
{describePlatformPublicWorkKind(entry)}
</PlatformPillBadge>
}
>
<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>
</PlatformNavigableListItem>
);
})}
</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 hostAvatarImageResultToFile(result: HostFileImportImageResult) {
const binary = atob(result.base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], result.fileName, { type: result.mimeType });
}
function validateProfileDisplayName(value: string) {
const normalized = value.trim();
if (!normalized) {
return '请输入昵称';
}
const length = Array.from(normalized).length;
if (length < 2 || length > 20) {
return '昵称需要 2 到 20 位';
}
if (!/^[\u4e00-\u9fffa-zA-Z0-9_]+$/u.test(normalized)) {
return '昵称仅支持中文、英文、数字和下划线';
}
return null;
}
function readImageIntrinsicSize(src: string) {
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const image = new Image();
image.onload = () => {
resolve({
width: image.naturalWidth,
height: image.naturalHeight,
});
};
image.onerror = () => reject(new Error('图片读取失败'));
image.src = src;
});
}
function loadAvatarFile(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('图片读取失败'));
return;
}
resolve(reader.result);
};
reader.onerror = () => reject(new Error('图片读取失败'));
reader.readAsDataURL(file);
});
}
function cropAvatarImage(params: {
source: string;
cropX: number;
cropY: number;
cropSize: number;
}) {
return new Promise<string>((resolve, reject) => {
const image = new Image();
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = AVATAR_OUTPUT_SIZE;
canvas.height = AVATAR_OUTPUT_SIZE;
const context = canvas.getContext('2d');
if (!context) {
reject(new Error('头像裁剪失败'));
return;
}
context.drawImage(
image,
params.cropX,
params.cropY,
params.cropSize,
params.cropSize,
0,
0,
AVATAR_OUTPUT_SIZE,
AVATAR_OUTPUT_SIZE,
);
resolve(canvas.toDataURL('image/png'));
};
image.onerror = () => reject(new Error('头像裁剪失败'));
image.src = params.source;
});
}
function 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"
footerClassName="grid grid-cols-2 gap-3 px-5 pb-5 pt-0 sm:px-5"
footer={
<>
<PlatformActionButton tone="secondary" onClick={onClose}>
取消
</PlatformActionButton>
<PlatformActionButton onClick={onSubmit} disabled={isSaving}>
{isSaving ? '保存中' : '保存'}
</PlatformActionButton>
</>
}
>
<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}
</PlatformProfileModalShell>
);
}
export function RpgEntryHomeView({
activeTab,
activeDesktopNavTab,
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,
onOpenProjects,
onRechargeSuccess,
profileTaskRefreshKey = 0,
profileGenerationQueueStatus = null,
initialProfilePopupPanel = null,
onInitialProfilePopupPanelConsumed,
createTabContent,
draftTabContent,
hasUnreadDraftUpdate = false,
allowHiddenActiveTab = 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 [qrScannerMode, setQrScannerMode] = useState<'camera' | 'resultOnly'>(
'camera',
);
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 [isApiKeysModalOpen, setIsApiKeysModalOpen] = useState(false);
const currentUser = authUi?.user ?? null;
const isAuthenticated = Boolean(currentUser);
const {
buyRechargeProduct,
closeNativeWechatPayment,
closeProfilePopupPanel,
confirmNativeWechatPayment,
copyInviteInfo,
inviteCopyState,
isLoadingRechargeCenter,
isLoadingReferral,
isLoadingWalletLedger,
isRechargeOpen,
isRewardCodeOpen,
isSubmittingReferralRedeem,
isSubmittingRewardCode,
isWalletLedgerOpen,
loadRechargeCenter,
loadWalletLedger,
nativeWechatPayment,
openProfilePopupPanel,
openRechargeOrRewardCodeModal,
openRewardCodeModal,
openWalletLedgerPanel,
profilePopupPanel,
rechargeCenter,
rechargeError,
rechargePaymentResult,
referralCenter,
referralError,
referralRedeemCode,
referralSuccess,
rewardCodeError,
rewardCodeInput,
rewardCodeSuccess,
setIsRechargeOpen,
setIsRewardCodeOpen,
setIsWalletLedgerOpen,
setRechargePaymentResult,
setReferralRedeemCode,
setRewardCodeInput,
submittingRechargeProductId,
submitReferralRedeemCode,
submitRewardCode,
walletLedger,
walletLedgerError,
wechatRechargeOrderConfirmationState,
} = usePlatformProfileCenterController({
activeTab,
isAuthenticated,
showRechargeEntry,
profileTaskRefreshKey,
onRechargeSuccess,
requestLogin: () => authUi?.openLoginModal(),
currentUser,
});
const openedInitialProfilePopupPanelRef = useRef<ProfilePopupPanel | null>(
null,
);
useEffect(() => {
if (!initialProfilePopupPanel) {
openedInitialProfilePopupPanelRef.current = null;
return;
}
if (
activeTab !== 'profile' ||
openedInitialProfilePopupPanelRef.current === initialProfilePopupPanel
) {
return;
}
openedInitialProfilePopupPanelRef.current = initialProfilePopupPanel;
openProfilePopupPanel(initialProfilePopupPanel);
onInitialProfilePopupPanelConsumed?.();
}, [
activeTab,
initialProfilePopupPanel,
onInitialProfilePopupPanelConsumed,
openProfilePopupPanel,
]);
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 discoverChannelTabs = useMemo(
() =>
visibleDiscoverChannels.map((channel) => ({
id: channel.id,
label: channel.label,
})),
[visibleDiscoverChannels],
);
const categoryGroups = useMemo(
() =>
buildPublicCategoryGroups(generalFeaturedEntries, generalLatestEntries),
[generalFeaturedEntries, generalLatestEntries],
);
const categoryGroupTabs = useMemo(
() =>
categoryGroups.map((group) => ({
id: group.tag,
label: group.tag,
})),
[categoryGroups],
);
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 handleDiscoverChannelChange = useCallback(
(channel: DiscoverChannel) => {
setDiscoverChannel(channel);
},
[],
);
const handleCategoryGroupChange = useCallback((tag: string) => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(tag);
}, []);
const visibleMobileTabs = useMemo<PlatformHomeTab[]>(() => ['profile'], []);
const visibleDesktopNavTabs = useMemo<PlatformNavTab[]>(
() =>
(isAuthenticated
? PLATFORM_DESKTOP_NAV_TABS
: GUEST_DESKTOP_NAV_TABS
).filter((tab) => !HIDDEN_DESKTOP_CREATION_NAV_TABS.has(tab)),
[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 tabIcons: Record<
PlatformNavTab,
ComponentType<{ className?: string }>
> = isAuthenticated
? {
home: Sparkles,
category: Compass,
create: Sparkles,
projects: FolderKanban,
saves: Pencil,
profile: UserRound,
}
: {
home: Gamepad2,
category: Compass,
create: Sparkles,
projects: FolderKanban,
saves: Pencil,
profile: UserRound,
};
const tabLabels = {
home: '推荐',
category: '发现',
create: '创作',
projects: '项目',
saves: '草稿',
profile: '我的',
} as const satisfies Record<PlatformNavTab, string>;
const resolvedDesktopNavTab = activeDesktopNavTab ?? activeTab;
useEffect(() => {
if (allowHiddenActiveTab) {
return;
}
if (!visibleMobileTabs.includes(activeTab) && !isDesktopLayout) {
onTabChange('profile');
}
}, [
activeTab,
allowHiddenActiveTab,
isDesktopLayout,
onTabChange,
visibleMobileTabs,
]);
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);
if (canUseNativeHostCapability('file.importImage')) {
void (async () => {
try {
const importedImageFile = await importHostImageFile();
if (!importedImageFile) {
return;
}
handleAvatarFileChange(
hostAvatarImageResultToFile(importedImageFile),
);
} catch (error: unknown) {
setAvatarError(
error instanceof Error ? error.message : '头像图片导入失败',
);
}
})();
return;
}
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);
setQrScannerMode('camera');
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 mobileCategoryPanelContent = activeCategoryGroup ? (
<>
<PlatformFilterToolbar
filterLabel={categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
filterCount={activeCategoryFilterCount}
tabItems={categoryGroupTabs}
activeTabId={activeCategoryGroup.tag}
sortLabel={activeCategorySortLabel}
layout="mobile"
onOpenFilter={() => setIsCategoryFilterPanelOpen(true)}
onTabChange={handleCategoryGroupChange}
onToggleSort={cycleCategorySortMode}
/>
<PlatformAsyncStatePanel
isEmpty={activeCategoryEntries.length === 0}
emptyState={
<PlatformEmptyState>当前筛选下没有作品。</PlatformEmptyState>
}
>
<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>
</PlatformAsyncStatePanel>
</>
) : null;
const renderDesktopCategorySection = (cardKeyPrefix: string) => {
const desktopCategoryPanelContent = activeCategoryGroup ? (
<>
<PlatformFilterToolbar
filterLabel={
categoryFilterApplied ? activeCategoryFilterLabel : '筛选'
}
filterCount={activeCategoryFilterCount}
tabItems={categoryGroupTabs}
activeTabId={activeCategoryGroup.tag}
sortLabel={activeCategorySortLabel}
layout="desktop"
onOpenFilter={() => setIsCategoryFilterPanelOpen(true)}
onTabChange={handleCategoryGroupChange}
onToggleSort={cycleCategorySortMode}
/>
<PlatformAsyncStatePanel
isEmpty={desktopCategoryGrid.length === 0}
emptyState={
<PlatformEmptyState>当前筛选下没有作品。</PlatformEmptyState>
}
>
<div className="grid gap-4 xl:grid-cols-3">
{desktopCategoryGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:${cardKeyPrefix}:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
</div>
</PlatformAsyncStatePanel>
</>
) : null;
return (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
<PlatformAsyncStatePanel
isLoading={isLoadingPlatform}
loadingState={
<PlatformEmptyState>正在读取作品分类...</PlatformEmptyState>
}
isEmpty={!activeCategoryGroup || activeCategoryRawCount === 0}
emptyState={
<PlatformEmptyState>暂时还没有可分类的作品。</PlatformEmptyState>
}
>
{desktopCategoryPanelContent}
</PlatformAsyncStatePanel>
</section>
);
};
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 [isRecommendDragResetting, setIsRecommendDragResetting] =
useState(false);
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;
}
setIsRecommendDragResetting(false);
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);
}
setIsRecommendDragResetting(true);
setRecommendDragOffsetY(0);
setRecommendDragCommitDirection(null);
window.requestAnimationFrame(() => {
setIsRecommendDragResetting(false);
});
}, 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) {
setIsRecommendDragResetting(false);
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;
setIsRecommendDragResetting(false);
setRecommendDragOffsetY(0);
},
[],
);
const recommendRailStyle = {
transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`,
} satisfies CSSProperties;
const recommendRailClassName = buildRecommendSwipeRailClassName({
offsetY: recommendDragOffsetY,
commitDirection: recommendDragCommitDirection,
isResetting: isRecommendDragResetting,
});
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`}
>
<PlatformUnderlineTabRail
items={PLATFORM_RANKING_TABS.map((tab) => ({
id: tab.id,
label: tab.label,
}))}
activeId={activeRankingTab}
onChange={setActiveRankingTab}
ariaLabel="作品排行"
variant="ranking"
/>
<PlatformAsyncStatePanel
isLoading={isLoadingPlatform}
loadingState={
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
}
isEmpty={rankingEntries.length === 0}
emptyState={
<PlatformEmptyState>
{activeRankingConfig.emptyText}
</PlatformEmptyState>
}
>
<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>
</PlatformAsyncStatePanel>
</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}
/>
) : (
<>
<PlatformUnderlineTabRail
items={discoverChannelTabs}
activeId={discoverChannel}
onChange={handleDiscoverChannelChange}
ariaLabel="发现频道"
/>
{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">
<PlatformAsyncStatePanel
isLoading={isLoadingPlatform}
loadingState={
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
}
isEmpty={!activeCategoryGroup || categoryGroups.length === 0}
emptyState={
<PlatformEmptyState>
公开广场暂时还没有可分类的作品。
</PlatformEmptyState>
}
>
{mobileCategoryPanelContent}
</PlatformAsyncStatePanel>
</section>
) : discoverChannel === 'edutainment' ? (
<section className="platform-mobile-home-feed">
<PlatformAsyncStatePanel
isLoading={isLoadingPlatform}
loadingState={
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
}
isEmpty={
edutainmentFeedEntries.length === 0 &&
!onOpenChildMotionDemo &&
!onOpenBabyLoveDrawing
}
emptyState={
<PlatformEmptyState>
暂时还没有可展示的作品。
</PlatformEmptyState>
}
>
<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>
</PlatformAsyncStatePanel>
</section>
) : (
<section
ref={mobileDiscoverFeedRef}
className="platform-mobile-home-feed"
>
<PlatformAsyncStatePanel
isLoading={isLoadingPlatform}
loadingState={
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
}
isEmpty={discoverFeedEntries.length === 0}
emptyState={
<PlatformEmptyState>
公开广场暂时还没有可展示的作品。
</PlatformEmptyState>
}
>
<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>
</PlatformAsyncStatePanel>
</section>
)}
</>
)}
</div>
);
const desktopDiscoverContent: ReactNode = (
<div className={DESKTOP_DISCOVER_PAGE_STAGE_CLASS}>
<PlatformUnderlineTabRail
items={discoverChannelTabs}
activeId={discoverChannel}
onChange={handleDiscoverChannelChange}
ariaLabel="发现频道"
/>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-[1.5rem]"
>
{platformError}
</PlatformStatusMessage>
) : null}
{discoverChannel === 'ranking' ? (
mobileRankingPanel
) : discoverChannel === 'category' ? (
renderDesktopCategorySection('desktop-discover-category')
) : discoverChannel === 'edutainment' ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title={EDUTAINMENT_WORK_TAG} detail="EDUTAINMENT" />
<PlatformAsyncStatePanel
isLoading={isLoadingPlatform}
loadingState={
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
}
isEmpty={
edutainmentFeedEntries.length === 0 &&
!onOpenChildMotionDemo &&
!onOpenBabyLoveDrawing
}
emptyState={
<PlatformEmptyState>暂时还没有可展示的作品。</PlatformEmptyState>
}
>
<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>
</PlatformAsyncStatePanel>
</section>
) : (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={discoverChannel === 'today' ? '今日游戏' : '推荐'}
detail={discoverChannel === 'today' ? 'TODAY GAMES' : 'RECOMMENDED'}
/>
<PlatformAsyncStatePanel
isLoading={isLoadingPlatform}
loadingState={
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
}
isEmpty={discoverFeedEntries.length === 0}
emptyState={
<PlatformEmptyState>
公开广场暂时还没有可展示的作品。
</PlatformEmptyState>
}
>
<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>
</PlatformAsyncStatePanel>
</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}
<PlatformAsyncStatePanel
isLoading={isLoadingPlatform}
loadingState={
<PlatformEmptyState>正在读取你的作品...</PlatformEmptyState>
}
isEmpty={myEntries.length === 0}
emptyState={
<PlatformEmptyState>
{isAuthenticated
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
: '登录后查看你的作品。'}
</PlatformEmptyState>
}
>
<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>
</PlatformAsyncStatePanel>
</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>
<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>
<PlatformProfileGenerationQueueCard
queueStatus={profileGenerationQueueStatus}
/>
<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()}
/>
<ProfileSettingsRow
label="开发者 API Key"
icon={KeyRound}
onClick={() => setIsApiKeysModalOpen(true)}
/>
</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}>
<HomeSeoIntro />
{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>
<PlatformAsyncStatePanel
isLoading={isLoadingPlatform}
loadingState={
<PlatformEmptyState>正在读取今日游戏...</PlatformEmptyState>
}
isEmpty={desktopTodayEntries.length === 0}
emptyState={
<PlatformEmptyState>
今天暂时还没有新游戏。
</PlatformEmptyState>
}
>
<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>
</PlatformAsyncStatePanel>
</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" />
<PlatformAsyncStatePanel
isLoading={isLoadingPlatform}
loadingState={
<PlatformEmptyState>正在读取推荐作品...</PlatformEmptyState>
}
isEmpty={desktopFeaturedGrid.length === 0}
emptyState={
<PlatformEmptyState>暂时还没有推荐作品。</PlatformEmptyState>
}
>
<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>
</PlatformAsyncStatePanel>
</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 (
<PlatformNavigableListItem
key={`${entry.ownerUserId}:${entry.profileId}:desktop-mine`}
onClick={() => onOpenLibraryDetail(entry)}
className="platform-desktop-trending-item px-4 py-4"
trailing={
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
{entry.visibility === 'published'
? '已发布'
: '草稿'}
</PlatformPillBadge>
}
>
<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>
</PlatformNavigableListItem>
);
})}
</div>
) : (
<div className="mt-3 space-y-3">
{visibleHistoryEntries.slice(0, 2).map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<PlatformNavigableListItem
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
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 px-4 py-4"
trailing={
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
浏览
</PlatformPillBadge>
}
>
<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>
</PlatformNavigableListItem>
);
})}
</div>
)}
</div>
</section>
) : null}
</div>
{renderDesktopCategorySection('desktop-category')}
</>
)}
</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) =>
isDesktopLayout || visibleMobileTabs.includes(tab) || tab === activeTab,
).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}
onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct}
onConfirmNativePayment={confirmNativeWechatPayment}
onCloseNativePayment={closeNativeWechatPayment}
/>
) : null;
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (
<PlatformRechargePaymentResultDialog
result={rechargePaymentResult}
onClose={() => setRechargePaymentResult(null)}
/>
) : null;
const rechargePaymentConfirmationMask: ReactNode =
wechatRechargeOrderConfirmationState ? (
<PlatformRechargePaymentConfirmationMask
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}
mode={qrScannerMode}
result={qrScannerResult}
onClose={() => {
setIsQrScannerOpen(false);
setQrScannerMode('camera');
setQrScannerError(null);
setQrScannerResult(null);
}}
onError={setQrScannerError}
onResult={(value) => {
setQrScannerError(null);
setQrScannerMode('resultOnly');
setQrScannerResult(value);
}}
/>
) : null;
const apiKeysModal: ReactNode = isApiKeysModalOpen ? (
<PlatformProfileApiKeysModal onClose={() => setIsApiKeysModalOpen(false)} />
) : null;
const desktopBrandLockup: ReactNode = (
<span className="creation-home-brand-lockup shrink-0">
<RpgEntryBrandLogo className="shrink-0" decorative />
</span>
);
const desktopSearchBar: ReactNode = (
<PublicCodeSearchBar
value={desktopSearchKeyword}
onChange={updateDesktopSearchKeyword}
onSubmit={submitDesktopSearch}
isSearching={!onSearchPublicCode || Boolean(isSearchingPublicCode)}
className="max-w-[34rem] flex-1"
/>
);
const desktopTopbarActions: ReactNode = (
<div className="platform-desktop-topbar__actions flex items-center gap-3">
{isAuthenticated ? (
<PlatformMudPointWalletEntry
variant="desktop"
balance={profileDashboardPresentation.walletBalance}
breakdown={rechargeCenter?.mudPointBalance ?? null}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
className="platform-desktop-create-wallet-chip"
onRequestDetails={loadRechargeCenter}
onRecharge={openRechargeOrRewardCodeModal}
onOpenLedger={openWalletLedgerPanel}
/>
) : 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>
);
const desktopRailNav: ReactNode = (
<nav className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3">
{visibleDesktopNavTabs.map((tab) => (
<DesktopTabButton
key={tab}
active={resolvedDesktopNavTab === tab}
label={tabLabels[tab]}
icon={tabIcons[tab]}
creationHomeIconSrc={CREATION_HOME_NAV_ICON_SRC[tab]}
emphasized={tab === 'create' && resolvedDesktopNavTab === 'create'}
showDot={false}
disabled={isRechargePaymentConfirmationPending}
onClick={() => {
if (isRechargePaymentConfirmationPending) {
return;
}
if (tab === 'create') {
onOpenCreateTypePicker();
return;
}
if (tab === 'projects') {
onOpenProjects?.();
return;
}
onTabChange(tab);
}}
/>
))}
</nav>
);
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') ? (
<PlatformMudPointWalletEntry
variant="mobile"
balance={profileDashboardPresentation.walletBalance}
breakdown={rechargeCenter?.mudPointBalance ?? null}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
className="platform-mobile-create-wallet-chip"
onRequestDetails={loadRechargeCenter}
onRecharge={openRechargeOrRewardCodeModal}
onOpenLedger={openWalletLedgerPanel}
/>
) : !isAuthenticated ? (
<PlatformActionButton
size="xs"
className="shrink-0"
onClick={openUserSurface}
>
<LogIn className="h-3.5 w-3.5" />
登录
</PlatformActionButton>
) : null}
</div>
) : null}
<HomeSeoIntro visuallyHidden />
<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 ${visibleMobileTabs.length === 5 ? 'grid-cols-5' : visibleMobileTabs.length === 4 ? 'grid-cols-4' : visibleMobileTabs.length === 3 ? 'grid-cols-3' : visibleMobileTabs.length === 2 ? 'grid-cols-2' : 'grid-cols-1'}`}
>
{visibleMobileTabs.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}
{apiKeysModal}
{rechargePaymentResultModal}
{qrScannerModal}
{categoryFilterDialog}
{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>
{isMobileRecommendTab && isAuthenticated ? (
<div
inert={isRechargePaymentConfirmationPending ? true : undefined}
className="platform-mobile-recommend-wallet-entry"
>
<PlatformMudPointWalletEntry
variant="mobile"
balance={profileDashboardPresentation.walletBalance}
breakdown={rechargeCenter?.mudPointBalance ?? null}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
onRequestDetails={loadRechargeCenter}
onRecharge={openRechargeOrRewardCodeModal}
onOpenLedger={openWalletLedgerPanel}
/>
</div>
) : null}
{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 platform-desktop-shell--workbench flex h-full min-h-0 flex-col p-5 xl:p-6">
<div className="platform-desktop-layout flex min-h-0 flex-1">
<aside className="platform-desktop-leftbar flex shrink-0 flex-col">
{desktopBrandLockup}
{desktopRailNav}
</aside>
<div className="platform-desktop-main flex min-w-0 flex-1 flex-col">
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="platform-desktop-topbar__brand-search flex min-w-0 flex-1 items-center gap-5">
{desktopSearchBar}
</div>
{desktopTopbarActions}
</div>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
</div>
</div>
</div>
</div>
</div>
{rewardCodeModal}
{rechargeModal}
{apiKeysModal}
{rechargePaymentResultModal}
{categoryFilterDialog}
{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;