Files
Genarrative/src/components/platform-entry/PlatformWorkDetailView.tsx
2026-05-02 17:56:42 +08:00

449 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
CircleHelp,
Clock3,
Copy,
Gamepad2,
GitFork,
Heart,
PencilLine,
Play,
Share2,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldDisplayTags,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldStats,
} from '../rpg-entry/rpgEntryWorldPresentation';
export interface PlatformWorkDetailViewProps {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
authorDisplayName?: string | null;
isBusy: boolean;
error: string | null;
visibleCoverCount?: number;
onBack: () => void;
onLike: () => void;
onStart: () => void;
onRemix: () => void;
actionMode?: 'remix' | 'edit';
}
function formatCompactCount(value: number) {
if (value >= 10000) {
const normalized = value / 10000;
return `${Number.isInteger(normalized) ? normalized.toFixed(0) : normalized.toFixed(1)}`;
}
return `${value}`;
}
function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
return '拼图';
}
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
return '大鱼吃小鱼';
}
if ('sourceType' in entry && entry.sourceType === 'match3d') {
return '抓大鹅';
}
return 'RPG';
}
function getAuthorAvatarLabel(authorDisplayName: string) {
return Array.from(authorDisplayName.trim() || '作')[0] ?? '作';
}
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
export function PlatformWorkDetailView({
entry,
authorAvatarUrl,
authorDisplayName,
isBusy,
error,
visibleCoverCount = 1,
onBack,
onLike,
onStart,
onRemix,
actionMode = 'remix',
}: PlatformWorkDetailViewProps) {
const coverSlides = useMemo(
() => resolvePlatformWorldCoverSlides(entry),
[entry],
);
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
const activeCoverSlide =
coverSlides[activeCoverIndex] ?? coverSlides[0] ?? null;
const coverImage = activeCoverSlide?.imageSrc ?? '';
const unlockedCoverCount = Math.max(1, Math.floor(visibleCoverCount));
const isActiveCoverVisible = activeCoverIndex < unlockedCoverCount;
const appIconImage = coverSlides[0]?.imageSrc ?? '';
const hasCoverCarousel = coverSlides.length > 1;
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const resolvedAuthorDisplayName =
authorDisplayName?.trim() || entry.authorDisplayName;
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = useMemo(
() =>
formatPlatformWorkDisplayTags(
[getSourceLabel(entry), ...buildPlatformWorldDisplayTags(entry, 3)],
4,
),
[entry],
);
const stats = resolvePlatformWorldStats(entry);
const workActionLabel = actionMode === 'edit' ? '作品编辑' : '作品改造';
const WorkActionIcon = actionMode === 'edit' ? PencilLine : GitFork;
const statItems = [
{
label: '游玩',
value: formatCompactCount(stats.playCount),
unit: '次',
icon: Gamepad2,
tone: 'play',
},
{
label: '改造',
value: formatCompactCount(stats.remixCount),
unit: '次',
icon: GitFork,
tone: 'remix',
},
{
label: '点赞',
value: formatCompactCount(stats.likeCount),
unit: '赞',
icon: Heart,
tone: 'like',
},
{
label: '日期',
value: formatPlatformWorldTime(stats.updatedAt ?? stats.publishedAt),
icon: Clock3,
tone: 'time',
isTime: true,
},
];
useEffect(() => {
setActiveCoverIndex(0);
}, [entry.profileId, coverSlides.length]);
useEffect(() => {
setActiveCoverIndex((current) =>
coverSlides.length > 0 ? Math.min(current, coverSlides.length - 1) : 0,
);
}, [coverSlides.length]);
useEffect(() => {
if (!hasCoverCarousel) {
return undefined;
}
const timerId = window.setInterval(() => {
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
}, PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS);
return () => {
window.clearInterval(timerId);
};
}, [coverSlides.length, hasCoverCarousel]);
const showPreviousCover = () => {
if (!hasCoverCarousel) {
return;
}
setActiveCoverIndex(
(current) => (current - 1 + coverSlides.length) % coverSlides.length,
);
};
const showNextCover = () => {
if (!hasCoverCarousel) {
return;
}
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
};
const copyPublicWorkCode = () => {
if (!publicWorkCode) {
return;
}
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
const sharePublicWork = () => {
if (!publicWorkCode) {
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
return (
<div className="platform-work-detail">
<div className="platform-work-detail__topbar">
<button
type="button"
className="platform-work-detail__icon-button"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft className="h-6 w-6" />
</button>
<div className="platform-work-detail__title"></div>
<button
type="button"
className="platform-work-detail__icon-button"
onClick={sharePublicWork}
disabled={!publicWorkCode}
aria-label="分享"
title="分享"
>
<Share2 className="h-5 w-5" />
</button>
</div>
<div className="platform-work-detail__scroll">
<section className="platform-work-detail__cover">
{coverImage ? (
<>
<ResolvedAssetImage
src={coverImage}
alt=""
aria-hidden="true"
className="platform-work-detail__cover-blur"
/>
<ResolvedAssetImage
src={coverImage}
alt={entry.worldName}
className={`platform-work-detail__cover-image${
isActiveCoverVisible
? ''
: ' platform-work-detail__cover-image--locked'
}`}
/>
{!isActiveCoverVisible ? (
<div
className="platform-work-detail__cover-lock"
aria-hidden="true"
>
<CircleHelp className="platform-work-detail__cover-lock-icon" />
</div>
) : null}
{hasCoverCarousel ? (
<>
<button
type="button"
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--prev"
onClick={showPreviousCover}
aria-label="上一张关卡图"
title="上一张关卡图"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--next"
onClick={showNextCover}
aria-label="下一张关卡图"
title="下一张关卡图"
>
<ChevronRight className="h-5 w-5" />
</button>
<div className="platform-work-detail__cover-dots">
{coverSlides.map((slide, index) => (
<button
key={slide.id}
type="button"
className={`platform-work-detail__cover-dot${
index === activeCoverIndex
? ' platform-work-detail__cover-dot--active'
: ''
}`}
onClick={() => setActiveCoverIndex(index)}
aria-label={`查看${slide.label || `${index + 1}`}`}
aria-current={
index === activeCoverIndex ? 'true' : undefined
}
/>
))}
</div>
</>
) : null}
</>
) : (
<div className="platform-work-detail__cover-fallback" />
)}
</section>
<section className="platform-work-detail__summary">
<div className="platform-work-detail__meta-row">
<div className="platform-work-detail__app-icon">
{appIconImage ? (
<ResolvedAssetImage
src={appIconImage}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
entry.worldName.slice(0, 1)
)}
</div>
<div className="min-w-0 flex-1">
<div className="platform-work-detail__name">{displayName}</div>
<div className="platform-work-detail__author">
<span className="platform-work-detail__author-avatar">
{normalizedAuthorAvatarUrl ? (
<ResolvedAssetImage
src={normalizedAuthorAvatarUrl}
alt=""
aria-hidden="true"
className="platform-work-detail__author-avatar-image"
/>
) : (
<span className="platform-work-detail__author-avatar-label">
{getAuthorAvatarLabel(resolvedAuthorDisplayName)}
</span>
)}
</span>
<span className="platform-work-detail__author-name">
{resolvedAuthorDisplayName}
</span>
</div>
</div>
<button
type="button"
className="platform-work-detail__like"
onClick={onLike}
disabled={isBusy}
aria-label={`点赞 ${formatCompactCount(stats.likeCount)}`}
title="点赞"
>
<Heart className="h-5 w-5 fill-current" />
</button>
</div>
<div className="platform-work-detail__stats">
{statItems.map((item) => (
<div
key={item.label}
className={`platform-work-detail__stat platform-work-detail__stat--${item.tone}`}
>
<div className="platform-work-detail__stat-head">
<span className="platform-work-detail__stat-icon">
<item.icon className="h-3.5 w-3.5" />
</span>
<span className="platform-work-detail__stat-label">
{item.label}
</span>
</div>
<div
className={`platform-work-detail__stat-value${
item.isTime ? ' platform-work-detail__stat-value--time' : ''
}`}
>
<span className="platform-work-detail__stat-number">
{item.value}
</span>
{item.unit ? (
<span className="platform-work-detail__stat-unit">
{item.unit}
</span>
) : null}
</div>
</div>
))}
</div>
</section>
<section className="platform-work-detail__body">
<div className="platform-work-detail__chips">
{tags.map((tag) => (
<span key={tag} className="platform-work-detail__chip">
{tag}
</span>
))}
</div>
<p className="platform-work-detail__copy">{entry.summaryText}</p>
{publicWorkCode ? (
<button
type="button"
className="platform-work-detail__code"
onClick={copyPublicWorkCode}
>
<Copy className="h-4 w-4" />
<span>{publicWorkCode}</span>
{copyState !== 'idle' ? (
<span>{copyState === 'copied' ? '已复制' : '复制失败'}</span>
) : null}
</button>
) : null}
{shareState !== 'idle' ? (
<div className="platform-work-detail__toast">
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
</div>
) : null}
{error ? (
<div className="platform-work-detail__error">{error}</div>
) : null}
</section>
</div>
<div className="platform-work-detail__bottom">
<button
type="button"
className="platform-work-detail__remix"
onClick={onRemix}
disabled={isBusy}
>
<WorkActionIcon className="h-5 w-5" />
{workActionLabel}
</button>
<button
type="button"
className="platform-work-detail__start"
onClick={onStart}
disabled={isBusy}
>
<Play className="h-5 w-5 fill-current" />
</button>
</div>
</div>
);
}