Лицензия, редактор, пульт и сборка
- Main: license service, IPC, router; закрытие окон; yauzl закрытие zip (EMFILE), zipRead тест - Editor: стабильный projectState без мигания, логотип и меню, строки UI, LayoutShell overlay - Control: ластик для всех типов эффектов, затухание/нарастание музыки при смене сцены - Сборка: vite, build/dev scripts, obfuscate-main и build-env скрипты с тестами; package.json Made-with: Cursor
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
|
||||
@@ -31,6 +32,7 @@ export function ControlApp() {
|
||||
const audioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||||
const [audioStateTick, setAudioStateTick] = useState(0);
|
||||
const audioLoadRunRef = useRef(0);
|
||||
const audioUnmountRef = useRef(false);
|
||||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const brushRef = useRef<{
|
||||
@@ -80,6 +82,13 @@ export function ControlApp() {
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
audioUnmountRef.current = false;
|
||||
return () => {
|
||||
audioUnmountRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const project = session?.project ?? null;
|
||||
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
||||
const currentScene =
|
||||
@@ -102,21 +111,62 @@ export function ControlApp() {
|
||||
useEffect(() => {
|
||||
audioLoadRunRef.current += 1;
|
||||
const runId = audioLoadRunRef.current;
|
||||
// Cleanup old audios on scene change.
|
||||
const els = audioElsRef.current;
|
||||
for (const el of els.values()) {
|
||||
try {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
els.clear();
|
||||
|
||||
const oldEls = new Map(audioElsRef.current);
|
||||
audioElsRef.current = new Map();
|
||||
audioMetaRef.current.clear();
|
||||
setAudioStateTick((x) => x + 1);
|
||||
|
||||
if (!project || !currentScene) return;
|
||||
const FADE_OUT_MS = 450;
|
||||
const fadeOutCtl = { raf: 0, cancelled: false };
|
||||
const finishFadeOut = (): void => {
|
||||
for (const el of oldEls.values()) {
|
||||
try {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
el.volume = 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
if (oldEls.size > 0) {
|
||||
const startVol = new Map<string, number>();
|
||||
for (const [id, el] of oldEls) {
|
||||
startVol.set(id, el.volume);
|
||||
}
|
||||
const t0 = performance.now();
|
||||
const tickOut = (now: number): void => {
|
||||
if (fadeOutCtl.cancelled || audioUnmountRef.current) {
|
||||
finishFadeOut();
|
||||
return;
|
||||
}
|
||||
const u = Math.min(1, (now - t0) / FADE_OUT_MS);
|
||||
for (const [id, el] of oldEls) {
|
||||
try {
|
||||
const v0 = startVol.get(id) ?? 1;
|
||||
el.volume = v0 * (1 - u);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (u < 1) {
|
||||
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
|
||||
} else {
|
||||
finishFadeOut();
|
||||
}
|
||||
};
|
||||
fadeOutCtl.raf = window.requestAnimationFrame(tickOut);
|
||||
}
|
||||
|
||||
if (!project || !currentScene) {
|
||||
return () => {
|
||||
fadeOutCtl.cancelled = true;
|
||||
window.cancelAnimationFrame(fadeOutCtl.raf);
|
||||
};
|
||||
}
|
||||
|
||||
const FADE_IN_MS = 550;
|
||||
void (async () => {
|
||||
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||||
for (const item of sceneAudioRefs) {
|
||||
@@ -126,6 +176,7 @@ export function ControlApp() {
|
||||
const el = new Audio(r.url);
|
||||
el.loop = item.loop;
|
||||
el.preload = 'auto';
|
||||
el.volume = item.autoplay ? 0 : 1;
|
||||
audioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||||
el.addEventListener('play', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('pause', () => setAudioStateTick((x) => x + 1));
|
||||
@@ -141,6 +192,7 @@ export function ControlApp() {
|
||||
try {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
el.volume = 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -157,9 +209,47 @@ export function ControlApp() {
|
||||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||
});
|
||||
setAudioStateTick((x) => x + 1);
|
||||
try {
|
||||
el.volume = 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||
try {
|
||||
el.volume = 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const tIn0 = performance.now();
|
||||
const tickIn = (now: number): void => {
|
||||
if (audioLoadRunRef.current !== runId || audioUnmountRef.current) {
|
||||
try {
|
||||
el.volume = 1;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
const u = Math.min(1, (now - tIn0) / FADE_IN_MS);
|
||||
try {
|
||||
el.volume = u;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (u < 1) window.requestAnimationFrame(tickIn);
|
||||
};
|
||||
window.requestAnimationFrame(tickIn);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
fadeOutCtl.cancelled = true;
|
||||
window.cancelAnimationFrame(fadeOutCtl.raf);
|
||||
};
|
||||
}, [api, currentScene, project, sceneAudioRefs]);
|
||||
|
||||
const anyPlaying = useMemo(() => {
|
||||
@@ -676,34 +766,8 @@ export function ControlApp() {
|
||||
setCursorN(p);
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
if (tool.tool === 'eraser') {
|
||||
const rN = tool.radiusN;
|
||||
const nearest = (fxState?.instances ?? [])
|
||||
.map((inst) => {
|
||||
if (inst.type === 'fog') {
|
||||
const d = inst.points.reduce((best, q) => {
|
||||
const dx = q.x - p.x;
|
||||
const dy = q.y - p.y;
|
||||
const dd = dx * dx + dy * dy;
|
||||
return Math.min(best, dd);
|
||||
}, Number.POSITIVE_INFINITY);
|
||||
return { id: inst.id, dd: d };
|
||||
}
|
||||
if (inst.type === 'lightning') {
|
||||
const dx = inst.end.x - p.x;
|
||||
const dy = inst.end.y - p.y;
|
||||
return { id: inst.id, dd: dx * dx + dy * dy };
|
||||
}
|
||||
if (inst.type === 'freeze') {
|
||||
const dx = inst.at.x - p.x;
|
||||
const dy = inst.at.y - p.y;
|
||||
return { id: inst.id, dd: dx * dx + dy * dy };
|
||||
}
|
||||
return { id: inst.id, dd: Number.POSITIVE_INFINITY };
|
||||
})
|
||||
.sort((a, b) => a.dd - b.dd)[0];
|
||||
if (nearest && nearest.dd <= rN * rN) {
|
||||
void fx.dispatch({ kind: 'instance.remove', id: nearest.id });
|
||||
}
|
||||
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
|
||||
if (id) void fx.dispatch({ kind: 'instance.remove', id });
|
||||
return;
|
||||
}
|
||||
brushRef.current = {
|
||||
@@ -714,10 +778,15 @@ export function ControlApp() {
|
||||
setDraftFxTick((x) => x + 1);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const b = brushRef.current;
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
setCursorN(p);
|
||||
if (tool.tool === 'eraser' && (e.buttons & 1) !== 0) {
|
||||
const id = pickEraseTargetId(fxState?.instances ?? [], p, tool.radiusN);
|
||||
if (id) void fx.dispatch({ kind: 'instance.remove', id });
|
||||
return;
|
||||
}
|
||||
const b = brushRef.current;
|
||||
if (!b?.points) return;
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (!last) return;
|
||||
|
||||
@@ -253,6 +253,37 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.licenseBlockTitle {
|
||||
font-weight: 900;
|
||||
font-size: var(--text-lg);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.licenseTextarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
color: var(--text0);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.eulaScroll {
|
||||
max-height: min(52vh, 420px);
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
white-space: pre-wrap;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.fileSuffix {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
|
||||
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
|
||||
import type { AssetId, MediaAsset, ProjectId, SceneAudioRef, SceneId } from '../../shared/types';
|
||||
import { AppLogo } from '../shared/branding/AppLogo';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
@@ -12,6 +14,7 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './EditorApp.module.css';
|
||||
import { DND_SCENE_ID_MIME, SceneGraph } from './graph/SceneGraph';
|
||||
import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals';
|
||||
import { useProjectState } from './state/projectState';
|
||||
|
||||
type SceneCard = {
|
||||
@@ -29,13 +32,22 @@ export function EditorApp() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [fileMenuOpen, setFileMenuOpen] = useState(false);
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [state, actions] = useProjectState();
|
||||
const [licenseSnap, setLicenseSnap] = useState<LicenseSnapshot | null>(null);
|
||||
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 [state, actions] = useProjectState(licenseActive);
|
||||
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 [];
|
||||
@@ -134,6 +146,45 @@ export function EditorApp() {
|
||||
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]);
|
||||
|
||||
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 {
|
||||
@@ -148,28 +199,63 @@ export function EditorApp() {
|
||||
|
||||
const exportModalInitialProjectId = state.project?.id ?? state.projects[0]?.id ?? null;
|
||||
|
||||
const bodyOverlay =
|
||||
licenseSnap === null ? (
|
||||
<div>
|
||||
<div className={styles.licenseBlockTitle}>Проверка лицензии…</div>
|
||||
<div className={styles.muted}>Подождите.</div>
|
||||
</div>
|
||||
) : !licenseSnap.active ? (
|
||||
<div>
|
||||
<div className={styles.licenseBlockTitle}>Требуется лицензия</div>
|
||||
<div className={styles.muted}>
|
||||
Укажите ключ в меню «Настройки» → «Указать ключ». До активации доступно только меню «Настройки».
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayoutShell
|
||||
bodyOverlay={bodyOverlay}
|
||||
topBar={
|
||||
<div className={styles.topBarRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.brandButton}
|
||||
onClick={() => void actions.closeProject()}
|
||||
onClick={() => {
|
||||
void actions.closeProject();
|
||||
}}
|
||||
title="К списку проектов"
|
||||
>
|
||||
<AppLogo className={styles.brandLogo} size={26} />
|
||||
<div className={styles.brandTitle}>DNDGamePlayer</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) => !v);
|
||||
}}
|
||||
>
|
||||
Настройки
|
||||
</button>
|
||||
<button
|
||||
ref={projectMenuBtnRef}
|
||||
type="button"
|
||||
data-projectmenu-root="1"
|
||||
className={styles.fileMenuTrigger}
|
||||
disabled={!licenseActive}
|
||||
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
|
||||
onClick={() => {
|
||||
if (!licenseActive) return;
|
||||
setFileMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setProjectMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
@@ -181,8 +267,12 @@ export function EditorApp() {
|
||||
type="button"
|
||||
data-filemenu-root="1"
|
||||
className={styles.fileMenuTrigger}
|
||||
disabled={!licenseActive}
|
||||
title={!licenseActive ? 'Доступно после активации лицензии' : undefined}
|
||||
onClick={() => {
|
||||
if (!licenseActive) return;
|
||||
setProjectMenuOpen(false);
|
||||
setSettingsMenuOpen(false);
|
||||
setFileMenuOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
@@ -200,10 +290,16 @@ export function EditorApp() {
|
||||
{state.project ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!graphStartGraphNodeId}
|
||||
title={graphStartSceneId ? undefined : 'Назначьте начальную сцену на графе (ПКМ по узлу)'}
|
||||
disabled={!licenseActive || !graphStartGraphNodeId}
|
||||
title={
|
||||
!licenseActive
|
||||
? 'Доступно после активации лицензии'
|
||||
: graphStartSceneId
|
||||
? undefined
|
||||
: 'Назначьте начальную сцену на графе (ПКМ по узлу)'
|
||||
}
|
||||
onClick={() => {
|
||||
if (!graphStartGraphNodeId) return;
|
||||
if (!licenseActive || !graphStartGraphNodeId) return;
|
||||
void (async () => {
|
||||
await getDndApi().invoke(ipcChannels.project.setCurrentGraphNode, {
|
||||
graphNodeId: graphStartGraphNodeId,
|
||||
@@ -319,6 +415,70 @@ export function EditorApp() {
|
||||
</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);
|
||||
if ((licenseSnap?.eulaAcceptedVersion ?? null) === EULA_CURRENT_VERSION) {
|
||||
setLicenseKeyModalOpen(true);
|
||||
} else {
|
||||
setOpenKeyAfterEula(true);
|
||||
setEulaModalOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Указать ключ
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
onClick={() => {
|
||||
setSettingsMenuOpen(false);
|
||||
setAboutLicenseOpen(true);
|
||||
}}
|
||||
>
|
||||
О лицензии
|
||||
</button>
|
||||
</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
|
||||
@@ -327,6 +487,17 @@ export function EditorApp() {
|
||||
className={styles.fileMenu}
|
||||
style={{ left: projectMenuPos.left, top: projectMenuPos.top }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
onClick={() => {
|
||||
setProjectMenuOpen(false);
|
||||
void actions.closeProject();
|
||||
}}
|
||||
>
|
||||
Начальный экран
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -372,7 +543,7 @@ export function EditorApp() {
|
||||
setRenameOpen(true);
|
||||
}}
|
||||
>
|
||||
Переименовать проект…
|
||||
Переименовать проект
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
@@ -894,7 +1065,6 @@ function SceneInspector({
|
||||
/>
|
||||
<span className={styles.spanXs}>Цикл</span>
|
||||
</label>
|
||||
<span>аудио</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Убрать из сцены"
|
||||
@@ -921,7 +1091,7 @@ function SceneInspector({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={onUploadMedia}>Загрузить аудио</Button>
|
||||
<Button onClick={onUploadMedia}>Загрузить</Button>
|
||||
</div>
|
||||
<div className={styles.spacer6} />
|
||||
<div className={styles.labelSm}>ВЕТВЛЕНИЯ</div>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import { EULA_CURRENT_VERSION } from '../../../shared/license/eulaVersion';
|
||||
import type { LicenseSnapshot } from '../../../shared/license/licenseSnapshot';
|
||||
import { EULA_RU_MARKDOWN } from '../../legal/eulaRu';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { Button } from '../../shared/ui/controls';
|
||||
import styles from '../EditorApp.module.css';
|
||||
|
||||
type LicenseTokenModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
};
|
||||
|
||||
export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalProps) {
|
||||
const [token, setToken] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setToken('');
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
}, [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;
|
||||
|
||||
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>
|
||||
<textarea
|
||||
className={styles.licenseTextarea}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Вставьте токен, выданный сервером лицензий…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={saving || !token.trim()}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await getDndApi().invoke(ipcChannels.license.setToken, { token: token.trim() });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type EulaModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAccepted: () => void;
|
||||
};
|
||||
|
||||
export function EulaModal({ open, onClose, onAccepted }: EulaModalProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
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="Закрыть" 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.eulaScroll}>{EULA_RU_MARKDOWN}</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
Не принимаю
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await getDndApi().invoke(ipcChannels.license.acceptEula, {
|
||||
version: EULA_CURRENT_VERSION,
|
||||
});
|
||||
onAccepted();
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Принимаю условия
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type LicenseAboutModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
snapshot: LicenseSnapshot | null;
|
||||
};
|
||||
|
||||
function reasonLabel(reason: LicenseSnapshot['reason']): string {
|
||||
switch (reason) {
|
||||
case 'ok':
|
||||
return 'Активна';
|
||||
case 'none':
|
||||
return 'Ключ не указан';
|
||||
case 'expired':
|
||||
return 'Срок действия истёк';
|
||||
case 'bad_signature':
|
||||
return 'Недействительная подпись';
|
||||
case 'bad_payload':
|
||||
return 'Неверный формат токена';
|
||||
case 'malformed':
|
||||
return 'Повреждённый токен';
|
||||
case 'not_yet_valid':
|
||||
return 'Ещё не действует';
|
||||
case 'wrong_device':
|
||||
return 'Другой привязанный компьютер';
|
||||
case 'revoked_remote':
|
||||
return 'Отозвана на сервере';
|
||||
default:
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
export function LicenseAboutModal({ open, onClose, snapshot }: LicenseAboutModalProps) {
|
||||
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 expText =
|
||||
snapshot?.summary?.exp != null
|
||||
? new Date(snapshot.summary.exp * 1000).toLocaleString('ru-RU', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
: '—';
|
||||
|
||||
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>
|
||||
{snapshot?.devSkip ? (
|
||||
<div className={styles.fieldError}>
|
||||
Режим разработки: проверка лицензии отключена (DND_SKIP_LICENSE).
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>СТАТУС</div>
|
||||
<div>{snapshot ? reasonLabel(snapshot.reason) : '—'}</div>
|
||||
</div>
|
||||
{snapshot?.summary ? (
|
||||
<>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>ПРОДУКТ</div>
|
||||
<div>{snapshot.summary.pid}</div>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>ID ЛИЦЕНЗИИ</div>
|
||||
<div style={{ wordBreak: 'break-all' }}>{snapshot.summary.sub}</div>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>ОКОНЧАНИЕ</div>
|
||||
<div>{expText}</div>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>УСТРОЙСТВО</div>
|
||||
<div style={{ wordBreak: 'break-all' }}>{snapshot.deviceId}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muted}>Нет данных лицензии.</div>
|
||||
)}
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
|
||||
@@ -54,9 +54,13 @@ function randomId(prefix: string): string {
|
||||
return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
|
||||
}
|
||||
|
||||
export function useProjectState(): readonly [State, Actions] {
|
||||
export function useProjectState(licenseActive: boolean): readonly [State, Actions] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<State>({ projects: [], project: null, selectedSceneId: null });
|
||||
const projectRef = useRef<Project | null>(null);
|
||||
useEffect(() => {
|
||||
projectRef.current = state.project;
|
||||
}, [state.project]);
|
||||
|
||||
const actions = useMemo<Actions>(() => {
|
||||
const refreshProjects = async () => {
|
||||
@@ -77,11 +81,11 @@ export function useProjectState(): readonly [State, Actions] {
|
||||
|
||||
const closeProject = async () => {
|
||||
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
|
||||
await refreshProjects();
|
||||
if (licenseActive) await refreshProjects();
|
||||
};
|
||||
|
||||
const createScene = async () => {
|
||||
const p = state.project;
|
||||
const p = projectRef.current;
|
||||
if (!p) return;
|
||||
const sceneId = randomId('scene') as SceneId;
|
||||
const scene: Scene = {
|
||||
@@ -307,16 +311,22 @@ export function useProjectState(): readonly [State, Actions] {
|
||||
exportProject,
|
||||
deleteProject,
|
||||
};
|
||||
}, [api, state.project]);
|
||||
}, [api, licenseActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!licenseActive) {
|
||||
queueMicrotask(() => {
|
||||
setState({ projects: [], project: null, selectedSceneId: null });
|
||||
});
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
await actions.refreshProjects();
|
||||
const listRes = await api.invoke(ipcChannels.project.list, {});
|
||||
setState((s) => ({ ...s, projects: listRes.projects }));
|
||||
const res = await api.invoke(ipcChannels.project.get, {});
|
||||
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null }));
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [licenseActive, api]);
|
||||
|
||||
return [state, actions] as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Текст для экрана принятия EULA (D9). Не заменяет консультацию юриста. */
|
||||
export const EULA_RU_MARKDOWN = `
|
||||
# Лицензионное соглашение с конечным пользователем (EULA)
|
||||
|
||||
Используя DNDGamePlayer («Программу»), вы соглашаетесь с условиями ниже.
|
||||
|
||||
## 1. Предоставление прав
|
||||
Правообладатель предоставляет вам неисключическую, непередаваемую лицензию на использование Программы в пределах приобретённой лицензии (активации).
|
||||
|
||||
## 2. Активация, срок, устройства
|
||||
Доступ к функциям может требовать онлайн- или офлайн-активации с помощью ключа. Лицензия может быть ограничена сроком действия и числом устройств. Подробности отображаются в разделе «О лицензии».
|
||||
|
||||
## 3. Отзыв
|
||||
Правообладатель вправе отозвать лицензию при нарушении условий или по иным основаниям, предусмотренным офертой. После отзыва Программа может ограничить доступ к функциям без обновления установленного у вас клиента (проверка статуса при наличии сети).
|
||||
|
||||
## 4. Отказ от гарантий
|
||||
Программа поставляется «как есть». По максимуму, допускаемому применимым правом, исключаются гарантии любого рода.
|
||||
|
||||
## 5. Ограничение ответственности
|
||||
Ответственность ограничивается суммой, уплаченной за лицензию, если иное не установлено императивным правом.
|
||||
|
||||
## 6. Применимое право
|
||||
Применимое право и разрешение споров — в соответствии с документами, сопровождающими вашу покупку, либо по выбору правообладателя, если отдельные документы не согласованы.
|
||||
`.trim();
|
||||
@@ -15,6 +15,7 @@
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr var(--inspector-w);
|
||||
gap: 0;
|
||||
@@ -22,6 +23,22 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bodyOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: rgba(10, 10, 12, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--text1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.col {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -7,13 +7,16 @@ type Props = {
|
||||
left: React.ReactNode;
|
||||
center: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
/** Блокировка основной области (под хедером), например без лицензии. */
|
||||
bodyOverlay?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function LayoutShell({ topBar, left, center, right }: Props) {
|
||||
export function LayoutShell({ topBar, left, center, right, bodyOverlay }: Props) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.topBar}>{topBar}</div>
|
||||
<div className={styles.body}>
|
||||
{bodyOverlay ? <div className={styles.bodyOverlay}>{bodyOverlay}</div> : null}
|
||||
<div className={styles.col}>{left}</div>
|
||||
<div className={styles.col}>{center}</div>
|
||||
<div className={styles.col}>{right}</div>
|
||||
|
||||
Reference in New Issue
Block a user