import { BadgeCheck, Clock3, Loader2, Share2, Trash2, } from 'lucide-react'; import { type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type TouchEvent as ReactTouchEvent, 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 CreationWorkShelfKind, 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 SWIPE_ACTION_WIDTH_PX = 76; const SWIPE_REVEAL_THRESHOLD_PX = 42; const SWIPE_DIRECTION_LOCK_PX = 8; const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = []; const CREATION_WORK_KIND_FALLBACK_COVER: Record = { rpg: '/creation-type-references/rpg.webp', 'big-fish': '/creation-type-references/big-fish.webp', match3d: '/creation-type-references/match3d.webp', 'square-hole': '/creation-type-references/square-hole.webp', 'jump-hop': '/creation-type-references/jump-hop.webp', puzzle: '/creation-type-references/puzzle.webp', 'baby-object-match': '/creation-type-references/creative-agent.webp', 'bark-battle': '/creation-type-references/bark-battle.webp', 'visual-novel': '/creation-type-references/visual-novel.webp', }; 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 clampSwipeOffset(value: number, revealWidth: number) { return Math.min(0, Math.max(-revealWidth, value)); } 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 suppressOpenResetTimerRef = useRef(null); const suppressOpenRef = useRef(false); const swipeGestureRef = useRef<{ pointerId: number; startX: number; startY: number; startOffset: number; isDragging: boolean; } | null>(null); const lastSwipeOffsetRef = useRef(0); const [isSwipeDragging, setIsSwipeDragging] = useState(false); const [isSwipeActionRevealed, setIsSwipeActionRevealed] = useState(false); const [swipeOffset, setSwipeOffset] = useState(0); const isPublished = item.status === 'published'; const canUseShareAction = isPublished && item.canShare && Boolean(item.sharePath); const swipeActionCount = onDelete ? 1 : 0; const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX; const canClaimPointIncentive = Boolean(onClaimPointIncentive) && (item.pointIncentive?.claimablePoints ?? 0) > 0; const displayTitle = formatPlatformWorkDisplayName(item.title); const fallbackCoverImageSrc = CREATION_WORK_KIND_FALLBACK_COVER[item.kind]; const { cardRef, deltas, displayValues, showGrowth } = usePublishedMetricAnimation( isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS, previousMetricValues, ); const coverFadeStyle = { WebkitMaskImage: 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.04) 18%, rgba(0, 0, 0, 0.18) 42%, rgba(0, 0, 0, 0.48) 70%, rgba(0, 0, 0, 0.72) 100%)', maskImage: 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.04) 18%, rgba(0, 0, 0, 0.18) 42%, rgba(0, 0, 0, 0.48) 70%, rgba(0, 0, 0, 0.72) 100%)', } as CSSProperties; const currentSwipeOffset = isSwipeDragging ? swipeOffset : isSwipeActionRevealed ? -swipeRevealWidth : 0; const cardSurfaceStyle = { '--creation-work-card-swipe-offset': `${currentSwipeOffset}px`, '--creation-work-card-cover-fallback': `url(${fallbackCoverImageSrc})`, } as CSSProperties; const swipeShellStyle = { '--creation-work-card-action-opacity': `${ swipeRevealWidth > 0 ? Math.min(1, Math.abs(currentSwipeOffset) / swipeRevealWidth) : 0 }`, } as CSSProperties; 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(() => { return () => { if (shareResetTimerRef.current !== null) { window.clearTimeout(shareResetTimerRef.current); } if (suppressOpenResetTimerRef.current !== null) { window.clearTimeout(suppressOpenResetTimerRef.current); } }; }, []); const closeSwipeActions = () => { setIsSwipeActionRevealed(false); setSwipeOffset(0); lastSwipeOffsetRef.current = 0; }; const revealSwipeActions = () => { if (swipeRevealWidth <= 0) { closeSwipeActions(); return; } setIsSwipeActionRevealed(true); setSwipeOffset(-swipeRevealWidth); lastSwipeOffsetRef.current = -swipeRevealWidth; }; const updateSwipeOffset = ( gesture: NonNullable, clientX: number, clientY: number, preventDefault: () => void, ) => { const deltaX = clientX - gesture.startX; const deltaY = clientY - gesture.startY; if (!gesture.isDragging) { if ( Math.abs(deltaX) < SWIPE_DIRECTION_LOCK_PX && Math.abs(deltaY) < SWIPE_DIRECTION_LOCK_PX ) { return; } if (Math.abs(deltaY) > Math.abs(deltaX)) { swipeGestureRef.current = null; return; } gesture.isDragging = true; setIsSwipeDragging(true); } // 中文注释:横向手势只移动卡片表层,删除动作保持在底层,避免列表滚动时误触。 preventDefault(); suppressOpenRef.current = true; const nextOffset = clampSwipeOffset( gesture.startOffset + deltaX, swipeRevealWidth, ); lastSwipeOffsetRef.current = nextOffset; setSwipeOffset(nextOffset); }; const finishSwipeGesture = (wasDragging: boolean) => { setIsSwipeDragging(false); if (!wasDragging) { return; } const shouldReveal = lastSwipeOffsetRef.current <= -Math.min(SWIPE_REVEAL_THRESHOLD_PX, swipeRevealWidth * 0.45); if (shouldReveal) { revealSwipeActions(); } else { closeSwipeActions(); } suppressOpenRef.current = true; scheduleOpenSuppressReset(); }; const scheduleOpenSuppressReset = () => { if (typeof window === 'undefined') { return; } if (suppressOpenResetTimerRef.current !== null) { window.clearTimeout(suppressOpenResetTimerRef.current); } suppressOpenResetTimerRef.current = window.setTimeout(() => { suppressOpenResetTimerRef.current = null; suppressOpenRef.current = false; }, 260); }; useEffect(() => { if (swipeActionCount > 0) { return; } closeSwipeActions(); }, [swipeActionCount]); const beginSwipeGesture = (event: ReactPointerEvent) => { if (swipeRevealWidth <= 0) { return; } if (event.pointerType === 'mouse' && event.button !== 0) { return; } swipeGestureRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0, isDragging: false, }; event.currentTarget.setPointerCapture?.(event.pointerId); }; const updateSwipeGesture = (event: ReactPointerEvent) => { const gesture = swipeGestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId) { return; } updateSwipeOffset( gesture, event.clientX, event.clientY, () => event.preventDefault(), ); }; const endSwipeGesture = (event: ReactPointerEvent) => { const gesture = swipeGestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId) { return; } event.currentTarget.releasePointerCapture?.(event.pointerId); swipeGestureRef.current = null; finishSwipeGesture(gesture.isDragging); }; const cancelSwipeGesture = (event: ReactPointerEvent) => { const gesture = swipeGestureRef.current; if (gesture?.pointerId === event.pointerId) { event.currentTarget.releasePointerCapture?.(event.pointerId); } swipeGestureRef.current = null; setIsSwipeDragging(false); if (isSwipeActionRevealed) { revealSwipeActions(); } else { closeSwipeActions(); } }; const beginTouchSwipeGesture = ( event: ReactTouchEvent, ) => { if (swipeRevealWidth <= 0) { return; } const touch = event.touches[0]; if (!touch) { return; } swipeGestureRef.current = { pointerId: -1, startX: touch.clientX, startY: touch.clientY, startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0, isDragging: false, }; }; const updateTouchSwipeGesture = ( event: ReactTouchEvent, ) => { const gesture = swipeGestureRef.current; const touch = event.touches[0]; if (!gesture || gesture.pointerId !== -1 || !touch) { return; } updateSwipeOffset( gesture, touch.clientX, touch.clientY, () => event.preventDefault(), ); }; const endTouchSwipeGesture = () => { const gesture = swipeGestureRef.current; if (!gesture || gesture.pointerId !== -1) { return; } swipeGestureRef.current = null; finishSwipeGesture(gesture.isDragging); }; const cancelTouchSwipeGesture = () => { const gesture = swipeGestureRef.current; if (!gesture || gesture.pointerId !== -1) { return; } swipeGestureRef.current = null; setIsSwipeDragging(false); if (isSwipeActionRevealed) { revealSwipeActions(); } else { closeSwipeActions(); } }; const handleCardOpen = () => { if (isSwipeActionRevealed) { closeSwipeActions(); return; } onOpen(); }; const handleCardKeyDown = (event: ReactKeyboardEvent) => { if ( (event.key === 'ArrowLeft' || event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) && swipeRevealWidth > 0 ) { event.preventDefault(); revealSwipeActions(); return; } if (event.key === 'Escape' && isSwipeActionRevealed) { event.preventDefault(); closeSwipeActions(); return; } if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); handleCardOpen(); }; return (
{swipeActionCount > 0 ? (
{onDelete ? ( ) : null}
) : null}
{ if (suppressOpenRef.current) { event.preventDefault(); suppressOpenRef.current = false; return; } handleCardOpen(); }} onKeyDown={handleCardKeyDown} onPointerDown={beginSwipeGesture} onPointerMove={updateSwipeGesture} onPointerUp={endSwipeGesture} onPointerCancel={cancelSwipeGesture} onTouchStart={beginTouchSwipeGesture} onTouchMove={updateTouchSwipeGesture} onTouchEnd={endTouchSwipeGesture} onTouchCancel={cancelTouchSwipeGesture} onContextMenu={(event) => { if (swipeRevealWidth <= 0) { return; } event.preventDefault(); revealSwipeActions(); }} style={cardSurfaceStyle} className={`creation-work-card platform-category-game-item platform-interactive-card cursor-pointer overflow-hidden text-left ${isPublished ? 'creation-work-card--published' : 'creation-work-card--draft'} ${item.isGenerating ? 'creation-work-card--generating' : ''} ${isSwipeDragging ? 'creation-work-card--swiping' : ''}`} >
{item.isGenerating ? ( {displayTitle}
{canUseShareAction ? ( ) : null}
{item.badges.slice(1).map((badge) => ( {formatPlatformWorkDisplayTag(badge.label)} ))}
{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}
{item.hasUnreadUpdate ? ( ) : null} {item.isGenerating ? ( ) : null}
); }