1
This commit is contained in:
@@ -1,64 +1,240 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Share2, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import type { CreationWorkShelfItem } from './creationWorkShelf';
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '最近更新';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
import {
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTag,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
type CreationWorkShelfBadgeTone,
|
||||
type CreationWorkShelfItem,
|
||||
type CreationWorkShelfMetric,
|
||||
type CreationWorkShelfMetricId,
|
||||
formatCreationMetricCount,
|
||||
} from './creationWorkShelf';
|
||||
|
||||
type CustomWorldWorkCardProps = {
|
||||
item: CreationWorkShelfItem;
|
||||
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>;
|
||||
onOpen: () => void;
|
||||
onExperience?: (() => void) | null;
|
||||
onDelete?: (() => void) | null;
|
||||
deleteBusy?: boolean;
|
||||
};
|
||||
|
||||
const BADGE_TONE_CLASS: Record<
|
||||
CreationWorkShelfItem['badges'][number]['tone'],
|
||||
string
|
||||
> = {
|
||||
const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
|
||||
warm: 'platform-pill--warm',
|
||||
success: 'platform-pill--success',
|
||||
neutral: 'platform-pill--neutral',
|
||||
};
|
||||
|
||||
export function CustomWorldWorkCard({
|
||||
item,
|
||||
onOpen,
|
||||
onExperience = null,
|
||||
onDelete = null,
|
||||
deleteBusy = false,
|
||||
}: CustomWorldWorkCardProps) {
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
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 copyPublicWorkCode = () => {
|
||||
if (!item.publicWorkCode) {
|
||||
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;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(item.publicWorkCode).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||
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,
|
||||
}: CustomWorldWorkCardProps) {
|
||||
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const shareResetTimerRef = useRef<number | null>(null);
|
||||
const isPublished = item.status === 'published';
|
||||
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}》`}
|
||||
@@ -71,7 +247,7 @@ export function CustomWorldWorkCard({
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}}
|
||||
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
|
||||
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}
|
||||
@@ -79,126 +255,127 @@ export function CustomWorldWorkCard({
|
||||
fallbackLabel="封面"
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="platform-cover-artwork absolute inset-0"
|
||||
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="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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]">
|
||||
{!isPublished && onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={deleteBusy}
|
||||
aria-label={deleteBusy ? '删除中' : '删除'}
|
||||
title={deleteBusy ? '删除中' : '删除作品'}
|
||||
className="pointer-events-auto absolute right-0 top-0 z-30 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="pointer-events-auto absolute right-0 top-0 z-30 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 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]} px-3 py-1 text-[10px]`}
|
||||
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]`}
|
||||
>
|
||||
{badge.label}
|
||||
{formatPlatformWorkDisplayTag(badge.label)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-[11px] text-[var(--platform-text-soft)]">
|
||||
{formatUpdatedAt(item.updatedAt)}
|
||||
</span>
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={deleteBusy}
|
||||
aria-label={deleteBusy ? '删除中' : '删除'}
|
||||
title={deleteBusy ? '删除中' : '删除作品'}
|
||||
className="pointer-events-auto relative z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{deleteBusy ? (
|
||||
<span className="text-xs leading-none">…</span>
|
||||
) : (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
<path d="M10 11v5" />
|
||||
<path d="M14 11v5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-h-0 xl:mt-3">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
|
||||
{item.title}
|
||||
<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-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
|
||||
<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>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
{item.publicWorkCode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copyPublicWorkCode();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]"
|
||||
aria-label={`复制作品号 ${item.publicWorkCode}`}
|
||||
title="复制作品号"
|
||||
{isPublished ? (
|
||||
<div className="mt-auto grid grid-cols-3 gap-1.5 pt-3 sm:gap-2 sm:pt-4 xl:pt-3">
|
||||
{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="shrink-0">作品号</span>
|
||||
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
|
||||
<Copy className="h-3 w-3 shrink-0" />
|
||||
{copyState !== 'idle' ? (
|
||||
<span className="shrink-0">
|
||||
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.metrics.map((metric) => (
|
||||
<span
|
||||
key={`${item.id}-${metric.id}`}
|
||||
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
|
||||
>
|
||||
<span className="creation-work-card-stat__label">
|
||||
{metric.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<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 className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
|
||||
{onExperience ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onExperience();
|
||||
}}
|
||||
className="platform-button platform-button--secondary pointer-events-auto relative z-30 min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
|
||||
>
|
||||
体验
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user