import React, { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { collectSceneIdsForSelections, findNpcNameConflicts, findSceneTitleConflicts, listExportedNpcsFromBundle, storylineSelectionKey, type NpcImportResolution, type NpcNameConflict, type SceneImportResolution, type SceneTitleConflict, type StorylineImportMergeReport, type StorylineLabels, type StorylineListItem, type StorylineSelection, } from '../../shared/graph/storylineExportImport'; import type { Project, ProjectId, SceneId } from '../../shared/types'; import { Button, Select } from '../shared/ui/controls'; import styles from './EditorApp.module.css'; import { useEditorI18n } from './i18n/EditorI18nContext'; export type ExportNpcOption = { id: string; name: string }; type ExportProjectModalProps = { open: boolean; projects: { id: ProjectId; name: string; fileName: string }[]; initialProjectId: ProjectId | null; storylineLabels: StorylineLabels; loadStorylines: ( projectId: ProjectId, ) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>; onClose: () => void; onExport: ( projectId: ProjectId, selections: StorylineSelection[], npcIds: string[], ) => Promise; }; export function ExportProjectModal({ open, projects, initialProjectId, storylineLabels: _storylineLabels, loadStorylines, onClose, onExport, }: ExportProjectModalProps) { const { t } = useEditorI18n(); const [step, setStep] = useState<'storylines' | 'npcs'>('storylines'); const [projectId, setProjectId] = useState(initialProjectId); const [storylines, setStorylines] = useState([]); const [npcs, setNpcs] = useState([]); const [selectedKeys, setSelectedKeys] = useState>(new Set()); const [selectedNpcIds, setSelectedNpcIds] = useState>(new Set()); const [loadingStorylines, setLoadingStorylines] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!open) return; setProjectId(initialProjectId); setSaving(false); setError(null); setSelectedKeys(new Set()); setSelectedNpcIds(new Set()); setStep('storylines'); setNpcs([]); }, [initialProjectId, open]); useEffect(() => { if (!open || !projectId) { setStorylines([]); setNpcs([]); return; } let cancelled = false; setLoadingStorylines(true); void (async () => { try { const res = await loadStorylines(projectId); if (cancelled) return; const list = Array.isArray(res?.storylines) ? res.storylines : []; const npcList = Array.isArray(res?.npcs) ? res.npcs : []; setStorylines(list); setNpcs(npcList); setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection)))); setSelectedNpcIds(new Set(npcList.map((n) => n.id))); setStep('storylines'); } catch (e) { if (!cancelled) { setStorylines([]); setNpcs([]); setSelectedKeys(new Set()); setSelectedNpcIds(new Set()); setError(e instanceof Error ? e.message : String(e)); } } finally { if (!cancelled) setLoadingStorylines(false); } })(); return () => { cancelled = true; }; }, [loadStorylines, open, projectId]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (step === 'npcs') setStep('storylines'); else onClose(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, open, step]); if (!open) return null; const canContinueStorylines = projectId !== null && projects.some((p) => p.id === projectId) && selectedKeys.size > 0 && !loadingStorylines; const toggleKey = (key: string, disabled?: boolean) => { if (disabled) return; setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const toggleNpc = (id: string) => { setSelectedNpcIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const selectedSelections = storylines .filter((item) => selectedKeys.has(storylineSelectionKey(item.selection))) .map((item) => item.selection); const runExport = (npcIds: string[]) => { if (!projectId || !canContinueStorylines) return; void (async () => { setSaving(true); setError(null); try { await onExport(projectId, selectedSelections, npcIds); onClose(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); } })(); }; const goNextFromStorylines = () => { if (!canContinueStorylines) return; if (npcs.length === 0) { runExport([]); return; } setStep('npcs'); }; return createPortal( <> {step === 'storylines' ? (
{t('export.project')}
toggleKey(key, disabled)} /> {item.label} {disabled && item.disabledReason === 'main_exists' ? ( — {t('storyline.mainExistsHint')} ) : null} ); })}
)}
{t('export.hint')}
) : (
{t('export.npcsHint')}
{npcs.map((n) => ( ))}
)} {error ?
{error}
: null}
{step === 'npcs' ? ( ) : ( )} {step === 'storylines' ? ( ) : ( )}
, document.body, ); } export type ImportPeekResult = { kind: 'file' | 'project'; filePath?: string; sourceProjectId?: ProjectId; projectName: string; storylines: StorylineListItem[]; sourceProject: Project; }; export type ImportSourceSelection = | { kind: 'file'; filePath: string; fileName: string } | { kind: 'project'; sourceProjectId: ProjectId }; type ImportSourceModalProps = { open: boolean; /** false — только импорт из файла (полный импорт проекта). */ canImportFromProject: boolean; projects: { id: ProjectId; name: string; fileName: string }[]; currentProjectId: ProjectId | null; pickFile: () => Promise<{ canceled: true } | { canceled: false; filePath: string }>; onClose: () => void; onContinue: (selection: ImportSourceSelection) => Promise; }; export function ImportSourceModal({ open, canImportFromProject, projects, currentProjectId, pickFile, onClose, onContinue, }: ImportSourceModalProps) { const { t } = useEditorI18n(); const [importKind, setImportKind] = useState<'project' | 'file'>('file'); const [sourceProjectId, setSourceProjectId] = useState(null); const [pickedFile, setPickedFile] = useState<{ path: string; name: string } | null>(null); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const availableProjects = useMemo( () => projects.filter((p) => p.id !== currentProjectId), [currentProjectId, projects], ); useEffect(() => { if (!open) return; setImportKind(canImportFromProject && availableProjects.length > 0 ? 'project' : 'file'); setSourceProjectId(availableProjects[0]?.id ?? null); setPickedFile(null); setSubmitting(false); setError(null); }, [availableProjects, canImportFromProject, 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]); if (!open) return null; const canContinue = !submitting && (importKind === 'project' ? canImportFromProject && sourceProjectId !== null : pickedFile !== null); return createPortal( <>
{canImportFromProject ? (
{t('importSource.type')}
setSourceProjectId((next as ProjectId) || null)} disabled={availableProjects.length === 0 || submitting} ariaLabel={t('importSource.project')} options={availableProjects.map((p) => ({ value: p.id, label: `${p.name} (${p.fileName})`, }))} /> {availableProjects.length === 0 ? (
{t('importSource.noOtherProjects')}
) : null}
) : (
{t('importSource.file')}
{pickedFile ? pickedFile.name : t('importSource.noFileSelected')}
)}
{error ?
{error}
: null}
, document.body, ); } type ImportStorylinesModalProps = { open: boolean; sourceName: string; storylines: StorylineListItem[]; onClose: () => void; onContinue: (selections: StorylineSelection[]) => void; }; export function ImportStorylinesModal({ open, sourceName, storylines, onClose, onContinue, }: ImportStorylinesModalProps) { const { t } = useEditorI18n(); const [selectedKeys, setSelectedKeys] = useState>(new Set()); useEffect(() => { if (!open) return; setSelectedKeys(new Set()); }, [open, sourceName]); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, open]); if (!open) return null; const toggleKey = (key: string, disabled?: boolean) => { if (disabled) return; setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const selectedSelections = storylines .filter((item) => selectedKeys.has(storylineSelectionKey(item.selection))) .map((item) => item.selection); const canContinue = selectedKeys.size > 0; return createPortal( <>
{t('importStoryline.source')}
{sourceName}
{t('storyline.section')}
{storylines.length === 0 ? (
{t('storyline.empty')}
) : (
{storylines.map((item) => { const key = storylineSelectionKey(item.selection); const disabled = item.disabled === true; return ( ); })}
)}
, document.body, ); } type SceneConflictModalProps = { open: boolean; conflicts: SceneTitleConflict[]; onClose: () => void; onConfirm: (resolutions: SceneImportResolution[]) => void; }; export function SceneConflictModal({ open, conflicts, onClose, onConfirm }: SceneConflictModalProps) { const { t } = useEditorI18n(); const [choices, setChoices] = useState>({}); useEffect(() => { if (!open) return; const init: Record = {}; for (const c of conflicts) { init[c.sourceSceneId] = c.matches[0]?.sceneId ?? 'create'; } setChoices(init); }, [conflicts, open]); if (!open) return null; return createPortal( <>

{t('importStoryline.conflictsHint')}

{conflicts.map((c) => (
{c.sourceTitle}
{ setChoices((prev) => ({ ...prev, [c.sourceNpcId]: v === 'create' ? 'create' : v, })); }} ariaLabel={c.sourceName} options={[ { value: 'create', label: t('importStoryline.createNewNpc') }, ...c.matches.map((m) => ({ value: m.npcId, label: t('importStoryline.useExistingNpc', { name: m.name }), })), ]} />
))}
, document.body, ); } type ImportReportModalProps = { open: boolean; report: StorylineImportMergeReport | null; onClose: () => void; }; export function ImportReportModal({ open, report, onClose }: ImportReportModalProps) { const { t } = useEditorI18n(); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, open]); if (!open || !report) return null; return createPortal( <>
  • {t('importStoryline.reportLines', { count: report.storylinesImported })}
  • {t('importStoryline.reportScenesCreated', { count: report.scenesCreated })}
  • {t('importStoryline.reportScenesReused', { count: report.scenesReused })}
  • {t('importStoryline.reportNpcsCreated', { count: report.npcsCreated })}
  • {t('importStoryline.reportNpcsReused', { count: report.npcsReused })}
  • {t('importStoryline.reportNodes', { count: report.graphNodesAdded })}
  • {t('importStoryline.reportEdges', { count: report.edgesAdded })}
  • {t('importStoryline.reportAssetsCopied', { count: report.assetsCopied })}
  • {t('importStoryline.reportAssetsReused', { count: report.assetsReused })}
  • {report.renamedSideTitles.length > 0 ? (
  • {t('importStoryline.reportRenamedSides', { names: report.renamedSideTitles.join(', ') })}
  • ) : null}
, document.body, ); } export function buildSceneResolutionsForImport( targetProject: Project, sourceProject: Project, selections: StorylineSelection[], conflicts: SceneTitleConflict[], userResolutions: SceneImportResolution[], ): SceneImportResolution[] { const sceneIds = collectSceneIdsForSelections(sourceProject, selections); const conflictIds = new Set(conflicts.map((c) => c.sourceSceneId)); const bySource = new Map(userResolutions.map((r) => [r.sourceSceneId, r])); const out: SceneImportResolution[] = []; for (const sid of sceneIds) { if (conflictIds.has(sid)) { const r = bySource.get(sid); if (r) out.push(r); continue; } out.push({ sourceSceneId: sid, mode: 'create' }); } return out; } export function computeImportConflicts( targetProject: Project, sourceProject: Project, selections: StorylineSelection[], ): SceneTitleConflict[] { const sceneIds = collectSceneIdsForSelections(sourceProject, selections); return findSceneTitleConflicts(targetProject, sourceProject, sceneIds); } export function buildNpcResolutionsForImport( _targetProject: Project, sourceProject: Project, conflicts: NpcNameConflict[], userResolutions: NpcImportResolution[], ): NpcImportResolution[] { const exported = listExportedNpcsFromBundle(sourceProject); const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId)); const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r])); const out: NpcImportResolution[] = []; for (const n of exported) { if (conflictIds.has(n.id)) { const r = bySource.get(n.id); if (r) out.push(r); continue; } out.push({ sourceNpcId: n.id, mode: 'create' }); } return out; } export function computeNpcImportConflicts( targetProject: Project, sourceProject: Project, ): NpcNameConflict[] { const exported = listExportedNpcsFromBundle(sourceProject); return findNpcNameConflicts( targetProject, sourceProject, exported.map((n) => n.id), ); } export function useStorylineLabels(): StorylineLabels { const { t } = useEditorI18n(); return useMemo( () => ({ main: t('storyline.main'), untitled: t('graph.untitled'), }), [t], ); }