Files
DndGamePlayer/app/renderer/npcs/NpcGroupModal.tsx
T
Ivan Fontosh f4c0ac1438 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>
2026-07-20 13:19:22 +08:00

134 lines
4.3 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 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,
);
}