Files
Genarrative/src/components/custom-world-home/CustomWorldWorkCard.tsx
2026-05-02 17:56:42 +08:00

440 lines
16 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 { 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<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 EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
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 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 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 (
<div
ref={cardRef}
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}`}
onClick={onOpen}
onKeyDown={(event) => {
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' : ''}`}
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0 opacity-70 saturate-[1.08]"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
<div className="pointer-events-auto absolute right-0 top-0 z-30 flex items-center gap-1">
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</button>
) : null}
{isPublished ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={!item.canShare || !item.sharePath}
title={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
</div>
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">
{item.badges.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
</div>
<div className="mt-3 min-h-0 sm:mt-4 xl:mt-3">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl xl:text-xl">
{displayTitle}
</div>
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
{item.summary}
</div>
</div>
{isPublished ? (
<div className="mt-auto space-y-2 pt-3 sm:pt-4 xl:pt-3">
{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="pointer-events-auto creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</div>
) : null}
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{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>
);
}