import { Share2, Trash2 } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { copyTextToClipboard } from '../../services/clipboard'; import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { formatPlatformWorkDisplayName, formatPlatformWorkDisplayTag, } from '../rpg-entry/rpgEntryWorldPresentation'; import { type CreationWorkShelfBadgeTone, type CreationWorkShelfItem, type CreationWorkShelfMetric, type CreationWorkShelfMetricId, formatCreationMetricCount, formatCreationPointIncentiveTotal, } from './creationWorkShelf'; type CustomWorldWorkCardProps = { item: CreationWorkShelfItem; previousMetricValues?: Partial>; onOpen: () => void; onDelete?: (() => void) | null; deleteBusy?: boolean; onClaimPointIncentive?: (() => void) | null; pointIncentiveBusy?: boolean; }; const BADGE_TONE_CLASS: Record = { warm: 'platform-pill--warm', success: 'platform-pill--success', neutral: 'platform-pill--neutral', }; const METRIC_ANIMATION_DURATION_MS = 820; const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = []; function easeOutCubic(progress: number) { return 1 - (1 - progress) ** 3; } function resolveMetricStartValue( metric: CreationWorkShelfMetric, previousMetricValues?: Partial>, ) { const previousValue = previousMetricValues?.[metric.id]; if (previousValue === undefined || previousValue >= metric.value) { return metric.value; } return Math.max(0, Math.floor(previousValue)); } function buildMetricValueMap( metrics: CreationWorkShelfMetric[], resolveValue: (metric: CreationWorkShelfMetric) => number, ) { return Object.fromEntries( metrics.map((metric) => [metric.id, resolveValue(metric)]), ) as Record; } function shouldAnimatePublishedMetrics() { if (typeof window === 'undefined') { return false; } return !window.navigator.userAgent.toLowerCase().includes('jsdom'); } function usePublishedMetricAnimation( metrics: CreationWorkShelfMetric[], previousMetricValues?: Partial>, ) { const cardRef = useRef(null); const [hasEnteredView, setHasEnteredView] = useState(false); const startValues = useMemo( () => buildMetricValueMap(metrics, (metric) => resolveMetricStartValue(metric, previousMetricValues), ), [metrics, previousMetricValues], ); const endValues = useMemo( () => buildMetricValueMap(metrics, (metric) => metric.value), [metrics], ); const deltas = useMemo( () => buildMetricValueMap(metrics, (metric) => Math.max(0, metric.value - startValues[metric.id]), ), [metrics, startValues], ); const hasGrowth = useMemo( () => Object.values(deltas).some((delta) => delta > 0), [deltas], ); const [displayValues, setDisplayValues] = useState(endValues); const [showGrowth, setShowGrowth] = useState(false); useEffect(() => { setShowGrowth(false); setHasEnteredView(false); setDisplayValues(hasGrowth ? startValues : endValues); }, [endValues, hasGrowth, startValues]); useEffect(() => { const element = cardRef.current; if (!element || !hasGrowth) { setHasEnteredView(true); return; } if (typeof window === 'undefined' || !('IntersectionObserver' in window)) { setHasEnteredView(true); return; } // 中文注释:指标增长只在卡片进入视口后启动,避免列表刷新时离屏卡片提前播放。 const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { setHasEnteredView(true); observer.disconnect(); } }, { rootMargin: '0px 0px -10% 0px', threshold: 0.28 }, ); observer.observe(element); return () => observer.disconnect(); }, [hasGrowth]); useEffect(() => { if (!hasEnteredView) { return; } if (!hasGrowth || !shouldAnimatePublishedMetrics()) { setDisplayValues(endValues); if (hasGrowth) { setShowGrowth(true); } return; } if (typeof window === 'undefined') { setDisplayValues(endValues); setShowGrowth(true); return; } let animationFrameId = 0; const startTime = window.performance.now(); const tick = (now: number) => { const progress = Math.min( 1, (now - startTime) / METRIC_ANIMATION_DURATION_MS, ); const easedProgress = easeOutCubic(progress); setDisplayValues( buildMetricValueMap(metrics, (metric) => { const startValue = startValues[metric.id]; const endValue = endValues[metric.id]; return Math.round( startValue + (endValue - startValue) * easedProgress, ); }), ); if (progress < 1) { animationFrameId = window.requestAnimationFrame(tick); return; } setDisplayValues(endValues); setShowGrowth(true); }; animationFrameId = window.requestAnimationFrame(tick); return () => { window.cancelAnimationFrame(animationFrameId); }; }, [endValues, hasEnteredView, hasGrowth, metrics, startValues]); return { cardRef, deltas, displayValues, showGrowth }; } export function CustomWorldWorkCard({ item, previousMetricValues, onOpen, onDelete = null, deleteBusy = false, onClaimPointIncentive = null, pointIncentiveBusy = false, }: CustomWorldWorkCardProps) { const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>( 'idle', ); const shareResetTimerRef = useRef(null); const isPublished = item.status === 'published'; const canClaimPointIncentive = Boolean(onClaimPointIncentive) && (item.pointIncentive?.claimablePoints ?? 0) > 0; const displayTitle = formatPlatformWorkDisplayName(item.title); const { cardRef, deltas, displayValues, showGrowth } = usePublishedMetricAnimation( isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS, previousMetricValues, ); const copyShareText = () => { const publicWorkCode = item.publicWorkCode?.trim(); const sharePath = item.sharePath?.trim(); if (!publicWorkCode || !sharePath) { return; } const shareUrl = typeof window === 'undefined' ? sharePath : new URL(sharePath, window.location.origin).href; const shareText = `邀请你来玩《${item.title}》\n作品号:${publicWorkCode}\n${shareUrl}`; void copyTextToClipboard(shareText).then((copied) => { setShareState(copied ? 'copied' : 'failed'); if (shareResetTimerRef.current !== null) { window.clearTimeout(shareResetTimerRef.current); } shareResetTimerRef.current = window.setTimeout(() => { shareResetTimerRef.current = null; setShareState('idle'); }, 1400); }); }; useEffect( () => () => { if (shareResetTimerRef.current !== null) { window.clearTimeout(shareResetTimerRef.current); } }, [], ); return (
{ if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); onOpen(); }} className={`platform-surface platform-interactive-card relative min-h-[9.5rem] cursor-pointer overflow-hidden px-2.5 py-2.5 text-left sm:min-h-[12.5rem] sm:px-4 sm:py-4 xl:min-h-[11.25rem] xl:px-4 xl:py-3.5 ${isPublished ? 'col-span-2 sm:col-span-1' : ''}`} >
{onDelete ? ( ) : null} {isPublished ? ( ) : null}
{item.badges.map((badge) => ( {formatPlatformWorkDisplayTag(badge.label)} ))}
{displayTitle}
{item.summary}
{isPublished ? (
{item.pointIncentive ? (
积分激励 {formatCreationPointIncentiveTotal( item.pointIncentive.totalPoints, )}
待领取 {formatCreationMetricCount( item.pointIncentive.claimablePoints, )}
) : null}
{item.metrics.map((metric) => (
{metric.label} {formatCreationMetricCount( displayValues[metric.id] ?? metric.value, )} {metric.unit} {showGrowth && deltas[metric.id] > 0 ? ( {formatCreationMetricCount(deltas[metric.id])} ) : null}
))}
) : null}
); }