10bb7013e6
Return control after copying the original asset, then run image optimization and thumbnail generation in the background with IPC progress updates. Block duplicate preview uploads and scene creation while requests are in flight. Co-authored-by: Cursor <cursoragent@cursor.com>
2178 lines
77 KiB
TypeScript
2178 lines
77 KiB
TypeScript
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 { 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 { 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 [renameOpen, setRenameOpen] = useState(false);
|
||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||
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 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'),
|
||
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 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 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 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);
|
||
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={!licenseActive || !graphStartGraphNodeId}
|
||
title={
|
||
!licenseActive
|
||
? t('top.afterLicense')
|
||
: graphStartSceneId
|
||
? undefined
|
||
: t('top.setStartScene')
|
||
}
|
||
onClick={() => {
|
||
if (!licenseActive || !graphStartGraphNodeId) return;
|
||
launchFromGraphNode(graphStartGraphNodeId);
|
||
}}
|
||
>
|
||
{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"
|
||
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={() => 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}
|
||
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)}
|
||
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');
|
||
return (
|
||
<SceneInspector
|
||
title={sc?.title ?? ''}
|
||
description={sc?.description ?? ''}
|
||
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)}
|
||
/>
|
||
);
|
||
})()
|
||
) : (
|
||
<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} 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);
|
||
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={() => {
|
||
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 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>;
|
||
onDelete: (id: ProjectId) => Promise<void>;
|
||
};
|
||
|
||
function ProjectPicker({
|
||
projects,
|
||
licenseActive,
|
||
openingProjectId,
|
||
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) => {
|
||
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;
|
||
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;
|
||
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;
|
||
};
|
||
|
||
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,
|
||
darkenScene,
|
||
previewBusy,
|
||
previewBusyText,
|
||
mediaAssets,
|
||
audioRefs,
|
||
onAudioRefsChange,
|
||
onPreviewVideoAutostartChange,
|
||
onDarkenSceneChange,
|
||
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}>{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>
|
||
);
|
||
}
|