Files
Genarrative/src/components/rpg-entry/RpgEntryWorldDetailView.tsx
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

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;