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:
Ivan Fontosh
2026-07-20 13:19:22 +08:00
parent 37ba855faf
commit f4c0ac1438
41 changed files with 5210 additions and 418 deletions
+145
View File
@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
export type FoundryImportSourceSelection =
| { kind: 'folder'; sourcePath: string }
| { kind: 'archive'; sourcePath: string };
type FoundryImportModalProps = {
open: boolean;
pickSource: (
mode: 'folder' | 'archive',
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
onClose: () => void;
onImport: (selection: FoundryImportSourceSelection) => Promise<void>;
};
export function FoundryImportModal({ open, pickSource, onClose, onImport }: FoundryImportModalProps) {
const { t } = useEditorI18n();
const [mode, setMode] = useState<'folder' | 'archive'>('folder');
const [picked, setPicked] = useState<{ path: string; name: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setMode('folder');
setPicked(null);
setSubmitting(false);
setError(null);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !submitting) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open, submitting]);
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!submitting) onClose();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('foundryImport.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!submitting) onClose();
}}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('foundryImport.hint')}</div>
<div className={styles.fieldLabel}>{t('foundryImport.sourceType')}</div>
<select
className={styles.selectInput}
value={mode}
disabled={submitting}
onChange={(e) => {
setMode(e.target.value as 'folder' | 'archive');
setPicked(null);
setError(null);
}}
>
<option value="folder">{t('foundryImport.folder')}</option>
<option value="archive">{t('foundryImport.archive')}</option>
</select>
<div className={styles.fieldLabel}>{t('foundryImport.source')}</div>
<div className={styles.importFileRow}>
<Button
disabled={submitting}
onClick={() => {
void (async () => {
setError(null);
const res = await pickSource(mode);
if (res.canceled) return;
const name = res.sourcePath.split(/[/\\]/).pop() ?? res.sourcePath;
setPicked({ path: res.sourcePath, name });
})();
}}
>
{mode === 'folder' ? t('foundryImport.chooseFolder') : t('foundryImport.chooseArchive')}
</Button>
<span className={styles.muted}>{picked ? picked.name : t('foundryImport.noSourceSelected')}</span>
</div>
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!picked || submitting}
onClick={() => {
if (!picked) return;
void (async () => {
setSubmitting(true);
setError(null);
try {
await onImport({
kind: mode,
sourcePath: picked.path,
});
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
})();
}}
>
{t('foundryImport.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}