Files
Genarrative/src/components/custom-world-home/CustomWorldWorkCard.tsx
kdletters 5859d738a0 Merge remote-tracking branch 'origin/master' into feat/recommend-runtime-guest
# Conflicts:
#	docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
2026-05-25 14:12:39 +08:00

849 lines
26 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 {
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<Record<CreationWorkShelfMetricId, number>>;
onOpen: () => void;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
onClaimPointIncentive?: (() => void) | null;
pointIncentiveBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
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<CreationWorkShelfKind, string> =
{
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<Record<CreationWorkShelfMetricId, number>>,
) {
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<CreationWorkShelfMetricId, number>;
}
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<Record<CreationWorkShelfMetricId, number>>,
) {
const cardRef = useRef<HTMLDivElement | null>(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<number | null>(null);
const suppressOpenResetTimerRef = useRef<number | null>(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<typeof swipeGestureRef.current>,
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
updateSwipeOffset(
gesture,
event.clientX,
event.clientY,
() => event.preventDefault(),
);
};
const endSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>,
) => {
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<HTMLDivElement>,
) => {
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<HTMLDivElement>) => {
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 (
<div
ref={cardRef}
style={swipeShellStyle}
className={`creation-work-card-shell ${
isSwipeDragging || isSwipeActionRevealed
? 'creation-work-card-shell--actions-visible'
: ''
}`}
>
{swipeActionCount > 0 ? (
<div
aria-hidden={!isSwipeActionRevealed}
className="creation-work-card__swipe-underlay"
>
<div className="creation-work-card__swipe-actions">
{onDelete ? (
<button
type="button"
tabIndex={isSwipeActionRevealed ? 0 : -1}
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="creation-work-card__swipe-button creation-work-card__swipe-button--danger"
>
{deleteBusy ? (
<span className="text-xs leading-none">...</span>
) : (
<Trash2 aria-hidden="true" className="h-4 w-4" />
)}
</button>
) : null}
</div>
</div>
) : null}
<div
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}${item.isGenerating ? ',生成中' : ''}`}
onClick={(event) => {
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' : ''}`}
>
<div className="creation-work-card__body platform-category-game-item__body">
<div className="creation-work-card__title-row platform-category-game-item__title-row">
<div className="creation-work-card__title-lockup">
<span
aria-label={
item.isGenerating
? '生成中'
: item.status === 'published'
? '已发布'
: '草稿'
}
className={`creation-work-card__state-mark creation-work-card__state-mark--${
item.isGenerating ? 'generating' : item.status
}`}
>
{item.isGenerating ? (
<Loader2 aria-hidden="true" className="h-3.5 w-3.5" />
) : item.status === 'published' ? (
<BadgeCheck aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Clock3 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</span>
<span className="creation-work-card__title platform-category-game-item__title">
{displayTitle}
</span>
</div>
{canUseShareAction ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
onTouchStart={(event) => {
event.stopPropagation();
}}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="creation-work-card__share-button"
>
<Share2 aria-hidden="true" className="h-4 w-4" />
</button>
) : null}
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">
{item.badges.slice(1).map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
<div className="creation-work-card__summary platform-category-game-item__summary">
{item.summary}
</div>
{isPublished ? (
<div className="creation-work-card__published-info">
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationPointIncentiveTotal(
item.pointIncentive.totalPoints,
)}
</span>
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationMetricCount(
item.pointIncentive.claimablePoints,
)}
</span>
</div>
<button
type="button"
disabled={!canClaimPointIncentive || pointIncentiveBusy}
onClick={(event) => {
event.stopPropagation();
onClaimPointIncentive?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</div>
) : null}
<div className="creation-work-card__metrics">
{item.metrics.map((metric) => (
<div
key={`${item.id}-${metric.id}`}
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
<span className="creation-work-card-stat__value">
<span className="creation-work-card-stat__number">
{formatCreationMetricCount(
displayValues[metric.id] ?? metric.value,
)}
</span>
<span className="creation-work-card-stat__unit">
{metric.unit}
</span>
</span>
{showGrowth && deltas[metric.id] > 0 ? (
<span className="creation-work-card-stat__growth">
<span aria-hidden="true"></span>
{formatCreationMetricCount(deltas[metric.id])}
</span>
) : null}
</div>
))}
</div>
</div>
) : null}
</div>
<div
className="creation-work-card__side-cover"
style={coverFadeStyle}
aria-hidden="true"
>
<div className="creation-work-card__side-cover-inner">
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
fallbackImageSrc={fallbackCoverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="absolute inset-0"
/>
</div>
</div>
{item.hasUnreadUpdate ? (
<span
aria-label="新生成完成"
className="creation-work-card__unread-dot"
/>
) : null}
{item.isGenerating ? (
<div
className="creation-work-card__generating-mask"
aria-hidden="true"
>
<span className="creation-work-card__spinner" />
<span>...</span>
</div>
) : null}
</div>
</div>
);
}