849 lines
26 KiB
TypeScript
849 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|