Files
DndGamePlayer/app/renderer/editor/EditorApp.tsx
T
Ivan Fontosh 7c858ba633 feat(phase1): rebrand to TTRPG Player and drop Git updates feed
Rename product to TTRPG Player (TTRPGPlayer / com.ttrpgplayer.app), use .ttrpg.zip for new saves while keeping .dnd.zip import, accept TTRPG- and DND- license keys on client, and remove sync-update-feed plus CI push to DndGamePlayerUpdates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 20:56:14 +08:00

1954 lines
69 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 } 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, 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 styles from './EditorApp.module.css';
import { buildNextSceneCardById } from './graph/sceneCardById';
import {
DND_SCENE_ID_MIME,
SceneGraph,
type SceneGraphSceneCard,
type SceneGraphUiStrings,
} from './graph/SceneGraph';
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 [renameOpen, setRenameOpen] = useState(false);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [previewBusy, setPreviewBusy] = useState(false);
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 sceneCardById = useStableSceneCardById(state.project);
const graphUi = useMemo<SceneGraphUiStrings>(
() => ({
badgeStart: t('graph.badgeStart'),
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'),
delete: t('common.delete'),
}),
[t],
);
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const projectMenuBtnRef = useRef<HTMLButtonElement | null>(null);
const settingsMenuBtnRef = 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 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 graphStartSceneId = useMemo(() => {
const p = state.project;
if (!p) return null;
const gn = p.sceneGraphNodes.find((n) => n.isStartScene);
return gn?.sceneId ?? null;
}, [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 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(() => {
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 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={() => {
void actions.closeProject();
}}
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);
setSettingsMenuOpen((v) => {
const next = !v;
if (next) setSettingsLangSubOpen(false);
return next;
});
}}
>
{t('top.settings')}
</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);
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);
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={!licenseActive || !graphStartGraphNodeId}
title={
!licenseActive
? t('top.afterLicense')
: graphStartSceneId
? undefined
: t('top.setStartScene')
}
onClick={() => {
if (!licenseActive || !graphStartGraphNodeId) return;
void (async () => {
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
graphNodeId: graphStartGraphNodeId,
});
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
})();
}}
>
{t('top.run')}
</Button>
) : 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" 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={() => void actions.selectScene(s.id)}
onDeleteScene={(id) => void actions.deleteScene(id)}
/>
))}
</div>
</div>
</>
) : (
<ProjectPicker
projects={state.projects}
licenseActive={licenseActive}
onCreate={actions.createProject}
onOpen={actions.openProject}
onDelete={actions.deleteProject}
/>
)}
</div>
}
center={
<div className={styles.editorGraphHost}>
{state.project ? (
<SceneGraph
graphUi={graphUi}
sceneGraphNodes={state.project.sceneGraphNodes}
sceneGraphEdges={state.project.sceneGraphEdges}
sceneCardById={sceneCardById}
currentSceneId={state.selectedSceneId}
onCurrentSceneChange={(id) => void actions.selectScene(id)}
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)}
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];
return (
<SceneInspector
title={sc?.title ?? ''}
description={sc?.description ?? ''}
previewAssetId={sc?.previewAssetId ?? null}
previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0}
previewBusy={previewBusy}
mediaAssets={sceneMediaAssets}
audioRefs={sceneAudioRefs}
onAudioRefsChange={(next) =>
void actions.updateScene(sid, { media: { audios: next } })
}
onPreviewVideoAutostartChange={(next) =>
void actions.updateScene(sid, { previewVideoAutostart: next })
}
onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) =>
void actions.updateScene(sid, { description })
}
onImportPreview={() => {
setPreviewBusy(true);
void (async () => {
try {
await actions.importScenePreview(sid);
} catch (e) {
setAppNotice({
title: t('common.error'),
message: e instanceof Error ? e.message : String(e),
});
} finally {
setPreviewBusy(false);
}
})();
}}
onClearPreview={() => void actions.clearScenePreview(sid)}
onRotatePreview={(previewRotationDeg) =>
void actions.updateScene(sid, { previewRotationDeg })
}
onUploadMedia={() => void actions.importMediaToScene(sid)}
/>
);
})()
) : (
<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}
/>
{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={() => {
setProjectMenuOpen(false);
void actions.closeProject();
}}
>
{t('projectMenu.home')}
</button>
<button
type="button"
role="menuitem"
className={styles.fileMenuItem}
onClick={() => {
setProjectMenuOpen(false);
void actions.importProject();
}}
>
{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)}
onSave={async (name, fileBaseName) => {
await actions.renameProject(name, fileBaseName);
}}
/>
) : null}
<ExportProjectModal
open={exportModalOpen}
projects={state.projects}
initialProjectId={exportModalInitialProjectId}
onClose={() => setExportModalOpen(false)}
onExport={async (projectId) => {
await actions.exportProject(projectId);
}}
/>
<CheckUpdatesModal open={checkUpdatesOpen} onClose={() => setCheckUpdatesOpen(false)} />
<SimpleMessageModal
open={appNotice !== null}
title={appNotice?.title ?? t('common.message')}
message={appNotice?.message ?? ''}
onClose={() => setAppNotice(null)}
/>
</>
);
}
type ExportProjectModalProps = {
open: boolean;
projects: { id: ProjectId; name: string; fileName: string }[];
initialProjectId: ProjectId | null;
onClose: () => void;
onExport: (projectId: ProjectId) => Promise<void>;
};
function ExportProjectModal({
open,
projects,
initialProjectId,
onClose,
onExport,
}: ExportProjectModalProps) {
const { t } = useEditorI18n();
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setProjectId(initialProjectId);
setSaving(false);
setError(null);
}, [initialProjectId, 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 canExport = projectId !== null && projects.some((p) => p.id === projectId);
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('export.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('export.project')}</div>
<select
className={styles.selectInput}
value={projectId ?? ''}
onChange={(e) => setProjectId((e.target.value as ProjectId) || null)}
disabled={projects.length === 0}
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.fileName})
</option>
))}
</select>
<div className={styles.muted}>{t('export.hint')}</div>
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!canExport || saving}
onClick={() => {
if (!projectId || !canExport) return;
void (async () => {
setSaving(true);
setError(null);
try {
await onExport(projectId);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
})();
}}
>
{t('export.saveAs')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type CheckUpdatesModalProps = {
open: boolean;
onClose: () => void;
};
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);
useEffect(() => {
if (!open) return;
startTransition(() => {
setPhase('checking');
setRes(null);
});
void getDndApi()
.invoke(ipcChannels.updater.check, {})
.then((r) => {
setRes(r);
setPhase('done');
})
.catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
setRes({ outcome: 'error', message });
setPhase('done');
});
}, [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 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>
<div className={styles.modalFooter}>
{showUpdateIdle ? (
<>
<Button variant="ghost" disabled={downloadBusy} onClick={onClose}>
{t('common.close')}
</Button>
<Button
variant="primary"
disabled={downloadBusy}
onClick={() => {
setDownloadBusy(true);
void getDndApi()
.invoke(ipcChannels.updater.downloadAndRestart, {})
.then((r) => {
if (!r.ok) {
setDownloadBusy(false);
setRes({ outcome: 'error', message: r.message });
}
})
.catch((e: unknown) => {
setDownloadBusy(false);
setRes({
outcome: 'error',
message: e instanceof Error ? e.message : String(e),
});
});
}}
>
{t('updates.download')}
</Button>
</>
) : showUpdateBusy ? (
<Button variant="primary" disabled>
{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;
onCreate: (name: string) => Promise<void>;
onOpen: (id: ProjectId) => Promise<void>;
onDelete: (id: ProjectId) => Promise<void>;
};
function ProjectPicker({ projects, licenseActive, onCreate, onOpen, 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) => (
<div key={p.id} className={styles.projectCard}>
<div
className={styles.projectCardBody}
onClick={() => {
if (!licenseActive) return;
void onOpen(p.id);
}}
role="button"
tabIndex={0}
title={!licenseActive ? t('picker.openDisabled') : undefined}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (!licenseActive) return;
void onOpen(p.id);
}
}}
>
<div className={styles.projectCardName}>{p.name}</div>
<div className={styles.projectCardMeta}>
{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}
title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={(e) => {
e.stopPropagation();
if (!licenseActive) 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;
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;
previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean;
previewRotationDeg: 0 | 90 | 180 | 270;
previewBusy: boolean;
mediaAssets: MediaAsset[];
audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void;
onPreviewVideoAutostartChange: (next: boolean) => void;
onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void;
onImportPreview: () => void;
onClearPreview: () => void;
onRotatePreview: (deg: 0 | 90 | 180 | 270) => void;
onUploadMedia: () => 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,
previewAssetId,
previewAssetType,
previewVideoAutostart,
previewRotationDeg,
previewBusy,
mediaAssets,
audioRefs,
onAudioRefsChange,
onPreviewVideoAutostartChange,
onTitleChange,
onDescriptionChange,
onImportPreview,
onClearPreview,
onRotatePreview,
onUploadMedia,
}: 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)}
/>
<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}>{t('scene.previewBusy')}</div>
</div>
</div>
) : null}
</div>
<div className={styles.actionsRow}>
<Button variant="primary" 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>
<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>
);
}