import React, { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { collectSceneIdsForSelections, filterNpcsForStorylineExport, findNpcNameConflicts, findSceneTitleConflicts, 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'; type ExportProjectModalProps = { open: boolean; projects: { id: ProjectId; name: string; fileName: string }[]; initialProjectId: ProjectId | null; storylineLabels: StorylineLabels; loadStorylines: (projectId: ProjectId) => Promise; onClose: () => void; onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise; }; export function ExportProjectModal({ open, projects, initialProjectId, storylineLabels, loadStorylines, onClose, onExport, }: ExportProjectModalProps) { const { t } = useEditorI18n(); const [projectId, setProjectId] = useState(initialProjectId); const [storylines, setStorylines] = useState([]); const [selectedKeys, setSelectedKeys] = 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()); }, [initialProjectId, open]); useEffect(() => { if (!open || !projectId) { setStorylines([]); return; } let cancelled = false; setLoadingStorylines(true); void (async () => { try { const list = await loadStorylines(projectId); if (cancelled) return; setStorylines(list); setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection)))); } catch (e) { if (!cancelled) 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') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, open]); if (!open) return null; const canExport = 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 selectedSelections = storylines .filter((item) => selectedKeys.has(storylineSelectionKey(item.selection))) .map((item) => item.selection); return createPortal( <>
{t('export.project')}
toggleKey(key, disabled)} /> {item.label} {disabled && item.disabledReason === 'main_exists' ? ( — {t('storyline.mainExistsHint')} ) : null} ); })}
)}
{t('export.hint')}
{error ?
{error}
: null}
, 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, selections: StorylineSelection[], conflicts: NpcNameConflict[], userResolutions: NpcImportResolution[], ): NpcImportResolution[] { const exported = filterNpcsForStorylineExport(sourceProject, selections); 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, selections: StorylineSelection[], ): NpcNameConflict[] { const exported = filterNpcsForStorylineExport(sourceProject, selections); 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], ); }