Files
Genarrative/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx

313 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ArrowLeft, Loader2, Play } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle';
import { buildBarkBattleDefaultOnomatopoeia } from '../../games/bark-battle/application/BarkBattleConfig';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = {
isBusy?: boolean;
error?: string | null;
onPreview: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
onBack?: () => void;
showBackButton?: boolean;
title?: string | null;
};
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
{ value: 'easy', label: '轻松' },
{ value: 'normal', label: '标准' },
{ value: 'hard', label: '硬核' },
];
const FIELD_LABEL_CLASS =
'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]';
const ACCENT_FIELD_LABEL_CLASS =
'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm';
const DEFAULT_THEME_DESCRIPTION = '阳光草坪上的圆形声浪擂台';
const DEFAULT_PLAYER_IMAGE_DESCRIPTION = '戴红色围巾的勇敢小狗';
const DEFAULT_OPPONENT_IMAGE_DESCRIPTION = '戴蓝色头带的活力小狗';
function buildDefaultOnomatopoeiaText(params: {
themeDescription: string;
playerImageDescription: string;
opponentImageDescription: string;
}) {
return buildBarkBattleDefaultOnomatopoeia(params).join('\n');
}
export function BarkBattleConfigEditor({
isBusy = false,
error: externalError = null,
onPreview,
onBack,
showBackButton = true,
title: headingTitle = '汪汪声浪大作战',
}: BarkBattleConfigEditorProps) {
const [title, setTitle] = useState('我的声浪竞技场');
const [description, setDescription] = useState('');
const [themeDescription, setThemeDescription] = useState(
DEFAULT_THEME_DESCRIPTION,
);
const [playerImageDescription, setPlayerImageDescription] = useState(
DEFAULT_PLAYER_IMAGE_DESCRIPTION,
);
const [opponentImageDescription, setOpponentImageDescription] = useState(
DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
);
const [isOnomatopoeiaCustomized, setIsOnomatopoeiaCustomized] =
useState(false);
const [onomatopoeiaText, setOnomatopoeiaText] = useState(() =>
buildDefaultOnomatopoeiaText({
themeDescription: DEFAULT_THEME_DESCRIPTION,
playerImageDescription: DEFAULT_PLAYER_IMAGE_DESCRIPTION,
opponentImageDescription: DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
}),
);
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (isOnomatopoeiaCustomized) {
return;
}
setOnomatopoeiaText(
buildDefaultOnomatopoeiaText({
themeDescription,
playerImageDescription,
opponentImageDescription,
}),
);
}, [
isOnomatopoeiaCustomized,
themeDescription,
playerImageDescription,
opponentImageDescription,
]);
const onomatopoeia = useMemo(
() =>
onomatopoeiaText
.split(/[\n,/|]+/u)
.map((word) => word.trim())
.filter(Boolean)
.slice(0, 24),
[onomatopoeiaText],
);
const payload = useMemo<BarkBattleConfigEditorPayload>(
() => ({
title: title.trim(),
description: description.trim(),
themeDescription: themeDescription.trim(),
playerImageDescription: playerImageDescription.trim(),
opponentImageDescription: opponentImageDescription.trim(),
onomatopoeia,
difficultyPreset,
}),
[
title,
description,
themeDescription,
playerImageDescription,
opponentImageDescription,
onomatopoeia,
difficultyPreset,
],
);
const runValidatedAction = (
action: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>,
) => {
if (!payload.title) {
setLocalError('请先填写作品标题');
return;
}
if (!payload.themeDescription) {
setLocalError('请先填写主题/场景描述');
return;
}
if (!payload.playerImageDescription || !payload.opponentImageDescription) {
setLocalError('请先填写双方形象描述');
return;
}
setLocalError(null);
void action(payload);
};
const visibleError = localError ?? externalError;
return (
<section
className="platform-remap-surface mx-auto flex min-h-full w-full max-w-5xl flex-col overflow-visible lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:overscroll-y-contain lg:pr-0.5"
aria-label="汪汪声浪轻配置编辑器"
>
{showBackButton && onBack ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div>
) : null}
<div className="flex min-h-0 flex-col lg:flex-1">
{headingTitle ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{headingTitle}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
</span>
</div>
</div>
) : null}
<div
className={`grid gap-3 lg:flex-1 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`}
>
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
<label className="block shrink-0">
<span className={FIELD_LABEL_CLASS}></span>
<input
value={title}
disabled={isBusy}
onChange={(event) => setTitle(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
maxLength={40}
aria-label="作品标题"
/>
</label>
<label className="block shrink-0">
<span className={FIELD_LABEL_CLASS}></span>
<textarea
value={description}
disabled={isBusy}
onChange={(event) => setDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
maxLength={160}
placeholder=""
aria-label="简介"
/>
</label>
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block">
<span className={FIELD_LABEL_CLASS}></span>
<select
value={difficultyPreset}
disabled={isBusy}
onChange={(event) =>
setDifficultyPreset(
event.target.value as BarkBattleDifficultyPreset,
)
}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
aria-label="难度预设"
>
{DIFFICULTY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}>
/
</span>
<textarea
value={themeDescription}
disabled={isBusy}
onChange={(event) => setThemeDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={240}
placeholder=""
aria-label="主题/场景描述"
/>
</label>
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block">
<span className={FIELD_LABEL_CLASS}></span>
<textarea
value={playerImageDescription}
disabled={isBusy}
onChange={(event) => setPlayerImageDescription(event.target.value)}
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={220}
aria-label="玩家形象描述"
/>
</label>
<label className="block">
<span className={FIELD_LABEL_CLASS}></span>
<textarea
value={opponentImageDescription}
disabled={isBusy}
onChange={(event) => setOpponentImageDescription(event.target.value)}
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={220}
aria-label="对手形象描述"
/>
</label>
</div>
<label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}></span>
<textarea
value={onomatopoeiaText}
disabled={isBusy}
onChange={(event) => {
setIsOnomatopoeiaCustomized(true);
setOnomatopoeiaText(event.target.value);
}}
className="h-[6.5rem] min-h-[6.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-black leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={260}
aria-label="拟声词"
/>
</label>
{visibleError ? (
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
{visibleError}
</div>
) : null}
</div>
<BarkBattlePreviewCard config={payload} />
</div>
</div>
<div className="mt-4 flex shrink-0 flex-wrap justify-center gap-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:mt-4 lg:pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={isBusy}
onClick={() => runValidatedAction(onPreview)}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
<span>{isBusy ? '处理中' : '生成草稿'}</span>
</span>
</button>
</div>
</section>
);
}