363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
import { ArrowLeft, Copy, Share2 } from 'lucide-react';
|
||
import { useState } from 'react';
|
||
|
||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||
import { copyTextToClipboard } from '../../services/clipboard';
|
||
import type { CustomWorldProfile } from '../../types';
|
||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||
import {
|
||
buildPlatformWorldDisplayTags,
|
||
describePlatformThemeLabel,
|
||
formatPlatformWorkDisplayName,
|
||
formatPlatformWorkDisplayTag,
|
||
formatPlatformWorldTime,
|
||
resolvePlatformPublicWorkCode,
|
||
resolvePlatformWorldCoverImage,
|
||
resolvePlatformWorldLeadPortrait,
|
||
} from './rpgEntryWorldPresentation';
|
||
|
||
export interface RpgEntryWorldDetailViewProps {
|
||
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
|
||
isMutating: boolean;
|
||
error: string | null;
|
||
onBack: () => void;
|
||
onStartGame: () => void;
|
||
onContinueEdit?: (() => void) | null;
|
||
onPublish?: (() => void) | null;
|
||
onDelete?: (() => void) | null;
|
||
onUnpublish?: (() => void) | null;
|
||
}
|
||
|
||
function ActionButton({
|
||
label,
|
||
onClick,
|
||
tone = 'default',
|
||
disabled = false,
|
||
}: {
|
||
label: string;
|
||
onClick: () => void;
|
||
tone?: 'default' | 'primary' | 'danger';
|
||
disabled?: boolean;
|
||
}) {
|
||
const toneClass =
|
||
tone === 'primary'
|
||
? 'platform-button platform-button--primary'
|
||
: tone === 'danger'
|
||
? 'platform-button platform-button--danger'
|
||
: 'platform-button platform-button--secondary';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className={`${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function RpgEntryWorldDetailView({
|
||
entry,
|
||
isMutating,
|
||
error,
|
||
onBack,
|
||
onStartGame,
|
||
onContinueEdit,
|
||
onPublish,
|
||
onDelete,
|
||
onUnpublish,
|
||
}: RpgEntryWorldDetailViewProps) {
|
||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||
'idle',
|
||
);
|
||
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||
'idle',
|
||
);
|
||
const canStartGame = entry.visibility === 'published';
|
||
const previewCharacters = buildCustomWorldPlayableCharacters(
|
||
entry.profile,
|
||
).slice(0, 3);
|
||
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
|
||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||
const tags = buildPlatformWorldDisplayTags(entry, 3);
|
||
const copyPublicWorkCode = () => {
|
||
if (!publicWorkCode) {
|
||
return;
|
||
}
|
||
|
||
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
||
setCopyState(copied ? 'copied' : 'failed');
|
||
window.setTimeout(() => setCopyState('idle'), 1400);
|
||
});
|
||
};
|
||
const sharePublicWork = () => {
|
||
if (!publicWorkCode) {
|
||
return;
|
||
}
|
||
|
||
const shareUrl = buildPublicWorkDetailUrl(publicWorkCode);
|
||
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${shareUrl}`;
|
||
|
||
void copyTextToClipboard(shareText).then((copied) => {
|
||
setShareState(copied ? 'copied' : 'failed');
|
||
window.setTimeout(() => setShareState('idle'), 1400);
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 flex-col">
|
||
<div className="mb-4 flex items-center justify-between gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
返回
|
||
</button>
|
||
<div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
|
||
{entry.visibility === 'published' ? '已发布' : '草稿'}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||
<div className="space-y-4 pb-2">
|
||
<div className="platform-surface platform-surface--hero relative overflow-hidden px-[18px] py-4">
|
||
{coverImage ? (
|
||
<ResolvedAssetImage
|
||
src={coverImage}
|
||
alt={entry.worldName}
|
||
className="absolute inset-0 h-full w-full object-cover opacity-38"
|
||
/>
|
||
) : null}
|
||
{leadPortrait ? (
|
||
<ResolvedAssetImage
|
||
src={leadPortrait}
|
||
alt=""
|
||
aria-hidden="true"
|
||
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
|
||
/>
|
||
) : null}
|
||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||
<div className="relative z-10">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="platform-pill platform-pill--warm">
|
||
{formatPlatformWorkDisplayTag(
|
||
describePlatformThemeLabel(entry.themeMode),
|
||
)}
|
||
</span>
|
||
<span className="platform-pill platform-pill--neutral px-3">
|
||
{entry.authorDisplayName}
|
||
</span>
|
||
<span className="platform-pill platform-pill--neutral px-3">
|
||
{entry.visibility === 'published'
|
||
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
|
||
: '仅自己可见'}
|
||
</span>
|
||
{publicWorkCode ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={copyPublicWorkCode}
|
||
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
||
aria-label={`复制作品号 ${publicWorkCode}`}
|
||
title="复制作品号"
|
||
>
|
||
<span>作品号 {publicWorkCode}</span>
|
||
<Copy className="h-3 w-3" />
|
||
{copyState !== 'idle' ? (
|
||
<span className="text-xs">
|
||
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={sharePublicWork}
|
||
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
||
aria-label={`分享作品 ${entry.worldName}`}
|
||
title="分享作品"
|
||
>
|
||
<Share2 className="h-3 w-3" />
|
||
<span>分享作品</span>
|
||
{shareState !== 'idle' ? (
|
||
<span className="text-xs">
|
||
{shareState === 'copied' ? '已复制' : '复制失败'}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
<div className="mt-4 text-3xl font-black text-white">
|
||
{displayName}
|
||
</div>
|
||
{entry.subtitle ? (
|
||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-300/88">
|
||
{entry.subtitle}
|
||
</div>
|
||
) : null}
|
||
<div className="mt-4 max-w-[36rem] text-sm leading-7 text-zinc-200/88">
|
||
{entry.summaryText || '等待补充世界摘要。'}
|
||
</div>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
{tags.map((tag, index) => (
|
||
<span
|
||
key={`world-detail-tag-${index}-${tag || 'empty'}`}
|
||
className="platform-pill platform-pill--neutral px-3"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||
<div className="platform-surface platform-surface--soft px-4 py-3.5">
|
||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||
世界信息
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-[var(--platform-text-strong)] sm:grid-cols-4">
|
||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
可玩角色
|
||
</div>
|
||
<div className="mt-2 text-lg font-bold">
|
||
{entry.playableNpcCount}
|
||
</div>
|
||
</div>
|
||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
地标
|
||
</div>
|
||
<div className="mt-2 text-lg font-bold">
|
||
{entry.landmarkCount}
|
||
</div>
|
||
</div>
|
||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
阵营
|
||
</div>
|
||
<div className="mt-2 text-lg font-bold">
|
||
{entry.profile.majorFactions.length}
|
||
</div>
|
||
</div>
|
||
<div className="platform-subpanel rounded-xl px-3 py-3">
|
||
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
冲突
|
||
</div>
|
||
<div className="mt-2 text-lg font-bold">
|
||
{entry.profile.coreConflicts.length}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5">
|
||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||
关键角色
|
||
</div>
|
||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||
{previewCharacters.map((character, index) => (
|
||
<div
|
||
key={character.id || `preview-character-${index}`}
|
||
className="platform-subpanel rounded-2xl px-3 py-3"
|
||
>
|
||
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
|
||
{character.title}
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||
{character.description}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5">
|
||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||
关键场景
|
||
</div>
|
||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||
{previewLandmarks.map((landmark, index) => (
|
||
<div
|
||
key={landmark.id || `preview-landmark-${index}`}
|
||
className="platform-subpanel rounded-2xl px-3 py-3"
|
||
>
|
||
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
|
||
{landmark.name}
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
|
||
{landmark.description}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="platform-surface platform-surface--soft px-4 py-3.5">
|
||
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
|
||
操作
|
||
</div>
|
||
<div className="mt-4 flex flex-col gap-3">
|
||
<ActionButton
|
||
label={canStartGame ? '开始游戏' : '请先发布作品'}
|
||
onClick={onStartGame}
|
||
tone="primary"
|
||
disabled={!canStartGame || isMutating}
|
||
/>
|
||
{onContinueEdit ? (
|
||
<ActionButton
|
||
label="继续创作"
|
||
onClick={onContinueEdit}
|
||
disabled={isMutating}
|
||
/>
|
||
) : null}
|
||
{onPublish ? (
|
||
<ActionButton
|
||
label="发布到广场"
|
||
onClick={onPublish}
|
||
tone="primary"
|
||
disabled={isMutating}
|
||
/>
|
||
) : null}
|
||
{onUnpublish ? (
|
||
<ActionButton
|
||
label="下架作品"
|
||
onClick={onUnpublish}
|
||
tone="danger"
|
||
disabled={isMutating}
|
||
/>
|
||
) : null}
|
||
{onDelete ? (
|
||
<ActionButton
|
||
label="删除作品"
|
||
onClick={onDelete}
|
||
tone="danger"
|
||
disabled={isMutating}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
{error ? (
|
||
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const PlatformWorldDetailView = RpgEntryWorldDetailView;
|