新增 PlatformStatusDialog 统一支付结果与确认中状态弹层 新增 PlatformProfileQrScannerModal 统一个人中心扫码面板 RpgEntryHomeView 改用共享组件并删除内联支付与扫码实现 更新 PlatformUiKit 收口文档与团队决策记录
5247 lines
177 KiB
TypeScript
5247 lines
177 KiB
TypeScript
import {
|
||
ArrowRight,
|
||
BookOpen,
|
||
Camera,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Clock3,
|
||
Coins,
|
||
Compass,
|
||
Crown,
|
||
Gamepad2,
|
||
GitFork,
|
||
Heart,
|
||
LogIn,
|
||
MessageCircle,
|
||
Palette,
|
||
Pencil,
|
||
ScanLine,
|
||
Search,
|
||
Settings,
|
||
Share2,
|
||
SlidersHorizontal,
|
||
Sparkles,
|
||
Star,
|
||
ThumbsUp,
|
||
Ticket,
|
||
UserRound,
|
||
} from 'lucide-react';
|
||
import {
|
||
type ComponentType,
|
||
type CSSProperties,
|
||
type PointerEvent,
|
||
type ReactNode,
|
||
type RefObject,
|
||
Suspense,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import profileClockImage from '../../../media/profile/_Image (1).png';
|
||
import profileGamepadImage from '../../../media/profile/_Image (2).png';
|
||
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
|
||
import profileCoinsImage from '../../../media/profile/_Image (4).png';
|
||
import profileGiftImage from '../../../media/profile/_Image (6).png';
|
||
import profileCommunityImage from '../../../media/profile/_Image (7).png';
|
||
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
|
||
import profileMascotImage from '../../../media/profile/_Image (9).png';
|
||
import profilePointImage from '../../../media/profile/_Image.png';
|
||
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
||
import type {
|
||
CustomWorldLibraryEntry,
|
||
PlatformBrowseHistoryEntry,
|
||
ProfileDashboardCardKey,
|
||
ProfileDashboardSummary,
|
||
ProfilePlayedWorkSummary,
|
||
ProfilePlayStatsResponse,
|
||
ProfileSaveArchiveSummary,
|
||
} from '../../../packages/shared/src/contracts/runtime';
|
||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||
import {
|
||
isGeneratedLegacyPath,
|
||
resolveAssetReadUrl,
|
||
} from '../../services/assetReadUrlService';
|
||
import type { AuthUser } from '../../services/authService';
|
||
import {
|
||
getPublicAuthUserByCode,
|
||
getPublicAuthUserById,
|
||
updateAuthProfile,
|
||
} from '../../services/authService';
|
||
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
|
||
import type { CustomWorldProfile } from '../../types';
|
||
import { useAuthUi } from '../auth/AuthUiContext';
|
||
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||
import {
|
||
getLegalDocument,
|
||
type LegalDocumentId,
|
||
} from '../common/legalDocuments';
|
||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||
import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||
import { PlatformStatusDialog } from '../common/PlatformStatusDialog';
|
||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||
import { PlatformTextField } from '../common/PlatformTextField';
|
||
import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../common/RuntimeResourcePendingMarker';
|
||
import { SquareImageCropModal } from '../common/SquareImageCropModal';
|
||
import { UnifiedModal } from '../common/UnifiedModal';
|
||
import {
|
||
buildCenteredSquareImageCropRect,
|
||
clampSquareImageCropRect,
|
||
type SquareImageCropRect,
|
||
} from '../common/squareImageCropModel';
|
||
import {
|
||
useCopyFeedback,
|
||
} from '../common/useCopyFeedback';
|
||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||
import {
|
||
canExposePublicWork,
|
||
EDUTAINMENT_WORK_TAG,
|
||
filterEdutainmentPublicWorks,
|
||
filterGeneralPublicWorks,
|
||
findPublicWorkForHistoryEntry,
|
||
isEdutainmentEntryEnabled,
|
||
} from '../platform-entry/platformEdutainmentVisibility';
|
||
import {
|
||
ProfileLegalSection,
|
||
ProfileSettingsRow,
|
||
ProfileShortcutButton,
|
||
ProfileStatCard,
|
||
ProfileStatCardSkeleton,
|
||
} from '../platform-entry/PlatformProfilePrimitives';
|
||
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
|
||
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
|
||
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
|
||
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
|
||
import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileReferralModal';
|
||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||
import { PlatformProfileTaskCenterModal } from '../platform-entry/PlatformProfileTaskCenterModal';
|
||
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
|
||
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
||
import {
|
||
type RechargePaymentResult,
|
||
usePlatformProfileCenterController,
|
||
} from '../platform-entry/usePlatformProfileCenterController';
|
||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||
import {
|
||
buildProfileDashboardPresentation,
|
||
formatSnapshotTime,
|
||
} from './rpgEntryProfileDashboardPresentation';
|
||
import { buildProfileTaskCardSummary } from './rpgEntryProfileTaskViewModel';
|
||
import {
|
||
buildPlatformRankingEntries,
|
||
buildPlatformRecommendFeedEntries,
|
||
buildPublicCategoryGroups,
|
||
buildPublicGalleryCardKey,
|
||
dedupePlatformPublicGalleryEntries,
|
||
DEFAULT_PLATFORM_CATEGORY_KIND_FILTER,
|
||
DEFAULT_PLATFORM_CATEGORY_SORT_MODE,
|
||
DEFAULT_PLATFORM_RANKING_TAB,
|
||
filterPlatformWorkSearchResults,
|
||
filterTodayPublishedEntries,
|
||
getAllPlatformPublicEntries,
|
||
getNextPlatformCategorySortMode,
|
||
getPlatformCategoryKindFilterOption,
|
||
getPlatformCategoryPrimaryMetric,
|
||
getPlatformCategorySortOption,
|
||
getPlatformPublicEntries,
|
||
getPlatformRankingMetric,
|
||
getPlatformRankingTabConfig,
|
||
getPlatformSearchableWorkIds,
|
||
getPlatformWorldLikeCount,
|
||
getPlatformWorldPlayCount,
|
||
getPlatformWorldRemixCount,
|
||
isExactPublicWorkCodeSearch,
|
||
matchesPlatformCategoryKindFilter,
|
||
PLATFORM_CATEGORY_KIND_FILTERS,
|
||
PLATFORM_CATEGORY_SORT_OPTIONS,
|
||
PLATFORM_RANKING_TABS,
|
||
type PlatformCategoryKindFilter,
|
||
type PlatformCategorySortMode,
|
||
type PlatformRankingMetric,
|
||
type PlatformRankingTab,
|
||
selectPlatformRecommendFeedWindow,
|
||
sortPlatformCategoryEntries,
|
||
} from './rpgEntryPublicGalleryViewModel';
|
||
import {
|
||
buildRecommendSwipeRailClassName,
|
||
clampRecommendDragOffset,
|
||
hasRecommendDragStarted,
|
||
RECOMMEND_ENTRY_COMMIT_ANIMATION_MS,
|
||
type RecommendSwipeDirection,
|
||
resolveRecommendCommitOffset,
|
||
resolveRecommendDragCommitDirection,
|
||
shouldAnimateRecommendSwipe,
|
||
} from './rpgEntryRecommendSwipeDeckModel';
|
||
import {
|
||
buildPlatformWorldDisplayTags,
|
||
describePlatformPublicWorkKind,
|
||
describePlatformThemeLabel,
|
||
formatPlatformCompactCount,
|
||
formatPlatformPublicAuthorAvatarLabel,
|
||
formatPlatformWorkDisplayName,
|
||
formatPlatformWorkDisplayTag,
|
||
formatPlatformWorldTime,
|
||
isBarkBattleGalleryEntry,
|
||
isBigFishGalleryEntry,
|
||
isEdutainmentGalleryEntry,
|
||
isPuzzleGalleryEntry,
|
||
isVisualNovelGalleryEntry,
|
||
type PlatformPublicGalleryCard,
|
||
type PlatformPublicWorkAuthorLookup,
|
||
resolvePlatformPublicWorkAuthorLookup,
|
||
resolvePlatformWorkAuthorDisplayName,
|
||
resolvePlatformWorldCoverImage,
|
||
resolvePlatformWorldCoverSlides,
|
||
resolvePlatformWorldFallbackCoverImage,
|
||
resolvePlatformWorldLeadPortrait,
|
||
} from './rpgEntryWorldPresentation';
|
||
|
||
export type PlatformHomeTab =
|
||
| 'home'
|
||
| 'category'
|
||
| 'create'
|
||
| 'saves'
|
||
| 'profile';
|
||
export interface RpgEntryHomeViewProps {
|
||
activeTab: PlatformHomeTab;
|
||
isDesktopLayout?: boolean;
|
||
onTabChange: (tab: PlatformHomeTab) => void;
|
||
hasSavedGame: boolean;
|
||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||
saveEntries: ProfileSaveArchiveSummary[];
|
||
saveError: string | null;
|
||
featuredEntries: PlatformPublicGalleryCard[];
|
||
latestEntries: PlatformPublicGalleryCard[];
|
||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||
historyEntries: PlatformBrowseHistoryEntry[];
|
||
profileDashboard: ProfileDashboardSummary | null;
|
||
isLoadingPlatform: boolean;
|
||
isLoadingDashboard: boolean;
|
||
isResumingSaveWorldKey: string | null;
|
||
platformError: string | null;
|
||
dashboardError: string | null;
|
||
onContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
|
||
onOpenCreateWorld: () => void;
|
||
onOpenCreateTypePicker: () => void;
|
||
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
|
||
onOpenChildMotionDemo?: () => void;
|
||
onOpenBabyLoveDrawing?: () => void;
|
||
onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void;
|
||
recommendRuntimeContent?: ReactNode;
|
||
activeRecommendEntryKey?: string | null;
|
||
isStartingRecommendEntry?: boolean;
|
||
isRecommendRuntimeReady?: boolean;
|
||
recommendRuntimeError?: string | null;
|
||
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||
onShareRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||
onOpenLibraryDetail: (
|
||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||
) => void;
|
||
onDeleteLibraryEntry?: (
|
||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||
) => void;
|
||
deletingLibraryEntryId?: string | null;
|
||
onSearchPublicCode?: (keyword: string) => void | Promise<boolean | void>;
|
||
isSearchingPublicCode?: boolean;
|
||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||
profilePlayStats?: ProfilePlayStatsResponse | null;
|
||
isProfilePlayStatsOpen?: boolean;
|
||
isProfilePlayStatsLoading?: boolean;
|
||
profilePlayStatsError?: string | null;
|
||
onCloseProfilePlayStats?: () => void;
|
||
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
|
||
onOpenFeedback?: () => void;
|
||
onRechargeSuccess?: () => void | Promise<void>;
|
||
profileTaskRefreshKey?: number;
|
||
createTabContent?: ReactNode;
|
||
draftTabContent?: ReactNode;
|
||
hasUnreadDraftUpdate?: boolean;
|
||
}
|
||
|
||
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
|
||
const HERO_SURFACE_CLASS =
|
||
'platform-surface platform-surface--hero platform-interactive-card min-w-0';
|
||
const MOBILE_PAGE_STAGE_CLASS =
|
||
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
|
||
const MOBILE_PROFILE_PAGE_STAGE_CLASS =
|
||
'platform-remap-surface min-w-0 space-y-4 pb-2';
|
||
const MOBILE_RECOMMEND_PAGE_STAGE_CLASS =
|
||
'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2';
|
||
const MOBILE_DISCOVER_PAGE_STAGE_CLASS =
|
||
'platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
|
||
const DESKTOP_PAGE_STAGE_CLASS =
|
||
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
|
||
const DESKTOP_DISCOVER_PAGE_STAGE_CLASS =
|
||
'platform-remap-surface min-w-0 space-y-5 pb-4';
|
||
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
|
||
'home',
|
||
'category',
|
||
'create',
|
||
'saves',
|
||
'profile',
|
||
];
|
||
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||
const AVATAR_OUTPUT_SIZE = 256;
|
||
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
|
||
const RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS = 520;
|
||
const RECOMMEND_RUNTIME_RESOURCE_IDLE_MS = 80;
|
||
const RECOMMEND_RUNTIME_READY_FRAME_COUNT = 2;
|
||
|
||
type RecommendResolvedCoverUrlMap = ReadonlyMap<string, string>;
|
||
|
||
function resolveRecommendDisplayCoverImage(
|
||
imageSrc: string,
|
||
fallbackSrc: string,
|
||
resolvedCoverUrls?: RecommendResolvedCoverUrlMap,
|
||
) {
|
||
const normalizedImageSrc = imageSrc.trim();
|
||
const normalizedFallbackSrc = fallbackSrc.trim();
|
||
|
||
if (!normalizedImageSrc) {
|
||
return (
|
||
resolvedCoverUrls?.get(normalizedFallbackSrc) ?? normalizedFallbackSrc
|
||
);
|
||
}
|
||
|
||
const resolvedImageSrc = resolvedCoverUrls?.get(normalizedImageSrc);
|
||
if (resolvedImageSrc) {
|
||
return resolvedImageSrc;
|
||
}
|
||
|
||
if (isGeneratedLegacyPath(normalizedImageSrc)) {
|
||
return (
|
||
resolvedCoverUrls?.get(normalizedFallbackSrc) ?? normalizedFallbackSrc
|
||
);
|
||
}
|
||
|
||
return normalizedImageSrc;
|
||
}
|
||
|
||
function resolveRecommendCardCoverImage(entry: PlatformPublicGalleryCard) {
|
||
const cardCoverSlide = resolvePlatformWorldCoverSlides(entry)[0] ?? null;
|
||
return (
|
||
cardCoverSlide?.imageSrc.trim() || resolvePlatformWorldCoverImage(entry)
|
||
);
|
||
}
|
||
|
||
function collectRecommendCoverPreloadUrls(
|
||
entries: PlatformPublicGalleryCard[],
|
||
) {
|
||
const urls = new Set<string>();
|
||
|
||
entries.forEach((entry) => {
|
||
resolvePlatformWorldCoverSlides(entry).forEach((slide) => {
|
||
const slideImageSrc = slide.imageSrc.trim();
|
||
if (slideImageSrc) {
|
||
urls.add(slideImageSrc);
|
||
}
|
||
});
|
||
[
|
||
resolveRecommendCardCoverImage(entry),
|
||
resolvePlatformWorldCoverImage(entry),
|
||
resolvePlatformWorldFallbackCoverImage(entry),
|
||
]
|
||
.map((url) => url.trim())
|
||
.filter(Boolean)
|
||
.forEach((url) => urls.add(url));
|
||
});
|
||
|
||
return [...urls];
|
||
}
|
||
|
||
function useResolvedRecommendCoverImages(
|
||
entries: PlatformPublicGalleryCard[],
|
||
): RecommendResolvedCoverUrlMap {
|
||
const preloadUrls = useMemo(
|
||
() => collectRecommendCoverPreloadUrls(entries),
|
||
[entries],
|
||
);
|
||
const preloadKey = preloadUrls.join('\n');
|
||
const [resolvedCoverUrls, setResolvedCoverUrls] = useState<
|
||
Map<string, string>
|
||
>(() => new Map());
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const cleanupCallbacks: Array<() => void> = [];
|
||
const preloadCoverImage = (
|
||
imageSrc: string,
|
||
onLoaded?: (loadedImageSrc: string) => void,
|
||
) => {
|
||
if (!imageSrc || typeof Image === 'undefined') {
|
||
onLoaded?.(imageSrc);
|
||
return;
|
||
}
|
||
|
||
const image = new Image();
|
||
const cleanupImage = () => {
|
||
image.onload = null;
|
||
image.onerror = null;
|
||
};
|
||
const finishImageLoad = () => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
cleanupImage();
|
||
onLoaded?.(imageSrc);
|
||
};
|
||
const finishImageError = () => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
cleanupImage();
|
||
};
|
||
|
||
image.decoding = 'async';
|
||
image.onload = finishImageLoad;
|
||
image.onerror = finishImageError;
|
||
image.src = imageSrc;
|
||
|
||
if (image.complete) {
|
||
finishImageLoad();
|
||
}
|
||
|
||
cleanupCallbacks.push(cleanupImage);
|
||
};
|
||
|
||
setResolvedCoverUrls((currentUrls) => {
|
||
const nextUrls = new Map<string, string>();
|
||
preloadUrls.forEach((url) => {
|
||
const cachedUrl = currentUrls.get(url);
|
||
if (cachedUrl) {
|
||
nextUrls.set(url, cachedUrl);
|
||
return;
|
||
}
|
||
if (!isGeneratedLegacyPath(url)) {
|
||
nextUrls.set(url, url);
|
||
}
|
||
});
|
||
return nextUrls;
|
||
});
|
||
|
||
preloadUrls.forEach((url) => {
|
||
if (!isGeneratedLegacyPath(url)) {
|
||
preloadCoverImage(url);
|
||
return;
|
||
}
|
||
|
||
void resolveAssetReadUrl(url)
|
||
.then((resolvedUrl) => {
|
||
if (cancelled || !resolvedUrl) {
|
||
return;
|
||
}
|
||
|
||
preloadCoverImage(resolvedUrl, (loadedUrl) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
setResolvedCoverUrls((currentUrls) => {
|
||
if (currentUrls.get(url) === loadedUrl) {
|
||
return currentUrls;
|
||
}
|
||
|
||
const nextUrls = new Map(currentUrls);
|
||
nextUrls.set(url, loadedUrl);
|
||
return nextUrls;
|
||
});
|
||
});
|
||
})
|
||
.catch(() => undefined);
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
|
||
};
|
||
}, [preloadKey, preloadUrls]);
|
||
|
||
return resolvedCoverUrls;
|
||
}
|
||
|
||
function scheduleRecommendRuntimeReady(
|
||
signal: AbortSignal,
|
||
onReady: () => void,
|
||
) {
|
||
if (signal.aborted) {
|
||
return null;
|
||
}
|
||
|
||
let animationFrameId: number | null = null;
|
||
let remainingFrameCount = RECOMMEND_RUNTIME_READY_FRAME_COUNT;
|
||
const tick = () => {
|
||
animationFrameId = null;
|
||
if (signal.aborted) {
|
||
return;
|
||
}
|
||
|
||
remainingFrameCount -= 1;
|
||
if (remainingFrameCount <= 0) {
|
||
onReady();
|
||
return;
|
||
}
|
||
|
||
animationFrameId = window.requestAnimationFrame(tick);
|
||
};
|
||
|
||
animationFrameId = window.requestAnimationFrame(tick);
|
||
return () => {
|
||
if (animationFrameId !== null) {
|
||
window.cancelAnimationFrame(animationFrameId);
|
||
}
|
||
};
|
||
}
|
||
|
||
function getRecommendRuntimeImageSource(image: HTMLImageElement) {
|
||
return (
|
||
image.currentSrc ||
|
||
image.getAttribute('src') ||
|
||
image.getAttribute('srcset') ||
|
||
''
|
||
).trim();
|
||
}
|
||
|
||
function getRecommendRuntimeMediaSource(media: HTMLMediaElement) {
|
||
return (media.currentSrc || media.getAttribute('src') || '').trim();
|
||
}
|
||
|
||
function collectRecommendRuntimeBackgroundUrls(root: HTMLElement) {
|
||
const urls = new Set<string>();
|
||
root.querySelectorAll<HTMLElement>('[style]').forEach((element) => {
|
||
const backgroundImage = element.style.backgroundImage;
|
||
if (!backgroundImage || backgroundImage === 'none') {
|
||
return;
|
||
}
|
||
|
||
const pattern = /url\((?:"([^"]*)"|'([^']*)'|([^)]*))\)/giu;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = pattern.exec(backgroundImage))) {
|
||
const url = (match[1] ?? match[2] ?? match[3] ?? '').trim();
|
||
if (url) {
|
||
urls.add(url);
|
||
}
|
||
}
|
||
});
|
||
return urls;
|
||
}
|
||
|
||
function readyRecommendRuntime(
|
||
root: HTMLElement | null,
|
||
signal: AbortSignal,
|
||
): Promise<boolean> {
|
||
if (!root || signal.aborted) {
|
||
return Promise.resolve(false);
|
||
}
|
||
|
||
const runtimeRoot = root;
|
||
|
||
return new Promise((resolve) => {
|
||
let scanFrameId: number | null = null;
|
||
let readyIdleTimeoutId: number | null = null;
|
||
let pendingRecheckTimeoutId: number | null = null;
|
||
let readyFrameCleanup: (() => void) | null = null;
|
||
let settled = false;
|
||
const cleanupCallbacks: Array<() => void> = [];
|
||
const pendingImageListeners = new Map<
|
||
HTMLImageElement,
|
||
{ src: string; cleanup: () => void }
|
||
>();
|
||
const pendingMediaListeners = new Map<
|
||
HTMLMediaElement,
|
||
{ src: string; cleanup: () => void }
|
||
>();
|
||
const settledImageSources = new WeakMap<HTMLImageElement, string>();
|
||
const settledMediaSources = new WeakMap<HTMLMediaElement, string>();
|
||
const loadedBackgroundUrls = new Set<string>();
|
||
const pendingBackgroundPreloads = new Map<string, () => void>();
|
||
|
||
const cancelReadySchedule = () => {
|
||
if (readyIdleTimeoutId !== null) {
|
||
window.clearTimeout(readyIdleTimeoutId);
|
||
readyIdleTimeoutId = null;
|
||
}
|
||
if (readyFrameCleanup) {
|
||
readyFrameCleanup();
|
||
readyFrameCleanup = null;
|
||
}
|
||
};
|
||
const cancelPendingRecheck = () => {
|
||
if (pendingRecheckTimeoutId !== null) {
|
||
window.clearTimeout(pendingRecheckTimeoutId);
|
||
pendingRecheckTimeoutId = null;
|
||
}
|
||
};
|
||
const finish = (value: boolean) => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
settled = true;
|
||
cancelReadySchedule();
|
||
cancelPendingRecheck();
|
||
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
|
||
if (scanFrameId !== null) {
|
||
window.cancelAnimationFrame(scanFrameId);
|
||
}
|
||
resolve(value);
|
||
};
|
||
const abort = () => finish(false);
|
||
signal.addEventListener('abort', abort, { once: true });
|
||
cleanupCallbacks.push(() => signal.removeEventListener('abort', abort));
|
||
cleanupCallbacks.push(() => {
|
||
pendingImageListeners.forEach(({ cleanup }) => cleanup());
|
||
pendingImageListeners.clear();
|
||
pendingMediaListeners.forEach(({ cleanup }) => cleanup());
|
||
pendingMediaListeners.clear();
|
||
pendingBackgroundPreloads.forEach((cleanup) => cleanup());
|
||
pendingBackgroundPreloads.clear();
|
||
});
|
||
|
||
const scheduleScan = () => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
cancelReadySchedule();
|
||
cancelPendingRecheck();
|
||
if (scanFrameId !== null) {
|
||
return;
|
||
}
|
||
scanFrameId = window.requestAnimationFrame(() => {
|
||
scanFrameId = null;
|
||
scanResources();
|
||
});
|
||
};
|
||
|
||
const scheduleReady = () => {
|
||
cancelReadySchedule();
|
||
readyIdleTimeoutId = window.setTimeout(() => {
|
||
readyIdleTimeoutId = null;
|
||
readyFrameCleanup = scheduleRecommendRuntimeReady(signal, () =>
|
||
finish(true),
|
||
);
|
||
if (readyFrameCleanup === null) {
|
||
finish(false);
|
||
}
|
||
}, RECOMMEND_RUNTIME_RESOURCE_IDLE_MS);
|
||
};
|
||
|
||
const preloadBackgroundUrl = (url: string) => {
|
||
if (loadedBackgroundUrls.has(url) || pendingBackgroundPreloads.has(url)) {
|
||
return;
|
||
}
|
||
if (typeof Image === 'undefined') {
|
||
loadedBackgroundUrls.add(url);
|
||
return;
|
||
}
|
||
|
||
const image = new Image();
|
||
const cleanup = () => {
|
||
image.onload = null;
|
||
image.onerror = null;
|
||
};
|
||
const markReady = () => {
|
||
cleanup();
|
||
pendingBackgroundPreloads.delete(url);
|
||
loadedBackgroundUrls.add(url);
|
||
scheduleScan();
|
||
};
|
||
image.decoding = 'async';
|
||
image.onload = markReady;
|
||
image.onerror = markReady;
|
||
pendingBackgroundPreloads.set(url, cleanup);
|
||
image.src = url;
|
||
if (image.complete) {
|
||
markReady();
|
||
}
|
||
};
|
||
|
||
function scanResources() {
|
||
if (signal.aborted) {
|
||
finish(false);
|
||
return;
|
||
}
|
||
|
||
const currentImages = new Set(
|
||
Array.from(runtimeRoot.querySelectorAll<HTMLImageElement>('img')),
|
||
);
|
||
const currentMedia = new Set(
|
||
Array.from(
|
||
runtimeRoot.querySelectorAll<HTMLMediaElement>('audio,video'),
|
||
),
|
||
);
|
||
pendingImageListeners.forEach((entry, image) => {
|
||
const currentSrc = getRecommendRuntimeImageSource(image);
|
||
if (
|
||
!currentImages.has(image) ||
|
||
currentSrc !== entry.src ||
|
||
!currentSrc
|
||
) {
|
||
entry.cleanup();
|
||
pendingImageListeners.delete(image);
|
||
}
|
||
});
|
||
pendingMediaListeners.forEach((entry, media) => {
|
||
const currentSrc = getRecommendRuntimeMediaSource(media);
|
||
if (
|
||
!currentMedia.has(media) ||
|
||
currentSrc !== entry.src ||
|
||
!currentSrc
|
||
) {
|
||
entry.cleanup();
|
||
pendingMediaListeners.delete(media);
|
||
}
|
||
});
|
||
|
||
currentImages.forEach((image) => {
|
||
const imageSrc = getRecommendRuntimeImageSource(image);
|
||
const settledImageSrc = settledImageSources.get(image);
|
||
if (settledImageSrc && settledImageSrc !== imageSrc) {
|
||
settledImageSources.delete(image);
|
||
}
|
||
if (!imageSrc || image.complete) {
|
||
if (imageSrc && image.complete) {
|
||
settledImageSources.set(image, imageSrc);
|
||
}
|
||
return;
|
||
}
|
||
if (settledImageSources.get(image) === imageSrc) {
|
||
return;
|
||
}
|
||
|
||
const existingEntry = pendingImageListeners.get(image);
|
||
if (existingEntry?.src === imageSrc) {
|
||
return;
|
||
}
|
||
existingEntry?.cleanup();
|
||
const markImageReady = () => {
|
||
const activeEntry = pendingImageListeners.get(image);
|
||
if (activeEntry?.src !== imageSrc) {
|
||
return;
|
||
}
|
||
settledImageSources.set(image, imageSrc);
|
||
activeEntry.cleanup();
|
||
pendingImageListeners.delete(image);
|
||
scheduleScan();
|
||
};
|
||
const cleanupImageListeners = () => {
|
||
image.removeEventListener('load', markImageReady);
|
||
image.removeEventListener('error', markImageReady);
|
||
};
|
||
image.addEventListener('load', markImageReady, { once: true });
|
||
image.addEventListener('error', markImageReady, { once: true });
|
||
pendingImageListeners.set(image, {
|
||
src: imageSrc,
|
||
cleanup: cleanupImageListeners,
|
||
});
|
||
|
||
if (image.complete) {
|
||
markImageReady();
|
||
}
|
||
});
|
||
|
||
currentMedia.forEach((media) => {
|
||
const mediaSrc = getRecommendRuntimeMediaSource(media);
|
||
const settledMediaSrc = settledMediaSources.get(media);
|
||
const mediaReadyThreshold =
|
||
typeof HTMLMediaElement !== 'undefined'
|
||
? HTMLMediaElement.HAVE_CURRENT_DATA
|
||
: 2;
|
||
if (settledMediaSrc && settledMediaSrc !== mediaSrc) {
|
||
settledMediaSources.delete(media);
|
||
}
|
||
if (
|
||
!mediaSrc ||
|
||
media.readyState >= mediaReadyThreshold ||
|
||
media.error
|
||
) {
|
||
if (mediaSrc && media.readyState >= mediaReadyThreshold) {
|
||
settledMediaSources.set(media, mediaSrc);
|
||
}
|
||
return;
|
||
}
|
||
if (settledMediaSources.get(media) === mediaSrc) {
|
||
return;
|
||
}
|
||
|
||
const existingEntry = pendingMediaListeners.get(media);
|
||
if (existingEntry?.src === mediaSrc) {
|
||
return;
|
||
}
|
||
existingEntry?.cleanup();
|
||
const markMediaReady = () => {
|
||
const activeEntry = pendingMediaListeners.get(media);
|
||
if (activeEntry?.src !== mediaSrc) {
|
||
return;
|
||
}
|
||
settledMediaSources.set(media, mediaSrc);
|
||
activeEntry.cleanup();
|
||
pendingMediaListeners.delete(media);
|
||
scheduleScan();
|
||
};
|
||
const cleanupMediaListeners = () => {
|
||
media.removeEventListener('loadeddata', markMediaReady);
|
||
media.removeEventListener('canplaythrough', markMediaReady);
|
||
media.removeEventListener('error', markMediaReady);
|
||
};
|
||
media.addEventListener('loadeddata', markMediaReady, { once: true });
|
||
media.addEventListener('canplaythrough', markMediaReady, {
|
||
once: true,
|
||
});
|
||
media.addEventListener('error', markMediaReady, { once: true });
|
||
pendingMediaListeners.set(media, {
|
||
src: mediaSrc,
|
||
cleanup: cleanupMediaListeners,
|
||
});
|
||
|
||
if (media.readyState >= mediaReadyThreshold || media.error) {
|
||
markMediaReady();
|
||
}
|
||
});
|
||
|
||
const currentBackgroundUrls =
|
||
collectRecommendRuntimeBackgroundUrls(runtimeRoot);
|
||
pendingBackgroundPreloads.forEach((cleanup, url) => {
|
||
if (!currentBackgroundUrls.has(url)) {
|
||
cleanup();
|
||
pendingBackgroundPreloads.delete(url);
|
||
}
|
||
});
|
||
currentBackgroundUrls.forEach((url) => preloadBackgroundUrl(url));
|
||
|
||
const hasPendingResourceMarker = Boolean(
|
||
runtimeRoot.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR),
|
||
);
|
||
if (
|
||
hasPendingResourceMarker ||
|
||
pendingImageListeners.size > 0 ||
|
||
pendingMediaListeners.size > 0 ||
|
||
pendingBackgroundPreloads.size > 0
|
||
) {
|
||
cancelReadySchedule();
|
||
if (pendingRecheckTimeoutId === null) {
|
||
pendingRecheckTimeoutId = window.setTimeout(() => {
|
||
pendingRecheckTimeoutId = null;
|
||
scheduleScan();
|
||
}, RECOMMEND_RUNTIME_RESOURCE_IDLE_MS);
|
||
}
|
||
return;
|
||
}
|
||
|
||
cancelPendingRecheck();
|
||
scheduleReady();
|
||
}
|
||
|
||
if (typeof MutationObserver !== 'undefined') {
|
||
const observer = new MutationObserver(scheduleScan);
|
||
observer.observe(runtimeRoot, {
|
||
childList: true,
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: [
|
||
'src',
|
||
'srcset',
|
||
'style',
|
||
'data-runtime-resource-pending',
|
||
],
|
||
});
|
||
cleanupCallbacks.push(() => observer.disconnect());
|
||
}
|
||
|
||
scanResources();
|
||
});
|
||
}
|
||
type DiscoverChannel =
|
||
| 'recommend'
|
||
| 'today'
|
||
| 'category'
|
||
| 'ranking'
|
||
| 'edutainment';
|
||
|
||
const DISCOVER_CHANNELS: Array<{
|
||
id: DiscoverChannel;
|
||
label: string;
|
||
}> = [
|
||
{ id: 'recommend', label: '推荐' },
|
||
{ id: 'today', label: '今日' },
|
||
{ id: 'category', label: '分类' },
|
||
{ id: 'ranking', label: '排行' },
|
||
];
|
||
const EDUTAINMENT_DISCOVER_CHANNEL = {
|
||
id: 'edutainment',
|
||
label: EDUTAINMENT_WORK_TAG,
|
||
} as const;
|
||
const BABY_LOVE_DRAWING_DEFAULT_CARD = {
|
||
title: '宝贝爱画',
|
||
subtitle: '空白画板',
|
||
summary: '挥动小手画一张画。',
|
||
};
|
||
const CHILD_MOTION_DEMO_DEFAULT_CARD = {
|
||
title: '热身关卡',
|
||
subtitle: '动作识别热身',
|
||
summary: '站位、招手和左右手活动。',
|
||
};
|
||
|
||
function ResolvedAssetBackdrop({
|
||
src,
|
||
fallbackSrc,
|
||
alt,
|
||
className,
|
||
ariaHidden = false,
|
||
}: {
|
||
src?: string | null;
|
||
fallbackSrc?: string | null;
|
||
alt: string;
|
||
className: string;
|
||
ariaHidden?: boolean;
|
||
}) {
|
||
return (
|
||
<ResolvedAssetImage
|
||
src={src}
|
||
fallbackSrc={fallbackSrc}
|
||
alt={alt}
|
||
aria-hidden={ariaHidden}
|
||
className={className}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function PlatformWorkCoverArtwork({
|
||
entry,
|
||
imageSrc,
|
||
fallbackSrc,
|
||
alt,
|
||
className,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
imageSrc?: string | null;
|
||
fallbackSrc?: string | null;
|
||
alt: string;
|
||
className: string;
|
||
}) {
|
||
if (isBarkBattleGalleryEntry(entry)) {
|
||
return (
|
||
<CustomWorldCoverArtwork
|
||
imageSrc={imageSrc}
|
||
fallbackImageSrc={fallbackSrc}
|
||
title={entry.worldName}
|
||
fallbackLabel="封面"
|
||
renderMode={entry.coverRenderMode}
|
||
characterImageSrcs={entry.coverCharacterImageSrcs}
|
||
className={className}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ResolvedAssetBackdrop
|
||
src={imageSrc}
|
||
fallbackSrc={fallbackSrc}
|
||
alt={alt}
|
||
className={className}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||
return (
|
||
<div className="mb-3">
|
||
<div className="text-[10px] font-semibold tracking-[0.26em] text-[var(--platform-text-soft)]">
|
||
{detail}
|
||
</div>
|
||
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||
{title}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PublicCodeSearchBar({
|
||
value,
|
||
onChange,
|
||
onSubmit,
|
||
isSearching,
|
||
className,
|
||
}: {
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
onSubmit: () => void;
|
||
isSearching: boolean;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={`platform-desktop-search flex min-w-0 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)] ${className ?? ''}`}
|
||
>
|
||
<Search className="h-4 w-4 shrink-0" />
|
||
<input
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
onSubmit();
|
||
}
|
||
}}
|
||
placeholder="搜索作品号、名称、作者、描述"
|
||
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={!value.trim() || isSearching}
|
||
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
|
||
>
|
||
{isSearching ? '搜索中' : '搜索'}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TopbarWalletShortcutButton({
|
||
variant,
|
||
balanceLabel,
|
||
onClick,
|
||
}: {
|
||
variant: 'mobile' | 'desktop';
|
||
balanceLabel: string;
|
||
onClick: () => void;
|
||
}) {
|
||
const isMobile = variant === 'mobile';
|
||
|
||
return (
|
||
<PlatformActionButton
|
||
tone="accentSoft"
|
||
shape="pill"
|
||
size="xs"
|
||
onClick={onClick}
|
||
aria-label={balanceLabel}
|
||
className={[
|
||
'shrink-0 [--platform-action-accent:#b65f2c]',
|
||
isMobile
|
||
? 'platform-mobile-create-wallet-chip !gap-1.5 !px-2.5 !py-1.5'
|
||
: 'platform-desktop-create-wallet-chip platform-desktop-search !px-3 !py-2.5',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')}
|
||
>
|
||
<PlatformIconBadge
|
||
icon={<Coins className="h-3.5 w-3.5" />}
|
||
size="xs"
|
||
tone="neutral"
|
||
className={
|
||
isMobile
|
||
? '!h-6 !w-6 !bg-[#ffe0ab] !text-[#cf7b34]'
|
||
: '!bg-[#ffe0ab] !text-[#cf7b34]'
|
||
}
|
||
/>
|
||
<span>{balanceLabel}</span>
|
||
</PlatformActionButton>
|
||
);
|
||
}
|
||
|
||
function WorldCard({
|
||
entry,
|
||
onClick,
|
||
className,
|
||
authorAvatarUrl,
|
||
authorSummary,
|
||
feedCardKey,
|
||
enableCoverCarousel = false,
|
||
isCoverCarouselActive = false,
|
||
variant = 'standard',
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
onClick: () => void;
|
||
className?: string;
|
||
authorAvatarUrl?: string | null;
|
||
authorSummary?: PublicUserSummary | null;
|
||
feedCardKey?: string;
|
||
enableCoverCarousel?: boolean;
|
||
isCoverCarouselActive?: boolean;
|
||
variant?: 'standard' | 'immersive';
|
||
}) {
|
||
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
|
||
const fallbackAssetCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
|
||
const coverSlides = useMemo(() => {
|
||
if (!enableCoverCarousel) {
|
||
return fallbackCoverImage
|
||
? [
|
||
{
|
||
id: 'cover',
|
||
imageSrc: fallbackCoverImage,
|
||
label: entry.worldName,
|
||
},
|
||
]
|
||
: [];
|
||
}
|
||
|
||
return resolvePlatformWorldCoverSlides(entry);
|
||
}, [enableCoverCarousel, entry, fallbackCoverImage]);
|
||
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
|
||
const visibleCoverIndex = isCoverCarouselActive ? activeCoverIndex : 0;
|
||
const activeCoverSlide =
|
||
coverSlides[visibleCoverIndex] ?? coverSlides[0] ?? null;
|
||
const coverImage = activeCoverSlide?.imageSrc ?? '';
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const tags = buildPlatformWorldDisplayTags(entry, 3);
|
||
const playCount = getPlatformWorldPlayCount(entry);
|
||
const remixCount = getPlatformWorldRemixCount(entry);
|
||
const likeCount = getPlatformWorldLikeCount(entry);
|
||
const typeLabel = describePlatformPublicWorkKind(entry);
|
||
const authorName = resolvePlatformWorkAuthorDisplayName(entry, authorSummary);
|
||
const authorAvatarLabel = formatPlatformPublicAuthorAvatarLabel(authorName);
|
||
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
|
||
const cardLabel = `${entry.worldName},${typeLabel},${formatPlatformCompactCount(playCount)}游玩,${formatPlatformCompactCount(remixCount)}改造,${formatPlatformCompactCount(likeCount)}点赞`;
|
||
const coverStats = [
|
||
{
|
||
label: '游玩',
|
||
value: playCount,
|
||
icon: Gamepad2,
|
||
},
|
||
{
|
||
label: '改造',
|
||
value: remixCount,
|
||
icon: Pencil,
|
||
},
|
||
{
|
||
label: '点赞',
|
||
value: likeCount,
|
||
icon: Heart,
|
||
},
|
||
];
|
||
|
||
useEffect(() => {
|
||
setActiveCoverIndex(0);
|
||
}, [entry.ownerUserId, entry.profileId, coverSlides.length]);
|
||
|
||
useEffect(() => {
|
||
if (!isCoverCarouselActive) {
|
||
setActiveCoverIndex(0);
|
||
}
|
||
}, [isCoverCarouselActive]);
|
||
|
||
useEffect(() => {
|
||
if (!isCoverCarouselActive || coverSlides.length <= 1) {
|
||
return undefined;
|
||
}
|
||
|
||
const timerId = window.setInterval(() => {
|
||
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
|
||
}, PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS);
|
||
|
||
return () => {
|
||
window.clearInterval(timerId);
|
||
};
|
||
}, [coverSlides.length, isCoverCarouselActive]);
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
aria-label={cardLabel}
|
||
data-mobile-feed-card-key={feedCardKey}
|
||
className={`platform-public-work-card ${variant === 'immersive' ? 'platform-public-work-card--immersive' : ''} platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
|
||
>
|
||
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
|
||
{coverImage ? (
|
||
<PlatformWorkCoverArtwork
|
||
entry={entry}
|
||
imageSrc={coverImage}
|
||
fallbackSrc={fallbackAssetCoverImage}
|
||
alt={entry.worldName}
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||
)}
|
||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.02),rgba(0,0,0,0.18))]" />
|
||
<div className="platform-public-work-card__cover-stats">
|
||
{coverStats.map(({ label, value, icon: Icon }) => (
|
||
<span key={label} className="platform-public-work-card__cover-stat">
|
||
<Icon
|
||
className={`h-3.5 w-3.5 ${label === '点赞' ? 'fill-current' : ''}`}
|
||
aria-hidden="true"
|
||
/>
|
||
<span>{formatPlatformCompactCount(value)}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="platform-public-work-card__body flex min-h-[7.25rem] flex-col gap-2 px-3.5 py-3">
|
||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
|
||
{displayName}
|
||
</div>
|
||
<div className="mt-1 flex min-w-0 items-center gap-1.5">
|
||
<span
|
||
aria-hidden="true"
|
||
className="platform-public-work-card__author-avatar"
|
||
>
|
||
{normalizedAuthorAvatarUrl ? (
|
||
<img
|
||
src={normalizedAuthorAvatarUrl}
|
||
alt=""
|
||
className="platform-public-work-card__author-avatar-image"
|
||
/>
|
||
) : (
|
||
authorAvatarLabel
|
||
)}
|
||
</span>
|
||
<span className="line-clamp-1 break-words text-[11px] font-semibold text-[var(--platform-text-soft)]">
|
||
{authorName}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<span className="platform-public-work-card__kind shrink-0">
|
||
{typeLabel}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="line-clamp-2 break-words text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)]">
|
||
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
|
||
</div>
|
||
|
||
<div className="mt-auto flex min-w-0 flex-wrap gap-1.5">
|
||
{tags.length > 0 ? (
|
||
tags.map((tag, index) => (
|
||
<PlatformPillBadge
|
||
key={`world-tag-${index}-${tag || 'empty'}`}
|
||
tone="neutral"
|
||
size="xxs"
|
||
className="max-w-full px-2.5 tracking-[0.18em]"
|
||
>
|
||
<span className="truncate">{tag}</span>
|
||
</PlatformPillBadge>
|
||
))
|
||
) : (
|
||
<PlatformPillBadge
|
||
tone="neutral"
|
||
size="xxs"
|
||
className="px-2.5 tracking-[0.18em]"
|
||
>
|
||
{describePlatformPublicWorkKind(entry)}
|
||
</PlatformPillBadge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function CreationLibraryCard({
|
||
entry,
|
||
onClick,
|
||
onDelete,
|
||
isDeleting = false,
|
||
}: {
|
||
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
|
||
onClick: () => void;
|
||
onDelete?: () => void;
|
||
isDeleting?: boolean;
|
||
}) {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||
const statusLabel = entry.visibility === 'published' ? '已发布' : '草稿';
|
||
const metaLabel =
|
||
entry.visibility === 'published'
|
||
? formatPlatformWorldTime(entry.publishedAt)
|
||
: '仅自己可见';
|
||
const primaryTag =
|
||
buildPlatformWorldDisplayTags(entry, 1)[0] ??
|
||
formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const summaryText =
|
||
entry.summaryText || entry.subtitle || '继续补完这个世界的设定与游玩入口。';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="platform-surface platform-interactive-card relative flex min-h-[13rem] w-full min-w-0 flex-col overflow-hidden px-3 py-3 text-left sm:min-h-[14rem] sm:px-3.5 sm:py-3.5"
|
||
>
|
||
{coverImage ? (
|
||
<ResolvedAssetBackdrop
|
||
src={coverImage}
|
||
alt={entry.worldName}
|
||
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||
/>
|
||
) : null}
|
||
{leadPortrait ? (
|
||
<ResolvedAssetImage
|
||
src={leadPortrait}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="absolute bottom-1.5 right-1.5 h-16 w-16 object-contain opacity-24 sm:h-20 sm:w-20"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||
{onDelete ? (
|
||
<PlatformActionButton
|
||
tone="danger"
|
||
size="xs"
|
||
shape="pill"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onDelete();
|
||
}}
|
||
disabled={isDeleting}
|
||
className="absolute right-2 top-2 z-20 min-h-0 px-2.5 py-1 text-[10px] disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{isDeleting ? '删除中' : '删除'}
|
||
</PlatformActionButton>
|
||
) : null}
|
||
<div className="relative z-10 flex h-full min-w-0 flex-col">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||
<PlatformPillBadge
|
||
tone={
|
||
entry.visibility === 'published' ? 'darkEmerald' : 'darkAmber'
|
||
}
|
||
size="xxs"
|
||
className="px-2 font-semibold tracking-[0.12em]"
|
||
>
|
||
{statusLabel}
|
||
</PlatformPillBadge>
|
||
<PlatformPillBadge
|
||
tone="darkNeutral"
|
||
size="xxs"
|
||
className="min-w-0 max-w-full px-2 font-medium text-[var(--platform-text-base)]"
|
||
>
|
||
<span className="truncate">{metaLabel}</span>
|
||
</PlatformPillBadge>
|
||
</div>
|
||
|
||
<div className="mt-auto min-w-0">
|
||
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-[var(--platform-text-strong)] sm:text-[1.12rem]">
|
||
{displayName}
|
||
</div>
|
||
{entry.subtitle ? (
|
||
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
|
||
{entry.subtitle}
|
||
</div>
|
||
) : null}
|
||
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)] sm:text-xs">
|
||
{summaryText}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||
<PlatformPillBadge
|
||
tone="darkNeutral"
|
||
size="xxs"
|
||
className="min-w-0 max-w-full px-2 font-semibold tracking-[0.1em] text-[color:color-mix(in_srgb,var(--platform-text-strong)_90%,transparent)]"
|
||
>
|
||
<span className="truncate">{primaryTag}</span>
|
||
</PlatformPillBadge>
|
||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-[var(--platform-text-base)]">
|
||
<span>
|
||
{entry.visibility === 'published' ? '进入世界' : '继续创作'}
|
||
</span>
|
||
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function RecommendRuntimePreviewCard({
|
||
entry,
|
||
position,
|
||
resolvedCoverUrls,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
position?: 'previous' | 'next' | 'cover';
|
||
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
|
||
}) {
|
||
const rawCoverImage = resolveRecommendCardCoverImage(entry);
|
||
const rawFallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
|
||
const resolvedCoverImage = resolveRecommendDisplayCoverImage(
|
||
rawCoverImage,
|
||
rawFallbackCoverImage,
|
||
resolvedCoverUrls,
|
||
);
|
||
const fallbackCoverImage =
|
||
resolvedCoverUrls?.get(rawFallbackCoverImage) ?? rawFallbackCoverImage;
|
||
const previewKey = `${buildPublicGalleryCardKey(entry)}:${position ?? 'preview'}`;
|
||
const shouldLockCoverImage = position === 'cover';
|
||
const [lockedCoverImage, setLockedCoverImage] = useState({
|
||
key: previewKey,
|
||
imageSrc: resolvedCoverImage,
|
||
fallbackSrc: fallbackCoverImage,
|
||
});
|
||
|
||
useEffect(() => {
|
||
setLockedCoverImage((currentValue) => {
|
||
if (shouldLockCoverImage) {
|
||
return currentValue;
|
||
}
|
||
return {
|
||
key: previewKey,
|
||
imageSrc: resolvedCoverImage,
|
||
fallbackSrc: fallbackCoverImage,
|
||
};
|
||
});
|
||
}, [
|
||
fallbackCoverImage,
|
||
previewKey,
|
||
resolvedCoverImage,
|
||
shouldLockCoverImage,
|
||
]);
|
||
|
||
const coverImage = shouldLockCoverImage
|
||
? lockedCoverImage.imageSrc
|
||
: resolvedCoverImage;
|
||
const displayFallbackCoverImage = shouldLockCoverImage
|
||
? lockedCoverImage.imageSrc
|
||
: fallbackCoverImage;
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const typeLabel = describePlatformPublicWorkKind(entry);
|
||
const previewClassName = `platform-recommend-runtime-preview ${
|
||
position === 'cover' ? 'platform-recommend-runtime-preview--cover' : ''
|
||
}`;
|
||
|
||
return (
|
||
<div
|
||
className={previewClassName}
|
||
aria-hidden="true"
|
||
data-preview-position={position}
|
||
>
|
||
{coverImage ? (
|
||
<PlatformWorkCoverArtwork
|
||
entry={entry}
|
||
imageSrc={coverImage}
|
||
fallbackSrc={displayFallbackCoverImage}
|
||
alt=""
|
||
className="absolute inset-0 h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||
)}
|
||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.08),rgba(0,0,0,0.42))]" />
|
||
<div className="platform-recommend-runtime-preview__body">
|
||
<span className="platform-public-work-card__kind">{typeLabel}</span>
|
||
<span className="platform-recommend-runtime-preview__title">
|
||
{displayName}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RecommendRuntimeCover({
|
||
entry,
|
||
className = '',
|
||
resolvedCoverUrls,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
className?: string;
|
||
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={`platform-recommend-runtime-cover ${className}`}
|
||
aria-hidden="true"
|
||
>
|
||
<RecommendRuntimePreviewCard
|
||
key={buildPublicGalleryCardKey(entry)}
|
||
entry={entry}
|
||
position="cover"
|
||
resolvedCoverUrls={resolvedCoverUrls}
|
||
/>
|
||
<div className="platform-recommend-runtime-loading" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RecommendRuntimeReadyProbe({
|
||
rootRef,
|
||
onReady,
|
||
}: {
|
||
rootRef: RefObject<HTMLDivElement | null>;
|
||
onReady: () => void;
|
||
}) {
|
||
useEffect(() => {
|
||
const abortController = new AbortController();
|
||
|
||
void readyRecommendRuntime(rootRef.current, abortController.signal).then(
|
||
(ready) => {
|
||
if (ready) {
|
||
onReady();
|
||
}
|
||
},
|
||
);
|
||
|
||
return () => abortController.abort();
|
||
}, [onReady, rootRef]);
|
||
|
||
return null;
|
||
}
|
||
|
||
function RecommendRuntimeVisual({
|
||
entry,
|
||
runtimeContent,
|
||
isStarting,
|
||
isRuntimeReady,
|
||
resolvedCoverUrls,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
runtimeContent?: ReactNode;
|
||
isStarting: boolean;
|
||
isRuntimeReady: boolean;
|
||
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
|
||
}) {
|
||
const [isRuntimeMounted, setIsRuntimeMounted] = useState(false);
|
||
const [isCoverMinVisible, setIsCoverMinVisible] = useState(true);
|
||
const activeEntryKey = buildPublicGalleryCardKey(entry);
|
||
const previousEntryKeyRef = useRef(activeEntryKey);
|
||
const runtimeVisibilityRef = useRef({
|
||
hasRuntimeContent: Boolean(runtimeContent),
|
||
isRuntimeMounted: false,
|
||
isRuntimeReady,
|
||
isStarting,
|
||
});
|
||
const runtimeViewportRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
runtimeVisibilityRef.current = {
|
||
hasRuntimeContent: Boolean(runtimeContent),
|
||
isRuntimeMounted,
|
||
isRuntimeReady,
|
||
isStarting,
|
||
};
|
||
}, [isRuntimeMounted, isRuntimeReady, isStarting, runtimeContent]);
|
||
|
||
useEffect(() => {
|
||
const currentRuntimeVisibility = runtimeVisibilityRef.current;
|
||
if (
|
||
previousEntryKeyRef.current !== activeEntryKey &&
|
||
currentRuntimeVisibility.hasRuntimeContent &&
|
||
currentRuntimeVisibility.isRuntimeMounted &&
|
||
currentRuntimeVisibility.isRuntimeReady &&
|
||
!currentRuntimeVisibility.isStarting
|
||
) {
|
||
setIsCoverMinVisible(false);
|
||
return undefined;
|
||
}
|
||
|
||
setIsCoverMinVisible(true);
|
||
const timeoutId = window.setTimeout(() => {
|
||
setIsCoverMinVisible(false);
|
||
}, RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS);
|
||
|
||
return () => window.clearTimeout(timeoutId);
|
||
}, [activeEntryKey]);
|
||
|
||
useEffect(() => {
|
||
if (previousEntryKeyRef.current === activeEntryKey) {
|
||
return;
|
||
}
|
||
previousEntryKeyRef.current = activeEntryKey;
|
||
setIsRuntimeMounted((currentValue) => {
|
||
// 中文注释:拼图推荐流“下一关”会在同一个 run 内切到相似作品;
|
||
// 此时只更新作品信息和分享基准,不应重显封面造成运行态闪跳。
|
||
if (currentValue && !isStarting && isRuntimeReady) {
|
||
return currentValue;
|
||
}
|
||
return false;
|
||
});
|
||
}, [activeEntryKey, isRuntimeReady, isStarting]);
|
||
|
||
const handleRuntimeReady = useCallback(() => {
|
||
if (!isStarting && isRuntimeReady) {
|
||
setIsRuntimeMounted(true);
|
||
}
|
||
}, [isRuntimeReady, isStarting]);
|
||
|
||
const shouldShowCover =
|
||
isCoverMinVisible ||
|
||
!runtimeContent ||
|
||
isStarting ||
|
||
!isRuntimeReady ||
|
||
!isRuntimeMounted;
|
||
|
||
return (
|
||
<div className="platform-recommend-runtime-visual">
|
||
{runtimeContent ? (
|
||
<Suspense fallback={null}>
|
||
<div
|
||
ref={runtimeViewportRef}
|
||
className="platform-recommend-runtime-viewport"
|
||
aria-hidden={shouldShowCover}
|
||
>
|
||
{runtimeContent}
|
||
<RecommendRuntimeReadyProbe
|
||
key={activeEntryKey}
|
||
rootRef={runtimeViewportRef}
|
||
onReady={handleRuntimeReady}
|
||
/>
|
||
</div>
|
||
</Suspense>
|
||
) : null}
|
||
<RecommendRuntimeCover
|
||
entry={entry}
|
||
resolvedCoverUrls={resolvedCoverUrls}
|
||
className={
|
||
shouldShowCover ? '' : 'platform-recommend-runtime-cover--hidden'
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RecommendSwipeCard({
|
||
entry,
|
||
authorAvatarUrl,
|
||
authorSummary,
|
||
isActive,
|
||
visual,
|
||
onDragPointerDown,
|
||
onDragPointerMove,
|
||
onDragPointerUp,
|
||
onDragPointerCancel,
|
||
onLike,
|
||
onShare,
|
||
onRemix,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
authorAvatarUrl?: string | null;
|
||
authorSummary?: PublicUserSummary | null;
|
||
isActive: boolean;
|
||
visual: ReactNode;
|
||
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
|
||
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
|
||
onLike?: () => void;
|
||
onShare?: () => void;
|
||
onRemix?: () => void;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={`platform-recommend-swipe-card ${isActive ? 'platform-recommend-swipe-card--active' : 'platform-recommend-swipe-card--preview'}`}
|
||
data-active={isActive ? 'true' : 'false'}
|
||
>
|
||
<div className="platform-recommend-swipe-card__visual">{visual}</div>
|
||
<div
|
||
className="platform-recommend-swipe-card__meta"
|
||
data-recommend-swipe-zone={isActive ? 'true' : 'false'}
|
||
>
|
||
<RecommendRuntimeMeta
|
||
entry={entry}
|
||
authorAvatarUrl={authorAvatarUrl}
|
||
authorSummary={authorSummary}
|
||
isActive={isActive}
|
||
onDragPointerDown={onDragPointerDown}
|
||
onDragPointerMove={onDragPointerMove}
|
||
onDragPointerUp={onDragPointerUp}
|
||
onDragPointerCancel={onDragPointerCancel}
|
||
onLike={onLike}
|
||
onShare={onShare}
|
||
onRemix={onRemix}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RecommendRuntimeMeta({
|
||
entry,
|
||
authorAvatarUrl,
|
||
authorSummary,
|
||
onDragPointerDown,
|
||
onDragPointerMove,
|
||
onDragPointerUp,
|
||
onDragPointerCancel,
|
||
onLike,
|
||
onShare,
|
||
onRemix,
|
||
isActive = true,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
authorAvatarUrl?: string | null;
|
||
authorSummary?: PublicUserSummary | null;
|
||
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
|
||
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
|
||
onLike?: () => void;
|
||
onShare?: () => void;
|
||
onRemix?: () => void;
|
||
isActive?: boolean;
|
||
}) {
|
||
const likeCount = getPlatformWorldLikeCount(entry);
|
||
const remixCount = getPlatformWorldRemixCount(entry);
|
||
const authorName = resolvePlatformWorkAuthorDisplayName(entry, authorSummary);
|
||
const authorAvatarLabel = formatPlatformPublicAuthorAvatarLabel(authorName);
|
||
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const stopActionPointer = (event: PointerEvent<HTMLButtonElement>) => {
|
||
event.stopPropagation();
|
||
};
|
||
|
||
return (
|
||
<section
|
||
className={`platform-recommend-work-meta ${
|
||
isActive
|
||
? 'platform-recommend-work-meta--active'
|
||
: 'platform-recommend-work-meta--preview'
|
||
}`}
|
||
aria-label={`${entry.worldName} 作品信息`}
|
||
aria-hidden={!isActive}
|
||
data-active={isActive ? 'true' : 'false'}
|
||
onPointerDown={(event) => {
|
||
onDragPointerDown?.(event);
|
||
}}
|
||
onPointerMove={onDragPointerMove}
|
||
onPointerUp={onDragPointerUp}
|
||
onPointerCancel={onDragPointerCancel}
|
||
>
|
||
<div className="platform-recommend-work-meta__row">
|
||
<div className="platform-recommend-work-meta__identity">
|
||
<span
|
||
className="platform-recommend-work-meta__avatar"
|
||
aria-hidden="true"
|
||
>
|
||
{normalizedAuthorAvatarUrl ? (
|
||
<img
|
||
src={normalizedAuthorAvatarUrl}
|
||
alt=""
|
||
className="h-full w-full rounded-full object-cover"
|
||
/>
|
||
) : (
|
||
authorAvatarLabel
|
||
)}
|
||
</span>
|
||
<span className="platform-recommend-work-meta__text">
|
||
<span className="platform-recommend-work-meta__author">
|
||
{authorName}
|
||
</span>
|
||
<span className="platform-recommend-work-meta__title">
|
||
{displayName}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div className="platform-recommend-work-meta__actions">
|
||
<PlatformIconButton
|
||
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--like"
|
||
label={`点赞 ${formatPlatformCompactCount(likeCount)}`}
|
||
title="点赞"
|
||
icon={<ThumbsUp className="h-5 w-5" aria-hidden="true" />}
|
||
onPointerDown={stopActionPointer}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onLike?.();
|
||
}}
|
||
disabled={!isActive || !onLike}
|
||
/>
|
||
<span
|
||
className="platform-recommend-work-meta__like-count"
|
||
aria-label={`${formatPlatformCompactCount(likeCount)} 个赞`}
|
||
>
|
||
{formatPlatformCompactCount(likeCount)}
|
||
</span>
|
||
<PlatformIconButton
|
||
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon"
|
||
label="分享"
|
||
title="分享"
|
||
icon={<Share2 className="h-5 w-5" aria-hidden="true" />}
|
||
onPointerDown={stopActionPointer}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onShare?.();
|
||
}}
|
||
disabled={!isActive || !onShare}
|
||
/>
|
||
<PlatformIconButton
|
||
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--remix"
|
||
label={`改造 ${formatPlatformCompactCount(remixCount)}`}
|
||
title="改造"
|
||
icon={<GitFork className="h-5 w-5" aria-hidden="true" />}
|
||
onPointerDown={stopActionPointer}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onRemix?.();
|
||
}}
|
||
disabled={!isActive || !onRemix}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function PlatformTabButton({
|
||
active,
|
||
label,
|
||
icon: Icon,
|
||
onClick,
|
||
emphasized = false,
|
||
showDot = false,
|
||
disabled = false,
|
||
}: {
|
||
active: boolean;
|
||
label: string;
|
||
icon: ComponentType<{ className?: string }>;
|
||
onClick: () => void;
|
||
emphasized?: boolean;
|
||
showDot?: boolean;
|
||
disabled?: boolean;
|
||
}) {
|
||
const ariaLabel = showDot ? `${label},有新草稿` : label;
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-label={ariaLabel}
|
||
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''} disabled:cursor-not-allowed disabled:opacity-55`}
|
||
>
|
||
<span className="platform-bottom-nav__button-content">
|
||
<span
|
||
className={`platform-bottom-nav__icon-shell ${emphasized ? 'platform-bottom-nav__primary-action' : ''}`}
|
||
>
|
||
<Icon className="platform-bottom-nav__icon" />
|
||
{showDot ? (
|
||
<span aria-hidden="true" className="platform-nav-unread-dot" />
|
||
) : null}
|
||
</span>
|
||
<span className="platform-bottom-nav__label">{label}</span>
|
||
{active ? (
|
||
<span
|
||
aria-hidden="true"
|
||
className="platform-bottom-nav__active-mark"
|
||
/>
|
||
) : null}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function DesktopTabButton({
|
||
active,
|
||
label,
|
||
icon: Icon,
|
||
onClick,
|
||
emphasized = false,
|
||
showDot = false,
|
||
disabled = false,
|
||
}: {
|
||
active: boolean;
|
||
label: string;
|
||
icon: ComponentType<{ className?: string }>;
|
||
onClick: () => void;
|
||
emphasized?: boolean;
|
||
showDot?: boolean;
|
||
disabled?: boolean;
|
||
}) {
|
||
const ariaLabel = showDot ? `${label},有新草稿` : label;
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-label={ariaLabel}
|
||
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''} disabled:cursor-not-allowed disabled:opacity-55`}
|
||
>
|
||
<span className="platform-desktop-rail__icon-shell">
|
||
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
|
||
{showDot ? (
|
||
<span aria-hidden="true" className="platform-nav-unread-dot" />
|
||
) : null}
|
||
</span>
|
||
<span className="platform-desktop-rail__label text-[11px] font-semibold tracking-[0.2em]">
|
||
{label}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlatformTabPanel({
|
||
tab,
|
||
activeTab,
|
||
children,
|
||
}: {
|
||
tab: PlatformHomeTab;
|
||
activeTab: PlatformHomeTab;
|
||
children: ReactNode;
|
||
}) {
|
||
const active = activeTab === tab;
|
||
|
||
// 主 Tab 保持挂载,只切换可见性,避免重页面卸载重建造成首帧闪烁。
|
||
return (
|
||
<section
|
||
id={`platform-tab-panel-${tab}`}
|
||
aria-hidden={!active}
|
||
className={`platform-tab-panel ${active ? 'platform-tab-panel--active' : 'platform-tab-panel--hidden'}`}
|
||
>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DesktopTrendingItem({
|
||
entry,
|
||
rank,
|
||
onClick,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
rank: number;
|
||
onClick: () => void;
|
||
}) {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const tags = buildPlatformWorldDisplayTags(entry, 2);
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="platform-desktop-trending-item flex items-center gap-4 px-4 py-4 text-left"
|
||
>
|
||
<div className="relative h-[5.5rem] w-[4.3rem] shrink-0 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-[rgba(255,255,255,0.66)]">
|
||
{coverImage ? (
|
||
<PlatformWorkCoverArtwork
|
||
entry={entry}
|
||
imageSrc={coverImage}
|
||
alt={entry.worldName}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.26))]" />
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
<span>{`${rank}`.padStart(2, '0')}</span>
|
||
<span className="truncate">
|
||
{describePlatformPublicWorkKind(entry)}
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
|
||
{displayName}
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_86%,transparent)]">
|
||
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{tags.length > 0 ? (
|
||
tags.map((tag, index) => (
|
||
<PlatformPillBadge
|
||
key={`${entry.profileId}-trend-tag-${index}-${tag}`}
|
||
tone="neutral"
|
||
size="xxs"
|
||
className="px-2.5 tracking-[0.18em]"
|
||
>
|
||
{tag}
|
||
</PlatformPillBadge>
|
||
))
|
||
) : (
|
||
<PlatformPillBadge
|
||
tone="neutral"
|
||
size="xxs"
|
||
className="px-2.5 tracking-[0.18em]"
|
||
>
|
||
{isBigFishGalleryEntry(entry)
|
||
? '大鱼'
|
||
: isPuzzleGalleryEntry(entry)
|
||
? '拼图'
|
||
: isEdutainmentGalleryEntry(entry)
|
||
? entry.templateName
|
||
: describePlatformPublicWorkKind(entry)}
|
||
</PlatformPillBadge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlatformRankingItem({
|
||
entry,
|
||
rank,
|
||
metric,
|
||
onClick,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
rank: number;
|
||
metric: PlatformRankingMetric;
|
||
onClick: () => void;
|
||
}) {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const tags = buildPlatformWorldDisplayTags(entry, 2);
|
||
const actionLabel =
|
||
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(entry)
|
||
? '试玩'
|
||
: '进入';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="platform-ranking-item w-full text-left"
|
||
>
|
||
<div className="platform-ranking-item__rank">{rank}</div>
|
||
<div className="platform-ranking-item__cover">
|
||
{coverImage ? (
|
||
<PlatformWorkCoverArtwork
|
||
entry={entry}
|
||
imageSrc={coverImage}
|
||
alt={entry.worldName}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="h-full w-full bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.24),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||
)}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
|
||
{displayName}
|
||
</div>
|
||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||
<span className="text-[var(--platform-warm-text)]">
|
||
{formatPlatformCompactCount(metric.value)}
|
||
</span>
|
||
<span>{metric.label}</span>
|
||
<span>·</span>
|
||
<span>{describePlatformPublicWorkKind(entry)}</span>
|
||
</div>
|
||
<div className="platform-ranking-item__tags">
|
||
{tags.map((tag, index) => (
|
||
<span key={`${entry.profileId}-ranking-tag-${index}-${tag}`}>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<span className="platform-ranking-item__action">{actionLabel}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlatformCategoryGameItem({
|
||
entry,
|
||
categoryTag,
|
||
onClick,
|
||
}: {
|
||
entry: PlatformPublicGalleryCard;
|
||
categoryTag: string;
|
||
onClick: () => void;
|
||
}) {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const tags = buildPlatformWorldDisplayTags(entry, 2);
|
||
const metric = getPlatformCategoryPrimaryMetric(entry);
|
||
const metaParts = [
|
||
describePlatformPublicWorkKind(entry),
|
||
...tags.filter((tag) => tag !== categoryTag),
|
||
].slice(0, 3);
|
||
const actionLabel =
|
||
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(entry)
|
||
? '试玩'
|
||
: '进入';
|
||
const summaryText =
|
||
entry.summaryText || entry.subtitle || `${displayName} 正在等待摘要。`;
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
aria-label={`${entry.worldName},${actionLabel}`}
|
||
className="platform-category-game-item"
|
||
>
|
||
<div className="platform-category-game-item__cover">
|
||
{coverImage ? (
|
||
<PlatformWorkCoverArtwork
|
||
entry={entry}
|
||
imageSrc={coverImage}
|
||
alt={entry.worldName}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="h-full w-full bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.26),transparent_32%),linear-gradient(135deg,rgba(255,118,117,0.44),rgba(89,164,255,0.36))]" />
|
||
)}
|
||
</div>
|
||
|
||
<div className="platform-category-game-item__body">
|
||
<div className="platform-category-game-item__title-row">
|
||
<span className="platform-category-game-item__title">
|
||
{displayName}
|
||
</span>
|
||
<span className="platform-category-game-item__badge">公开</span>
|
||
</div>
|
||
|
||
<div className="platform-category-game-item__meta">
|
||
<span className="platform-category-game-item__metric">
|
||
<Star className="h-3.5 w-3.5 fill-current" />
|
||
<span>{formatPlatformCompactCount(metric.value)}</span>
|
||
</span>
|
||
<span>{metric.label}</span>
|
||
{metaParts.length > 0 ? <span>{metaParts.join(' · ')}</span> : null}
|
||
</div>
|
||
|
||
<div className="platform-category-game-item__summary">
|
||
{summaryText}
|
||
</div>
|
||
</div>
|
||
|
||
<span className="platform-category-game-item__action">{actionLabel}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlatformCategoryFilterDialog({
|
||
kindFilter,
|
||
sortMode,
|
||
resultCount,
|
||
onKindFilterChange,
|
||
onSortModeChange,
|
||
onReset,
|
||
onClose,
|
||
}: {
|
||
kindFilter: PlatformCategoryKindFilter;
|
||
sortMode: PlatformCategorySortMode;
|
||
resultCount: number;
|
||
onKindFilterChange: (filter: PlatformCategoryKindFilter) => void;
|
||
onSortModeChange: (mode: PlatformCategorySortMode) => void;
|
||
onReset: () => void;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<UnifiedModal
|
||
open
|
||
title="分类筛选"
|
||
description={`${resultCount} 个作品`}
|
||
onClose={onClose}
|
||
closeOnBackdrop
|
||
closeOnEscape={false}
|
||
closeLabel="关闭分类筛选"
|
||
portal={false}
|
||
size="sm"
|
||
zIndexClassName="z-[90]"
|
||
overlayClassName="platform-modal-backdrop !px-3 !py-4"
|
||
panelClassName="platform-category-filter-dialog relative !rounded-[1.35rem]"
|
||
headerClassName="border-b-0 px-4 py-4"
|
||
titleClassName="text-base font-black"
|
||
descriptionClassName="mt-0.5 text-xs font-semibold text-[var(--platform-text-soft)]"
|
||
bodyClassName="grid gap-4 !px-4 !pb-4 !pt-0 sm:!px-4 sm:!pb-4 sm:!pt-0"
|
||
>
|
||
<div className="grid gap-2">
|
||
<div className="text-xs font-black text-[var(--platform-text-soft)]">
|
||
玩法
|
||
</div>
|
||
<div className="platform-category-filter-dialog__options">
|
||
{PLATFORM_CATEGORY_KIND_FILTERS.map((option) => {
|
||
const active = option.id === kindFilter;
|
||
|
||
return (
|
||
<button
|
||
key={option.id}
|
||
type="button"
|
||
onClick={() => onKindFilterChange(option.id)}
|
||
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<div className="text-xs font-black text-[var(--platform-text-soft)]">
|
||
排序
|
||
</div>
|
||
<div className="platform-category-filter-dialog__options">
|
||
{PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => {
|
||
const active = option.id === sortMode;
|
||
|
||
return (
|
||
<button
|
||
key={option.id}
|
||
type="button"
|
||
onClick={() => onSortModeChange(option.id)}
|
||
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="platform-category-filter-dialog__actions">
|
||
<button
|
||
type="button"
|
||
onClick={onReset}
|
||
className="platform-category-filter-dialog__action platform-category-filter-dialog__action--secondary"
|
||
>
|
||
重置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="platform-category-filter-dialog__action platform-category-filter-dialog__action--primary"
|
||
>
|
||
完成
|
||
</button>
|
||
</div>
|
||
</UnifiedModal>
|
||
);
|
||
}
|
||
|
||
function PlatformWorkSearchResults({
|
||
keyword,
|
||
entries,
|
||
onOpen,
|
||
onClear,
|
||
}: {
|
||
keyword: string;
|
||
entries: PlatformPublicGalleryCard[];
|
||
onOpen: (entry: PlatformPublicGalleryCard) => void;
|
||
onClear: () => void;
|
||
}) {
|
||
const trimmedKeyword = keyword.trim();
|
||
if (!trimmedKeyword) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5 sm:px-5 sm:py-4`}>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<SectionHeader title="搜索结果" detail={`${entries.length} 个作品`} />
|
||
<PlatformIconButton
|
||
onClick={onClear}
|
||
label="清空搜索"
|
||
title="清空搜索"
|
||
icon={<span aria-hidden="true">×</span>}
|
||
/>
|
||
</div>
|
||
{entries.length > 0 ? (
|
||
<div className="grid min-w-0 gap-2.5">
|
||
{entries.slice(0, 12).map((entry) => {
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const workCode = getPlatformSearchableWorkIds(entry)[0] ?? '';
|
||
const summaryText =
|
||
entry.summaryText || entry.subtitle || '等待补充世界摘要。';
|
||
|
||
return (
|
||
<button
|
||
key={`${buildPublicGalleryCardKey(entry)}:search-result`}
|
||
type="button"
|
||
onClick={() => onOpen(entry)}
|
||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="line-clamp-1 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
{displayName}
|
||
</div>
|
||
<div className="mt-1 line-clamp-1 text-xs text-[var(--platform-text-soft)]">
|
||
{entry.authorDisplayName}
|
||
{workCode ? ` · ${workCode}` : ''}
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||
{summaryText}
|
||
</div>
|
||
</div>
|
||
<PlatformPillBadge
|
||
tone="neutral"
|
||
size="xxs"
|
||
className="shrink-0 px-3 tracking-[0.18em]"
|
||
>
|
||
{describePlatformPublicWorkKind(entry)}
|
||
</PlatformPillBadge>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>没有匹配的公开作品。</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
async function getPublicWorkAuthorSummary(
|
||
authorLookup: PlatformPublicWorkAuthorLookup,
|
||
): Promise<PublicUserSummary | null> {
|
||
if (authorLookup.source === 'publicUserCode') {
|
||
return getPublicAuthUserByCode(authorLookup.value);
|
||
}
|
||
|
||
if (authorLookup.source === 'ownerUserId') {
|
||
return getPublicAuthUserById(authorLookup.value);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||
if (user?.publicUserCode?.trim()) {
|
||
return user.publicUserCode.trim();
|
||
}
|
||
|
||
const raw =
|
||
user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() || '00000000';
|
||
|
||
return `SY-${raw.slice(-8).padStart(8, '0')}`;
|
||
}
|
||
|
||
function getUserAvatarLabel(user: AuthUser | null | undefined) {
|
||
return (user?.displayName || '叙').slice(0, 1).toUpperCase();
|
||
}
|
||
|
||
function validateProfileDisplayName(value: string) {
|
||
const normalized = value.trim();
|
||
if (!normalized) {
|
||
return '请输入昵称';
|
||
}
|
||
const length = Array.from(normalized).length;
|
||
if (length < 2 || length > 20) {
|
||
return '昵称需要 2 到 20 位';
|
||
}
|
||
if (!/^[\u4e00-\u9fffa-zA-Z0-9_]+$/u.test(normalized)) {
|
||
return '昵称仅支持中文、英文、数字和下划线';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function readImageIntrinsicSize(src: string) {
|
||
return new Promise<{ width: number; height: number }>((resolve, reject) => {
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
resolve({
|
||
width: image.naturalWidth,
|
||
height: image.naturalHeight,
|
||
});
|
||
};
|
||
image.onerror = () => reject(new Error('图片读取失败'));
|
||
image.src = src;
|
||
});
|
||
}
|
||
|
||
function loadAvatarFile(file: File) {
|
||
return new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
if (typeof reader.result !== 'string') {
|
||
reject(new Error('图片读取失败'));
|
||
return;
|
||
}
|
||
resolve(reader.result);
|
||
};
|
||
reader.onerror = () => reject(new Error('图片读取失败'));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function cropAvatarImage(params: {
|
||
source: string;
|
||
cropX: number;
|
||
cropY: number;
|
||
cropSize: number;
|
||
}) {
|
||
return new Promise<string>((resolve, reject) => {
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = AVATAR_OUTPUT_SIZE;
|
||
canvas.height = AVATAR_OUTPUT_SIZE;
|
||
const context = canvas.getContext('2d');
|
||
if (!context) {
|
||
reject(new Error('头像裁剪失败'));
|
||
return;
|
||
}
|
||
|
||
context.drawImage(
|
||
image,
|
||
params.cropX,
|
||
params.cropY,
|
||
params.cropSize,
|
||
params.cropSize,
|
||
0,
|
||
0,
|
||
AVATAR_OUTPUT_SIZE,
|
||
AVATAR_OUTPUT_SIZE,
|
||
);
|
||
resolve(canvas.toDataURL('image/png'));
|
||
};
|
||
image.onerror = () => reject(new Error('头像裁剪失败'));
|
||
image.src = params.source;
|
||
});
|
||
}
|
||
|
||
const PROFILE_MODAL_OVERLAY_CLASS =
|
||
'platform-modal-backdrop !items-center !justify-center !px-4 !py-6';
|
||
|
||
function ProfileNicknameModal({
|
||
value,
|
||
error,
|
||
isSaving,
|
||
onChange,
|
||
onClose,
|
||
onSubmit,
|
||
}: {
|
||
value: string;
|
||
error: string | null;
|
||
isSaving: boolean;
|
||
onChange: (value: string) => void;
|
||
onClose: () => void;
|
||
onSubmit: () => void;
|
||
}) {
|
||
return (
|
||
<PlatformProfileModalShell
|
||
title="修改昵称"
|
||
onClose={onClose}
|
||
closeLabel="关闭昵称修改"
|
||
closeVariant="profileCompact"
|
||
panelClassName="platform-remap-surface !max-w-sm rounded-[1.4rem]"
|
||
bodyClassName="px-5 py-5"
|
||
>
|
||
<label className="block">
|
||
<span className="sr-only">新昵称</span>
|
||
<PlatformTextField
|
||
autoFocus
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
onSubmit();
|
||
}
|
||
}}
|
||
maxLength={20}
|
||
surface="editorDark"
|
||
size="lg"
|
||
density="roomy"
|
||
className="rounded-2xl border-white/12 bg-white/10 text-[var(--platform-text-strong)] focus:border-[var(--platform-surface-hover-border)]"
|
||
placeholder="输入新昵称"
|
||
/>
|
||
</label>
|
||
{error ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="tinted"
|
||
className="mt-3 rounded-2xl border-rose-400/25 text-rose-600"
|
||
>
|
||
{error}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||
<PlatformActionButton tone="secondary" onClick={onClose}>
|
||
取消
|
||
</PlatformActionButton>
|
||
<PlatformActionButton onClick={onSubmit} disabled={isSaving}>
|
||
{isSaving ? '保存中' : '保存'}
|
||
</PlatformActionButton>
|
||
</div>
|
||
</PlatformProfileModalShell>
|
||
);
|
||
}
|
||
|
||
function RechargePaymentResultModal({
|
||
result,
|
||
onClose,
|
||
}: {
|
||
result: RechargePaymentResult;
|
||
onClose: () => void;
|
||
}) {
|
||
const status =
|
||
result.kind === 'success'
|
||
? 'success'
|
||
: result.kind === 'pending'
|
||
? 'loading'
|
||
: result.kind === 'cancel'
|
||
? 'cancel'
|
||
: 'error';
|
||
|
||
return (
|
||
<PlatformStatusDialog
|
||
status={status}
|
||
title={result.title}
|
||
description={result.message}
|
||
onClose={onClose}
|
||
action={{ label: '知道了', onClick: onClose }}
|
||
zIndexClassName="z-[90]"
|
||
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function RechargePaymentConfirmationMask({ orderId }: { orderId: string }) {
|
||
return (
|
||
<PlatformStatusDialog
|
||
status="confirming"
|
||
title="正在确认支付"
|
||
description={`订单 ${orderId} 正在同步到账状态,请先停留在当前页面。`}
|
||
onClose={() => undefined}
|
||
closeDisabled
|
||
zIndexClassName="z-[95]"
|
||
overlayClassName={PROFILE_MODAL_OVERLAY_CLASS}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function RpgEntryHomeView({
|
||
activeTab,
|
||
isDesktopLayout: isDesktopLayoutProp,
|
||
onTabChange,
|
||
saveEntries,
|
||
saveError,
|
||
featuredEntries,
|
||
latestEntries,
|
||
myEntries,
|
||
historyEntries,
|
||
profileDashboard,
|
||
isLoadingPlatform,
|
||
isLoadingDashboard,
|
||
isResumingSaveWorldKey,
|
||
platformError,
|
||
dashboardError,
|
||
onResumeSave,
|
||
onOpenCreateTypePicker,
|
||
onOpenGalleryDetail,
|
||
onOpenChildMotionDemo,
|
||
onOpenBabyLoveDrawing,
|
||
onOpenRecommendGalleryDetail,
|
||
recommendRuntimeContent,
|
||
activeRecommendEntryKey = null,
|
||
isStartingRecommendEntry = false,
|
||
isRecommendRuntimeReady = false,
|
||
recommendRuntimeError = null,
|
||
onSelectNextRecommendEntry,
|
||
onSelectPreviousRecommendEntry,
|
||
onLikeRecommendEntry,
|
||
onShareRecommendEntry,
|
||
onRemixRecommendEntry,
|
||
onOpenLibraryDetail,
|
||
onDeleteLibraryEntry,
|
||
deletingLibraryEntryId = null,
|
||
onSearchPublicCode,
|
||
isSearchingPublicCode = false,
|
||
onOpenProfileDashboardCard,
|
||
profilePlayStats = null,
|
||
isProfilePlayStatsOpen = false,
|
||
isProfilePlayStatsLoading = false,
|
||
profilePlayStatsError = null,
|
||
onCloseProfilePlayStats,
|
||
onOpenPlayedWork,
|
||
onOpenFeedback,
|
||
onRechargeSuccess,
|
||
profileTaskRefreshKey = 0,
|
||
createTabContent,
|
||
draftTabContent,
|
||
hasUnreadDraftUpdate = false,
|
||
}: RpgEntryHomeViewProps) {
|
||
const authUi = useAuthUi();
|
||
const showRechargeEntry = shouldShowRechargeEntry();
|
||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||
const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState('');
|
||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||
const [qrScannerError, setQrScannerError] = useState<string | null>(null);
|
||
const [qrScannerResult, setQrScannerResult] = useState<string | null>(null);
|
||
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [categoryKindFilter, setCategoryKindFilter] =
|
||
useState<PlatformCategoryKindFilter>(DEFAULT_PLATFORM_CATEGORY_KIND_FILTER);
|
||
const [categorySortMode, setCategorySortMode] =
|
||
useState<PlatformCategorySortMode>(DEFAULT_PLATFORM_CATEGORY_SORT_MODE);
|
||
const [isCategoryFilterPanelOpen, setIsCategoryFilterPanelOpen] =
|
||
useState(false);
|
||
const [discoverChannel, setDiscoverChannel] =
|
||
useState<DiscoverChannel>('recommend');
|
||
const mobileDiscoverFeedRef = useRef<HTMLElement | null>(null);
|
||
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
|
||
string | null
|
||
>(null);
|
||
const hasManualCategoryTagSelectionRef = useRef(false);
|
||
const pendingPublicAuthorKeysRef = useRef<Set<string>>(new Set());
|
||
const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState<
|
||
Record<string, PublicUserSummary | null>
|
||
>({});
|
||
const [activeRankingTab, setActiveRankingTab] = useState<PlatformRankingTab>(
|
||
DEFAULT_PLATFORM_RANKING_TAB,
|
||
);
|
||
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
|
||
() => new Set([activeTab]),
|
||
);
|
||
const { copyState: profileCopyState, copyText: copyProfileText } =
|
||
useCopyFeedback();
|
||
const [activeLegalDocumentId, setActiveLegalDocumentId] =
|
||
useState<LegalDocumentId | null>(null);
|
||
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
|
||
const [nicknameInput, setNicknameInput] = useState('');
|
||
const [nicknameError, setNicknameError] = useState<string | null>(null);
|
||
const [isSavingNickname, setIsSavingNickname] = useState(false);
|
||
const [avatarSource, setAvatarSource] = useState<string | null>(null);
|
||
const [avatarImageSize, setAvatarImageSize] = useState<{
|
||
width: number;
|
||
height: number;
|
||
} | null>(null);
|
||
const [avatarCrop, setAvatarCrop] = useState<SquareImageCropRect>({
|
||
x: 0,
|
||
y: 0,
|
||
size: 1,
|
||
});
|
||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
|
||
const currentUser = authUi?.user ?? null;
|
||
const isAuthenticated = Boolean(currentUser);
|
||
const {
|
||
activeRechargeTab,
|
||
buyRechargeProduct,
|
||
claimTaskReward,
|
||
claimingTaskId,
|
||
closeProfilePopupPanel,
|
||
confirmNativeWechatPayment,
|
||
copyInviteInfo,
|
||
inviteCopyState,
|
||
isLoadingRechargeCenter,
|
||
isLoadingReferral,
|
||
isLoadingTaskCenter,
|
||
isLoadingWalletLedger,
|
||
isRechargeOpen,
|
||
isRewardCodeOpen,
|
||
isSubmittingReferralRedeem,
|
||
isSubmittingRewardCode,
|
||
isTaskCenterOpen,
|
||
isWalletLedgerOpen,
|
||
loadRechargeCenter,
|
||
loadTaskCenter,
|
||
loadWalletLedger,
|
||
nativeWechatPayment,
|
||
openProfilePopupPanel,
|
||
openRechargeOrRewardCodeModal,
|
||
openRewardCodeModal,
|
||
openTaskCenterPanel,
|
||
openWalletLedgerPanel,
|
||
profilePopupPanel,
|
||
rechargeCenter,
|
||
rechargeError,
|
||
rechargePaymentResult,
|
||
referralCenter,
|
||
referralError,
|
||
referralRedeemCode,
|
||
referralSuccess,
|
||
rewardCodeError,
|
||
rewardCodeInput,
|
||
rewardCodeSuccess,
|
||
setActiveRechargeTab,
|
||
setIsRechargeOpen,
|
||
setIsRewardCodeOpen,
|
||
setIsTaskCenterOpen,
|
||
setIsWalletLedgerOpen,
|
||
setRechargePaymentResult,
|
||
setReferralRedeemCode,
|
||
setRewardCodeInput,
|
||
submittingRechargeProductId,
|
||
submitReferralRedeemCode,
|
||
submitRewardCode,
|
||
taskCenter,
|
||
taskCenterError,
|
||
taskClaimSuccess,
|
||
walletLedger,
|
||
walletLedgerError,
|
||
wechatRechargeOrderConfirmationState,
|
||
} = usePlatformProfileCenterController({
|
||
activeTab,
|
||
isAuthenticated,
|
||
showRechargeEntry,
|
||
profileTaskRefreshKey,
|
||
onRechargeSuccess,
|
||
requestLogin: () => authUi?.openLoginModal(),
|
||
currentUser,
|
||
});
|
||
const edutainmentEntryEnabled = isEdutainmentEntryEnabled();
|
||
const [fallbackDesktopLayout] = useState(getInitialPlatformDesktopLayout);
|
||
const isDesktopLayout = isDesktopLayoutProp ?? fallbackDesktopLayout;
|
||
const openRecommendGalleryDetail =
|
||
onOpenRecommendGalleryDetail ?? onOpenGalleryDetail;
|
||
const generalFeaturedEntries = useMemo(
|
||
() => filterGeneralPublicWorks(featuredEntries),
|
||
[featuredEntries],
|
||
);
|
||
const featuredShelf = useMemo(
|
||
() => generalFeaturedEntries.slice(0, 6),
|
||
[generalFeaturedEntries],
|
||
);
|
||
const generalLatestEntries = useMemo(
|
||
() => filterGeneralPublicWorks(latestEntries),
|
||
[latestEntries],
|
||
);
|
||
const allEdutainmentEntries = useMemo(
|
||
() => filterEdutainmentPublicWorks([...featuredEntries, ...latestEntries]),
|
||
[featuredEntries, latestEntries],
|
||
);
|
||
const edutainmentEntries = useMemo(
|
||
() => (edutainmentEntryEnabled ? allEdutainmentEntries : []),
|
||
[allEdutainmentEntries, edutainmentEntryEnabled],
|
||
);
|
||
const visibleDiscoverChannels = useMemo(
|
||
() =>
|
||
edutainmentEntryEnabled
|
||
? [...DISCOVER_CHANNELS, EDUTAINMENT_DISCOVER_CHANNEL]
|
||
: DISCOVER_CHANNELS,
|
||
[edutainmentEntryEnabled],
|
||
);
|
||
const categoryGroups = useMemo(
|
||
() =>
|
||
buildPublicCategoryGroups(generalFeaturedEntries, generalLatestEntries),
|
||
[generalFeaturedEntries, generalLatestEntries],
|
||
);
|
||
const publicEntries = useMemo(
|
||
() =>
|
||
getPlatformPublicEntries(generalFeaturedEntries, generalLatestEntries),
|
||
[generalFeaturedEntries, generalLatestEntries],
|
||
);
|
||
const allPublicEntries = useMemo(
|
||
() => getAllPlatformPublicEntries(featuredEntries, latestEntries),
|
||
[featuredEntries, latestEntries],
|
||
);
|
||
const visibleHistoryEntries = useMemo(
|
||
() =>
|
||
historyEntries.filter((entry) => {
|
||
const matchedPublicWork = findPublicWorkForHistoryEntry(
|
||
entry,
|
||
allPublicEntries,
|
||
);
|
||
return !matchedPublicWork || canExposePublicWork(matchedPublicWork);
|
||
}),
|
||
[allPublicEntries, historyEntries],
|
||
);
|
||
const workSearchResults = useMemo(
|
||
() =>
|
||
filterPlatformWorkSearchResults(publicEntries, activeWorkSearchKeyword),
|
||
[activeWorkSearchKeyword, publicEntries],
|
||
);
|
||
const getPublicEntryAuthorAvatarUrl = useCallback(
|
||
(entry: PlatformPublicGalleryCard) => {
|
||
const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry);
|
||
if (!authorLookup) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
publicAuthorSummariesByKey[authorLookup.key]?.avatarUrl?.trim() || null
|
||
);
|
||
},
|
||
[publicAuthorSummariesByKey],
|
||
);
|
||
const getPublicEntryAuthorSummary = useCallback(
|
||
(entry: PlatformPublicGalleryCard) => {
|
||
const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry);
|
||
if (!authorLookup) {
|
||
return null;
|
||
}
|
||
|
||
return publicAuthorSummariesByKey[authorLookup.key] ?? null;
|
||
},
|
||
[publicAuthorSummariesByKey],
|
||
);
|
||
const activeCategoryGroup =
|
||
categoryGroups.find((group) => group.tag === selectedCategoryTag) ??
|
||
categoryGroups[0] ??
|
||
null;
|
||
const activeCategoryEntries = useMemo(() => {
|
||
if (!activeCategoryGroup) {
|
||
return [];
|
||
}
|
||
|
||
return sortPlatformCategoryEntries(
|
||
activeCategoryGroup.entries.filter((entry) =>
|
||
matchesPlatformCategoryKindFilter(entry, categoryKindFilter),
|
||
),
|
||
categorySortMode,
|
||
);
|
||
}, [activeCategoryGroup, categoryKindFilter, categorySortMode]);
|
||
const activeCategoryRawCount = activeCategoryGroup?.entries.length ?? 0;
|
||
const activeCategoryFilterLabel =
|
||
getPlatformCategoryKindFilterOption(categoryKindFilter).label;
|
||
const activeCategorySortLabel =
|
||
getPlatformCategorySortOption(categorySortMode).label;
|
||
const activeCategoryFilterCount = activeCategoryEntries.length;
|
||
const categoryFilterApplied =
|
||
categoryKindFilter !== DEFAULT_PLATFORM_CATEGORY_KIND_FILTER;
|
||
const visibleTabs = useMemo<PlatformHomeTab[]>(
|
||
() =>
|
||
isAuthenticated
|
||
? ['home', 'category', 'create', 'saves', 'profile']
|
||
: ['home', 'create', 'category'],
|
||
[isAuthenticated],
|
||
);
|
||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||
const avatarLabel = getUserAvatarLabel(authUi?.user);
|
||
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
|
||
const activeLegalDocument = activeLegalDocumentId
|
||
? getLegalDocument(activeLegalDocumentId)
|
||
: null;
|
||
const profileDashboardPresentation = useMemo(
|
||
() => buildProfileDashboardPresentation(profileDashboard),
|
||
[profileDashboard],
|
||
);
|
||
const remainingNarrativeCoins = profileDashboardPresentation.walletBalance;
|
||
const profileTaskCardSummary = useMemo(
|
||
() => buildProfileTaskCardSummary(taskCenter),
|
||
[taskCenter],
|
||
);
|
||
const tabIcons: Record<
|
||
PlatformHomeTab,
|
||
ComponentType<{ className?: string }>
|
||
> = isAuthenticated
|
||
? {
|
||
home: Sparkles,
|
||
category: Compass,
|
||
create: Sparkles,
|
||
saves: Pencil,
|
||
profile: UserRound,
|
||
}
|
||
: {
|
||
home: Gamepad2,
|
||
category: Compass,
|
||
create: Sparkles,
|
||
saves: Pencil,
|
||
profile: UserRound,
|
||
};
|
||
const tabLabels = {
|
||
home: '推荐',
|
||
category: '发现',
|
||
create: '创作',
|
||
saves: '草稿',
|
||
profile: '我的',
|
||
} as const;
|
||
|
||
useEffect(() => {
|
||
if (!visibleTabs.includes(activeTab)) {
|
||
onTabChange(isAuthenticated ? 'home' : 'category');
|
||
}
|
||
}, [activeTab, isAuthenticated, onTabChange, visibleTabs]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!visibleDiscoverChannels.some((channel) => channel.id === discoverChannel)
|
||
) {
|
||
setDiscoverChannel('recommend');
|
||
}
|
||
}, [discoverChannel, visibleDiscoverChannels]);
|
||
|
||
useEffect(() => {
|
||
setVisitedTabs((currentTabs) => {
|
||
if (currentTabs.has(activeTab)) {
|
||
return currentTabs;
|
||
}
|
||
|
||
const nextTabs = new Set(currentTabs);
|
||
nextTabs.add(activeTab);
|
||
return nextTabs;
|
||
});
|
||
}, [activeTab]);
|
||
|
||
useEffect(() => {
|
||
if (categoryGroups.length === 0) {
|
||
setSelectedCategoryTag(null);
|
||
hasManualCategoryTagSelectionRef.current = false;
|
||
return;
|
||
}
|
||
|
||
const firstCategoryGroup = categoryGroups[0];
|
||
const selectedCategoryGroup =
|
||
categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null;
|
||
if (
|
||
firstCategoryGroup &&
|
||
(!selectedCategoryGroup ||
|
||
(!hasManualCategoryTagSelectionRef.current &&
|
||
firstCategoryGroup.tag !== selectedCategoryGroup.tag))
|
||
) {
|
||
setSelectedCategoryTag(firstCategoryGroup.tag);
|
||
}
|
||
if (
|
||
selectedCategoryTag &&
|
||
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
|
||
) {
|
||
hasManualCategoryTagSelectionRef.current = false;
|
||
}
|
||
}, [categoryGroups, selectedCategoryTag]);
|
||
|
||
useEffect(() => {
|
||
const authorLookupsByKey = new Map<
|
||
string,
|
||
PlatformPublicWorkAuthorLookup
|
||
>();
|
||
publicEntries.forEach((entry) => {
|
||
const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry);
|
||
if (authorLookup) {
|
||
authorLookupsByKey.set(authorLookup.key, authorLookup);
|
||
}
|
||
});
|
||
const missingAuthorLookups = Array.from(authorLookupsByKey.values()).filter(
|
||
(authorLookup) =>
|
||
!(authorLookup.key in publicAuthorSummariesByKey) &&
|
||
!pendingPublicAuthorKeysRef.current.has(authorLookup.key),
|
||
);
|
||
|
||
if (missingAuthorLookups.length === 0) {
|
||
return undefined;
|
||
}
|
||
|
||
let cancelled = false;
|
||
missingAuthorLookups.forEach((authorLookup) => {
|
||
pendingPublicAuthorKeysRef.current.add(authorLookup.key);
|
||
});
|
||
|
||
// 中文注释:头像来自公开用户摘要,失败时缓存空值,避免首页滚动时反复打公开用户接口。
|
||
void Promise.all(
|
||
missingAuthorLookups.map(async (authorLookup) => {
|
||
try {
|
||
const author = await getPublicWorkAuthorSummary(authorLookup);
|
||
return [authorLookup.key, author] as const;
|
||
} catch {
|
||
return [authorLookup.key, null] as const;
|
||
} finally {
|
||
pendingPublicAuthorKeysRef.current.delete(authorLookup.key);
|
||
}
|
||
}),
|
||
).then((results) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
|
||
setPublicAuthorSummariesByKey((currentSummaries) => {
|
||
let changed = false;
|
||
const nextSummaries = { ...currentSummaries };
|
||
|
||
results.forEach(([authorLookupKey, author]) => {
|
||
if (authorLookupKey in nextSummaries) {
|
||
return;
|
||
}
|
||
|
||
nextSummaries[authorLookupKey] = author;
|
||
changed = true;
|
||
});
|
||
|
||
return changed ? nextSummaries : currentSummaries;
|
||
});
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [publicAuthorSummariesByKey, publicEntries]);
|
||
const openUserSurface = () => {
|
||
if (authUi?.user) {
|
||
authUi.openAccountModal();
|
||
return;
|
||
}
|
||
authUi?.openLoginModal();
|
||
};
|
||
const copyProfilePublicUserCode = () => {
|
||
void copyProfileText(publicUserCode);
|
||
};
|
||
const openNicknameModal = () => {
|
||
const user = authUi?.user;
|
||
if (!user) {
|
||
authUi?.openLoginModal();
|
||
return;
|
||
}
|
||
|
||
setNicknameInput(user.displayName);
|
||
setNicknameError(null);
|
||
setIsNicknameModalOpen(true);
|
||
};
|
||
const submitNickname = () => {
|
||
if (!authUi?.user || isSavingNickname) {
|
||
return;
|
||
}
|
||
|
||
const validationError = validateProfileDisplayName(nicknameInput);
|
||
if (validationError) {
|
||
setNicknameError(validationError);
|
||
return;
|
||
}
|
||
|
||
const nextName = nicknameInput.trim();
|
||
setIsSavingNickname(true);
|
||
setNicknameError(null);
|
||
void updateAuthProfile({ displayName: nextName })
|
||
.then((nextUser) => {
|
||
authUi.setCurrentUser(nextUser);
|
||
setIsNicknameModalOpen(false);
|
||
})
|
||
.catch((error: unknown) => {
|
||
setNicknameError(
|
||
error instanceof Error ? error.message : '昵称保存失败',
|
||
);
|
||
})
|
||
.finally(() => setIsSavingNickname(false));
|
||
};
|
||
const openAvatarPicker = () => {
|
||
if (!authUi?.user) {
|
||
authUi?.openLoginModal();
|
||
return;
|
||
}
|
||
|
||
setAvatarError(null);
|
||
avatarFileInputRef.current?.click();
|
||
};
|
||
const handleAvatarFileChange = (file: File | null) => {
|
||
if (avatarFileInputRef.current) {
|
||
avatarFileInputRef.current.value = '';
|
||
}
|
||
if (!file) {
|
||
return;
|
||
}
|
||
if (!AVATAR_ALLOWED_TYPES.has(file.type)) {
|
||
setAvatarError('头像仅支持 jpg、png、webp');
|
||
return;
|
||
}
|
||
if (file.size > AVATAR_MAX_FILE_SIZE) {
|
||
setAvatarError('头像图片不能超过 5MB');
|
||
return;
|
||
}
|
||
|
||
setAvatarError(null);
|
||
void loadAvatarFile(file)
|
||
.then(async (source) => {
|
||
const imageSize = await readImageIntrinsicSize(source);
|
||
setAvatarSource(source);
|
||
setAvatarImageSize(imageSize);
|
||
setAvatarCrop(buildCenteredSquareImageCropRect(imageSize));
|
||
})
|
||
.catch((error: unknown) => {
|
||
setAvatarError(
|
||
error instanceof Error ? error.message : '头像图片读取失败',
|
||
);
|
||
});
|
||
};
|
||
const updateAvatarCrop = useCallback(
|
||
(nextCrop: SquareImageCropRect) => {
|
||
if (!avatarImageSize) {
|
||
return;
|
||
}
|
||
|
||
setAvatarCrop(clampSquareImageCropRect(avatarImageSize, nextCrop));
|
||
},
|
||
[avatarImageSize],
|
||
);
|
||
const submitAvatar = () => {
|
||
if (
|
||
!avatarSource ||
|
||
!avatarImageSize ||
|
||
avatarCrop.size <= 0 ||
|
||
isSavingAvatar
|
||
) {
|
||
return;
|
||
}
|
||
|
||
setIsSavingAvatar(true);
|
||
setAvatarError(null);
|
||
void cropAvatarImage({
|
||
source: avatarSource,
|
||
cropX: avatarCrop.x,
|
||
cropY: avatarCrop.y,
|
||
cropSize: avatarCrop.size,
|
||
})
|
||
.then((avatarDataUrl) => updateAuthProfile({ avatarDataUrl }))
|
||
.then((nextUser) => {
|
||
authUi?.setCurrentUser(nextUser);
|
||
setAvatarSource(null);
|
||
setAvatarImageSize(null);
|
||
})
|
||
.catch((error: unknown) => {
|
||
setAvatarError(error instanceof Error ? error.message : '头像上传失败');
|
||
})
|
||
.finally(() => setIsSavingAvatar(false));
|
||
};
|
||
const openQrScannerPanel = () => {
|
||
if (!authUi?.user) {
|
||
authUi?.openLoginModal();
|
||
return;
|
||
}
|
||
|
||
setQrScannerError(null);
|
||
setQrScannerResult(null);
|
||
setIsQrScannerOpen(true);
|
||
};
|
||
const clearWorkSearch = () => {
|
||
setActiveWorkSearchKeyword('');
|
||
setDesktopSearchKeyword('');
|
||
setMobileSearchKeyword('');
|
||
};
|
||
const updateDesktopSearchKeyword = (value: string) => {
|
||
setDesktopSearchKeyword(value);
|
||
if (!value.trim()) {
|
||
setActiveWorkSearchKeyword('');
|
||
}
|
||
};
|
||
const updateMobileSearchKeyword = (value: string) => {
|
||
setMobileSearchKeyword(value);
|
||
if (!value.trim()) {
|
||
setActiveWorkSearchKeyword('');
|
||
}
|
||
};
|
||
const submitWorkSearch = (keyword: string) => {
|
||
const trimmedKeyword = keyword.trim();
|
||
if (!trimmedKeyword) {
|
||
return;
|
||
}
|
||
|
||
// 中文注释:优先使用首页已经聚合好的公开作品读模型做模糊命中;
|
||
// 无本地命中时继续走既有编号直达兜底,避免破坏深链搜索。
|
||
const matchedEntries = filterPlatformWorkSearchResults(
|
||
publicEntries,
|
||
trimmedKeyword,
|
||
);
|
||
const hiddenEdutainmentMatches = filterPlatformWorkSearchResults(
|
||
allEdutainmentEntries,
|
||
trimmedKeyword,
|
||
);
|
||
if (
|
||
matchedEntries.length > 0 &&
|
||
isExactPublicWorkCodeSearch(matchedEntries, trimmedKeyword) &&
|
||
onSearchPublicCode &&
|
||
!isSearchingPublicCode
|
||
) {
|
||
setActiveWorkSearchKeyword('');
|
||
void onSearchPublicCode(trimmedKeyword);
|
||
return;
|
||
}
|
||
|
||
if (matchedEntries.length > 0) {
|
||
setActiveWorkSearchKeyword(trimmedKeyword);
|
||
return;
|
||
}
|
||
|
||
if (hiddenEdutainmentMatches.length > 0) {
|
||
setActiveWorkSearchKeyword(trimmedKeyword);
|
||
return;
|
||
}
|
||
|
||
setActiveWorkSearchKeyword('');
|
||
if (!onSearchPublicCode || isSearchingPublicCode) {
|
||
return;
|
||
}
|
||
|
||
void onSearchPublicCode(trimmedKeyword);
|
||
};
|
||
const submitDesktopSearch = () => {
|
||
submitWorkSearch(desktopSearchKeyword);
|
||
};
|
||
const submitMobileSearch = () => {
|
||
submitWorkSearch(mobileSearchKeyword);
|
||
};
|
||
const cycleCategorySortMode = () => {
|
||
setCategorySortMode(getNextPlatformCategorySortMode(categorySortMode));
|
||
};
|
||
const desktopHeroEntry =
|
||
featuredShelf[0] ?? generalLatestEntries[0] ?? myEntries[0] ?? null;
|
||
const desktopHeroCover = desktopHeroEntry
|
||
? resolvePlatformWorldCoverImage(desktopHeroEntry)
|
||
: null;
|
||
const desktopHeroStripEntries = (
|
||
featuredShelf.length > 0 ? featuredShelf : generalLatestEntries
|
||
).slice(0, 5);
|
||
// 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。
|
||
const recommendedFeedEntries = useMemo(
|
||
() =>
|
||
buildPlatformRecommendFeedEntries(featuredShelf, generalLatestEntries),
|
||
[featuredShelf, generalLatestEntries],
|
||
);
|
||
const desktopRecommendEntries = recommendedFeedEntries;
|
||
const desktopTodayEntries = useMemo(
|
||
() => filterTodayPublishedEntries(generalLatestEntries),
|
||
[generalLatestEntries],
|
||
);
|
||
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
|
||
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
|
||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||
const resolvedRecommendCoverUrls = useResolvedRecommendCoverImages(
|
||
recommendedFeedEntries,
|
||
);
|
||
const discoverFeedEntries = useMemo(() => {
|
||
const sourceEntries =
|
||
discoverChannel === 'recommend'
|
||
? recommendedFeedEntries
|
||
: filterTodayPublishedEntries(generalLatestEntries);
|
||
|
||
return dedupePlatformPublicGalleryEntries(sourceEntries);
|
||
}, [discoverChannel, generalLatestEntries, recommendedFeedEntries]);
|
||
const edutainmentFeedEntries = useMemo(
|
||
() => dedupePlatformPublicGalleryEntries(edutainmentEntries),
|
||
[edutainmentEntries],
|
||
);
|
||
const mobileFeedCarouselEnabled =
|
||
!isDesktopLayout &&
|
||
activeTab === 'category' &&
|
||
(discoverChannel === 'recommend' || discoverChannel === 'today');
|
||
useEffect(() => {
|
||
if (!mobileFeedCarouselEnabled) {
|
||
setMobileCenteredCardKey(null);
|
||
return undefined;
|
||
}
|
||
|
||
const feedElement = mobileDiscoverFeedRef.current;
|
||
const scrollElement = feedElement?.closest('.platform-tab-panel');
|
||
if (!feedElement || !scrollElement) {
|
||
setMobileCenteredCardKey(null);
|
||
return undefined;
|
||
}
|
||
|
||
let frameId: number | null = null;
|
||
const updateCenteredCard = () => {
|
||
frameId = null;
|
||
const cards = Array.from(
|
||
feedElement.querySelectorAll<HTMLElement>(
|
||
'[data-mobile-feed-card-key]',
|
||
),
|
||
);
|
||
const viewportRect = scrollElement.getBoundingClientRect();
|
||
const viewportCenterY =
|
||
viewportRect.top + Math.max(0, viewportRect.height) / 2;
|
||
let closestKey: string | null = null;
|
||
let closestDistance = Number.POSITIVE_INFINITY;
|
||
|
||
cards.forEach((card) => {
|
||
const cardKey = card.dataset.mobileFeedCardKey;
|
||
if (!cardKey) {
|
||
return;
|
||
}
|
||
|
||
const cardRect = card.getBoundingClientRect();
|
||
if (
|
||
cardRect.bottom <= viewportRect.top ||
|
||
cardRect.top >= viewportRect.bottom
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const cardCenterY = cardRect.top + cardRect.height / 2;
|
||
const distance = Math.abs(cardCenterY - viewportCenterY);
|
||
if (distance < closestDistance) {
|
||
closestDistance = distance;
|
||
closestKey = cardKey;
|
||
}
|
||
});
|
||
|
||
setMobileCenteredCardKey((current) =>
|
||
current === closestKey ? current : closestKey,
|
||
);
|
||
};
|
||
const scheduleUpdate = () => {
|
||
if (frameId !== null) {
|
||
return;
|
||
}
|
||
|
||
frameId =
|
||
typeof window.requestAnimationFrame === 'function'
|
||
? window.requestAnimationFrame(updateCenteredCard)
|
||
: window.setTimeout(updateCenteredCard, 0);
|
||
};
|
||
|
||
scheduleUpdate();
|
||
scrollElement.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||
window.addEventListener('resize', scheduleUpdate);
|
||
|
||
return () => {
|
||
if (frameId !== null) {
|
||
if (typeof window.cancelAnimationFrame === 'function') {
|
||
window.cancelAnimationFrame(frameId);
|
||
} else {
|
||
window.clearTimeout(frameId);
|
||
}
|
||
}
|
||
scrollElement.removeEventListener('scroll', scheduleUpdate);
|
||
window.removeEventListener('resize', scheduleUpdate);
|
||
};
|
||
}, [
|
||
discoverChannel,
|
||
discoverFeedEntries,
|
||
activeTab,
|
||
mobileFeedCarouselEnabled,
|
||
]);
|
||
const activeRankingConfig = getPlatformRankingTabConfig(activeRankingTab);
|
||
const rankingEntries = useMemo(
|
||
() =>
|
||
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
|
||
[activeRankingTab, publicEntries],
|
||
);
|
||
const recommendFeedWindow = useMemo(
|
||
() =>
|
||
selectPlatformRecommendFeedWindow(
|
||
recommendedFeedEntries,
|
||
activeRecommendEntryKey,
|
||
),
|
||
[activeRecommendEntryKey, recommendedFeedEntries],
|
||
);
|
||
const activeRecommendEntry = recommendFeedWindow.activeEntry;
|
||
const previousRecommendEntry = recommendFeedWindow.previousEntry;
|
||
const nextRecommendEntry = recommendFeedWindow.nextEntry;
|
||
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
|
||
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
|
||
useState<RecommendSwipeDirection | null>(null);
|
||
const activeRecommendEntryKeyForSelection =
|
||
recommendFeedWindow.activeEntryKey;
|
||
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
|
||
const recommendDragStartRef = useRef<{
|
||
pointerId: number;
|
||
startY: number;
|
||
dragging: boolean;
|
||
} | null>(null);
|
||
const commitRecommendDrag = useCallback(
|
||
(direction: RecommendSwipeDirection) => {
|
||
if (recommendDragCommitDirection) {
|
||
return;
|
||
}
|
||
|
||
setRecommendDragCommitDirection(direction);
|
||
const panelHeight =
|
||
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
|
||
setRecommendDragOffsetY(
|
||
resolveRecommendCommitOffset(
|
||
direction,
|
||
panelHeight,
|
||
window.innerHeight,
|
||
),
|
||
);
|
||
window.setTimeout(() => {
|
||
if (direction === 1) {
|
||
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||
} else {
|
||
onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||
}
|
||
setRecommendDragOffsetY(0);
|
||
setRecommendDragCommitDirection(null);
|
||
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
|
||
},
|
||
[
|
||
activeRecommendEntryKeyForSelection,
|
||
onSelectNextRecommendEntry,
|
||
onSelectPreviousRecommendEntry,
|
||
recommendDragCommitDirection,
|
||
],
|
||
);
|
||
const beginRecommendDrag = useCallback(
|
||
(event: PointerEvent<HTMLElement>) => {
|
||
if (
|
||
recommendDragCommitDirection ||
|
||
!activeRecommendEntry ||
|
||
recommendedFeedEntries.length <= 1
|
||
) {
|
||
return;
|
||
}
|
||
|
||
recommendDragStartRef.current = {
|
||
pointerId: event.pointerId,
|
||
startY: event.clientY,
|
||
dragging: false,
|
||
};
|
||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||
},
|
||
[
|
||
activeRecommendEntry,
|
||
recommendDragCommitDirection,
|
||
recommendedFeedEntries.length,
|
||
],
|
||
);
|
||
const moveRecommendDrag = useCallback((event: PointerEvent<HTMLElement>) => {
|
||
const drag = recommendDragStartRef.current;
|
||
if (!drag) {
|
||
return;
|
||
}
|
||
|
||
const deltaY = event.clientY - drag.startY;
|
||
drag.dragging = drag.dragging || hasRecommendDragStarted(deltaY);
|
||
if (!drag.dragging) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
const cardHeight =
|
||
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
|
||
setRecommendDragOffsetY(clampRecommendDragOffset(deltaY, cardHeight));
|
||
}, []);
|
||
const endRecommendDrag = useCallback(
|
||
(event: PointerEvent<HTMLElement>) => {
|
||
const drag = recommendDragStartRef.current;
|
||
if (!drag) {
|
||
return;
|
||
}
|
||
|
||
event.currentTarget.releasePointerCapture?.(drag.pointerId);
|
||
recommendDragStartRef.current = null;
|
||
const deltaY = event.clientY - drag.startY;
|
||
const commitDirection = resolveRecommendDragCommitDirection(deltaY);
|
||
if (!commitDirection) {
|
||
setRecommendDragOffsetY(0);
|
||
return;
|
||
}
|
||
|
||
commitRecommendDrag(commitDirection);
|
||
},
|
||
[commitRecommendDrag],
|
||
);
|
||
const cancelRecommendDrag = useCallback(
|
||
(event: PointerEvent<HTMLElement>) => {
|
||
const drag = recommendDragStartRef.current;
|
||
if (drag) {
|
||
event.currentTarget.releasePointerCapture?.(drag.pointerId);
|
||
}
|
||
recommendDragStartRef.current = null;
|
||
setRecommendDragOffsetY(0);
|
||
},
|
||
[],
|
||
);
|
||
const recommendRailStyle = {
|
||
transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`,
|
||
} satisfies CSSProperties;
|
||
const recommendRailClassName = buildRecommendSwipeRailClassName({
|
||
offsetY: recommendDragOffsetY,
|
||
commitDirection: recommendDragCommitDirection,
|
||
});
|
||
const selectNextRecommendEntry = useCallback(() => {
|
||
if (
|
||
shouldAnimateRecommendSwipe({
|
||
isAuthenticated,
|
||
hasActiveEntry: Boolean(activeRecommendEntry),
|
||
entryCount: recommendedFeedEntries.length,
|
||
})
|
||
) {
|
||
commitRecommendDrag(1);
|
||
return;
|
||
}
|
||
|
||
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||
}, [
|
||
activeRecommendEntry,
|
||
activeRecommendEntryKeyForSelection,
|
||
commitRecommendDrag,
|
||
isAuthenticated,
|
||
onSelectNextRecommendEntry,
|
||
recommendedFeedEntries.length,
|
||
]);
|
||
const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
|
||
const openLeadPublicEntry = () => {
|
||
if (leadPublicEntry) {
|
||
openRecommendGalleryDetail(leadPublicEntry);
|
||
return;
|
||
}
|
||
|
||
onTabChange('category');
|
||
};
|
||
|
||
const mobileRankingPanel: ReactNode = (
|
||
<section
|
||
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
|
||
>
|
||
<div
|
||
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
|
||
role="tablist"
|
||
aria-label="作品排行"
|
||
>
|
||
{PLATFORM_RANKING_TABS.map((tab) => {
|
||
const active = tab.id === activeRankingTab;
|
||
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={() => setActiveRankingTab(tab.id)}
|
||
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
|
||
) : rankingEntries.length > 0 ? (
|
||
<div className="mt-3 grid min-w-0 gap-2.5">
|
||
{rankingEntries.map((entry, index) => (
|
||
<PlatformRankingItem
|
||
key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`}
|
||
entry={entry}
|
||
rank={index + 1}
|
||
metric={getPlatformRankingMetric(entry, activeRankingTab)}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>{activeRankingConfig.emptyText}</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
);
|
||
|
||
const mobileRecommendContent: ReactNode = (
|
||
<div
|
||
className={`${MOBILE_RECOMMEND_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
|
||
>
|
||
{platformError ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="tinted"
|
||
size="md"
|
||
className="rounded-2xl"
|
||
>
|
||
{platformError}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
|
||
{isLoadingPlatform ? (
|
||
<section className="platform-recommend-runtime-panel">
|
||
<div className="platform-recommend-runtime-state">
|
||
正在读取公开作品...
|
||
</div>
|
||
</section>
|
||
) : recommendRuntimeError ? (
|
||
<section className="platform-recommend-runtime-panel">
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
activeRecommendEntry
|
||
? openRecommendGalleryDetail(activeRecommendEntry)
|
||
: undefined
|
||
}
|
||
className="platform-recommend-runtime-state platform-recommend-runtime-state--button"
|
||
>
|
||
{recommendRuntimeError}
|
||
</button>
|
||
</section>
|
||
) : activeRecommendEntry ? (
|
||
<div
|
||
ref={recommendCardStageRef}
|
||
className="platform-recommend-swipe-stage"
|
||
>
|
||
<div
|
||
className={`platform-recommend-swipe-rail ${recommendRailClassName}`}
|
||
style={recommendRailStyle}
|
||
>
|
||
{previousRecommendEntry ? (
|
||
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--previous">
|
||
<RecommendSwipeCard
|
||
entry={previousRecommendEntry}
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
|
||
previousRecommendEntry,
|
||
)}
|
||
authorSummary={getPublicEntryAuthorSummary(
|
||
previousRecommendEntry,
|
||
)}
|
||
isActive={false}
|
||
visual={
|
||
<RecommendRuntimePreviewCard
|
||
entry={previousRecommendEntry}
|
||
position="previous"
|
||
resolvedCoverUrls={resolvedRecommendCoverUrls}
|
||
/>
|
||
}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--current">
|
||
<RecommendSwipeCard
|
||
entry={activeRecommendEntry}
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
|
||
activeRecommendEntry,
|
||
)}
|
||
authorSummary={getPublicEntryAuthorSummary(
|
||
activeRecommendEntry,
|
||
)}
|
||
isActive
|
||
visual={
|
||
<RecommendRuntimeVisual
|
||
entry={activeRecommendEntry}
|
||
runtimeContent={recommendRuntimeContent}
|
||
isStarting={isStartingRecommendEntry}
|
||
isRuntimeReady={isRecommendRuntimeReady}
|
||
resolvedCoverUrls={resolvedRecommendCoverUrls}
|
||
/>
|
||
}
|
||
onDragPointerDown={beginRecommendDrag}
|
||
onDragPointerMove={moveRecommendDrag}
|
||
onDragPointerUp={endRecommendDrag}
|
||
onDragPointerCancel={cancelRecommendDrag}
|
||
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
|
||
onShare={() => onShareRecommendEntry?.(activeRecommendEntry)}
|
||
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
|
||
/>
|
||
</div>
|
||
|
||
{nextRecommendEntry ? (
|
||
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--next">
|
||
<RecommendSwipeCard
|
||
entry={nextRecommendEntry}
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
|
||
nextRecommendEntry,
|
||
)}
|
||
authorSummary={getPublicEntryAuthorSummary(
|
||
nextRecommendEntry,
|
||
)}
|
||
isActive={false}
|
||
visual={
|
||
<RecommendRuntimePreviewCard
|
||
entry={nextRecommendEntry}
|
||
position="next"
|
||
resolvedCoverUrls={resolvedRecommendCoverUrls}
|
||
/>
|
||
}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<section className="platform-recommend-runtime-panel">
|
||
<PlatformEmptyState>
|
||
公开广场暂时还没有可展示的作品。
|
||
</PlatformEmptyState>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const mobileDiscoverContent: ReactNode = (
|
||
<div
|
||
className={`${MOBILE_DISCOVER_PAGE_STAGE_CLASS} platform-mobile-home-stage`}
|
||
>
|
||
<PublicCodeSearchBar
|
||
value={mobileSearchKeyword}
|
||
onChange={updateMobileSearchKeyword}
|
||
onSubmit={submitMobileSearch}
|
||
isSearching={isSearchingPublicCode}
|
||
/>
|
||
|
||
{activeWorkSearchKeyword.trim() ? (
|
||
<PlatformWorkSearchResults
|
||
keyword={activeWorkSearchKeyword}
|
||
entries={workSearchResults}
|
||
onOpen={onOpenGalleryDetail}
|
||
onClear={clearWorkSearch}
|
||
/>
|
||
) : (
|
||
<>
|
||
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||
{visibleDiscoverChannels.map((channel) => {
|
||
const active = discoverChannel === channel.id;
|
||
return (
|
||
<button
|
||
key={channel.id}
|
||
type="button"
|
||
onClick={() => setDiscoverChannel(channel.id)}
|
||
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
|
||
>
|
||
{channel.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{platformError ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="tinted"
|
||
size="md"
|
||
className="rounded-2xl"
|
||
>
|
||
{platformError}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
|
||
{discoverChannel === 'ranking' ? (
|
||
mobileRankingPanel
|
||
) : discoverChannel === 'category' ? (
|
||
<section className="platform-category-list-panel">
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
|
||
) : categoryGroups.length > 0 && activeCategoryGroup ? (
|
||
<>
|
||
<div className="platform-category-filter-row">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsCategoryFilterPanelOpen(true)}
|
||
aria-haspopup="dialog"
|
||
className="platform-category-filter-button"
|
||
>
|
||
<SlidersHorizontal className="h-4 w-4" />
|
||
<span>
|
||
{categoryFilterApplied
|
||
? activeCategoryFilterLabel
|
||
: '筛选'}
|
||
</span>
|
||
<span className="platform-category-filter-button__count">
|
||
{activeCategoryFilterCount}
|
||
</span>
|
||
</button>
|
||
<span className="platform-category-filter-divider" />
|
||
<div className="platform-category-chip-scroll scrollbar-hide">
|
||
{categoryGroups.map((group) => {
|
||
const active = group.tag === activeCategoryGroup.tag;
|
||
|
||
return (
|
||
<button
|
||
key={group.tag}
|
||
type="button"
|
||
onClick={() => {
|
||
hasManualCategoryTagSelectionRef.current = true;
|
||
setSelectedCategoryTag(group.tag);
|
||
}}
|
||
className={`platform-category-chip ${active ? 'platform-category-chip--active' : ''}`}
|
||
>
|
||
{group.tag}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={cycleCategorySortMode}
|
||
className="platform-category-sort-button"
|
||
>
|
||
<span>按{activeCategorySortLabel}排序</span>
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
</button>
|
||
|
||
{activeCategoryEntries.length > 0 ? (
|
||
<div className="platform-category-game-list">
|
||
{activeCategoryEntries.map((entry) => (
|
||
<PlatformCategoryGameItem
|
||
key={`${buildPublicGalleryCardKey(entry)}:mobile-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
|
||
entry={entry}
|
||
categoryTag={activeCategoryGroup.tag}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>
|
||
当前筛选下没有作品。
|
||
</PlatformEmptyState>
|
||
)}
|
||
</>
|
||
) : (
|
||
<PlatformEmptyState>
|
||
公开广场暂时还没有可分类的作品。
|
||
</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
) : discoverChannel === 'edutainment' ? (
|
||
<section className="platform-mobile-home-feed">
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
|
||
) : edutainmentFeedEntries.length > 0 ||
|
||
onOpenChildMotionDemo ||
|
||
onOpenBabyLoveDrawing ? (
|
||
<div className="grid min-w-0 gap-3">
|
||
{edutainmentFeedEntries.map((entry) => {
|
||
const cardKey = buildPublicGalleryCardKey(entry);
|
||
|
||
return (
|
||
<WorldCard
|
||
key={`${cardKey}:mobile-edutainment`}
|
||
entry={entry}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
className="w-full"
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||
authorSummary={getPublicEntryAuthorSummary(entry)}
|
||
feedCardKey={cardKey}
|
||
/>
|
||
);
|
||
})}
|
||
{onOpenChildMotionDemo ? (
|
||
<button
|
||
type="button"
|
||
className="platform-edutainment-level-card"
|
||
onClick={onOpenChildMotionDemo}
|
||
>
|
||
<span className="platform-edutainment-level-card__icon">
|
||
<Camera className="h-7 w-7" />
|
||
</span>
|
||
<span className="platform-edutainment-level-card__body">
|
||
<strong>{CHILD_MOTION_DEMO_DEFAULT_CARD.title}</strong>
|
||
<span>{CHILD_MOTION_DEMO_DEFAULT_CARD.subtitle}</span>
|
||
</span>
|
||
<span className="platform-edutainment-level-card__summary">
|
||
{CHILD_MOTION_DEMO_DEFAULT_CARD.summary}
|
||
</span>
|
||
</button>
|
||
) : null}
|
||
{onOpenBabyLoveDrawing ? (
|
||
<button
|
||
type="button"
|
||
className="platform-edutainment-level-card"
|
||
onClick={onOpenBabyLoveDrawing}
|
||
>
|
||
<span className="platform-edutainment-level-card__icon">
|
||
<Palette className="h-7 w-7" />
|
||
</span>
|
||
<span className="platform-edutainment-level-card__body">
|
||
<strong>{BABY_LOVE_DRAWING_DEFAULT_CARD.title}</strong>
|
||
<span>{BABY_LOVE_DRAWING_DEFAULT_CARD.subtitle}</span>
|
||
</span>
|
||
<span className="platform-edutainment-level-card__summary">
|
||
{BABY_LOVE_DRAWING_DEFAULT_CARD.summary}
|
||
</span>
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>
|
||
暂时还没有可展示的作品。
|
||
</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
) : (
|
||
<section
|
||
ref={mobileDiscoverFeedRef}
|
||
className="platform-mobile-home-feed"
|
||
>
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
|
||
) : discoverFeedEntries.length > 0 ? (
|
||
<div className="grid min-w-0 gap-3">
|
||
{discoverFeedEntries.map(
|
||
(entry: PlatformPublicGalleryCard) => {
|
||
const cardKey = buildPublicGalleryCardKey(entry);
|
||
|
||
return (
|
||
<WorldCard
|
||
key={`${cardKey}:mobile-feed:${discoverChannel}`}
|
||
entry={entry}
|
||
onClick={() => onOpenGalleryDetail(entry)}
|
||
className="w-full"
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||
authorSummary={getPublicEntryAuthorSummary(entry)}
|
||
feedCardKey={cardKey}
|
||
enableCoverCarousel={mobileFeedCarouselEnabled}
|
||
isCoverCarouselActive={
|
||
mobileCenteredCardKey === cardKey
|
||
}
|
||
/>
|
||
);
|
||
},
|
||
)}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>
|
||
公开广场暂时还没有可展示的作品。
|
||
</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const desktopDiscoverContent: ReactNode = (
|
||
<div className={DESKTOP_DISCOVER_PAGE_STAGE_CLASS}>
|
||
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||
{visibleDiscoverChannels.map((channel) => {
|
||
const active = discoverChannel === channel.id;
|
||
|
||
return (
|
||
<button
|
||
key={`desktop-${channel.id}`}
|
||
type="button"
|
||
onClick={() => setDiscoverChannel(channel.id)}
|
||
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
|
||
>
|
||
{channel.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{platformError ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="tinted"
|
||
size="md"
|
||
className="rounded-[1.5rem]"
|
||
>
|
||
{platformError}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
|
||
{discoverChannel === 'ranking' ? (
|
||
mobileRankingPanel
|
||
) : discoverChannel === 'category' ? (
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取作品分类...</PlatformEmptyState>
|
||
) : activeCategoryGroup && activeCategoryRawCount > 0 ? (
|
||
<>
|
||
<div className="mb-4 flex min-w-0 items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsCategoryFilterPanelOpen(true)}
|
||
aria-haspopup="dialog"
|
||
className="platform-category-filter-button"
|
||
>
|
||
<SlidersHorizontal className="h-4 w-4" />
|
||
<span>
|
||
{categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
|
||
</span>
|
||
<span className="platform-category-filter-button__count">
|
||
{activeCategoryFilterCount}
|
||
</span>
|
||
</button>
|
||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||
{categoryGroups.map((group) => {
|
||
const active = group.tag === activeCategoryGroup.tag;
|
||
|
||
return (
|
||
<button
|
||
key={`${group.tag}:desktop-discover-category`}
|
||
type="button"
|
||
onClick={() => {
|
||
hasManualCategoryTagSelectionRef.current = true;
|
||
setSelectedCategoryTag(group.tag);
|
||
}}
|
||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||
>
|
||
{group.tag}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={cycleCategorySortMode}
|
||
className="platform-category-sort-button shrink-0"
|
||
>
|
||
<span>{activeCategorySortLabel}</span>
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
{desktopCategoryGrid.length > 0 ? (
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
{desktopCategoryGrid.map((entry) => (
|
||
<WorldCard
|
||
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
|
||
entry={entry}
|
||
onClick={() => openRecommendGalleryDetail(entry)}
|
||
className="w-full min-w-0"
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||
authorSummary={getPublicEntryAuthorSummary(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>当前筛选下没有作品。</PlatformEmptyState>
|
||
)}
|
||
</>
|
||
) : (
|
||
<PlatformEmptyState>暂时还没有可分类的作品。</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
) : discoverChannel === 'edutainment' ? (
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader title={EDUTAINMENT_WORK_TAG} detail="EDUTAINMENT" />
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
|
||
) : edutainmentFeedEntries.length > 0 ||
|
||
onOpenChildMotionDemo ||
|
||
onOpenBabyLoveDrawing ? (
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
{edutainmentFeedEntries.map((entry) => (
|
||
<WorldCard
|
||
key={`${buildPublicGalleryCardKey(entry)}:desktop-edutainment`}
|
||
entry={entry}
|
||
onClick={() => openRecommendGalleryDetail(entry)}
|
||
className="w-full min-w-0"
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||
authorSummary={getPublicEntryAuthorSummary(entry)}
|
||
/>
|
||
))}
|
||
{onOpenChildMotionDemo ? (
|
||
<button
|
||
type="button"
|
||
className="platform-edutainment-level-card"
|
||
onClick={onOpenChildMotionDemo}
|
||
>
|
||
<span className="platform-edutainment-level-card__icon">
|
||
<Camera className="h-7 w-7" />
|
||
</span>
|
||
<span className="platform-edutainment-level-card__body">
|
||
<strong>{CHILD_MOTION_DEMO_DEFAULT_CARD.title}</strong>
|
||
<span>{CHILD_MOTION_DEMO_DEFAULT_CARD.subtitle}</span>
|
||
</span>
|
||
<span className="platform-edutainment-level-card__summary">
|
||
{CHILD_MOTION_DEMO_DEFAULT_CARD.summary}
|
||
</span>
|
||
</button>
|
||
) : null}
|
||
{onOpenBabyLoveDrawing ? (
|
||
<button
|
||
type="button"
|
||
className="platform-edutainment-level-card"
|
||
onClick={onOpenBabyLoveDrawing}
|
||
>
|
||
<span className="platform-edutainment-level-card__icon">
|
||
<Palette className="h-7 w-7" />
|
||
</span>
|
||
<span className="platform-edutainment-level-card__body">
|
||
<strong>{BABY_LOVE_DRAWING_DEFAULT_CARD.title}</strong>
|
||
<span>{BABY_LOVE_DRAWING_DEFAULT_CARD.subtitle}</span>
|
||
</span>
|
||
<span className="platform-edutainment-level-card__summary">
|
||
{BABY_LOVE_DRAWING_DEFAULT_CARD.summary}
|
||
</span>
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>暂时还没有可展示的作品。</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
) : (
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader
|
||
title={discoverChannel === 'today' ? '今日游戏' : '推荐'}
|
||
detail={discoverChannel === 'today' ? 'TODAY GAMES' : 'RECOMMENDED'}
|
||
/>
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
|
||
) : discoverFeedEntries.length > 0 ? (
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
{discoverFeedEntries.map((entry) => (
|
||
<WorldCard
|
||
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-feed:${discoverChannel}`}
|
||
entry={entry}
|
||
onClick={() => openRecommendGalleryDetail(entry)}
|
||
className="w-full min-w-0"
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||
authorSummary={getPublicEntryAuthorSummary(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>
|
||
公开广场暂时还没有可展示的作品。
|
||
</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
const categoryContent: ReactNode = isDesktopLayout
|
||
? desktopDiscoverContent
|
||
: mobileDiscoverContent;
|
||
|
||
const fallbackCreateStartContent: ReactNode = (
|
||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||
<button
|
||
type="button"
|
||
onClick={onOpenCreateTypePicker}
|
||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
|
||
>
|
||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||
<PlatformPillBadge
|
||
tone="cool"
|
||
size="xxs"
|
||
className="w-fit tracking-[0.18em]"
|
||
>
|
||
CREATE
|
||
</PlatformPillBadge>
|
||
<div className="min-w-0">
|
||
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
|
||
开启新的创作
|
||
</div>
|
||
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
|
||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||
</div>
|
||
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
|
||
<span>选择类型并继续</span>
|
||
<ArrowRight className="h-4 w-4" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
const fallbackDraftContent: ReactNode = (
|
||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||
<section>
|
||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||
{platformError ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="tinted"
|
||
size="md"
|
||
className="rounded-2xl"
|
||
>
|
||
{platformError}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取你的作品...</PlatformEmptyState>
|
||
) : myEntries.length > 0 ? (
|
||
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
|
||
{myEntries.map(
|
||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||
<CreationLibraryCard
|
||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||
entry={entry}
|
||
onClick={() => onOpenLibraryDetail(entry)}
|
||
onDelete={
|
||
onDeleteLibraryEntry
|
||
? () => onDeleteLibraryEntry(entry)
|
||
: undefined
|
||
}
|
||
isDeleting={deletingLibraryEntryId === entry.profileId}
|
||
/>
|
||
),
|
||
)}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>
|
||
{isAuthenticated
|
||
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
|
||
: '登录后查看你的作品。'}
|
||
</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
|
||
const createContent: ReactNode =
|
||
createTabContent ?? fallbackCreateStartContent;
|
||
|
||
const savesContent: ReactNode = (
|
||
<>
|
||
{platformError ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="tinted"
|
||
size="md"
|
||
className="rounded-2xl"
|
||
>
|
||
{platformError}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
{draftTabContent ?? fallbackDraftContent}
|
||
</>
|
||
);
|
||
|
||
const profileContent: ReactNode = (
|
||
<div className={`${MOBILE_PROFILE_PAGE_STAGE_CLASS} platform-profile-page`}>
|
||
{authUi?.user ? (
|
||
<>
|
||
<section className="platform-profile-header">
|
||
<img
|
||
src={profileStillLifeImage}
|
||
alt=""
|
||
className="platform-profile-scene-decor"
|
||
/>
|
||
<div className="platform-profile-header__identity">
|
||
<div className="platform-profile-header__identity-row flex min-w-0 items-center gap-4">
|
||
<button
|
||
type="button"
|
||
onClick={openAvatarPicker}
|
||
className="platform-profile-avatar relative h-[5.15rem] w-[5.15rem] shrink-0 rounded-full"
|
||
aria-label="上传头像"
|
||
>
|
||
{avatarUrl ? (
|
||
<img
|
||
src={avatarUrl}
|
||
alt=""
|
||
className="h-full w-full rounded-full object-cover"
|
||
/>
|
||
) : (
|
||
<img
|
||
src={profileMascotImage}
|
||
alt=""
|
||
className="h-full w-full rounded-full object-cover"
|
||
/>
|
||
)}
|
||
<span className="platform-profile-camera absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full">
|
||
<Camera className="h-3.5 w-3.5" />
|
||
</span>
|
||
</button>
|
||
<input
|
||
ref={avatarFileInputRef}
|
||
type="file"
|
||
aria-label="上传头像"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
className="hidden"
|
||
onChange={(event) =>
|
||
handleAvatarFileChange(event.target.files?.[0] ?? null)
|
||
}
|
||
/>
|
||
|
||
<div className="platform-profile-header__text min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<div className="platform-profile-header__name truncate text-[18px] font-black leading-tight text-[var(--platform-text-strong)]">
|
||
{authUi.user.displayName}
|
||
</div>
|
||
<PlatformIconButton
|
||
label="修改昵称"
|
||
icon={<Pencil className="h-3.5 w-3.5" />}
|
||
onClick={openNicknameModal}
|
||
className="platform-profile-edit-button"
|
||
/>
|
||
</div>
|
||
<div className="platform-profile-header__code mt-2 flex flex-wrap items-center gap-2 text-[12px] text-[var(--platform-text-base)]">
|
||
<span>陶泥号: {publicUserCode}</span>
|
||
<CopyFeedbackButton
|
||
state={profileCopyState}
|
||
onClick={copyProfilePublicUserCode}
|
||
className="platform-profile-copy-button"
|
||
idleLabel="复制"
|
||
showIcon={false}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={openRechargeOrRewardCodeModal}
|
||
className="platform-profile-membership-card"
|
||
aria-label="查看权益"
|
||
>
|
||
<span className="platform-profile-membership-card__badge">
|
||
<Crown className="platform-profile-membership-card__crown" />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="platform-profile-membership-card__title block text-[16px] font-black leading-tight text-white">
|
||
普通用户
|
||
</span>
|
||
<span className="platform-profile-membership-card__subtitle mt-1.5 block text-[12px] font-medium text-white/92">
|
||
升级会员,享专属特权与福利
|
||
</span>
|
||
</span>
|
||
<span className="platform-profile-membership-card__action">
|
||
查看权益
|
||
</span>
|
||
</button>
|
||
|
||
<section
|
||
className="platform-profile-stats-panel"
|
||
aria-label="我的数据"
|
||
>
|
||
<div className="platform-profile-stats-grid grid grid-cols-3 gap-3">
|
||
{isLoadingDashboard ? (
|
||
<>
|
||
<ProfileStatCardSkeleton />
|
||
<ProfileStatCardSkeleton />
|
||
<ProfileStatCardSkeleton />
|
||
</>
|
||
) : dashboardError ? (
|
||
<>
|
||
<ProfileStatCard
|
||
cardKey="wallet"
|
||
label="泥点余额"
|
||
value="暂不可用"
|
||
icon={Coins}
|
||
imageSrc={profilePointImage}
|
||
onClick={openWalletLedgerPanel}
|
||
/>
|
||
<ProfileStatCard
|
||
cardKey="playTime"
|
||
label="累计游玩"
|
||
value="暂不可用"
|
||
icon={Clock3}
|
||
imageSrc={profileClockImage}
|
||
onClick={onOpenProfileDashboardCard}
|
||
/>
|
||
<ProfileStatCard
|
||
cardKey="playedWorks"
|
||
label="已玩游戏"
|
||
value="暂不可用"
|
||
icon={BookOpen}
|
||
imageSrc={profileGamepadImage}
|
||
onClick={onOpenProfileDashboardCard}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<ProfileStatCard
|
||
cardKey="wallet"
|
||
label="泥点余额"
|
||
value={profileDashboardPresentation.walletBalanceLabel}
|
||
icon={Coins}
|
||
imageSrc={profilePointImage}
|
||
onClick={openWalletLedgerPanel}
|
||
/>
|
||
<ProfileStatCard
|
||
cardKey="playTime"
|
||
label="累计游戏时长"
|
||
value={profileDashboardPresentation.totalPlayTimeLabel}
|
||
icon={Clock3}
|
||
imageSrc={profileClockImage}
|
||
onClick={onOpenProfileDashboardCard}
|
||
/>
|
||
<ProfileStatCard
|
||
cardKey="playedWorks"
|
||
label="已玩游戏数量"
|
||
value={profileDashboardPresentation.playedWorkCountLabel}
|
||
icon={BookOpen}
|
||
imageSrc={profileGamepadImage}
|
||
onClick={onOpenProfileDashboardCard}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={openTaskCenterPanel}
|
||
className="platform-profile-daily-task-card"
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="platform-profile-daily-task-card__title block text-[15px] font-black text-[var(--platform-text-strong)]">
|
||
每日任务
|
||
</span>
|
||
<span className="platform-profile-daily-task-card__desc mt-2 block text-[12px] font-medium text-[var(--platform-text-base)]">
|
||
完成任务可领取{' '}
|
||
<span className="text-[#c45b2a]">
|
||
{profileTaskCardSummary.rewardPoints}
|
||
</span>{' '}
|
||
泥点
|
||
</span>
|
||
<span className="platform-profile-daily-task-card__progress mt-3 flex items-center gap-3">
|
||
<span className="platform-profile-daily-task-card__progress-value text-[13px] font-semibold text-[#dc3f0e]">
|
||
{profileTaskCardSummary.progressCount} /{' '}
|
||
{profileTaskCardSummary.threshold}
|
||
</span>
|
||
<span className="platform-profile-daily-task-card__track">
|
||
<span
|
||
className="platform-profile-daily-task-card__bar"
|
||
style={{
|
||
width: `${profileTaskCardSummary.progressPercent}%`,
|
||
}}
|
||
/>
|
||
</span>
|
||
</span>
|
||
</span>
|
||
<img
|
||
src={profileMascotImage}
|
||
alt=""
|
||
className="platform-profile-daily-task-card__mascot"
|
||
/>
|
||
</button>
|
||
|
||
<section
|
||
className="platform-profile-shortcut-panel"
|
||
aria-label="常用功能"
|
||
>
|
||
<div className="platform-profile-shortcut-grid grid w-full !grid-cols-4">
|
||
<ProfileShortcutButton
|
||
label="泥点充值"
|
||
subLabel="充值泥点"
|
||
icon={Coins}
|
||
imageSrc={profileCoinsImage}
|
||
onClick={openRechargeOrRewardCodeModal}
|
||
/>
|
||
<ProfileShortcutButton
|
||
label="兑换码"
|
||
subLabel="领取福利"
|
||
icon={Ticket}
|
||
imageSrc={profileGiftImage}
|
||
onClick={openRewardCodeModal}
|
||
/>
|
||
<ProfileShortcutButton
|
||
label="玩家社区"
|
||
subLabel="交流心得"
|
||
icon={MessageCircle}
|
||
imageSrc={profileCommunityImage}
|
||
onClick={() => openProfilePopupPanel('community')}
|
||
/>
|
||
<ProfileShortcutButton
|
||
label="反馈与建议"
|
||
subLabel="帮我们优化产品"
|
||
icon={MessageCircle}
|
||
imageSrc={profileFeedbackImage}
|
||
onClick={onOpenFeedback}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section
|
||
className="platform-profile-settings-panel"
|
||
aria-label="设置入口"
|
||
>
|
||
<ProfileSettingsRow
|
||
label="通用设置"
|
||
icon={Settings}
|
||
onClick={() => authUi.openSettingsModal()}
|
||
/>
|
||
</section>
|
||
|
||
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
|
||
</>
|
||
) : (
|
||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||
<PlatformSubpanel as="div" radius="lg" padding="md">
|
||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||
尚未登录
|
||
</div>
|
||
<PlatformActionButton
|
||
className="mt-4"
|
||
onClick={() => authUi?.openLoginModal()}
|
||
>
|
||
登录
|
||
</PlatformActionButton>
|
||
</PlatformSubpanel>
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const desktopHomeContent: ReactNode = (
|
||
<div className={DESKTOP_PAGE_STAGE_CLASS}>
|
||
{platformError ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="tinted"
|
||
size="md"
|
||
className="rounded-[1.5rem]"
|
||
>
|
||
{platformError}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
|
||
{activeWorkSearchKeyword.trim() ? (
|
||
<PlatformWorkSearchResults
|
||
keyword={activeWorkSearchKeyword}
|
||
entries={workSearchResults}
|
||
onOpen={onOpenGalleryDetail}
|
||
onClear={clearWorkSearch}
|
||
/>
|
||
) : (
|
||
<>
|
||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.55fr)_22rem]">
|
||
<button
|
||
type="button"
|
||
onClick={openLeadPublicEntry}
|
||
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
|
||
>
|
||
{desktopHeroCover ? (
|
||
desktopHeroEntry ? (
|
||
<PlatformWorkCoverArtwork
|
||
entry={desktopHeroEntry}
|
||
imageSrc={desktopHeroCover}
|
||
alt=""
|
||
className="absolute inset-0 h-full w-full object-cover opacity-34"
|
||
/>
|
||
) : (
|
||
<ResolvedAssetBackdrop
|
||
src={desktopHeroCover}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="absolute inset-0 h-full w-full object-cover opacity-34"
|
||
/>
|
||
)
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<PlatformPillBadge
|
||
tone="warning"
|
||
size="xxs"
|
||
className="tracking-[0.18em]"
|
||
>
|
||
作品
|
||
</PlatformPillBadge>
|
||
<PlatformPillBadge
|
||
tone="neutral"
|
||
size="xxs"
|
||
className="px-3 tracking-[0.18em]"
|
||
>
|
||
{leadPublicEntry
|
||
? describePlatformPublicWorkKind(leadPublicEntry)
|
||
: '作品'}
|
||
</PlatformPillBadge>
|
||
</div>
|
||
|
||
<div className="max-w-[35rem]">
|
||
<div className="text-5xl font-semibold leading-[1.08] text-white">
|
||
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
|
||
</div>
|
||
<div className="mt-4 text-base leading-8 text-zinc-200/86">
|
||
{leadPublicEntry?.summaryText ||
|
||
leadPublicEntry?.subtitle ||
|
||
'挑一个玩家作品,开始今天的游玩。'}
|
||
</div>
|
||
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
|
||
<span>查看作品</span>
|
||
<ArrowRight className="h-4 w-4" />
|
||
</div>
|
||
</div>
|
||
|
||
{desktopHeroStripEntries.length > 0 ? (
|
||
<div className="grid gap-3 sm:grid-cols-5">
|
||
{desktopHeroStripEntries.map((entry, index) => {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const displayName = formatPlatformWorkDisplayName(
|
||
entry.worldName,
|
||
);
|
||
return (
|
||
<div
|
||
key={`${entry.ownerUserId}:${entry.profileId}:hero-strip`}
|
||
className="platform-subpanel overflow-hidden rounded-[1.15rem]"
|
||
>
|
||
<div className="relative aspect-[1.35/1] overflow-hidden">
|
||
{coverImage ? (
|
||
<PlatformWorkCoverArtwork
|
||
entry={entry}
|
||
imageSrc={coverImage}
|
||
alt=""
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.34))]" />
|
||
</div>
|
||
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-[color:color-mix(in_srgb,var(--platform-text-base)_82%,transparent)]">
|
||
<span className="text-[var(--platform-text-soft)]">
|
||
{`${index + 1}`.padStart(2, '0')}
|
||
</span>
|
||
<span className="line-clamp-1">{displayName}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</button>
|
||
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<div className="mb-4 flex items-start justify-between gap-3">
|
||
<SectionHeader title="今日游戏" detail="TODAY GAMES" />
|
||
<PlatformPillBadge
|
||
tone="neutral"
|
||
size="xxs"
|
||
className="px-3 tracking-[0.18em]"
|
||
>
|
||
TODAY
|
||
</PlatformPillBadge>
|
||
</div>
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取今日游戏...</PlatformEmptyState>
|
||
) : desktopTodayEntries.length > 0 ? (
|
||
<div className="space-y-3">
|
||
{desktopTodayEntries.slice(0, 3).map((entry, index) => (
|
||
<DesktopTrendingItem
|
||
key={`${buildPublicGalleryCardKey(entry)}:desktop-today`}
|
||
entry={entry}
|
||
rank={index + 1}
|
||
onClick={() => openRecommendGalleryDetail(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>今天暂时还没有新游戏。</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
<div
|
||
className={`grid gap-5 ${desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
|
||
>
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader title="推荐" detail="RECOMMENDED" />
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取推荐作品...</PlatformEmptyState>
|
||
) : desktopFeaturedGrid.length > 0 ? (
|
||
<div className="grid gap-4 xl:grid-cols-2">
|
||
{desktopFeaturedGrid.map((entry) => (
|
||
<WorldCard
|
||
key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
|
||
entry={entry}
|
||
onClick={() => openRecommendGalleryDetail(entry)}
|
||
className="w-full min-w-0"
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||
authorSummary={getPublicEntryAuthorSummary(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>暂时还没有推荐作品。</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
|
||
{desktopLibraryPreview.length > 0 ||
|
||
visibleHistoryEntries.length > 0 ? (
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader
|
||
title={
|
||
desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'
|
||
}
|
||
detail="QUICK ACCESS"
|
||
/>
|
||
|
||
<div>
|
||
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
|
||
{desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
|
||
</div>
|
||
|
||
{desktopLibraryPreview.length > 0 ? (
|
||
<div className="mt-3 space-y-3">
|
||
{desktopLibraryPreview.map((entry) => {
|
||
const displayName = formatPlatformWorkDisplayName(
|
||
entry.worldName,
|
||
);
|
||
|
||
return (
|
||
<button
|
||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-mine`}
|
||
type="button"
|
||
onClick={() => onOpenLibraryDetail(entry)}
|
||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||
{displayName}
|
||
</div>
|
||
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
|
||
{entry.visibility === 'published'
|
||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||
: '草稿待完善'}
|
||
</div>
|
||
</div>
|
||
<PlatformPillBadge
|
||
tone="neutral"
|
||
size="xxs"
|
||
className="px-3 tracking-[0.18em]"
|
||
>
|
||
{entry.visibility === 'published'
|
||
? '已发布'
|
||
: '草稿'}
|
||
</PlatformPillBadge>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="mt-3 space-y-3">
|
||
{visibleHistoryEntries.slice(0, 2).map((entry) => {
|
||
const displayName = formatPlatformWorkDisplayName(
|
||
entry.worldName,
|
||
);
|
||
|
||
return (
|
||
<button
|
||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
|
||
type="button"
|
||
onClick={() =>
|
||
openRecommendGalleryDetail({
|
||
ownerUserId: entry.ownerUserId,
|
||
profileId: entry.profileId,
|
||
publicWorkCode: null,
|
||
authorPublicUserCode: null,
|
||
visibility: 'published',
|
||
publishedAt: entry.visitedAt,
|
||
updatedAt: entry.visitedAt,
|
||
worldName: entry.worldName,
|
||
subtitle: entry.subtitle,
|
||
summaryText: entry.summaryText,
|
||
coverImageSrc: entry.coverImageSrc,
|
||
themeMode: entry.themeMode,
|
||
authorDisplayName: entry.authorDisplayName,
|
||
playableNpcCount: 0,
|
||
landmarkCount: 0,
|
||
likeCount: 0,
|
||
})
|
||
}
|
||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
|
||
{displayName}
|
||
</div>
|
||
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
|
||
作者:{entry.authorDisplayName}
|
||
</div>
|
||
</div>
|
||
<PlatformPillBadge
|
||
tone="neutral"
|
||
size="xxs"
|
||
className="px-3 tracking-[0.18em]"
|
||
>
|
||
浏览
|
||
</PlatformPillBadge>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
|
||
<section className="platform-desktop-panel px-5 py-5">
|
||
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
|
||
{isLoadingPlatform ? (
|
||
<PlatformEmptyState>正在读取作品分类...</PlatformEmptyState>
|
||
) : activeCategoryGroup && activeCategoryRawCount > 0 ? (
|
||
<>
|
||
<div className="mb-4 flex min-w-0 items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsCategoryFilterPanelOpen(true)}
|
||
aria-haspopup="dialog"
|
||
className="platform-category-filter-button"
|
||
>
|
||
<SlidersHorizontal className="h-4 w-4" />
|
||
<span>
|
||
{categoryFilterApplied
|
||
? activeCategoryFilterLabel
|
||
: '筛选'}
|
||
</span>
|
||
<span className="platform-category-filter-button__count">
|
||
{activeCategoryFilterCount}
|
||
</span>
|
||
</button>
|
||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||
{categoryGroups.map((group) => {
|
||
const active = group.tag === activeCategoryGroup.tag;
|
||
|
||
return (
|
||
<button
|
||
key={`${group.tag}:desktop-category`}
|
||
type="button"
|
||
onClick={() => {
|
||
hasManualCategoryTagSelectionRef.current = true;
|
||
setSelectedCategoryTag(group.tag);
|
||
}}
|
||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||
>
|
||
{group.tag}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={cycleCategorySortMode}
|
||
className="platform-category-sort-button shrink-0"
|
||
>
|
||
<span>{activeCategorySortLabel}</span>
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
{desktopCategoryGrid.length > 0 ? (
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
{desktopCategoryGrid.map((entry) => (
|
||
<WorldCard
|
||
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
|
||
entry={entry}
|
||
onClick={() => openRecommendGalleryDetail(entry)}
|
||
className="w-full min-w-0"
|
||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||
authorSummary={getPublicEntryAuthorSummary(entry)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<PlatformEmptyState>当前筛选下没有作品。</PlatformEmptyState>
|
||
)}
|
||
</>
|
||
) : (
|
||
<PlatformEmptyState>暂时还没有可分类的作品。</PlatformEmptyState>
|
||
)}
|
||
</section>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
const tabContentById = {
|
||
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
|
||
category: categoryContent,
|
||
create: createContent,
|
||
saves: savesContent,
|
||
profile: profileContent,
|
||
} satisfies Record<PlatformHomeTab, ReactNode>;
|
||
const tabPanels = PLATFORM_HOME_TABS.filter((tab) =>
|
||
visibleTabs.includes(tab),
|
||
).map((tab) => {
|
||
const shouldMountPanel = tab === activeTab || visitedTabs.has(tab);
|
||
|
||
return (
|
||
<PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}>
|
||
{shouldMountPanel ? tabContentById[tab] : null}
|
||
</PlatformTabPanel>
|
||
);
|
||
});
|
||
const profileEditModals: ReactNode = (
|
||
<>
|
||
{isNicknameModalOpen ? (
|
||
<ProfileNicknameModal
|
||
value={nicknameInput}
|
||
error={nicknameError}
|
||
isSaving={isSavingNickname}
|
||
onChange={(value) => {
|
||
setNicknameInput(value);
|
||
setNicknameError(null);
|
||
}}
|
||
onClose={() => setIsNicknameModalOpen(false)}
|
||
onSubmit={submitNickname}
|
||
/>
|
||
) : null}
|
||
{avatarSource && avatarImageSize ? (
|
||
<SquareImageCropModal
|
||
source={avatarSource}
|
||
imageSize={avatarImageSize}
|
||
cropRect={avatarCrop}
|
||
titleId="profile-avatar-crop-title"
|
||
labels={{
|
||
title: '裁剪头像',
|
||
close: '关闭头像裁剪',
|
||
editor: '头像裁剪操作区',
|
||
previewAlt: '头像裁剪预览',
|
||
cancel: '取消',
|
||
submit: '上传',
|
||
saving: '上传中',
|
||
}}
|
||
error={avatarError}
|
||
isSaving={isSavingAvatar}
|
||
onCropRectChange={updateAvatarCrop}
|
||
onClose={() => {
|
||
setAvatarSource(null);
|
||
setAvatarImageSize(null);
|
||
setAvatarError(null);
|
||
}}
|
||
onSubmit={submitAvatar}
|
||
/>
|
||
) : null}
|
||
{avatarError && !avatarSource ? (
|
||
<div className="pointer-events-none fixed left-1/2 top-5 z-[90] w-[min(92vw,22rem)] -translate-x-1/2 rounded-2xl border border-rose-400/25 bg-white px-4 py-3 text-center text-sm font-semibold text-rose-600 shadow-2xl">
|
||
{avatarError}
|
||
</div>
|
||
) : null}
|
||
</>
|
||
);
|
||
const rewardCodeModal: ReactNode = isRewardCodeOpen ? (
|
||
<PlatformProfileRewardCodeRedeemModal
|
||
value={rewardCodeInput}
|
||
isSubmitting={isSubmittingRewardCode}
|
||
error={rewardCodeError}
|
||
success={rewardCodeSuccess}
|
||
onChange={setRewardCodeInput}
|
||
onSubmit={submitRewardCode}
|
||
onClose={() => setIsRewardCodeOpen(false)}
|
||
/>
|
||
) : null;
|
||
const rechargeModal: ReactNode = isRechargeOpen ? (
|
||
<PlatformProfileRechargeModal
|
||
center={rechargeCenter}
|
||
isLoading={isLoadingRechargeCenter}
|
||
error={rechargeError}
|
||
submittingProductId={submittingRechargeProductId}
|
||
nativePayment={nativeWechatPayment}
|
||
activeTab={activeRechargeTab}
|
||
onTabChange={setActiveRechargeTab}
|
||
onClose={() => setIsRechargeOpen(false)}
|
||
onRetry={loadRechargeCenter}
|
||
onBuy={buyRechargeProduct}
|
||
onConfirmNativePayment={confirmNativeWechatPayment}
|
||
/>
|
||
) : null;
|
||
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (
|
||
<RechargePaymentResultModal
|
||
result={rechargePaymentResult}
|
||
onClose={() => setRechargePaymentResult(null)}
|
||
/>
|
||
) : null;
|
||
const rechargePaymentConfirmationMask: ReactNode =
|
||
wechatRechargeOrderConfirmationState ? (
|
||
<RechargePaymentConfirmationMask
|
||
orderId={wechatRechargeOrderConfirmationState.orderId}
|
||
/>
|
||
) : null;
|
||
const isRechargePaymentConfirmationPending = Boolean(
|
||
wechatRechargeOrderConfirmationState,
|
||
);
|
||
const categoryFilterDialog: ReactNode = isCategoryFilterPanelOpen ? (
|
||
<PlatformCategoryFilterDialog
|
||
kindFilter={categoryKindFilter}
|
||
sortMode={categorySortMode}
|
||
resultCount={activeCategoryFilterCount}
|
||
onKindFilterChange={setCategoryKindFilter}
|
||
onSortModeChange={setCategorySortMode}
|
||
onReset={() => {
|
||
setCategoryKindFilter('all');
|
||
setCategorySortMode('composite');
|
||
}}
|
||
onClose={() => setIsCategoryFilterPanelOpen(false)}
|
||
/>
|
||
) : null;
|
||
const qrScannerModal: ReactNode = isQrScannerOpen ? (
|
||
<PlatformProfileQrScannerModal
|
||
error={qrScannerError}
|
||
result={qrScannerResult}
|
||
onClose={() => {
|
||
setIsQrScannerOpen(false);
|
||
setQrScannerError(null);
|
||
setQrScannerResult(null);
|
||
}}
|
||
onError={setQrScannerError}
|
||
onResult={(value) => {
|
||
setQrScannerError(null);
|
||
setQrScannerResult(value);
|
||
}}
|
||
/>
|
||
) : null;
|
||
|
||
if (!isDesktopLayout) {
|
||
const isMobileRecommendTab = activeTab === 'home';
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
inert={isRechargePaymentConfirmationPending ? true : undefined}
|
||
className={`platform-mobile-entry-shell ${isMobileRecommendTab ? 'platform-mobile-entry-shell--recommend' : ''} flex h-full min-h-0 min-w-0 flex-col overflow-hidden`}
|
||
>
|
||
{!isMobileRecommendTab ? (
|
||
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||
<RpgEntryBrandLogo />
|
||
{isAuthenticated && activeTab === 'profile' ? (
|
||
<div className="flex items-center gap-2.5">
|
||
<PlatformIconButton
|
||
label="扫码"
|
||
icon={<ScanLine className="h-5 w-5" />}
|
||
onClick={openQrScannerPanel}
|
||
className="platform-profile-header__icon-button"
|
||
/>
|
||
<PlatformIconButton
|
||
label="打开设置"
|
||
icon={<Settings className="h-5 w-5" />}
|
||
onClick={() => authUi?.openSettingsModal()}
|
||
className="platform-profile-header__icon-button"
|
||
/>
|
||
</div>
|
||
) : isAuthenticated &&
|
||
(activeTab === 'create' || activeTab === 'saves') ? (
|
||
<TopbarWalletShortcutButton
|
||
variant="mobile"
|
||
balanceLabel={
|
||
profileDashboardPresentation.walletBalanceWithUnitLabel
|
||
}
|
||
onClick={openRechargeOrRewardCodeModal}
|
||
/>
|
||
) : !isAuthenticated ? (
|
||
<PlatformActionButton
|
||
size="xs"
|
||
className="shrink-0"
|
||
onClick={openUserSurface}
|
||
>
|
||
<LogIn className="h-3.5 w-3.5" />
|
||
登录
|
||
</PlatformActionButton>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||
{tabPanels}
|
||
</div>
|
||
|
||
<div className="platform-mobile-bottom-dock min-w-0 shrink-0">
|
||
<div
|
||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||
>
|
||
{visibleTabs.map((tab) => (
|
||
<PlatformTabButton
|
||
key={tab}
|
||
active={activeTab === tab}
|
||
label={
|
||
activeTab === 'home' && tab === 'home'
|
||
? '下一个'
|
||
: tabLabels[tab]
|
||
}
|
||
icon={
|
||
activeTab === 'home' && tab === 'home'
|
||
? ChevronDown
|
||
: tabIcons[tab]
|
||
}
|
||
emphasized={tab === 'create'}
|
||
showDot={tab === 'saves' && hasUnreadDraftUpdate}
|
||
disabled={isRechargePaymentConfirmationPending}
|
||
onClick={() => {
|
||
if (isRechargePaymentConfirmationPending) {
|
||
return;
|
||
}
|
||
if (activeTab === 'home' && tab === 'home') {
|
||
selectNextRecommendEntry();
|
||
return;
|
||
}
|
||
|
||
onTabChange(tab);
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{profilePopupPanel ? (
|
||
<PlatformProfileReferralModal
|
||
panel={profilePopupPanel}
|
||
center={referralCenter}
|
||
isLoading={isLoadingReferral}
|
||
isSubmittingRedeem={isSubmittingReferralRedeem}
|
||
redeemCode={referralRedeemCode}
|
||
copyInviteState={inviteCopyState}
|
||
error={referralError}
|
||
success={referralSuccess}
|
||
onClose={closeProfilePopupPanel}
|
||
onCopyInvite={copyInviteInfo}
|
||
onRedeemCodeChange={setReferralRedeemCode}
|
||
onSubmitRedeemCode={submitReferralRedeemCode}
|
||
/>
|
||
) : null}
|
||
{rewardCodeModal}
|
||
{rechargeModal}
|
||
{rechargePaymentResultModal}
|
||
{qrScannerModal}
|
||
{categoryFilterDialog}
|
||
{isTaskCenterOpen ? (
|
||
<PlatformProfileTaskCenterModal
|
||
center={taskCenter}
|
||
isLoading={isLoadingTaskCenter}
|
||
error={taskCenterError}
|
||
success={taskClaimSuccess}
|
||
claimingTaskId={claimingTaskId}
|
||
fallbackBalance={remainingNarrativeCoins}
|
||
onClose={() => setIsTaskCenterOpen(false)}
|
||
onRetry={loadTaskCenter}
|
||
onClaim={claimTaskReward}
|
||
/>
|
||
) : null}
|
||
{isProfilePlayStatsOpen ? (
|
||
<PlatformProfilePlayedWorksModal
|
||
stats={profilePlayStats}
|
||
isLoading={isProfilePlayStatsLoading}
|
||
error={profilePlayStatsError}
|
||
saveEntries={saveEntries}
|
||
saveError={saveError}
|
||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||
onClose={onCloseProfilePlayStats ?? (() => undefined)}
|
||
onOpenWork={onOpenPlayedWork}
|
||
onResumeSave={onResumeSave}
|
||
/>
|
||
) : null}
|
||
{isWalletLedgerOpen ? (
|
||
<PlatformProfileWalletLedgerModal
|
||
ledger={walletLedger}
|
||
fallbackBalance={remainingNarrativeCoins}
|
||
isLoading={isLoadingWalletLedger}
|
||
error={walletLedgerError}
|
||
onClose={() => setIsWalletLedgerOpen(false)}
|
||
onRetry={loadWalletLedger}
|
||
/>
|
||
) : null}
|
||
<LegalDocumentModal
|
||
document={activeLegalDocument}
|
||
open={Boolean(activeLegalDocument)}
|
||
platformTheme={authUi?.platformTheme}
|
||
onClose={() => setActiveLegalDocumentId(null)}
|
||
/>
|
||
{profileEditModals}
|
||
</div>
|
||
{rechargePaymentConfirmationMask}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
inert={isRechargePaymentConfirmationPending ? true : undefined}
|
||
className="flex h-full min-h-0 flex-col"
|
||
>
|
||
<div className="flex h-full min-h-0 flex-col">
|
||
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
|
||
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
|
||
<div className="flex min-w-0 flex-1 items-center gap-5">
|
||
<RpgEntryBrandLogo className="shrink-0" decorative />
|
||
<PublicCodeSearchBar
|
||
value={desktopSearchKeyword}
|
||
onChange={updateDesktopSearchKeyword}
|
||
onSubmit={submitDesktopSearch}
|
||
isSearching={
|
||
!onSearchPublicCode || Boolean(isSearchingPublicCode)
|
||
}
|
||
className="max-w-[34rem] flex-1"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{isAuthenticated &&
|
||
(activeTab === 'create' || activeTab === 'saves') ? (
|
||
<TopbarWalletShortcutButton
|
||
variant="desktop"
|
||
balanceLabel={
|
||
profileDashboardPresentation.walletBalanceWithUnitLabel
|
||
}
|
||
onClick={openRechargeOrRewardCodeModal}
|
||
/>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
onClick={openUserSurface}
|
||
className="platform-desktop-search flex items-center gap-3 px-3 py-2.5 text-left"
|
||
>
|
||
<span
|
||
className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full text-base font-black text-white"
|
||
style={{
|
||
background: 'var(--platform-profile-avatar-fill)',
|
||
boxShadow: 'var(--platform-profile-avatar-shadow)',
|
||
}}
|
||
>
|
||
{avatarUrl ? (
|
||
<img
|
||
src={avatarUrl}
|
||
alt=""
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
avatarLabel
|
||
)}
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||
{authUi?.user?.displayName || '登录'}
|
||
</span>
|
||
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
|
||
{authUi?.user ? publicUserCode : '账号入口'}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 flex min-h-0 gap-5">
|
||
<aside className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3">
|
||
{visibleTabs.map((tab) => (
|
||
<DesktopTabButton
|
||
key={tab}
|
||
active={activeTab === tab}
|
||
label={tabLabels[tab]}
|
||
icon={tabIcons[tab]}
|
||
emphasized={tab === 'create'}
|
||
showDot={tab === 'saves' && hasUnreadDraftUpdate}
|
||
disabled={isRechargePaymentConfirmationPending}
|
||
onClick={() => {
|
||
if (isRechargePaymentConfirmationPending) {
|
||
return;
|
||
}
|
||
onTabChange(tab);
|
||
}}
|
||
/>
|
||
))}
|
||
</aside>
|
||
|
||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||
{tabPanels}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{rewardCodeModal}
|
||
{rechargeModal}
|
||
{rechargePaymentResultModal}
|
||
{categoryFilterDialog}
|
||
{isTaskCenterOpen ? (
|
||
<PlatformProfileTaskCenterModal
|
||
center={taskCenter}
|
||
isLoading={isLoadingTaskCenter}
|
||
error={taskCenterError}
|
||
success={taskClaimSuccess}
|
||
claimingTaskId={claimingTaskId}
|
||
fallbackBalance={remainingNarrativeCoins}
|
||
onClose={() => setIsTaskCenterOpen(false)}
|
||
onRetry={loadTaskCenter}
|
||
onClaim={claimTaskReward}
|
||
/>
|
||
) : null}
|
||
{profilePopupPanel ? (
|
||
<PlatformProfileReferralModal
|
||
panel={profilePopupPanel}
|
||
center={referralCenter}
|
||
isLoading={isLoadingReferral}
|
||
isSubmittingRedeem={isSubmittingReferralRedeem}
|
||
redeemCode={referralRedeemCode}
|
||
copyInviteState={inviteCopyState}
|
||
error={referralError}
|
||
success={referralSuccess}
|
||
onClose={closeProfilePopupPanel}
|
||
onCopyInvite={copyInviteInfo}
|
||
onRedeemCodeChange={setReferralRedeemCode}
|
||
onSubmitRedeemCode={submitReferralRedeemCode}
|
||
/>
|
||
) : null}
|
||
{isProfilePlayStatsOpen ? (
|
||
<PlatformProfilePlayedWorksModal
|
||
stats={profilePlayStats}
|
||
isLoading={isProfilePlayStatsLoading}
|
||
error={profilePlayStatsError}
|
||
saveEntries={saveEntries}
|
||
saveError={saveError}
|
||
isResumingSaveWorldKey={isResumingSaveWorldKey}
|
||
onClose={onCloseProfilePlayStats ?? (() => undefined)}
|
||
onOpenWork={onOpenPlayedWork}
|
||
onResumeSave={onResumeSave}
|
||
/>
|
||
) : null}
|
||
{isWalletLedgerOpen ? (
|
||
<PlatformProfileWalletLedgerModal
|
||
ledger={walletLedger}
|
||
fallbackBalance={remainingNarrativeCoins}
|
||
isLoading={isLoadingWalletLedger}
|
||
error={walletLedgerError}
|
||
onClose={() => setIsWalletLedgerOpen(false)}
|
||
onRetry={loadWalletLedger}
|
||
/>
|
||
) : null}
|
||
<LegalDocumentModal
|
||
document={activeLegalDocument}
|
||
open={Boolean(activeLegalDocument)}
|
||
platformTheme={authUi?.platformTheme}
|
||
onClose={() => setActiveLegalDocumentId(null)}
|
||
/>
|
||
{profileEditModals}
|
||
{rechargePaymentConfirmationMask}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export const PlatformHomeView = RpgEntryHomeView;
|