440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|