37ba855faf
Add a dedicated NPC editor window, directed relations, control/presentation avatar overlay, and ru/en help for the new section. Co-authored-by: Cursor <cursoragent@cursor.com>
110 lines
3.2 KiB
TypeScript
110 lines
3.2 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import { Button, Input } from '../shared/ui/controls';
|
||
import editorStyles from '../editor/EditorApp.module.css';
|
||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||
|
||
type NpcRelationModalProps = {
|
||
open: boolean;
|
||
initialLabel?: string;
|
||
onClose: () => void;
|
||
onSave: (label: string) => Promise<void>;
|
||
};
|
||
|
||
export function NpcRelationModal({ open, initialLabel = '', onClose, onSave }: NpcRelationModalProps) {
|
||
const { t } = useEditorI18n();
|
||
const [label, setLabel] = useState(initialLabel);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setLabel(initialLabel);
|
||
setSaving(false);
|
||
setError(null);
|
||
}, [initialLabel, open]);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [onClose, open]);
|
||
|
||
const trimmed = label.trim();
|
||
const canSave = trimmed.length >= 1 && !saving;
|
||
|
||
if (!open) return null;
|
||
|
||
return createPortal(
|
||
<>
|
||
<button
|
||
type="button"
|
||
aria-label={t('common.close')}
|
||
onClick={onClose}
|
||
className={editorStyles.modalBackdrop}
|
||
/>
|
||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||
<div className={editorStyles.modalHeader}>
|
||
<div className={editorStyles.modalTitle}>
|
||
{initialLabel ? t('npcs.relationEditTitle') : t('npcs.relationCreateTitle')}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label={t('common.close')}
|
||
onClick={onClose}
|
||
className={editorStyles.modalClose}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className={editorStyles.fieldGrid}>
|
||
<div className={editorStyles.fieldLabel}>{t('npcs.relationLabel')}</div>
|
||
<Input
|
||
value={label}
|
||
onChange={setLabel}
|
||
placeholder={t('npcs.relationLabelPlaceholder')}
|
||
/>
|
||
{trimmed.length < 1 ? (
|
||
<div className={editorStyles.fieldError}>{t('npcs.relationLabelRequired')}</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{error ? <div className={editorStyles.fieldError}>{error}</div> : null}
|
||
|
||
<div className={editorStyles.modalFooter}>
|
||
<Button onClick={onClose} disabled={saving}>
|
||
{t('common.cancel')}
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
disabled={!canSave}
|
||
onClick={() => {
|
||
if (!canSave) return;
|
||
void (async () => {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
await onSave(trimmed);
|
||
onClose();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{t('common.save')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|