feat(npcs): add groups, storyline bindings, and Foundry import
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>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user