f4c0ac1438
Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts. Co-authored-by: Cursor <cursoragent@cursor.com>
134 lines
4.3 KiB
TypeScript
134 lines
4.3 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import { DEFAULT_NPC_GROUP_COLOR } from '../../shared/npcs/npcGroups';
|
||
import type { ProjectNpcGroup } from '../../shared/types';
|
||
import editorStyles from '../editor/EditorApp.module.css';
|
||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||
import { Button, Input } from '../shared/ui/controls';
|
||
|
||
import styles from './NpcGroupModal.module.css';
|
||
|
||
type NpcGroupModalProps = {
|
||
open: boolean;
|
||
initial: ProjectNpcGroup | null;
|
||
siblingNames: string[];
|
||
onClose: () => void;
|
||
onSave: (input: { name: string; color: string }) => Promise<void>;
|
||
};
|
||
|
||
function normalizeName(input: string): string {
|
||
return input.trim().toLowerCase();
|
||
}
|
||
|
||
export function NpcGroupModal({ open, initial, siblingNames, onClose, onSave }: NpcGroupModalProps) {
|
||
const { t } = useEditorI18n();
|
||
const [name, setName] = useState('');
|
||
const [color, setColor] = useState(DEFAULT_NPC_GROUP_COLOR);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setName(initial?.name ?? '');
|
||
setColor(initial?.color ?? DEFAULT_NPC_GROUP_COLOR);
|
||
setSaving(false);
|
||
setError(null);
|
||
}, [initial, 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 = name.trim();
|
||
const nameOk = trimmed.length >= 1;
|
||
const nameDup = useMemo(() => {
|
||
if (!nameOk) return false;
|
||
const key = normalizeName(trimmed);
|
||
const except = normalizeName(initial?.name ?? '');
|
||
return siblingNames.some((n) => {
|
||
const nk = normalizeName(n);
|
||
return nk === key && nk !== except;
|
||
});
|
||
}, [initial?.name, nameOk, siblingNames, trimmed]);
|
||
|
||
const canSave = nameOk && !nameDup && !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}>{initial ? t('npcs.editGroup') : t('npcs.addGroup')}</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.groupName')}</div>
|
||
<div className={styles.nameColorRow}>
|
||
<input
|
||
type="color"
|
||
className={styles.colorInput}
|
||
value={color}
|
||
onChange={(e) => setColor(e.target.value)}
|
||
aria-label={t('npcs.groupColor')}
|
||
/>
|
||
<Input value={name} onChange={setName} placeholder={t('npcs.groupName')} />
|
||
</div>
|
||
{!nameOk ? <div className={editorStyles.fieldError}>{t('npcs.groupNameRequired')}</div> : null}
|
||
{nameDup ? <div className={editorStyles.fieldError}>{t('npcs.groupNameDup')}</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({ name: trimmed, color });
|
||
onClose();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{t('common.save')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|