Files
DndGamePlayer/app/renderer/editor/EditorApp.tsx
T
Ivan Fontosh c8ab9dd567 feat(editor): побочные сюжетные линии и импорт/экспорт линий
Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на
пульте: визуальное выделение компонент графа, запрет недопустимых связей
между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий.

Реализован partial export/import сюжетных линий:
- экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json;
- импорт в открытый проект с выбором линий, разрешением конфликтов названий
  сцен (одна модалка на операцию) и отчётом о результате;
- новое окно источника импорта: «Из проекта» (dropdown, без текущего) или
  «Из файла»; на главном экране — только полный импорт из файла.

Исправлены гонки при открытии/закрытии проекта (сериализация open/close в
main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и
старт Electron в dev после готовности Vite.

Добавлены unit-тесты для lineage, export/import и контрактов zipStore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:47:28 +08:00

2330 lines
84 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, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
ipcChannels,
type UpdaterCheckResponse,
type UpdaterProgressEvent,
} from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import { PROJECT_ZIP_EXTENSION } from '../../shared/project/projectZipExtension';
import type {
AssetId,
GraphNodeId,
MediaAsset,
Project,
ProjectId,
SceneAudioRef,
SceneId,
} from '../../shared/types';
import { AppLogo } from '../shared/branding/AppLogo';
import { getDndApi } from '../shared/dndApi';
import { RotatedImage } from '../shared/RotatedImage';
import { Button, Input } from '../shared/ui/controls';
import { LayoutShell } from '../shared/ui/LayoutShell';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport';
import {
buildSceneResolutionsForImport,
computeImportConflicts,
ExportProjectModal,
ImportReportModal,
ImportSourceModal,
ImportStorylinesModal,
SceneConflictModal,
useStorylineLabels,
type ImportPeekResult,
type ImportSourceSelection,
} from './StorylineTransferModals';
import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals';
import styles from './EditorApp.module.css';
import { buildNextSceneCardById } from './graph/sceneCardById';
import {
DND_SCENE_ID_MIME,
SceneGraph,
type SceneGraphSceneCard,
type SceneGraphUiStrings,
} from './graph/SceneGraph';
import type { HelpSectionId } from './help/helpSections';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
import type { ProjectNoticeCode } from './state/projectState';
import { useProjectState } from './state/projectState';
type SceneCard = {
id: SceneId;
title: string;
active: boolean;
previewAssetId: AssetId | null;
previewThumbAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
};
/** Лёгкая карта сцен для графа: стабильные ссылки на объекты, пока не меняются поля карточки. */
function useStableSceneCardById(project: Project | null): Record<SceneId, SceneGraphSceneCard> {
const recordRef = useRef<Record<SceneId, SceneGraphSceneCard>>({});
const projectIdRef = useRef<ProjectId | null>(null);
/* Ref cache: avoid new Record / per-scene object identities when only irrelevant Scene fields change
* (e.g. description). react-hooks/refs disallows ref access during render; this is intentional. */
/* eslint-disable react-hooks/refs -- stable graph input identity */
return useMemo(() => {
if (!project) {
recordRef.current = {};
projectIdRef.current = null;
return {};
}
if (projectIdRef.current !== project.id) {
recordRef.current = {};
projectIdRef.current = project.id;
}
const prevRecord = recordRef.current;
const nextMap = buildNextSceneCardById(prevRecord, project);
recordRef.current = nextMap;
return nextMap;
}, [project]);
/* eslint-enable react-hooks/refs */
}
export function EditorApp() {
const { t, locale, setLocale } = useEditorI18n();
const [appVersionText, setAppVersionText] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [fileMenuOpen, setFileMenuOpen] = useState(false);
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false);
const [aboutMenuOpen, setAboutMenuOpen] = useState(false);
const [appAboutOpen, setAppAboutOpen] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false);
const [instructionsSection, setInstructionsSection] = useState<HelpSectionId>('overview');
const [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [importSourceOpen, setImportSourceOpen] = useState(false);
const [importPeek, setImportPeek] = useState<ImportPeekResult | null>(null);
const [importStorylinesOpen, setImportStorylinesOpen] = useState(false);
const [importConflictsOpen, setImportConflictsOpen] = useState(false);
const [importConflicts, setImportConflicts] = useState<ReturnType<typeof computeImportConflicts>>([]);
const [pendingImportSelections, setPendingImportSelections] = useState<StorylineSelection[]>([]);
const [importReportOpen, setImportReportOpen] = useState(false);
const [importReport, setImportReport] = useState<StorylineImportMergeReport | null>(null);
const [previewDialogSceneId, setPreviewDialogSceneId] = useState<SceneId | null>(null);
const [presentationOpen, setPresentationOpen] = useState(false);
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
const [checkUpdatesOpen, setCheckUpdatesOpen] = useState(false);
const [appPackaged, setAppPackaged] = useState(false);
const [licenseKeyModalOpen, setLicenseKeyModalOpen] = useState(false);
const [eulaModalOpen, setEulaModalOpen] = useState(false);
const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false);
const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false);
const licenseActive = licenseSnap?.active === true;
const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null);
const onProjectNotice = useCallback(
(code: ProjectNoticeCode) => {
const handlers: Record<ProjectNoticeCode, () => void> = {
campaign_audio_empty: () =>
setAppNotice({ title: t('common.message'), message: t('notice.campaignAudioEmpty') }),
};
handlers[code]();
},
[t],
);
const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice });
const renameFromPickerRef = useRef(false);
const sceneCardById = useStableSceneCardById(state.project);
const graphUi = useMemo<SceneGraphUiStrings>(
() => ({
badgeStart: t('graph.badgeStart'),
badgeSideStory: t('graph.badgeSideStory'),
untitled: t('graph.untitled'),
videoBadge: t('graph.videoBadge'),
audioBadge: t('graph.audioBadge'),
loop: t('graph.loop'),
autoplay: t('graph.autoplay'),
previewAutostart: t('graph.previewAutostart'),
videoLoop: t('graph.videoLoop'),
zoomBar: t('graph.zoomBar'),
zoomIn: t('graph.zoomIn'),
zoomOut: t('graph.zoomOut'),
fitAll: t('graph.fitAll'),
closeMenu: t('common.closeMenu'),
startScene: t('graph.startScene'),
unsetStartScene: t('graph.unsetStartScene'),
sideStoryStartScene: t('graph.sideStoryStartScene'),
unsetSideStoryStartScene: t('graph.unsetSideStoryStartScene'),
runFromScene: t('graph.runFromScene'),
delete: t('common.delete'),
}),
[t],
);
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const settingsMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const aboutMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const [fileMenuPos, setFileMenuPos] = useState<{ left: number; top: number } | null>(null);
const [projectMenuPos, setProjectMenuPos] = useState<{ left: number; top: number } | null>(null);
const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null);
const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null);
const [selectedGraphNodeId, setSelectedGraphNodeId] = useState<GraphNodeId | null>(null);
useEffect(() => {
setSelectedGraphNodeId(null);
}, [state.project?.id]);
useEffect(() => {
if (!state.project || !selectedGraphNodeId) return;
if (!state.project.sceneGraphNodes.some((n) => n.id === selectedGraphNodeId)) {
setSelectedGraphNodeId(null);
}
}, [selectedGraphNodeId, state.project?.sceneGraphNodes]);
const scenes = useMemo<SceneCard[]>(() => {
const p = state.project;
if (!p) return [];
const createdAtSortKey = (sceneId: string): number => {
// sceneId создаётся как `${prefix}_${rand}_${Date.now().toString(16)}`
const last = sceneId.split('_').at(-1) ?? '';
const n = Number.parseInt(last, 16);
return Number.isFinite(n) ? n : 0;
};
return Object.values(p.scenes)
.map((s) => ({
id: s.id,
title: s.title,
active: s.id === state.selectedSceneId,
previewAssetId: s.previewAssetId,
previewThumbAssetId: s.previewThumbAssetId,
previewAssetType: s.previewAssetType,
previewVideoAutostart: s.previewVideoAutostart,
previewRotationDeg: s.previewRotationDeg,
}))
.sort((a, b) => createdAtSortKey(b.id) - createdAtSortKey(a.id));
}, [state.project, state.selectedSceneId]);
const filtered = useMemo(
() => scenes.filter((s) => s.title.toLowerCase().includes(query.trim().toLowerCase())),
[query, scenes],
);
const sceneMediaAssets = useMemo<MediaAsset[]>(() => {
const p = state.project;
const sid = state.selectedSceneId;
if (!p || !sid) return [];
const scene = p.scenes[sid];
if (!scene) return [];
const ids = [...scene.media.videos, ...scene.media.audios.map((a) => a.assetId)];
return ids.map((id) => p.assets[id]).filter((a): a is MediaAsset => Boolean(a));
}, [state.project, state.selectedSceneId]);
const sceneAudioRefs = useMemo<SceneAudioRef[]>(() => {
const p = state.project;
const sid = state.selectedSceneId;
if (!p || !sid) return [];
const scene = p.scenes[sid];
if (!scene) return [];
return scene.media.audios;
}, [state.project, state.selectedSceneId]);
const campaignAudioRefs = useMemo<SceneAudioRef[]>(() => {
return state.project?.campaignAudios ?? [];
}, [state.project]);
const campaignAudioAssets = useMemo<MediaAsset[]>(() => {
const p = state.project;
if (!p) return [];
return campaignAudioRefs.map((r) => p.assets[r.assetId]).filter((a): a is MediaAsset => Boolean(a));
}, [campaignAudioRefs, state.project]);
const graphStartGraphNodeId = useMemo(() => {
const p = state.project;
if (!p) return null;
const gn = p.sceneGraphNodes.find((n) => n.isStartScene);
return gn?.id ?? null;
}, [state.project]);
const runDisabled = !licenseActive || !graphStartGraphNodeId;
const runHelpSection: HelpSectionId = !licenseActive ? 'license' : 'graph';
const runHelpTooltip = !licenseActive ? t('top.afterLicense') : t('top.setStartScene');
const launchFromGraphNode = useCallback(
(graphNodeId: GraphNodeId) => {
if (!licenseActive) return;
void (async () => {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId });
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
})();
},
[licenseActive],
);
const currentProjectName = state.project?.meta.name ?? '';
const currentFileBaseName = state.project?.meta.fileBaseName ?? '';
const existingProjectNames = useMemo(() => state.projects.map((p) => p.name), [state.projects]);
const existingFileBaseNames = useMemo(() => {
return state.projects.map((p) => p.fileName.replace(/\.dnd\.zip$/iu, ''));
}, [state.projects]);
useEffect(() => {
if (!fileMenuOpen) return;
const r = fileMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setFileMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setFileMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-filemenu-root="1"]')) return;
setFileMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [fileMenuOpen]);
useEffect(() => {
if (!projectMenuOpen) return;
const r = projectMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setProjectMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setProjectMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-projectmenu-root="1"]')) return;
setProjectMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [projectMenuOpen]);
useEffect(() => {
if (!settingsMenuOpen) return;
const r = settingsMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setSettingsMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setSettingsMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-settingsmenu-root="1"]')) return;
setSettingsMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [settingsMenuOpen]);
useEffect(() => {
if (!settingsMenuOpen) setSettingsLangSubOpen(false);
}, [settingsMenuOpen]);
useEffect(() => {
if (!aboutMenuOpen) return;
const r = aboutMenuBtnRef.current?.getBoundingClientRect() ?? null;
queueMicrotask(() => {
if (r) {
setAboutMenuPos({ left: r.left, top: r.bottom + 10 });
} else {
setAboutMenuPos(null);
}
});
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (!t) return;
if (t.closest('[data-aboutmenu-root="1"]')) return;
setAboutMenuOpen(false);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [aboutMenuOpen]);
useEffect(() => {
let off: (() => void) | null = null;
void (async () => {
try {
const snap = await getDndApi().invoke(ipcChannels.windows.getMultiWindowState, {});
setPresentationOpen(snap.open);
} catch {
// ignore
}
off = getDndApi().on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
setPresentationOpen(open);
});
})();
return () => {
off?.();
};
}, []);
const reloadLicense = useCallback(() => {
void (async () => {
try {
const s = await getDndApi().invoke(ipcChannels.license.getStatus, {});
setLicenseSnap(s);
} catch {
setLicenseSnap(null);
}
})();
}, []);
useEffect(() => {
reloadLicense();
const unsub = getDndApi().on(ipcChannels.license.statusChanged, () => {
reloadLicense();
});
return unsub;
}, [reloadLicense]);
useEffect(() => {
void (async () => {
try {
const r = await getDndApi().invoke(ipcChannels.app.getVersion, {});
const label = r.buildNumber ? `v${r.version} · ${r.buildNumber}` : `v${r.version}`;
setAppVersionText(label);
setAppPackaged(r.packaged);
} catch {
setAppVersionText(null);
}
})();
}, []);
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
const storylineLabels = useStorylineLabels();
const targetHasMainStart = state.project?.sceneGraphNodes.some((n) => n.isStartScene) ?? false;
const loadProjectStorylines = useCallback(
(projectId: ProjectId) => actions.getProjectStorylines(projectId, storylineLabels),
[actions, storylineLabels],
);
const runStorylineMerge = useCallback(
async (selections: StorylineSelection[], resolutions: SceneImportResolution[]) => {
if (!importPeek) return;
const { report } =
importPeek.kind === 'project' && importPeek.sourceProjectId
? await actions.mergeImportFromProject(importPeek.sourceProjectId, selections, resolutions)
: await actions.mergeImportZip(importPeek.filePath!, selections, resolutions);
setImportReport(report);
setImportReportOpen(true);
setImportPeek(null);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setPendingImportSelections([]);
setImportConflicts([]);
},
[actions, importPeek],
);
const handleImportSourceContinue = useCallback(
async (selection: ImportSourceSelection) => {
if (!state.project) {
if (selection.kind === 'file') {
await actions.importProjectFromPath(selection.filePath);
setImportSourceOpen(false);
}
return;
}
const peek =
selection.kind === 'project'
? await actions.peekImportFromProject(
selection.sourceProjectId,
storylineLabels,
targetHasMainStart,
)
: await actions.peekImportZipPath(selection.filePath, storylineLabels, targetHasMainStart);
setImportPeek({
kind: selection.kind,
...(selection.kind === 'file'
? { filePath: selection.filePath }
: { sourceProjectId: selection.sourceProjectId }),
projectName: peek.projectName,
storylines: peek.storylines,
sourceProject: peek.sourceProject,
});
setImportSourceOpen(false);
setImportStorylinesOpen(true);
},
[actions, state.project, storylineLabels, targetHasMainStart],
);
const handleImportProject = useCallback(() => {
setProjectMenuOpen(false);
setImportSourceOpen(true);
}, []);
const goHome = useCallback(() => {
setProjectMenuOpen(false);
setExportModalOpen(false);
setImportSourceOpen(false);
setImportStorylinesOpen(false);
setImportConflictsOpen(false);
setImportReportOpen(false);
setImportPeek(null);
void actions.closeProject();
}, [actions]);
const bodyOverlay =
licenseSnap === null ? (
<div>
<div className={styles.licenseBlockTitle}>{t('license.checkingTitle')}</div>
<div className={styles.muted}>{t('license.checkingWait')}</div>
</div>
) : !licenseSnap.active ? (
<div>
<div className={styles.licenseBlockTitle}>{t('license.requiredTitle')}</div>
<div className={styles.muted}>{t('license.requiredHint')}</div>
</div>
) : undefined;
return (
<>
{presentationOpen
? createPortal(
<div className={styles.editorLockOverlay} role="dialog" aria-label={t('presentation.overlay')}>
<div className={styles.editorLockModal}>
<div className={styles.editorLockTitle}>{t('presentation.title')}</div>
<div className={styles.editorLockText}>{t('presentation.body')}</div>
</div>
</div>,
document.body,
)
: null}
{state.zipProgress
? createPortal(
<div className={styles.progressOverlay} role="dialog" aria-label={t('zip.progress')}>
<div className={styles.progressModal}>
<div className={styles.progressTitle}>
{state.zipProgress.kind === 'import' ? t('zip.importTitle') : t('zip.exportTitle')}
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${String(Math.max(0, Math.min(100, state.zipProgress.percent)))}%` }}
/>
</div>
<div className={styles.progressMeta}>
<div>{state.zipProgress.detail ?? state.zipProgress.stage}</div>
<div>{state.zipProgress.percent}%</div>
</div>
</div>
</div>,
document.body,
)
: null}
<LayoutShell
bodyOverlay={bodyOverlay}
topBar={
<div className={styles.topBarRow}>
<button
type="button"
className={styles.brandButton}
onClick={() => {
goHome();
}}
title={t('top.backToProjects')}
>
<AppLogo className={styles.brandLogo} size={26} />
<div className={styles.brandTitle}>{t('app.brandTitle')}</div>
</button>
<div className={styles.fileToolbar}>
<button
ref={settingsMenuBtnRef}
type="button"
data-settingsmenu-root="1"
className={styles.fileMenuTrigger}
onClick={() => {
setFileMenuOpen(false);
setProjectMenuOpen(false);
setAboutMenuOpen(false);
setSettingsMenuOpen((v) => {
const next = !v;
if (next) setSettingsLangSubOpen(false);
return next;
});
}}
>
{t('top.settings')}
</button>
<button
ref={aboutMenuBtnRef}
type="button"
data-aboutmenu-root="1"
className={styles.fileMenuTrigger}
onClick={() => {
setFileMenuOpen(false);
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setAboutMenuOpen((v) => !v);
}}
>
{t('top.aboutApp')}
</button>
<button
ref={projectMenuBtnRef}
type="button"
data-projectmenu-root="1"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setFileMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setProjectMenuOpen((v) => !v);
}}
>
{t('top.project')}
</button>
{state.project ? (
<button
ref={fileMenuBtnRef}
type="button"
data-filemenu-root="1"
className={styles.fileMenuTrigger}
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
setProjectMenuOpen(false);
setSettingsMenuOpen(false);
setAboutMenuOpen(false);
setFileMenuOpen((v) => !v);
}}
>
{t('top.file')}
</button>
) : null}
</div>
<div className={styles.flex1} />
{appVersionText ? (
<div className={styles.appVersion} title={t('top.appVersion')}>
{appVersionText}
</div>
) : null}
<div className={styles.headerActions}>
{state.project ? (
<>
<Button
variant="primary"
disabled={runDisabled}
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId) return;
launchFromGraphNode(graphStartGraphNodeId);
}}
>
{t('top.run')}
</Button>
{runDisabled ? (
<Button
variant="ghost"
iconOnly
ariaLabel={t('top.runHelpAria')}
title={runHelpTooltip}
tooltipPlacement="bottom-left"
onClick={() => {
setInstructionsSection(runHelpSection);
setInstructionsOpen(true);
}}
>
?
</Button>
) : null}
</>
) : null}
</div>
</div>
}
left={
<div className={styles.editorSidebar}>
{state.project ? (
<>
<div className={styles.gridTools}>
<Input value={query} onChange={setQuery} placeholder={t('scenes.search')} />
<Button
variant="primary"
disabled={state.creatingScene}
onClick={() => void actions.createScene()}
>
{t('scenes.new')}
</Button>
</div>
<div className={styles.spacer14} />
<div className={styles.sidebarScroll}>
<div className={styles.sceneListGrid}>
{filtered.map((s) => (
<SceneListCard
key={s.id}
scene={s}
onSelect={() => {
setSelectedGraphNodeId(null);
void actions.selectScene(s.id);
}}
onDeleteScene={(id) => void actions.deleteScene(id)}
/>
))}
</div>
</div>
</>
) : (
<ProjectPicker
projects={state.projects}
licenseActive={licenseActive}
openingProjectId={state.openingProjectId}
onCreate={actions.createProject}
onOpen={actions.openProject}
onRename={(id) => {
renameFromPickerRef.current = true;
void (async () => {
try {
await actions.openProject(id);
setRenameOpen(true);
} catch (e) {
renameFromPickerRef.current = false;
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
})();
}}
onDelete={actions.deleteProject}
/>
)}
</div>
}
center={
<div className={styles.editorGraphHost}>
{state.project ? (
<SceneGraph
graphUi={graphUi}
sceneGraphNodes={state.project.sceneGraphNodes}
sceneGraphEdges={state.project.sceneGraphEdges}
sceneCardById={sceneCardById}
selectedGraphNodeId={selectedGraphNodeId}
onGraphNodeSelect={(graphNodeId, sceneId) => {
setSelectedGraphNodeId(graphNodeId);
void actions.selectScene(sceneId);
}}
onConnect={(sourceGn, targetGn) => void actions.addSceneGraphEdge(sourceGn, targetGn)}
onDisconnect={(edgeId) => void actions.removeSceneGraphEdge(edgeId)}
onNodePositionCommit={(nodeId, x, y) =>
void actions.updateSceneGraphNodePosition(nodeId, x, y)
}
onRemoveGraphNodes={(ids) => {
void Promise.all(ids.map((id) => actions.removeSceneGraphNode(id)));
}}
onRemoveGraphNode={(id) => void actions.removeSceneGraphNode(id)}
onSetGraphNodeStart={(graphNodeId) => void actions.setSceneGraphNodeStart(graphNodeId)}
onSetGraphNodeSideStoryStart={(graphNodeId) =>
void actions.setSceneGraphNodeSideStoryStart(graphNodeId)
}
onRunFromGraphNode={launchFromGraphNode}
onDropSceneFromList={(sceneId, x, y) => void actions.addSceneGraphNode(sceneId, x, y)}
/>
) : (
<div className={styles.centerEmpty} />
)}
</div>
}
right={
<div className={styles.editorInspector}>
<div className={styles.inspectorScroll}>
{state.project ? (
<>
<div className={styles.inspectorTitle}>{t('scenes.inspectorGame')}</div>
<CampaignInspector
audioRefs={campaignAudioRefs}
mediaAssets={campaignAudioAssets}
onAudioRefsChange={(next) => void actions.updateCampaignAudios(next)}
onUploadAudio={() => {
void (async () => {
try {
await actions.importCampaignAudio();
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
}
})();
}}
/>
<div className={styles.spacer18} />
<div className={styles.inspectorTitle}>{t('scenes.inspectorScene')}</div>
{state.selectedSceneId ? (
(() => {
const proj = state.project;
const sid = state.selectedSceneId;
const sc = proj.scenes[sid];
const previewImport = state.scenePreviewImports[sid] ?? null;
const previewBusy = previewDialogSceneId === sid || previewImport !== null;
const previewBusyText =
previewDialogSceneId === sid
? t('scene.previewBusySelecting')
: previewImport?.phase === 'done'
? t('scene.previewReady')
: previewImport?.phase === 'error'
? t('scene.previewFailed')
: previewImport !== null
? t('scene.previewOptimizing')
: t('scene.previewBusy');
const sideStoryStartNodes = proj.sceneGraphNodes.filter(
(n) => n.sceneId === sid && n.isSideStoryStart,
);
return (
<SceneInspector
title={sc?.title ?? ''}
description={sc?.description ?? ''}
sideStoryStartNodes={sideStoryStartNodes}
previewAssetId={sc?.previewAssetId ?? null}
previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0}
darkenScene={sc?.darkenScene ?? false}
previewBusy={previewBusy}
previewBusyText={previewBusyText}
mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs}
onAudioRefsChange={(next) =>
void actions.updateScene(sid, { media: { audios: next } })
}
onPreviewVideoAutostartChange={(next) =>
void actions.updateScene(sid, { previewVideoAutostart: next })
}
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) =>
void actions.updateScene(sid, { description })
}
onImportPreview={() => {
if (previewBusy) return;
setPreviewDialogSceneId(sid);
void (async () => {
try {
await actions.importScenePreview(sid);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
} finally {
setPreviewDialogSceneId((cur) => (cur === sid ? null : cur));
}
})();
}}
onClearPreview={() => void actions.clearScenePreview(sid)}
onRotatePreview={(previewRotationDeg) =>
void actions.updateScene(sid, { previewRotationDeg })
}
onUploadMedia={() => void actions.importMediaToScene(sid)}
onSideStoryLineTitleChange={(graphNodeId, title) =>
void actions.updateSideStoryLineTitle(graphNodeId, title)
}
/>
);
})()
) : (
<div className={styles.muted}>{t('scenes.selectHint')}</div>
)}
</>
) : (
<div className={styles.muted}>{t('scenes.openProjectHint')}</div>
)}
</div>
</div>
}
/>
{settingsMenuOpen && settingsMenuPos
? createPortal(
<div
role="menu"
data-settingsmenu-root="1"
className={styles.fileMenu}
style={{ left: settingsMenuPos.left, top: settingsMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
if ((licenseSnap?.eulaAcceptedVersion ?? null) === EULA_CURRENT_VERSION) {
setLicenseKeyModalOpen(true);
} else {
setOpenKeyAfterEula(true);
setEulaModalOpen(true);
}
}}
>
{t('menu.enterKey')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setAboutLicenseOpen(true);
}}
>
{t('menu.aboutLicense')}
</button>
{licenseActive && appPackaged ? (
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setCheckUpdatesOpen(true);
}}
>
{t('menu.checkUpdates')}
</button>
) : null}
<div className={styles.fileMenuSubHost} role="presentation">
<button
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={settingsLangSubOpen}
className={[styles.fileMenuItem, styles.fileMenuItemExpand].join(' ')}
onClick={(e) => {
e.stopPropagation();
setSettingsLangSubOpen((o) => !o);
}}
>
<span>{t('menu.language')}</span>
<span aria-hidden></span>
</button>
{settingsLangSubOpen ? (
<div
role="menu"
aria-label={t('menu.language')}
className={styles.fileMenuSub}
data-settings-submenu-root="1"
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setLocale('ru');
}}
>
{locale === 'ru' ? '✓ ' : ''}
{t('menu.langRu')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setSettingsMenuOpen(false);
setSettingsLangSubOpen(false);
setLocale('en');
}}
>
{locale === 'en' ? '✓ ' : ''}
{t('menu.langEn')}
</button>
</div>
) : null}
</div>
</div>,
document.body,
)
: null}
<LicenseTokenModal
open={licenseKeyModalOpen}
onClose={() => setLicenseKeyModalOpen(false)}
onSaved={() => {
reloadLicense();
}}
/>
<EulaModal
open={eulaModalOpen}
onClose={() => {
setEulaModalOpen(false);
setOpenKeyAfterEula(false);
}}
onAccepted={() => {
if (openKeyAfterEula) {
setLicenseKeyModalOpen(true);
}
setOpenKeyAfterEula(false);
}}
/>
<LicenseAboutModal
open={aboutLicenseOpen}
onClose={() => setAboutLicenseOpen(false)}
snapshot={licenseSnap}
/>
<AppAboutModal open={appAboutOpen} onClose={() => setAppAboutOpen(false)} appVersion={appVersionText} />
<InstructionsModal
open={instructionsOpen}
initialSection={instructionsSection}
onClose={() => setInstructionsOpen(false)}
/>
{aboutMenuOpen && aboutMenuPos
? createPortal(
<div
role="menu"
data-aboutmenu-root="1"
className={styles.fileMenu}
style={{ left: aboutMenuPos.left, top: aboutMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setAboutMenuOpen(false);
setAppAboutOpen(true);
}}
>
{t('menu.aboutProgram')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setAboutMenuOpen(false);
setInstructionsSection('overview');
setInstructionsOpen(true);
}}
>
{t('menu.instructions')}
</button>
</div>,
document.body,
)
: null}
{projectMenuOpen && projectMenuPos
? createPortal(
<div
role="menu"
data-projectmenu-root="1"
className={styles.fileMenu}
style={{ left: projectMenuPos.left, top: projectMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
goHome();
}}
>
{t('projectMenu.home')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
void handleImportProject();
}}
>
{t('projectMenu.import')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
disabled={state.projects.length === 0}
title={state.projects.length === 0 ? t('projectMenu.noProjects') : undefined}
onClick={() => {
setProjectMenuOpen(false);
setExportModalOpen(true);
}}
>
{t('projectMenu.export')}
</button>
</div>,
document.body,
)
: null}
{fileMenuOpen && fileMenuPos && state.project
? createPortal(
<div
role="menu"
data-filemenu-root="1"
className={styles.fileMenu}
style={{ left: fileMenuPos.left, top: fileMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setFileMenuOpen(false);
setRenameOpen(true);
}}
>
{t('fileMenu.rename')}
</button>
</div>,
document.body,
)
: null}
{state.project ? (
<RenameProjectModal
open={renameOpen}
projectNameInitial={currentProjectName}
fileBaseNameInitial={currentFileBaseName}
existingProjectNames={existingProjectNames}
existingFileBaseNames={existingFileBaseNames}
onClose={() => {
setRenameOpen(false);
if (renameFromPickerRef.current) {
renameFromPickerRef.current = false;
void actions.closeProject();
}
}}
onSave={async (name, fileBaseName) => {
await actions.renameProject(name, fileBaseName);
}}
/>
) : null}
<ExportProjectModal
open={exportModalOpen}
projects={state.projects}
initialProjectId={exportModalInitialProjectId}
storylineLabels={storylineLabels}
loadStorylines={loadProjectStorylines}
onClose={() => setExportModalOpen(false)}
onExport={async (projectId, selections) => {
await actions.exportProject(projectId, selections, storylineLabels);
}}
/>
<ImportSourceModal
open={importSourceOpen}
canImportFromProject={state.project !== null}
projects={state.projects}
currentProjectId={state.project?.id ?? null}
pickFile={actions.pickImportZipFile}
onClose={() => setImportSourceOpen(false)}
onContinue={handleImportSourceContinue}
/>
<ImportStorylinesModal
open={importStorylinesOpen}
sourceName={importPeek?.projectName ?? ''}
storylines={importPeek?.storylines ?? []}
onClose={() => {
setImportStorylinesOpen(false);
setImportPeek(null);
}}
onContinue={(selections) => {
if (!importPeek || !state.project) return;
const conflicts = computeImportConflicts(state.project, importPeek.sourceProject, selections);
setPendingImportSelections(selections);
if (conflicts.length > 0) {
setImportConflicts(conflicts);
setImportConflictsOpen(true);
setImportStorylinesOpen(false);
return;
}
const resolutions = buildSceneResolutionsForImport(
state.project,
importPeek.sourceProject,
selections,
[],
[],
);
void runStorylineMerge(selections, resolutions);
}}
/>
<SceneConflictModal
open={importConflictsOpen}
conflicts={importConflicts}
onClose={() => {
setImportConflictsOpen(false);
setImportPeek(null);
setPendingImportSelections([]);
setImportConflicts([]);
}}
onConfirm={(userResolutions) => {
if (!importPeek || !state.project) return;
const resolutions = buildSceneResolutionsForImport(
state.project,
importPeek.sourceProject,
pendingImportSelections,
importConflicts,
userResolutions,
);
void runStorylineMerge(pendingImportSelections, resolutions);
}}
/>
<ImportReportModal
open={importReportOpen}
report={importReport}
onClose={() => {
setImportReportOpen(false);
setImportReport(null);
}}
/>
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<SimpleMessageModal
open={appNotice !== null}
title={appNotice?.title ?? t('common.message')}
message={appNotice?.message ?? ''}
onClose={() => setAppNotice(null)}
/>
</>
);
}
type CheckUpdatesModalProps = {
open: boolean;
onClose: () => void;
};
function formatUpdaterStageLabel(
t: (key: string, vars?: Record<string, string | number>) => string,
ev: UpdaterProgressEvent | null,
): string {
if (!ev) return t('updates.stage.checking');
const percentSuffix =
ev.phase === 'downloading' && ev.percent !== undefined
? t('updates.stagePercent', { percent: ev.percent })
: '';
switch (ev.phase) {
case 'checking':
return t('updates.stage.checking');
case 'available':
return t('updates.stage.available', { version: ev.version ?? '?' });
case 'not-available':
return t('updates.stage.not-available');
case 'downloading':
return t('updates.stage.downloading', { percent: percentSuffix });
case 'installing':
return t('updates.stage.installing');
case 'error':
return ev.message ? `${t('updates.stage.error')}: ${ev.message}` : t('updates.stage.error');
default:
return t('updates.stage.checking');
}
}
function CheckUpdatesModal({ open, onClose }: CheckUpdatesModalProps) {
const { t } = useEditorI18n();
const [phase, setPhase] = useState<'idle' | 'checking' | 'done'>('idle');
const [res, setRes] = useState<UpdaterCheckResponse | null>(null);
const [downloadBusy, setDownloadBusy] = useState(false);
const [progress, setProgress] = useState<UpdaterProgressEvent | null>(null);
useEffect(() => {
if (!open) return;
startTransition(() => {
setPhase('checking');
setRes(null);
setProgress({ phase: 'checking' });
setDownloadBusy(false);
});
void getDndApi()
.invoke(ipcChannels.updater.check, {})
.then((r) => {
setRes(r);
setPhase('done');
if (r.outcome === 'available') {
setProgress({ phase: 'available', version: r.version });
} else if (r.outcome === 'current') {
setProgress({ phase: 'not-available', version: r.currentVersion });
} else if (r.outcome === 'error') {
setProgress({ phase: 'error', message: r.message });
}
})
.catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
setRes({ outcome: 'error', message });
setPhase('done');
setProgress({ phase: 'error', message });
});
}, [open]);
useEffect(() => {
if (!open) return;
return getDndApi().on(ipcChannels.updater.progress, (ev) => {
setProgress(ev);
});
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !downloadBusy) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [downloadBusy, onClose, open]);
if (!open) return null;
const stageLine = t('updates.stageLine', { stage: formatUpdaterStageLabel(t, progress) });
const body =
phase === 'checking' || res === null ? (
<div className={styles.muted}>{t('updates.checking')}</div>
) : res.outcome === 'available' ? (
<div className={styles.muted}>{t('updates.available', { version: res.version })}</div>
) : res.outcome === 'current' ? (
<div className={styles.muted}>{t('updates.current', { version: res.currentVersion })}</div>
) : res.outcome === 'error' ? (
<div className={styles.muted}>{t('updates.error', { message: res.message })}</div>
) : res.outcome === 'not_packaged' ? (
<div className={styles.muted}>{t('updates.notPackaged')}</div>
) : (
<div className={styles.muted}>{t('updates.noLicense')}</div>
);
const showUpdateIdle = phase === 'done' && res !== null && res.outcome === 'available' && !downloadBusy;
const showUpdateBusy = phase === 'done' && res !== null && res.outcome === 'available' && downloadBusy;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!downloadBusy) onClose();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('updates.dialogTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
disabled={downloadBusy}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
{body}
<div className={styles.muted}>{stageLine}</div>
</div>
<div className={styles.modalFooter}>
{showUpdateIdle ? (
<>
<Button variant="ghost" disabled={downloadBusy} onClick={onClose}>
{t('common.close')}
</Button>
<Button
variant="primary"
disabled={downloadBusy}
onClick={() => {
setDownloadBusy(true);
setProgress({ phase: 'downloading', version: res.version, percent: 0 });
void getDndApi()
.invoke(ipcChannels.updater.downloadAndRestart, { version: res.version })
.then((r) => {
if (!r.ok) {
setDownloadBusy(false);
setRes({ outcome: 'error', message: r.message });
setProgress({ phase: 'error', message: r.message });
}
})
.catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
setDownloadBusy(false);
setRes({ outcome: 'error', message });
setProgress({ phase: 'error', message });
});
}}
>
{t('updates.download')}
</Button>
</>
) : showUpdateBusy ? (
<Button variant="primary" disabled>
{progress?.phase === 'installing' ? t('updates.stage.installing') : t('updates.downloading')}
</Button>
) : (
<Button variant="primary" disabled={downloadBusy} onClick={onClose}>
{t('common.close')}
</Button>
)}
</div>
</div>
</>,
document.body,
);
}
type SimpleMessageModalProps = {
open: boolean;
title?: string;
message: string;
onClose: () => void;
};
function SimpleMessageModal({ open, title, message, onClose }: SimpleMessageModalProps) {
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) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{title ?? t('common.message')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>{message}</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.close')}</Button>
<Button variant="primary" onClick={onClose}>
{t('common.understood')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ConfirmDeleteProjectModalProps = {
open: boolean;
projectName: string;
busy: boolean;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
};
function ConfirmDeleteProjectModal({
open,
projectName,
busy,
onCancel,
onConfirm,
}: ConfirmDeleteProjectModalProps) {
const { t } = useEditorI18n();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !busy) onCancel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [busy, onCancel, open]);
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!busy) onCancel();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('confirmDelete.title')}</div>
<button
type="button"
aria-label={t('common.close')}
disabled={busy}
onClick={onCancel}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('confirmDelete.body', { name: projectName })}</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={onCancel} disabled={busy}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={busy}
onClick={() => {
void onConfirm();
}}
>
{t('common.delete')}
</Button>
</div>
</div>
</>,
document.body,
);
}
function isValidFileBaseName(input: string): boolean {
const trimmed = input.trim();
if (trimmed.length < 3) return false;
return !/[<>:"/\\|?*]/gu.test(trimmed);
}
function normalizeName(input: string): string {
return input.trim().toLowerCase();
}
type RenameProjectModalProps = {
open: boolean;
projectNameInitial: string;
fileBaseNameInitial: string;
existingProjectNames: string[];
existingFileBaseNames: string[];
onClose: () => void;
onSave: (projectName: string, fileBaseName: string) => Promise<void>;
};
function RenameProjectModal({
open,
projectNameInitial,
fileBaseNameInitial,
existingProjectNames,
existingFileBaseNames,
onClose,
onSave,
}: RenameProjectModalProps) {
const { t } = useEditorI18n();
const [projectName, setProjectName] = useState(projectNameInitial);
const [fileBaseName, setFileBaseName] = useState(fileBaseNameInitial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setProjectName(projectNameInitial);
setFileBaseName(fileBaseNameInitial);
setSaving(false);
setError(null);
}, [fileBaseNameInitial, open, projectNameInitial]);
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 trimmedProjectName = projectName.trim();
const trimmedFileBase = fileBaseName.trim();
const projectNameOk = trimmedProjectName.length >= 3;
const fileNameOk = isValidFileBaseName(trimmedFileBase);
const projectNameDup =
normalizeName(trimmedProjectName) !== normalizeName(projectNameInitial) &&
existingProjectNames.some((n) => normalizeName(n) === normalizeName(trimmedProjectName));
const fileNameDup =
normalizeName(trimmedFileBase) !== normalizeName(fileBaseNameInitial) &&
existingFileBaseNames.some((n) => normalizeName(n) === normalizeName(trimmedFileBase));
const canSave = projectNameOk && fileNameOk && !projectNameDup && !fileNameDup && !saving;
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('rename.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('rename.projectName')}</div>
<Input value={projectName} onChange={setProjectName} placeholder={t('rename.projectPlaceholder')} />
{!projectNameOk ? <div className={styles.fieldError}>{t('rename.projectMin')}</div> : null}
{projectNameDup ? <div className={styles.fieldError}>{t('rename.projectDup')}</div> : null}
</div>
<div className={styles.fieldGrid}>
<div className={styles.fieldLabel}>{t('rename.fileName')}</div>
<div className={styles.rowFlex}>
<div className={styles.flex1}>
<Input value={fileBaseName} onChange={setFileBaseName} placeholder="my_campaign" />
</div>
<div className={styles.fileSuffix}>{PROJECT_ZIP_EXTENSION}</div>
</div>
{!fileNameOk ? <div className={styles.fieldError}>{t('rename.fileInvalid')}</div> : null}
{fileNameDup ? <div className={styles.fieldError}>{t('rename.fileDup')}</div> : null}
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving} title={saving ? t('rename.saving') : undefined}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canSave}
onClick={() => {
if (!canSave) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onSave(trimmedProjectName, trimmedFileBase);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type ProjectPickerProps = {
projects: { id: ProjectId; name: string; updatedAt: string }[];
licenseActive: boolean;
openingProjectId: ProjectId | null;
onCreate: (name: string) => Promise<void>;
onOpen: (id: ProjectId) => Promise<void>;
onRename: (id: ProjectId) => void;
onDelete: (id: ProjectId) => Promise<void>;
};
function ProjectPicker({
projects,
licenseActive,
openingProjectId,
onCreate,
onOpen,
onRename,
onDelete,
}: ProjectPickerProps) {
const { t, locale } = useEditorI18n();
const [name, setName] = useState(() => t('picker.defaultName'));
const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null);
const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null);
const [pendingDelete, setPendingDelete] = useState<{ id: ProjectId; name: string } | null>(null);
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
if (!rowMenuFor) return;
const onDown = (e: MouseEvent) => {
const tgt = e.target as HTMLElement | null;
if (!tgt) return;
if (tgt.closest('[data-project-row-menu-root="1"]')) return;
setRowMenuFor(null);
setRowMenuPos(null);
};
window.addEventListener('mousedown', onDown);
return () => window.removeEventListener('mousedown', onDown);
}, [rowMenuFor]);
return (
<div className={styles.projectPicker}>
<div className={styles.projectPickerTitle}>{t('picker.title')}</div>
<div className={styles.projectPickerForm}>
<Input value={name} onChange={setName} placeholder={t('picker.newPlaceholder')} />
<Button
variant="primary"
disabled={!licenseActive}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => {
if (!licenseActive) return;
void onCreate(name);
}}
>
{t('picker.create')}
</Button>
</div>
<div className={styles.spacer6} />
<div className={styles.sectionLabel}>{t('picker.existing')}</div>
{!licenseActive && projects.length > 0 ? (
<>
<div className={styles.muted}>{t('picker.lockedHint')}</div>
<div className={styles.spacer6} />
</>
) : null}
<div className={styles.projectListScroll}>
<div className={styles.projectList}>
{projects.map((p) => {
const isOpening = openingProjectId === p.id;
const openDisabled = !licenseActive || openingProjectId !== null;
return (
<div key={p.id} className={styles.projectCard}>
<div
className={[
styles.projectCardBody,
isOpening ? styles.projectCardBodyOpening : null,
openDisabled && !isOpening ? styles.projectCardBodyDisabled : null,
]
.filter((className): className is string => Boolean(className))
.join(' ')}
onClick={() => {
if (openDisabled) return;
void onOpen(p.id);
}}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
role="button"
tabIndex={openDisabled ? -1 : 0}
aria-busy={isOpening}
title={
!licenseActive ? t('picker.openDisabled') : isOpening ? t('picker.opening') : undefined
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (openDisabled) return;
void onOpen(p.id);
}
}}
>
<div className={styles.projectCardName}>{p.name}</div>
<div className={styles.projectCardMeta}>
{isOpening
? t('picker.opening')
: new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')}
</div>
</div>
<button
type="button"
className={styles.projectCardMenuBtn}
data-project-row-menu-root="1"
aria-label={t('picker.projectMenu')}
aria-haspopup="menu"
aria-expanded={rowMenuFor === p.id}
disabled={!licenseActive || openingProjectId !== null}
title={
!licenseActive
? t('top.afterLicense')
: openingProjectId !== null
? t('picker.opening')
: undefined
}
onClick={(e) => {
e.stopPropagation();
if (!licenseActive || openingProjectId !== null) return;
const r = e.currentTarget.getBoundingClientRect();
const menuW = 220;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8));
setRowMenuPos({ left, top: r.bottom + 8 });
setRowMenuFor((cur) => (cur === p.id ? null : p.id));
}}
>
</button>
</div>
);
})}
{projects.length === 0 ? <div className={styles.muted}>{t('picker.empty')}</div> : null}
</div>
</div>
{rowMenuFor && rowMenuPos
? createPortal(
<div
role="menu"
data-project-row-menu-root="1"
className={styles.fileMenu}
style={{ left: rowMenuPos.left, top: rowMenuPos.top }}
>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
const id = rowMenuFor;
setRowMenuFor(null);
setRowMenuPos(null);
if (!id) return;
onRename(id);
}}
>
{t('fileMenu.rename')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
const id = rowMenuFor;
const proj = projects.find((x) => x.id === id);
setRowMenuFor(null);
setRowMenuPos(null);
if (!id || !proj) return;
setPendingDelete({ id, name: proj.name });
}}
>
{t('common.delete')}
</button>
</div>,
document.body,
)
: null}
<ConfirmDeleteProjectModal
open={pendingDelete !== null}
projectName={pendingDelete?.name ?? ''}
busy={deleteSubmitting}
onCancel={() => {
if (deleteSubmitting) return;
setPendingDelete(null);
}}
onConfirm={async () => {
if (!pendingDelete) return;
const { id } = pendingDelete;
setDeleteSubmitting(true);
try {
await onDelete(id);
setPendingDelete(null);
} catch (e) {
setDeleteError(e instanceof Error ? e.message : String(e));
setPendingDelete(null);
} finally {
setDeleteSubmitting(false);
}
}}
/>
<SimpleMessageModal
open={deleteError !== null}
title={t('confirmDelete.failedTitle')}
message={deleteError ?? ''}
onClose={() => setDeleteError(null)}
/>
</div>
);
}
type SceneInspectorProps = {
title: string;
description: string;
sideStoryStartNodes: { id: GraphNodeId; sideStoryLineTitle: string }[];
previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
darkenScene: boolean;
previewBusy: boolean;
previewBusyText: string;
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
onPreviewVideoAutostartChange: (next: boolean) => void;
onDarkenSceneChange: (next: boolean) => void;
onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void;
onImportPreview: () => void;
onClearPreview: () => void;
onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
onUploadMedia: () => void;
onSideStoryLineTitleChange: (graphNodeId: GraphNodeId, title: string) => void;
};
type CampaignInspectorProps = {
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
onUploadAudio: () => void;
};
function CampaignInspector({
mediaAssets,
audioRefs,
onAudioRefsChange,
onUploadAudio,
}: CampaignInspectorProps) {
const { t } = useEditorI18n();
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
return (
<div className={styles.sceneInspector}>
<div className={styles.labelSm}>{t('campaign.label')}</div>
<div className={styles.audioDrop}>
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
) : (
<div className={styles.audioList}>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('campaign.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div>
)}
<Button onClick={onUploadAudio}>{t('campaign.upload')}</Button>
</div>
</div>
);
}
function SceneInspector({
title,
description,
sideStoryStartNodes,
previewAssetId,
previewAssetType,
previewVideoAutostart,
previewRotationDeg,
darkenScene,
previewBusy,
previewBusyText,
mediaAssets,
audioRefs,
onAudioRefsChange,
onPreviewVideoAutostartChange,
onDarkenSceneChange,
onTitleChange,
onDescriptionChange,
onImportPreview,
onClearPreview,
onRotatePreview,
onUploadMedia,
onSideStoryLineTitleChange,
}: SceneInspectorProps) {
const { t } = useEditorI18n();
const previewUrl = useAssetUrl(previewAssetId);
const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]);
return (
<div className={styles.sceneInspector}>
<div className={styles.labelSm}>{t('scene.title')}</div>
<Input value={title} onChange={onTitleChange} />
<div className={styles.spacer8} />
<div className={styles.labelSm}>{t('scene.description')}</div>
<textarea
className={styles.textarea}
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
/>
{sideStoryStartNodes.length > 0 ? (
<>
<div className={styles.spacer8} />
{sideStoryStartNodes.map((gn) => (
<div key={gn.id}>
<div className={styles.labelSm}>{t('scene.sideStoryLineTitle')}</div>
<Input
value={gn.sideStoryLineTitle}
onChange={(v) => onSideStoryLineTitleChange(gn.id, v)}
/>
<div className={styles.spacer8} />
</div>
))}
</>
) : null}
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.preview')}</div>
<div className={styles.hint}>{t('scene.previewHint')}</div>
<div className={styles.previewBox}>
{previewUrl && previewAssetType === 'image' ? (
<div className={styles.previewFill}>
<RotatedImage
url={previewUrl}
rotationDeg={previewRotationDeg}
mode="cover"
style={{ width: '100%', height: '100%' }}
/>
</div>
) : previewUrl && previewAssetType === 'video' ? (
<div className={styles.previewFill}>
<video
src={previewUrl}
muted
playsInline
autoPlay={previewVideoAutostart}
loop
preload="metadata"
className={styles.videoCover}
/>
</div>
) : (
<div className={styles.previewEmpty}>{t('scene.previewEmpty')}</div>
)}
{previewBusy ? (
<div className={styles.previewBusyOverlay} aria-live="polite">
<div className={styles.previewBusyModal}>
<div className={styles.previewSpinner} aria-hidden />
<div className={styles.previewBusyText}>{previewBusyText}</div>
</div>
</div>
) : null}
</div>
<div className={styles.actionsRow}>
<Button variant="primary" disabled={previewBusy} onClick={onImportPreview}>
{previewAssetId ? t('scene.change') : t('campaign.upload')}
</Button>
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : null}
{previewAssetId && previewAssetType === 'video' ? (
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={previewVideoAutostart}
onChange={(e) => onPreviewVideoAutostartChange(e.target.checked)}
/>
<span className={styles.spanSm}>{t('scene.autostart')}</span>
</label>
) : null}
{previewAssetId && previewAssetType === 'image' ? (
<Button
onClick={() => {
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
onRotatePreview(next);
}}
>
{t('scene.rotate')}
</Button>
) : null}
</div>
{previewAssetId && previewAssetType === 'image' ? (
<>
<div className={styles.spacer6} />
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={darkenScene}
onChange={(e) => onDarkenSceneChange(e.target.checked)}
/>
<span className={styles.spanSm}>{t('scene.darkenScene')}</span>
</label>
</>
) : null}
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.audio')}</div>
<div className={styles.audioDrop}>
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
) : (
<div className={styles.audioList}>
{mediaAssets
.filter((a) => a.type === 'audio')
.map((a) => (
<div key={a.id} className={styles.audioRow}>
<span className={styles.audioName}>{a.originalName}</span>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.autoplay ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={audioById.get(a.id)?.loop ?? false}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === a.id ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<button
type="button"
title={t('scene.removeTitle')}
className={styles.audioRemove}
onClick={() => {
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== a.id));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
))}
</div>
)}
<Button onClick={onUploadMedia}>{t('campaign.upload')}</Button>
</div>
<div className={styles.spacer6} />
<div className={styles.labelSm}>{t('scene.branching')}</div>
<div className={styles.hintBlock}>{t('scene.branchingHint')}</div>
</div>
);
}
type SceneListCardProps = {
scene: SceneCard;
onSelect: () => void;
onDeleteScene: (sceneId: SceneId) => void;
};
function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
const { t } = useEditorI18n();
const thumbUrl = useAssetUrl(scene.previewThumbAssetId);
const previewUrl = useAssetUrl(scene.previewAssetId);
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!menu) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMenu(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [menu]);
const menuPos = useMemo(() => {
if (!menu) return null;
const pad = 8;
const mw = 180;
const mh = 48;
return {
x: Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad)),
y: Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad)),
};
}, [menu]);
const cardClass = [styles.sceneCard, scene.active ? styles.sceneCardActive : ''].filter(Boolean).join(' ');
return (
<div
draggable
className={cardClass}
onDragStart={(e) => {
e.dataTransfer.setData(DND_SCENE_ID_MIME, scene.id);
e.dataTransfer.effectAllowed = 'copy';
}}
onClick={onSelect}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onSelect();
}}
>
<div className={thumbUrl || previewUrl ? styles.sceneThumb : styles.sceneThumbEmpty}>
{thumbUrl ? (
<div className={styles.sceneThumbInner}>
<RotatedImage
url={thumbUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
/>
</div>
) : previewUrl && scene.previewAssetType === 'image' ? (
<div className={styles.sceneThumbInner}>
<RotatedImage
url={previewUrl}
rotationDeg={scene.previewRotationDeg}
mode="cover"
loading="lazy"
decoding="async"
/>
</div>
) : previewUrl && scene.previewAssetType === 'video' ? (
<div className={styles.sceneThumbInner}>
<video
src={previewUrl}
muted
playsInline
preload="metadata"
className={styles.sceneThumbVideo}
onLoadedData={(e) => {
const v = e.currentTarget;
try {
v.currentTime = 0;
v.pause();
} catch {
// ignore
}
}}
/>
</div>
) : (
<div className={styles.sceneThumbEmptyInner} aria-hidden />
)}
</div>
<div className={styles.sceneCardBody}>
<div className={styles.sceneCardHeader}>
{scene.active ? <div className={styles.badgeCurrent}>{t('sceneCard.current')}</div> : null}
<button
type="button"
aria-label={t('sceneCard.menu')}
className={styles.sceneMenuBtn}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setMenu({ x: e.clientX, y: e.clientY });
}}
>
</button>
</div>
<div className={styles.sceneCardTitle}>{scene.title}</div>
</div>
{menu && menuPos
? createPortal(
<>
<button
type="button"
aria-label={t('common.closeMenu')}
className={styles.menuBackdrop}
onClick={() => setMenu(null)}
/>
<div
role="menu"
tabIndex={-1}
className={styles.sceneCtxMenu}
style={{ left: menuPos.x, top: menuPos.y }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') setMenu(null);
}}
>
<button
type="button"
role="menuitem"
className={styles.sceneCtxDanger}
onClick={() => {
onDeleteScene(scene.id);
setMenu(null);
}}
>
{t('common.delete')}
</button>
</div>
</>,
document.body,
)
: null}
</div>
);
}