80103a00e7
Show update stage in the modal, forward electron-updater progress over IPC, and install immediately when the build is already cached. Rename window titles to TTRPG - *. Co-authored-by: Cursor <cursoragent@cursor.com>
2010 lines
71 KiB
TypeScript
2010 lines
71 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, 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 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={() => {
|
||
if (res?.outcome !== 'available') return;
|
||
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;
|
||
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>
|
||
);
|
||
}
|