a6cbcc273e
Made-with: Cursor
1067 lines
39 KiB
TypeScript
1067 lines
39 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||
import type { AssetId, MediaAsset, 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 { DND_SCENE_ID_MIME, SceneGraph } from './graph/SceneGraph';
|
||
import { useProjectState } from './state/projectState';
|
||
|
||
type SceneCard = {
|
||
id: SceneId;
|
||
title: string;
|
||
active: boolean;
|
||
previewAssetId: AssetId | null;
|
||
previewAssetType: 'image' | 'video' | null;
|
||
previewVideoAutostart: boolean;
|
||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||
};
|
||
|
||
export function EditorApp() {
|
||
const [appVersionText, setAppVersionText] = useState<string | null>(null);
|
||
const [query, setQuery] = useState('');
|
||
const [fileMenuOpen, setFileMenuOpen] = useState(false);
|
||
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
||
const [renameOpen, setRenameOpen] = useState(false);
|
||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||
const [state, actions] = useProjectState();
|
||
const fileMenuBtnRef = useRef<HTMLButtonElement | null>(null);
|
||
const projectMenuBtnRef = 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 scenes = useMemo<SceneCard[]>(() => {
|
||
const p = state.project;
|
||
if (!p) return [];
|
||
return Object.values(p.scenes).map((s) => ({
|
||
id: s.id,
|
||
title: s.title,
|
||
active: s.id === state.selectedSceneId,
|
||
previewAssetId: s.previewAssetId,
|
||
previewAssetType: s.previewAssetType,
|
||
previewVideoAutostart: s.previewVideoAutostart,
|
||
previewRotationDeg: s.previewRotationDeg,
|
||
}));
|
||
}, [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 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(() => {
|
||
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);
|
||
} catch {
|
||
setAppVersionText(null);
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
|
||
|
||
return (
|
||
<>
|
||
<LayoutShell
|
||
topBar={
|
||
<div className={styles.topBarRow}>
|
||
<button
|
||
type="button"
|
||
className={styles.brandButton}
|
||
onClick={() => void actions.closeProject()}
|
||
title="К списку проектов"
|
||
>
|
||
<AppLogo className={styles.brandLogo} size={26} />
|
||
<div className={styles.brandTitle}>DNDGamePlayer</div>
|
||
</button>
|
||
<div className={styles.fileToolbar}>
|
||
<button
|
||
ref={projectMenuBtnRef}
|
||
type="button"
|
||
data-projectmenu-root="1"
|
||
className={styles.fileMenuTrigger}
|
||
onClick={() => {
|
||
setFileMenuOpen(false);
|
||
setProjectMenuOpen((v) => !v);
|
||
}}
|
||
>
|
||
Проект
|
||
</button>
|
||
{state.project ? (
|
||
<button
|
||
ref={fileMenuBtnRef}
|
||
type="button"
|
||
data-filemenu-root="1"
|
||
className={styles.fileMenuTrigger}
|
||
onClick={() => {
|
||
setProjectMenuOpen(false);
|
||
setFileMenuOpen((v) => !v);
|
||
}}
|
||
>
|
||
Файл
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className={styles.flex1} />
|
||
{appVersionText ? (
|
||
<div className={styles.appVersion} title="Версия приложения">
|
||
{appVersionText}
|
||
</div>
|
||
) : null}
|
||
<div className={styles.headerActions}>
|
||
{state.project ? (
|
||
<Button
|
||
variant="primary"
|
||
disabled={!graphStartGraphNodeId}
|
||
title={graphStartSceneId ? undefined : 'Назначьте начальную сцену на графе (ПКМ по узлу)'}
|
||
onClick={() => {
|
||
if (!graphStartGraphNodeId) return;
|
||
void (async () => {
|
||
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
|
||
graphNodeId: graphStartGraphNodeId,
|
||
});
|
||
await getDndApi().invoke(ipcChannels.windows.openMultiWindow, {});
|
||
})();
|
||
}}
|
||
>
|
||
Запустить
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
}
|
||
left={
|
||
<div className={styles.editorSidebar}>
|
||
{state.project ? (
|
||
<>
|
||
<div className={styles.gridTools}>
|
||
<Input value={query} onChange={setQuery} placeholder="Поиск сцен…" />
|
||
<Button variant="primary" onClick={() => void actions.createScene()}>
|
||
+ Новая сцена
|
||
</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}
|
||
onCreate={actions.createProject}
|
||
onOpen={actions.openProject}
|
||
onDelete={actions.deleteProject}
|
||
/>
|
||
)}
|
||
</div>
|
||
}
|
||
center={
|
||
<div className={styles.editorGraphHost}>
|
||
{state.project ? (
|
||
<SceneGraph
|
||
sceneGraphNodes={state.project.sceneGraphNodes}
|
||
sceneGraphEdges={state.project.sceneGraphEdges}
|
||
sceneById={state.project.scenes}
|
||
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.inspectorTitle}>Свойства сцены</div>
|
||
<div className={styles.inspectorScroll}>
|
||
{state.project && 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}
|
||
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={() => void actions.importScenePreview(sid)}
|
||
onClearPreview={() => void actions.clearScenePreview(sid)}
|
||
onRotatePreview={(previewRotationDeg) =>
|
||
void actions.updateScene(sid, { previewRotationDeg })
|
||
}
|
||
onUploadMedia={() => void actions.importMediaToScene(sid)}
|
||
/>
|
||
);
|
||
})()
|
||
) : (
|
||
<div className={styles.muted}>Откройте проект, чтобы редактировать сцену.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
}
|
||
/>
|
||
{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.importProject();
|
||
}}
|
||
>
|
||
Импорт
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
className={styles.fileMenuItem}
|
||
disabled={state.projects.length === 0}
|
||
title={state.projects.length === 0 ? 'Нет сохранённых проектов' : undefined}
|
||
onClick={() => {
|
||
setProjectMenuOpen(false);
|
||
setExportModalOpen(true);
|
||
}}
|
||
>
|
||
Экспорт
|
||
</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);
|
||
}}
|
||
>
|
||
Переименовать проект…
|
||
</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);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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 [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="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||
<div className={styles.modalHeader}>
|
||
<div className={styles.modalTitle}>Экспорт проекта</div>
|
||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.fieldGrid}>
|
||
<div className={styles.fieldLabel}>ПРОЕКТ</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}>
|
||
Далее откроется окно сохранения: укажите имя и папку для файла .dnd.zip — будет создана копия
|
||
архива проекта.
|
||
</div>
|
||
</div>
|
||
|
||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||
|
||
<div className={styles.modalFooter}>
|
||
<Button onClick={onClose} disabled={saving} title={saving ? 'Экспорт…' : undefined}>
|
||
Отмена
|
||
</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);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
Сохранить как…
|
||
</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 [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="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||
<div className={styles.modalHeader}>
|
||
<div className={styles.modalTitle}>Переименовать проект</div>
|
||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.fieldGrid}>
|
||
<div className={styles.fieldLabel}>НАЗВАНИЕ ПРОЕКТА</div>
|
||
<Input value={projectName} onChange={setProjectName} placeholder="Название проекта…" />
|
||
{!projectNameOk ? <div className={styles.fieldError}>Минимум 3 символа.</div> : null}
|
||
{projectNameDup ? (
|
||
<div className={styles.fieldError}>Проект с таким названием уже существует.</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className={styles.fieldGrid}>
|
||
<div className={styles.fieldLabel}>НАЗВАНИЕ ФАЙЛА ПРОЕКТА</div>
|
||
<div className={styles.rowFlex}>
|
||
<div className={styles.flex1}>
|
||
<Input value={fileBaseName} onChange={setFileBaseName} placeholder="my_campaign" />
|
||
</div>
|
||
<div className={styles.fileSuffix}>.dnd.zip</div>
|
||
</div>
|
||
{!fileNameOk ? (
|
||
<div className={styles.fieldError}>Минимум 3 символа, без символов {'<>:"/\\|?*'}</div>
|
||
) : null}
|
||
{fileNameDup ? (
|
||
<div className={styles.fieldError}>Файл проекта с таким названием уже существует.</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||
|
||
<div className={styles.modalFooter}>
|
||
<Button onClick={onClose} disabled={saving} title={saving ? 'Сохранение…' : undefined}>
|
||
Отмена
|
||
</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);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
type ProjectPickerProps = {
|
||
projects: { id: ProjectId; name: string; updatedAt: string }[];
|
||
onCreate: (name: string) => Promise<void>;
|
||
onOpen: (id: ProjectId) => Promise<void>;
|
||
onDelete: (id: ProjectId) => Promise<void>;
|
||
};
|
||
|
||
function ProjectPicker({ projects, onCreate, onOpen, onDelete }: ProjectPickerProps) {
|
||
const [name, setName] = useState('Моя кампания');
|
||
const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null);
|
||
const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!rowMenuFor) return;
|
||
const onDown = (e: MouseEvent) => {
|
||
const t = e.target as HTMLElement | null;
|
||
if (!t) return;
|
||
if (t.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}>Проекты</div>
|
||
<div className={styles.projectPickerForm}>
|
||
<Input value={name} onChange={setName} placeholder="Название нового проекта…" />
|
||
<Button variant="primary" onClick={() => void onCreate(name)}>
|
||
Создать проект
|
||
</Button>
|
||
</div>
|
||
<div className={styles.spacer6} />
|
||
<div className={styles.sectionLabel}>СУЩЕСТВУЮЩИЕ</div>
|
||
<div className={styles.projectListScroll}>
|
||
<div className={styles.projectList}>
|
||
{projects.map((p) => (
|
||
<div key={p.id} className={styles.projectCard}>
|
||
<div
|
||
className={styles.projectCardBody}
|
||
onClick={() => void onOpen(p.id)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') void onOpen(p.id);
|
||
}}
|
||
>
|
||
<div className={styles.projectCardName}>{p.name}</div>
|
||
<div className={styles.projectCardMeta}>{new Date(p.updatedAt).toLocaleString('ru-RU')}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={styles.projectCardMenuBtn}
|
||
data-project-row-menu-root="1"
|
||
aria-label="Меню проекта"
|
||
aria-haspopup="menu"
|
||
aria-expanded={rowMenuFor === p.id}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
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}>Пока нет проектов.</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 ||
|
||
!window.confirm(
|
||
`Удалить проект «${proj.name}» безвозвратно? Файл и кэш будут стёрты с диска.`,
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
void onDelete(id);
|
||
}}
|
||
>
|
||
Удалить
|
||
</button>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
: null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type SceneInspectorProps = {
|
||
title: string;
|
||
description: string;
|
||
previewAssetId: AssetId | null;
|
||
previewAssetType: 'image' | 'video' | null;
|
||
previewVideoAutostart: boolean;
|
||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||
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;
|
||
};
|
||
|
||
function SceneInspector({
|
||
title,
|
||
description,
|
||
previewAssetId,
|
||
previewAssetType,
|
||
previewVideoAutostart,
|
||
previewRotationDeg,
|
||
mediaAssets,
|
||
audioRefs,
|
||
onAudioRefsChange,
|
||
onPreviewVideoAutostartChange,
|
||
onTitleChange,
|
||
onDescriptionChange,
|
||
onImportPreview,
|
||
onClearPreview,
|
||
onRotatePreview,
|
||
onUploadMedia,
|
||
}: SceneInspectorProps) {
|
||
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}>НАЗВАНИЕ СЦЕНЫ</div>
|
||
<Input value={title} onChange={onTitleChange} />
|
||
<div className={styles.spacer8} />
|
||
<div className={styles.labelSm}>ОПИСАНИЕ</div>
|
||
<textarea
|
||
className={styles.textarea}
|
||
value={description}
|
||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||
/>
|
||
<div className={styles.spacer6} />
|
||
<div className={styles.labelSm}>ПРЕВЬЮ СЦЕНЫ</div>
|
||
<div className={styles.hint}>Отдельный файл изображения (PNG, JPG, WebP, GIF и т.д.).</div>
|
||
<div className={styles.previewBox}>
|
||
{previewUrl && previewAssetType === 'image' ? (
|
||
<RotatedImage url={previewUrl} rotationDeg={previewRotationDeg} mode="cover" />
|
||
) : previewUrl && previewAssetType === 'video' ? (
|
||
<video
|
||
src={previewUrl}
|
||
muted
|
||
playsInline
|
||
autoPlay={previewVideoAutostart}
|
||
loop
|
||
preload="metadata"
|
||
className={styles.videoCover}
|
||
/>
|
||
) : (
|
||
<div className={styles.previewEmpty}>Превью не задано</div>
|
||
)}
|
||
</div>
|
||
<div className={styles.actionsRow}>
|
||
<Button variant="primary" onClick={onImportPreview}>
|
||
{previewAssetId ? 'Изменить' : 'Загрузить'}
|
||
</Button>
|
||
{previewAssetId ? <Button onClick={onClearPreview}>Очистить</Button> : null}
|
||
{previewAssetId && previewAssetType === 'video' ? (
|
||
<label className={styles.checkboxLabel}>
|
||
<input
|
||
type="checkbox"
|
||
checked={previewVideoAutostart}
|
||
onChange={(e) => onPreviewVideoAutostartChange(e.target.checked)}
|
||
/>
|
||
<span className={styles.spanSm}>Автостарт</span>
|
||
</label>
|
||
) : null}
|
||
{previewAssetId && previewAssetType === 'image' ? (
|
||
<Button
|
||
onClick={() => {
|
||
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
|
||
onRotatePreview(next);
|
||
}}
|
||
>
|
||
Повернуть
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
<div className={styles.spacer6} />
|
||
<div className={styles.labelSm}>АУДИО СЦЕНЫ</div>
|
||
<div className={styles.audioDrop}>
|
||
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? (
|
||
<div className={[styles.muted, styles.spanSm].join(' ')}>Файлов пока нет. Добавьте аудио.</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}>Авто</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}>Цикл</span>
|
||
</label>
|
||
<span>аудио</span>
|
||
<button
|
||
type="button"
|
||
title="Убрать из сцены"
|
||
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}>Загрузить аудио</Button>
|
||
</div>
|
||
<div className={styles.spacer6} />
|
||
<div className={styles.labelSm}>ВЕТВЛЕНИЯ</div>
|
||
<div className={styles.hintBlock}>
|
||
Перетащите сцену из списка на граф. С одной карточки можно задать несколько вариантов — по одной связи
|
||
на каждую целевую сцену. Повторно к той же сцене (включая вторую карточку той же сцены на графе)
|
||
подключить нельзя.
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type SceneListCardProps = {
|
||
scene: SceneCard;
|
||
onSelect: () => void;
|
||
onDeleteScene: (sceneId: SceneId) => void;
|
||
};
|
||
|
||
function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) {
|
||
const url = 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={url ? styles.sceneThumb : styles.sceneThumbEmpty}>
|
||
{url && scene.previewAssetType === 'image' ? (
|
||
<div className={styles.sceneThumbInner}>
|
||
<RotatedImage url={url} rotationDeg={scene.previewRotationDeg} mode="cover" />
|
||
</div>
|
||
) : url && scene.previewAssetType === 'video' ? (
|
||
<div className={styles.sceneThumbInner}>
|
||
<video
|
||
src={url}
|
||
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}>ТЕКУЩАЯ</div> : null}
|
||
<button
|
||
type="button"
|
||
aria-label="Меню сцены"
|
||
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="Закрыть меню"
|
||
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);
|
||
}}
|
||
>
|
||
Удалить
|
||
</button>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
)
|
||
: null}
|
||
</div>
|
||
);
|
||
}
|