330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { ArrowLeft, Copy } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
|
|
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
|
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
|
import { copyTextToClipboard } from '../../services/clipboard';
|
|
import type { CustomWorldProfile } from '../../types';
|
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
|
import {
|
|
buildPlatformWorldTags,
|
|
describePlatformThemeLabel,
|
|
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 canStartGame = entry.visibility === 'published';
|
|
const previewCharacters = buildCustomWorldPlayableCharacters(
|
|
entry.profile,
|
|
).slice(0, 3);
|
|
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
|
|
const tags = [
|
|
...new Set(
|
|
buildPlatformWorldTags(entry)
|
|
.map((tag) => tag.trim())
|
|
.filter(Boolean),
|
|
),
|
|
].slice(0, 3);
|
|
const copyPublicWorkCode = () => {
|
|
if (!publicWorkCode) {
|
|
return;
|
|
}
|
|
|
|
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
|
setCopyState(copied ? 'copied' : 'failed');
|
|
window.setTimeout(() => setCopyState('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">
|
|
{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>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-4 text-3xl font-black text-white">
|
|
{entry.worldName}
|
|
</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;
|