feat: i18n control, Gitea auto-update CI, license-gated updater, fixes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -243,6 +243,33 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.fileMenuSubHost {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fileMenuItemExpand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fileMenuSub {
|
||||
position: absolute;
|
||||
left: calc(100% + 6px);
|
||||
top: 0;
|
||||
min-width: 200px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated-2);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
z-index: calc(var(--z-file-menu) + 1);
|
||||
}
|
||||
|
||||
.modalBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
+407
-137
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
@@ -50,11 +50,53 @@ export const DND_SCENE_ID_MIME = 'application/x-dnd-scene-id';
|
||||
const SCENE_CARD_W = 220;
|
||||
const SCENE_CARD_H = 248;
|
||||
|
||||
/** UI strings for the scene graph (passed from editor i18n). */
|
||||
export type SceneGraphUiStrings = {
|
||||
badgeStart: string;
|
||||
untitled: string;
|
||||
videoBadge: string;
|
||||
audioBadge: string;
|
||||
loop: string;
|
||||
autoplay: string;
|
||||
previewAutostart: string;
|
||||
videoLoop: string;
|
||||
zoomBar: string;
|
||||
zoomIn: string;
|
||||
zoomOut: string;
|
||||
fitAll: string;
|
||||
closeMenu: string;
|
||||
startScene: string;
|
||||
unsetStartScene: string;
|
||||
delete: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SCENE_GRAPH_UI: SceneGraphUiStrings = {
|
||||
badgeStart: 'НАЧАЛО',
|
||||
untitled: 'Без названия',
|
||||
videoBadge: 'Видео',
|
||||
audioBadge: 'Аудио',
|
||||
loop: 'Цикл',
|
||||
autoplay: 'Автостарт',
|
||||
previewAutostart: 'Авто превью',
|
||||
videoLoop: 'Цикл видео',
|
||||
zoomBar: 'Масштаб графа',
|
||||
zoomIn: 'Увеличить',
|
||||
zoomOut: 'Уменьшить',
|
||||
fitAll: 'Показать всё',
|
||||
closeMenu: 'Закрыть меню',
|
||||
startScene: 'Начальная сцена',
|
||||
unsetStartScene: 'Снять метку «Начальная сцена»',
|
||||
delete: 'Удалить',
|
||||
};
|
||||
|
||||
const GraphUiContext = createContext<SceneGraphUiStrings>(DEFAULT_SCENE_GRAPH_UI);
|
||||
|
||||
export type SceneGraphProps = {
|
||||
sceneGraphNodes: SceneGraphNode[];
|
||||
sceneGraphEdges: SceneGraphEdge[];
|
||||
sceneCardById: Record<SceneId, SceneGraphSceneCard>;
|
||||
currentSceneId: SceneId | null;
|
||||
graphUi?: SceneGraphUiStrings;
|
||||
onCurrentSceneChange: (id: SceneId) => void;
|
||||
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
|
||||
onDisconnect: (edgeId: string) => void;
|
||||
@@ -131,6 +173,7 @@ function IconVideoPreviewAutostart() {
|
||||
}
|
||||
|
||||
function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
const ui = useContext(GraphUiContext);
|
||||
const thumbUrl = useAssetUrl(data.previewThumbAssetId);
|
||||
const previewUrl = useAssetUrl(data.previewAssetId);
|
||||
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
|
||||
@@ -141,7 +184,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
<Handle type="target" position={Position.Top} className={styles.handle} />
|
||||
<div className={cardClass}>
|
||||
<div className={styles.previewShell}>
|
||||
{data.isStartScene ? <div className={styles.badgeStart}>НАЧАЛО</div> : null}
|
||||
{data.isStartScene ? <div className={styles.badgeStart}>{ui.badgeStart}</div> : null}
|
||||
{thumbUrl ? (
|
||||
<div className={styles.previewFill}>
|
||||
{data.previewRotationDeg === 0 ? (
|
||||
@@ -209,12 +252,12 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
{showCornerVideo || showCornerAudio ? (
|
||||
<div className={styles.cornerBadges}>
|
||||
{showCornerVideo ? (
|
||||
<span className={styles.mediaBadge} title="Видео">
|
||||
<span className={styles.mediaBadge} title={ui.videoBadge}>
|
||||
<IconVideoBadge />
|
||||
</span>
|
||||
) : null}
|
||||
{showCornerAudio ? (
|
||||
<span className={styles.mediaBadge} title="Аудио">
|
||||
<span className={styles.mediaBadge} title={ui.audioBadge}>
|
||||
<IconAudioBadge />
|
||||
</span>
|
||||
) : null}
|
||||
@@ -222,19 +265,19 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.nodeBody}>
|
||||
<div className={styles.title}>{data.title || 'Без названия'}</div>
|
||||
<div className={styles.title}>{data.title || ui.untitled}</div>
|
||||
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParams}>
|
||||
{data.hasAnyAudioLoop ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconLoopParam />
|
||||
<span>Цикл</span>
|
||||
<span>{ui.loop}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconAutoplayParam />
|
||||
<span>Автостарт</span>
|
||||
<span>{ui.autoplay}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -244,13 +287,13 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
{data.showPreviewVideoAutostart ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconVideoPreviewAutostart />
|
||||
<span>Авто превью</span>
|
||||
<span>{ui.previewAutostart}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{data.showPreviewVideoLoop ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconLoopParam />
|
||||
<span>Цикл видео</span>
|
||||
<span>{ui.videoLoop}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -265,18 +308,19 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
const nodeTypes = { sceneCard: SceneCardNode };
|
||||
|
||||
function GraphZoomToolbar() {
|
||||
const ui = useContext(GraphUiContext);
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const zoom = useStore((s) => s.transform[2]);
|
||||
const pct = Math.max(1, Math.round(zoom * 100));
|
||||
|
||||
return (
|
||||
<Panel position="bottom-center" className={styles.zoomPanel}>
|
||||
<div className={styles.zoomBar} role="toolbar" aria-label="Масштаб графа">
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label="Увеличить">
|
||||
<div className={styles.zoomBar} role="toolbar" aria-label={ui.zoomBar}>
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label={ui.zoomIn}>
|
||||
+
|
||||
</button>
|
||||
<span className={styles.zoomPct}>{pct}%</span>
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label="Уменьшить">
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label={ui.zoomOut}>
|
||||
−
|
||||
</button>
|
||||
<span className={styles.zoomDivider} aria-hidden />
|
||||
@@ -284,8 +328,8 @@ function GraphZoomToolbar() {
|
||||
type="button"
|
||||
className={styles.zoomBtn}
|
||||
onClick={() => fitView({ padding: 0.25 })}
|
||||
aria-label="Показать всё"
|
||||
title="Показать всё"
|
||||
aria-label={ui.fitAll}
|
||||
title={ui.fitAll}
|
||||
>
|
||||
<svg className={styles.zoomFitIcon} viewBox="0 0 24 24" width={18} height={18} aria-hidden>
|
||||
<path
|
||||
@@ -307,6 +351,7 @@ function SceneGraphCanvas({
|
||||
sceneGraphEdges,
|
||||
sceneCardById,
|
||||
currentSceneId,
|
||||
graphUi,
|
||||
onCurrentSceneChange,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
@@ -316,6 +361,7 @@ function SceneGraphCanvas({
|
||||
onSetGraphNodeStart,
|
||||
onDropSceneFromList,
|
||||
}: SceneGraphProps) {
|
||||
const ui = graphUi ?? DEFAULT_SCENE_GRAPH_UI;
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
|
||||
|
||||
@@ -446,109 +492,111 @@ function SceneGraphCanvas({
|
||||
}, [menu]);
|
||||
|
||||
return (
|
||||
<div className={styles.canvasWrap}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStop={(_, node) => {
|
||||
onNodePositionCommit(node.id as GraphNodeId, node.position.x, node.position.y);
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
isValidConnection={isValidConnection}
|
||||
onConnect={onConnectInternal}
|
||||
onEdgesDelete={(eds) => {
|
||||
for (const ed of eds) {
|
||||
onDisconnect(ed.id);
|
||||
}
|
||||
}}
|
||||
onEdgeClick={(_, edge) => {
|
||||
onDisconnect(edge.id);
|
||||
}}
|
||||
onNodesDelete={(nds) => {
|
||||
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
|
||||
}}
|
||||
onNodeClick={(_, node) => {
|
||||
setMenu(null);
|
||||
const d = node.data as SceneCardData;
|
||||
onCurrentSceneChange(d.sceneId);
|
||||
}}
|
||||
onNodeContextMenu={(e, node) => {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
setMenu(null);
|
||||
}}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
onInit={(instance) => {
|
||||
instance.fitView({ padding: 0.25 });
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
panOnScroll
|
||||
selectionOnDrag={false}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={18} size={1} color="rgba(255,255,255,0.06)" />
|
||||
<GraphZoomToolbar />
|
||||
</ReactFlow>
|
||||
{menu && menuPosition
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть меню"
|
||||
className={styles.menuBackdrop}
|
||||
onClick={() => setMenu(null)}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: menuPosition.x, top: menuPosition.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setMenu(null);
|
||||
}}
|
||||
>
|
||||
<GraphUiContext.Provider value={ui}>
|
||||
<div className={styles.canvasWrap}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStop={(_, node) => {
|
||||
onNodePositionCommit(node.id as GraphNodeId, node.position.x, node.position.y);
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
isValidConnection={isValidConnection}
|
||||
onConnect={onConnectInternal}
|
||||
onEdgesDelete={(eds) => {
|
||||
for (const ed of eds) {
|
||||
onDisconnect(ed.id);
|
||||
}
|
||||
}}
|
||||
onEdgeClick={(_, edge) => {
|
||||
onDisconnect(edge.id);
|
||||
}}
|
||||
onNodesDelete={(nds) => {
|
||||
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
|
||||
}}
|
||||
onNodeClick={(_, node) => {
|
||||
setMenu(null);
|
||||
const d = node.data as SceneCardData;
|
||||
onCurrentSceneChange(d.sceneId);
|
||||
}}
|
||||
onNodeContextMenu={(e, node) => {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
setMenu(null);
|
||||
}}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
onInit={(instance) => {
|
||||
instance.fitView({ padding: 0.25 });
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
panOnScroll
|
||||
selectionOnDrag={false}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={18} size={1} color="rgba(255,255,255,0.06)" />
|
||||
<GraphZoomToolbar />
|
||||
</ReactFlow>
|
||||
{menu && menuPosition
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
onClick={() => {
|
||||
if (menuNodeIsStart) {
|
||||
onSetGraphNodeStart(null);
|
||||
} else {
|
||||
onSetGraphNodeStart(menu.graphNodeId);
|
||||
}
|
||||
setMenu(null);
|
||||
aria-label={ui.closeMenu}
|
||||
className={styles.menuBackdrop}
|
||||
onClick={() => setMenu(null)}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: menuPosition.x, top: menuPosition.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setMenu(null);
|
||||
}}
|
||||
>
|
||||
{menuNodeIsStart ? 'Снять метку «Начальная сцена»' : 'Начальная сцена'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItemDanger}
|
||||
onClick={() => {
|
||||
onRemoveGraphNode(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
onClick={() => {
|
||||
if (menuNodeIsStart) {
|
||||
onSetGraphNodeStart(null);
|
||||
} else {
|
||||
onSetGraphNodeStart(menu.graphNodeId);
|
||||
}
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{menuNodeIsStart ? ui.unsetStartScene : ui.startScene}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItemDanger}
|
||||
onClick={() => {
|
||||
onRemoveGraphNode(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{ui.delete}
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</GraphUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
EDITOR_LOCALE_STORAGE_KEY,
|
||||
inferEditorLocaleFromSystem,
|
||||
normalizeEditorLocale,
|
||||
translateEditorMessage,
|
||||
type EditorLocale,
|
||||
} from './editorMessages';
|
||||
|
||||
type EditorI18nContextValue = {
|
||||
locale: EditorLocale;
|
||||
setLocale: (next: EditorLocale) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
};
|
||||
|
||||
const EditorI18nContext = createContext<EditorI18nContextValue | null>(null);
|
||||
|
||||
function readInitialLocale(): EditorLocale {
|
||||
try {
|
||||
return normalizeEditorLocale(localStorage.getItem(EDITOR_LOCALE_STORAGE_KEY));
|
||||
} catch {
|
||||
return inferEditorLocaleFromSystem();
|
||||
}
|
||||
}
|
||||
|
||||
export function EditorI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<EditorLocale>(readInitialLocale);
|
||||
|
||||
const setLocale = useCallback((next: EditorLocale) => {
|
||||
setLocaleState(next);
|
||||
try {
|
||||
localStorage.setItem(EDITOR_LOCALE_STORAGE_KEY, next);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const t = useCallback(
|
||||
(key: string, vars?: Record<string, string | number>) => translateEditorMessage(locale, key, vars),
|
||||
[locale],
|
||||
);
|
||||
|
||||
const value = useMemo<EditorI18nContextValue>(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
|
||||
|
||||
return <EditorI18nContext.Provider value={value}>{children}</EditorI18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useEditorI18n(): EditorI18nContextValue {
|
||||
const ctx = useContext(EditorI18nContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useEditorI18n must be used within EditorI18nProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { inferEditorLocaleFromSystem, normalizeEditorLocale } from './editorMessages';
|
||||
|
||||
void test('inferEditorLocaleFromSystem: en-* wins when listed first', () => {
|
||||
assert.equal(inferEditorLocaleFromSystem(['en-GB', 'ru-RU']), 'en');
|
||||
});
|
||||
|
||||
void test('inferEditorLocaleFromSystem: ru-* wins when listed first', () => {
|
||||
assert.equal(inferEditorLocaleFromSystem(['ru-RU', 'en-US']), 'ru');
|
||||
});
|
||||
|
||||
void test('inferEditorLocaleFromSystem: unknown tags fall back to ru', () => {
|
||||
assert.equal(inferEditorLocaleFromSystem(['de-DE', 'fr']), 'ru');
|
||||
});
|
||||
|
||||
void test('inferEditorLocaleFromSystem: empty list → ru', () => {
|
||||
assert.equal(inferEditorLocaleFromSystem([]), 'ru');
|
||||
});
|
||||
|
||||
void test('normalizeEditorLocale: trims stored en/ru', () => {
|
||||
assert.equal(normalizeEditorLocale(' EN '), 'en');
|
||||
assert.equal(normalizeEditorLocale('ru '), 'ru');
|
||||
});
|
||||
|
||||
void test('normalizeEditorLocale: blank or invalid defers to infer (explicit list)', () => {
|
||||
assert.equal(normalizeEditorLocale(''), inferEditorLocaleFromSystem([]));
|
||||
assert.equal(normalizeEditorLocale('xx'), inferEditorLocaleFromSystem([]));
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
export type EditorLocale = 'ru' | 'en';
|
||||
|
||||
export const EDITOR_LOCALE_STORAGE_KEY = 'dnd_editor_locale';
|
||||
|
||||
function primaryLanguageTag(lang: string): string {
|
||||
const trimmed = lang.trim().toLowerCase();
|
||||
if (!trimmed) return '';
|
||||
const sep = trimmed.search(/[-_]/);
|
||||
return sep === -1 ? trimmed : trimmed.slice(0, sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор `ru` / `en` по языку ОС/браузера, если пользователь ещё не сохранил язык в `localStorage`.
|
||||
* В Electron совпадает с локалью системы (Chromium подставляет `navigator.languages`).
|
||||
*/
|
||||
export function inferEditorLocaleFromSystem(languages?: readonly string[]): EditorLocale {
|
||||
let list: string[];
|
||||
if (languages !== undefined) {
|
||||
list = [...languages];
|
||||
} else if (typeof navigator !== 'undefined') {
|
||||
list = [...navigator.languages];
|
||||
if (navigator.language) {
|
||||
list.push(navigator.language);
|
||||
}
|
||||
list = list.filter((x) => x.trim() !== '');
|
||||
} else {
|
||||
list = [];
|
||||
}
|
||||
for (const lang of list) {
|
||||
const tag = primaryLanguageTag(lang);
|
||||
if (tag === 'en') return 'en';
|
||||
if (tag === 'ru') return 'ru';
|
||||
}
|
||||
return 'ru';
|
||||
}
|
||||
|
||||
export function normalizeEditorLocale(raw: string | null | undefined): EditorLocale {
|
||||
if (raw == null) {
|
||||
return inferEditorLocaleFromSystem();
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return inferEditorLocaleFromSystem();
|
||||
}
|
||||
const s = trimmed.toLowerCase();
|
||||
if (s === 'en') return 'en';
|
||||
if (s === 'ru') return 'ru';
|
||||
return inferEditorLocaleFromSystem();
|
||||
}
|
||||
|
||||
/** Flat message table; `{name}` placeholders supported in `translate`. */
|
||||
export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
ru: {
|
||||
'common.close': 'Закрыть',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.save': 'Сохранить',
|
||||
'common.understood': 'Понятно',
|
||||
'common.message': 'Сообщение',
|
||||
'common.error': 'Ошибка',
|
||||
'common.delete': 'Удалить',
|
||||
'common.closeMenu': 'Закрыть меню',
|
||||
|
||||
'notice.campaignAudioEmpty': 'Аудио не добавлено. Проверьте формат файла.',
|
||||
|
||||
'license.checkingTitle': 'Проверка лицензии…',
|
||||
'license.checkingWait': 'Подождите.',
|
||||
'license.requiredTitle': 'Требуется лицензия',
|
||||
'license.requiredHint':
|
||||
'Укажите ключ в меню «Настройки» → «Указать ключ». До активации доступно только меню «Настройки».',
|
||||
'license.tokenTitle': 'Указать ключ',
|
||||
'license.tokenKey': 'КЛЮЧ',
|
||||
'license.tokenPlaceholder': 'Продуктовый ключ DND-...',
|
||||
'license.tokenSaving': 'Сохранение…',
|
||||
'license.eulaTitle': 'Лицензионное соглашение',
|
||||
'license.eulaReject': 'Не принимаю',
|
||||
'license.eulaAccept': 'Принимаю условия',
|
||||
'license.eulaNoteEn':
|
||||
'The binding legal text below is in Russian. If you need an English summary, contact support.',
|
||||
'license.aboutTitle': 'О лицензии',
|
||||
'license.aboutDevSkip': 'Режим разработки: проверка лицензии отключена (DND_SKIP_LICENSE).',
|
||||
'license.aboutStatus': 'СТАТУС',
|
||||
'license.aboutProduct': 'ПРОДУКТ',
|
||||
'license.aboutLicenseId': 'ID ЛИЦЕНЗИИ',
|
||||
'license.aboutExpiry': 'ОКОНЧАНИЕ',
|
||||
'license.aboutDevice': 'УСТРОЙСТВО',
|
||||
'license.aboutNoData': 'Нет данных лицензии.',
|
||||
'license.reason.ok': 'Активна',
|
||||
'license.reason.none': 'Ключ не указан',
|
||||
'license.reason.expired': 'Срок действия истёк',
|
||||
'license.reason.bad_signature': 'Недействительная подпись',
|
||||
'license.reason.bad_payload': 'Неверный формат токена',
|
||||
'license.reason.malformed': 'Повреждённый токен',
|
||||
'license.reason.not_yet_valid': 'Ещё не действует',
|
||||
'license.reason.wrong_device': 'Другой привязанный компьютер',
|
||||
'license.reason.revoked_remote': 'Отозвана на сервере',
|
||||
|
||||
'presentation.overlay': 'Презентация запущена',
|
||||
'presentation.title': 'Презентация запущена',
|
||||
'presentation.body':
|
||||
'Редактор заблокирован. Закройте окна «Презентация» и «Панель управления», чтобы продолжить.',
|
||||
|
||||
'zip.progress': 'Прогресс операции',
|
||||
'zip.importTitle': 'Импорт проекта',
|
||||
'zip.exportTitle': 'Экспорт проекта',
|
||||
|
||||
'top.settings': 'Настройки',
|
||||
'top.project': 'Проект',
|
||||
'top.file': 'Файл',
|
||||
'top.backToProjects': 'К списку проектов',
|
||||
'top.appVersion': 'Версия приложения',
|
||||
'top.run': 'Запустить',
|
||||
'top.afterLicense': 'Доступно после активации лицензии',
|
||||
'top.setStartScene': 'Назначьте начальную сцену на графе (ПКМ по узлу)',
|
||||
|
||||
'menu.enterKey': 'Указать ключ',
|
||||
'menu.aboutLicense': 'О лицензии',
|
||||
'menu.language': 'Язык',
|
||||
'menu.langRu': 'Русский',
|
||||
'menu.langEn': 'English',
|
||||
|
||||
'projectMenu.home': 'Начальный экран',
|
||||
'projectMenu.import': 'Импорт',
|
||||
'projectMenu.export': 'Экспорт',
|
||||
'projectMenu.noProjects': 'Нет сохранённых проектов',
|
||||
|
||||
'fileMenu.rename': 'Переименовать проект',
|
||||
|
||||
'scenes.search': 'Поиск сцен…',
|
||||
'scenes.new': '+ Новая сцена',
|
||||
'scenes.inspectorGame': 'Свойства игры',
|
||||
'scenes.inspectorScene': 'Свойства сцены',
|
||||
'scenes.selectHint': 'Выберите сцену слева, чтобы редактировать её свойства.',
|
||||
'scenes.openProjectHint': 'Откройте проект, чтобы редактировать кампанию и сцены.',
|
||||
|
||||
'rename.title': 'Переименовать проект',
|
||||
'rename.projectName': 'НАЗВАНИЕ ПРОЕКТА',
|
||||
'rename.projectPlaceholder': 'Название проекта…',
|
||||
'rename.projectMin': 'Минимум 3 символа.',
|
||||
'rename.projectDup': 'Проект с таким названием уже существует.',
|
||||
'rename.fileName': 'НАЗВАНИЕ ФАЙЛА ПРОЕКТА',
|
||||
'rename.fileInvalid': 'Минимум 3 символа, без символов <>:"/\\|?*',
|
||||
'rename.fileDup': 'Файл проекта с таким названием уже существует.',
|
||||
'rename.saving': 'Сохранение…',
|
||||
|
||||
'export.title': 'Экспорт проекта',
|
||||
'export.project': 'ПРОЕКТ',
|
||||
'export.hint':
|
||||
'Далее откроется окно сохранения: укажите имя и папку для файла .dnd.zip — будет создана копия архива проекта.',
|
||||
'export.exporting': 'Экспорт…',
|
||||
'export.saveAs': 'Сохранить как…',
|
||||
|
||||
'confirmDelete.title': 'Удаление проекта',
|
||||
'confirmDelete.body': 'Удалить проект «{name}» безвозвратно? Файл и кэш будут стёрты с диска.',
|
||||
'confirmDelete.failedTitle': 'Не удалось удалить',
|
||||
|
||||
'picker.title': 'Проекты',
|
||||
'picker.newPlaceholder': 'Название нового проекта…',
|
||||
'picker.create': 'Создать проект',
|
||||
'picker.existing': 'СУЩЕСТВУЮЩИЕ',
|
||||
'picker.lockedHint':
|
||||
'Открытие и создание — после активации лицензии. Список показывает файлы в папке приложения.',
|
||||
'picker.empty': 'Пока нет проектов.',
|
||||
'picker.projectMenu': 'Меню проекта',
|
||||
'picker.openDisabled': 'Открытие проекта — после активации лицензии',
|
||||
'picker.defaultName': 'Моя кампания',
|
||||
|
||||
'campaign.label': 'АУДИО ИГРЫ',
|
||||
'campaign.noFiles': 'Файлов пока нет. Добавьте аудио.',
|
||||
'campaign.auto': 'Авто',
|
||||
'campaign.loop': 'Цикл',
|
||||
'campaign.removeTitle': 'Убрать из кампании',
|
||||
'campaign.upload': 'Загрузить',
|
||||
|
||||
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
|
||||
'scene.description': 'ОПИСАНИЕ',
|
||||
'scene.preview': 'ПРЕВЬЮ СЦЕНЫ',
|
||||
'scene.previewHint': 'Файл изображения (PNG, JPG, WebP, GIF и т.д.).',
|
||||
'scene.previewEmpty': 'Превью не задано',
|
||||
'scene.previewBusy': 'Загрузка и оптимизация изображения…',
|
||||
'scene.change': 'Изменить',
|
||||
'scene.clear': 'Очистить',
|
||||
'scene.autostart': 'Автостарт',
|
||||
'scene.rotate': 'Повернуть',
|
||||
'scene.audio': 'АУДИО СЦЕНЫ',
|
||||
'scene.removeTitle': 'Убрать из сцены',
|
||||
'scene.branching': 'ВЕТВЛЕНИЯ',
|
||||
'scene.branchingHint':
|
||||
'Перетащите сцену из списка на граф. С одной карточки можно задать несколько вариантов — по одной связи на каждую целевую сцену. Повторно к той же сцене (включая вторую карточку той же сцены на графе) подключить нельзя.',
|
||||
|
||||
'sceneCard.current': 'ТЕКУЩАЯ',
|
||||
'sceneCard.menu': 'Меню сцены',
|
||||
|
||||
'graph.badgeStart': 'НАЧАЛО',
|
||||
'graph.untitled': 'Без названия',
|
||||
'graph.videoBadge': 'Видео',
|
||||
'graph.audioBadge': 'Аудио',
|
||||
'graph.loop': 'Цикл',
|
||||
'graph.autoplay': 'Автостарт',
|
||||
'graph.previewAutostart': 'Авто превью',
|
||||
'graph.videoLoop': 'Цикл видео',
|
||||
'graph.zoomBar': 'Масштаб графа',
|
||||
'graph.zoomIn': 'Увеличить',
|
||||
'graph.zoomOut': 'Уменьшить',
|
||||
'graph.fitAll': 'Показать всё',
|
||||
'graph.startScene': 'Начальная сцена',
|
||||
'graph.unsetStartScene': 'Снять метку «Начальная сцена»',
|
||||
|
||||
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
|
||||
'control.effects': 'ЭФФЕКТЫ',
|
||||
'control.tools': 'Инструменты',
|
||||
'control.fieldEffects': 'Эффекты поля',
|
||||
'control.actionEffects': 'Эффекты действий',
|
||||
'control.eraser': 'Ластик',
|
||||
'control.clearEffects': 'Очистить эффекты',
|
||||
'control.fog': 'Туман',
|
||||
'control.rain': 'Дождь',
|
||||
'control.fire': 'Огонь',
|
||||
'control.water': 'Вода',
|
||||
'control.lightning': 'Молния',
|
||||
'control.sunbeam': 'Луч света',
|
||||
'control.freeze': 'Заморозка',
|
||||
'control.poisonCloud': 'Облако яда',
|
||||
'control.brushRadius': 'Радиус кисти',
|
||||
'control.storyLine': 'СЮЖЕТНАЯ ЛИНИЯ',
|
||||
'control.gotoScene': 'Перейти к этой сцене',
|
||||
'control.currentSceneBadge': 'ТЕКУЩАЯ СЦЕНА',
|
||||
'control.passed': 'Пройдено',
|
||||
'control.noActiveScene': 'Нет активной сцены.',
|
||||
'control.screenPreview': 'Предпросмотр экрана',
|
||||
'control.stopPresentation': 'Выключить демонстрацию',
|
||||
'control.videoBrushHint':
|
||||
'Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для изображения).',
|
||||
'control.branches': 'Варианты ветвления',
|
||||
'control.option': 'ОПЦИЯ {n}',
|
||||
'control.unnamed': 'Без названия',
|
||||
'control.switchScene': 'Переключить',
|
||||
'control.noBranches': 'Нет вариантов перехода.',
|
||||
'control.endPresentation': 'Завершить показ',
|
||||
'control.music': 'Музыка',
|
||||
'control.sceneMusic': 'МУЗЫКА СЦЕНЫ',
|
||||
'control.gameMusic': 'МУЗЫКА ИГРЫ',
|
||||
'control.noSceneAudio': 'В текущей сцене нет аудио.',
|
||||
'control.noGameAudio': 'В игре нет аудио.',
|
||||
'control.modeAuto': 'Авто',
|
||||
'control.modeManual': 'Ручн.',
|
||||
'control.once': 'Один раз',
|
||||
'control.loop': 'Цикл',
|
||||
'control.scrubSeek': 'Клик — перемотка',
|
||||
'control.durationUnknown': 'Длительность неизвестна',
|
||||
'control.pauseSceneMusic': 'Пауза (сцена)',
|
||||
'control.pauseSceneMusicTitle': 'В сцене есть музыка',
|
||||
'control.pauseCampaignTitle': 'Пауза: в сцене есть музыка',
|
||||
'control.playFailed': 'Не удалось запустить.',
|
||||
'control.audioAutoplayBlocked':
|
||||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||
'control.audioNoUrl': 'URL не получен',
|
||||
'control.audioNoUrlDetail': 'Не удалось получить dnd://asset URL для аудио.',
|
||||
'control.audioBlocked': 'Ошибка/блок',
|
||||
'control.audioError': 'Ошибка',
|
||||
'control.audioMediaError': 'MediaError code={code} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)',
|
||||
'control.audioLoading': 'Загрузка…',
|
||||
'control.audioPlaying': 'Играет',
|
||||
'control.audioPaused': 'Пауза',
|
||||
'control.audioStopped': 'Остановлено',
|
||||
'control.previewTrackLabel': 'Превью без субтитров',
|
||||
'control.transportPlay': 'Воспроизведение',
|
||||
'control.transportPause': 'Пауза',
|
||||
'control.transportStop': 'Стоп',
|
||||
},
|
||||
en: {
|
||||
'common.close': 'Close',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.save': 'Save',
|
||||
'common.understood': 'OK',
|
||||
'common.message': 'Message',
|
||||
'common.error': 'Error',
|
||||
'common.delete': 'Delete',
|
||||
'common.closeMenu': 'Close menu',
|
||||
|
||||
'notice.campaignAudioEmpty': 'No audio was added. Check the file format.',
|
||||
|
||||
'license.checkingTitle': 'Checking license…',
|
||||
'license.checkingWait': 'Please wait.',
|
||||
'license.requiredTitle': 'License required',
|
||||
'license.requiredHint':
|
||||
'Enter your key via Settings → Enter license key. Until activation, only Settings is available.',
|
||||
'license.tokenTitle': 'Enter license key',
|
||||
'license.tokenKey': 'KEY',
|
||||
'license.tokenPlaceholder': 'DND product key…',
|
||||
'license.tokenSaving': 'Saving…',
|
||||
'license.eulaTitle': 'End User License Agreement',
|
||||
'license.eulaReject': 'Decline',
|
||||
'license.eulaAccept': 'I accept the terms',
|
||||
'license.eulaNoteEn':
|
||||
'The binding legal text below is in Russian. If you need an English summary, contact support.',
|
||||
'license.aboutTitle': 'About license',
|
||||
'license.aboutDevSkip': 'Development mode: license checks are disabled (DND_SKIP_LICENSE).',
|
||||
'license.aboutStatus': 'STATUS',
|
||||
'license.aboutProduct': 'PRODUCT',
|
||||
'license.aboutLicenseId': 'LICENSE ID',
|
||||
'license.aboutExpiry': 'EXPIRES',
|
||||
'license.aboutDevice': 'DEVICE',
|
||||
'license.aboutNoData': 'No license data.',
|
||||
'license.reason.ok': 'Active',
|
||||
'license.reason.none': 'No key provided',
|
||||
'license.reason.expired': 'Expired',
|
||||
'license.reason.bad_signature': 'Invalid signature',
|
||||
'license.reason.bad_payload': 'Invalid token format',
|
||||
'license.reason.malformed': 'Malformed token',
|
||||
'license.reason.not_yet_valid': 'Not yet valid',
|
||||
'license.reason.wrong_device': 'Wrong bound device',
|
||||
'license.reason.revoked_remote': 'Revoked on server',
|
||||
|
||||
'presentation.overlay': 'Presentation running',
|
||||
'presentation.title': 'Presentation running',
|
||||
'presentation.body': 'The editor is locked. Close the Presentation and Control windows to continue.',
|
||||
|
||||
'zip.progress': 'Operation progress',
|
||||
'zip.importTitle': 'Import project',
|
||||
'zip.exportTitle': 'Export project',
|
||||
|
||||
'top.settings': 'Settings',
|
||||
'top.project': 'Project',
|
||||
'top.file': 'File',
|
||||
'top.backToProjects': 'Back to projects',
|
||||
'top.appVersion': 'App version',
|
||||
'top.run': 'Run',
|
||||
'top.afterLicense': 'Available after license activation',
|
||||
'top.setStartScene': 'Set a start scene on the graph (right‑click a node)',
|
||||
|
||||
'menu.enterKey': 'Enter license key',
|
||||
'menu.aboutLicense': 'About license',
|
||||
'menu.language': 'Language',
|
||||
'menu.langRu': 'Русский',
|
||||
'menu.langEn': 'English',
|
||||
|
||||
'projectMenu.home': 'Home',
|
||||
'projectMenu.import': 'Import',
|
||||
'projectMenu.export': 'Export',
|
||||
'projectMenu.noProjects': 'No saved projects',
|
||||
|
||||
'fileMenu.rename': 'Rename project',
|
||||
|
||||
'scenes.search': 'Search scenes…',
|
||||
'scenes.new': '+ New scene',
|
||||
'scenes.inspectorGame': 'Game properties',
|
||||
'scenes.inspectorScene': 'Scene properties',
|
||||
'scenes.selectHint': 'Select a scene on the left to edit its properties.',
|
||||
'scenes.openProjectHint': 'Open a project to edit the campaign and scenes.',
|
||||
|
||||
'rename.title': 'Rename project',
|
||||
'rename.projectName': 'PROJECT NAME',
|
||||
'rename.projectPlaceholder': 'Project name…',
|
||||
'rename.projectMin': 'At least 3 characters.',
|
||||
'rename.projectDup': 'A project with this name already exists.',
|
||||
'rename.fileName': 'PROJECT FILE NAME',
|
||||
'rename.fileInvalid': 'At least 3 characters; forbidden characters <>:"/\\|?*',
|
||||
'rename.fileDup': 'A project file with this name already exists.',
|
||||
'rename.saving': 'Saving…',
|
||||
|
||||
'export.title': 'Export project',
|
||||
'export.project': 'PROJECT',
|
||||
'export.hint':
|
||||
'A save dialog will open: choose a name and folder for the .dnd.zip file — a copy of the project archive will be created.',
|
||||
'export.exporting': 'Exporting…',
|
||||
'export.saveAs': 'Save as…',
|
||||
|
||||
'confirmDelete.title': 'Delete project',
|
||||
'confirmDelete.body':
|
||||
'Permanently delete project “{name}”? The file and cache will be removed from disk.',
|
||||
'confirmDelete.failedTitle': 'Could not delete',
|
||||
|
||||
'picker.title': 'Projects',
|
||||
'picker.newPlaceholder': 'New project name…',
|
||||
'picker.create': 'Create project',
|
||||
'picker.existing': 'EXISTING',
|
||||
'picker.lockedHint':
|
||||
'Opening and creating projects require an active license. The list still shows files in the app folder.',
|
||||
'picker.empty': 'No projects yet.',
|
||||
'picker.projectMenu': 'Project menu',
|
||||
'picker.openDisabled': 'Open project — after license activation',
|
||||
'picker.defaultName': 'My campaign',
|
||||
|
||||
'campaign.label': 'GAME AUDIO',
|
||||
'campaign.noFiles': 'No files yet. Add audio.',
|
||||
'campaign.auto': 'Auto',
|
||||
'campaign.loop': 'Loop',
|
||||
'campaign.removeTitle': 'Remove from campaign',
|
||||
'campaign.upload': 'Upload',
|
||||
|
||||
'scene.title': 'SCENE TITLE',
|
||||
'scene.description': 'DESCRIPTION',
|
||||
'scene.preview': 'SCENE PREVIEW',
|
||||
'scene.previewHint': 'Image file (PNG, JPG, WebP, GIF, etc.).',
|
||||
'scene.previewEmpty': 'No preview',
|
||||
'scene.previewBusy': 'Loading and optimizing image…',
|
||||
'scene.change': 'Change',
|
||||
'scene.clear': 'Clear',
|
||||
'scene.autostart': 'Autostart',
|
||||
'scene.rotate': 'Rotate',
|
||||
'scene.audio': 'SCENE AUDIO',
|
||||
'scene.removeTitle': 'Remove from scene',
|
||||
'scene.branching': 'BRANCHING',
|
||||
'scene.branchingHint':
|
||||
'Drag a scene from the list onto the graph. One card can branch to several targets — one link per target scene. You cannot link twice to the same target (including a second card of the same scene).',
|
||||
|
||||
'sceneCard.current': 'CURRENT',
|
||||
'sceneCard.menu': 'Scene menu',
|
||||
|
||||
'graph.badgeStart': 'START',
|
||||
'graph.untitled': 'Untitled',
|
||||
'graph.videoBadge': 'Video',
|
||||
'graph.audioBadge': 'Audio',
|
||||
'graph.loop': 'Loop',
|
||||
'graph.autoplay': 'Autoplay',
|
||||
'graph.previewAutostart': 'Preview autostart',
|
||||
'graph.videoLoop': 'Video loop',
|
||||
'graph.zoomBar': 'Graph zoom',
|
||||
'graph.zoomIn': 'Zoom in',
|
||||
'graph.zoomOut': 'Zoom out',
|
||||
'graph.fitAll': 'Fit view',
|
||||
'graph.startScene': 'Start scene',
|
||||
'graph.unsetStartScene': 'Clear start scene mark',
|
||||
|
||||
'control.remoteTitle': 'CONTROL PANEL',
|
||||
'control.effects': 'EFFECTS',
|
||||
'control.tools': 'Tools',
|
||||
'control.fieldEffects': 'Field effects',
|
||||
'control.actionEffects': 'Action effects',
|
||||
'control.eraser': 'Eraser',
|
||||
'control.clearEffects': 'Clear effects',
|
||||
'control.fog': 'Fog',
|
||||
'control.rain': 'Rain',
|
||||
'control.fire': 'Fire',
|
||||
'control.water': 'Water',
|
||||
'control.lightning': 'Lightning',
|
||||
'control.sunbeam': 'Sunbeam',
|
||||
'control.freeze': 'Freeze',
|
||||
'control.poisonCloud': 'Poison cloud',
|
||||
'control.brushRadius': 'Brush radius',
|
||||
'control.storyLine': 'STORYLINE',
|
||||
'control.gotoScene': 'Go to this scene',
|
||||
'control.currentSceneBadge': 'CURRENT SCENE',
|
||||
'control.passed': 'Visited',
|
||||
'control.noActiveScene': 'No active scene.',
|
||||
'control.screenPreview': 'Screen preview',
|
||||
'control.stopPresentation': 'Stop presentation',
|
||||
'control.videoBrushHint':
|
||||
'Video preview: effect brush is disabled (like on the presentation screen — overlay is for images only).',
|
||||
'control.branches': 'Branch options',
|
||||
'control.option': 'OPTION {n}',
|
||||
'control.unnamed': 'Untitled',
|
||||
'control.switchScene': 'Switch',
|
||||
'control.noBranches': 'No transitions available.',
|
||||
'control.endPresentation': 'End presentation',
|
||||
'control.music': 'Music',
|
||||
'control.sceneMusic': 'SCENE MUSIC',
|
||||
'control.gameMusic': 'GAME MUSIC',
|
||||
'control.noSceneAudio': 'No audio in the current scene.',
|
||||
'control.noGameAudio': 'No game audio.',
|
||||
'control.modeAuto': 'Auto',
|
||||
'control.modeManual': 'Manual',
|
||||
'control.once': 'Once',
|
||||
'control.loop': 'Loop',
|
||||
'control.scrubSeek': 'Click to seek',
|
||||
'control.durationUnknown': 'Duration unknown',
|
||||
'control.pauseSceneMusic': 'Paused (scene)',
|
||||
'control.pauseSceneMusicTitle': 'Scene has music',
|
||||
'control.pauseCampaignTitle': 'Paused: scene has music',
|
||||
'control.playFailed': 'Could not start playback.',
|
||||
'control.audioAutoplayBlocked': 'Autoplay was blocked (user gesture required) or playback failed.',
|
||||
'control.audioNoUrl': 'No URL',
|
||||
'control.audioNoUrlDetail': 'Could not get dnd://asset URL for audio.',
|
||||
'control.audioBlocked': 'Error / blocked',
|
||||
'control.audioError': 'Error',
|
||||
'control.audioMediaError': 'MediaError code={code} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)',
|
||||
'control.audioLoading': 'Loading…',
|
||||
'control.audioPlaying': 'Playing',
|
||||
'control.audioPaused': 'Paused',
|
||||
'control.audioStopped': 'Stopped',
|
||||
'control.previewTrackLabel': 'Preview (no captions)',
|
||||
'control.transportPlay': 'Play',
|
||||
'control.transportPause': 'Pause',
|
||||
'control.transportStop': 'Stop',
|
||||
},
|
||||
};
|
||||
|
||||
export function translateEditorMessage(
|
||||
locale: EditorLocale,
|
||||
key: string,
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
let s = EDITOR_MESSAGES[locale][key] ?? EDITOR_MESSAGES.ru[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
s = s.split(`{${k}}`).join(String(v));
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { EULA_RU_MARKDOWN } from '../../legal/eulaRu';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
import { Button } from '../../shared/ui/controls';
|
||||
import styles from '../EditorApp.module.css';
|
||||
import { useEditorI18n } from '../i18n/EditorI18nContext';
|
||||
|
||||
type LicenseTokenModalProps = {
|
||||
open: boolean;
|
||||
@@ -16,6 +17,7 @@ type LicenseTokenModalProps = {
|
||||
};
|
||||
|
||||
export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [token, setToken] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -40,28 +42,38 @@ export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalP
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||||
<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}>Указать ключ</div>
|
||||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||||
<div className={styles.modalTitle}>{t('license.tokenTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>КЛЮЧ</div>
|
||||
<div className={styles.fieldLabel}>{t('license.tokenKey')}</div>
|
||||
<textarea
|
||||
className={styles.licenseTextarea}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Продуктовый ключ DND-..."
|
||||
placeholder={t('license.tokenPlaceholder')}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
Отмена
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -82,7 +94,7 @@ export function LicenseTokenModal({ open, onClose, onSaved }: LicenseTokenModalP
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,6 +110,7 @@ type EulaModalProps = {
|
||||
};
|
||||
|
||||
export function EulaModal({ open, onClose, onAccepted }: EulaModalProps) {
|
||||
const { t, locale } = useEditorI18n();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -113,18 +126,29 @@ export function EulaModal({ open, onClose, onAccepted }: EulaModalProps) {
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||||
<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}>Лицензионное соглашение</div>
|
||||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||||
<div className={styles.modalTitle}>{t('license.eulaTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{locale === 'en' ? <div className={styles.muted}>{t('license.eulaNoteEn')}</div> : null}
|
||||
<div className={styles.eulaScroll}>{EULA_RU_MARKDOWN}</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving}>
|
||||
Не принимаю
|
||||
{t('license.eulaReject')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -144,7 +168,7 @@ export function EulaModal({ open, onClose, onAccepted }: EulaModalProps) {
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Принимаю условия
|
||||
{t('license.eulaAccept')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,32 +183,16 @@ type LicenseAboutModalProps = {
|
||||
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;
|
||||
}
|
||||
function licenseReasonLabel(t: (key: string) => string, reason: LicenseSnapshot['reason']): string {
|
||||
const key = `license.reason.${reason}`;
|
||||
const label = t(key);
|
||||
return label === key ? reason : label;
|
||||
}
|
||||
|
||||
export function LicenseAboutModal({ open, onClose, snapshot }: LicenseAboutModalProps) {
|
||||
const { t, locale } = useEditorI18n();
|
||||
const dateLocale = locale === 'en' ? 'en-US' : 'ru-RU';
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -198,7 +206,7 @@ export function LicenseAboutModal({ open, onClose, snapshot }: LicenseAboutModal
|
||||
|
||||
const expText =
|
||||
snapshot?.summary?.exp != null
|
||||
? new Date(snapshot.summary.exp * 1000).toLocaleString('ru-RU', {
|
||||
? new Date(snapshot.summary.exp * 1000).toLocaleString(dateLocale, {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
@@ -206,48 +214,54 @@ export function LicenseAboutModal({ open, onClose, snapshot }: LicenseAboutModal
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalBackdrop} />
|
||||
<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}>О лицензии</div>
|
||||
<button type="button" aria-label="Закрыть" onClick={onClose} className={styles.modalClose}>
|
||||
<div className={styles.modalTitle}>{t('license.aboutTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={onClose}
|
||||
className={styles.modalClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{snapshot?.devSkip ? (
|
||||
<div className={styles.fieldError}>
|
||||
Режим разработки: проверка лицензии отключена (DND_SKIP_LICENSE).
|
||||
</div>
|
||||
) : null}
|
||||
{snapshot?.devSkip ? <div className={styles.fieldError}>{t('license.aboutDevSkip')}</div> : null}
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>СТАТУС</div>
|
||||
<div>{snapshot ? reasonLabel(snapshot.reason) : '—'}</div>
|
||||
<div className={styles.fieldLabel}>{t('license.aboutStatus')}</div>
|
||||
<div>{snapshot ? licenseReasonLabel(t, snapshot.reason) : '—'}</div>
|
||||
</div>
|
||||
{snapshot?.summary ? (
|
||||
<>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>ПРОДУКТ</div>
|
||||
<div className={styles.fieldLabel}>{t('license.aboutProduct')}</div>
|
||||
<div>{snapshot.summary.pid}</div>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>ID ЛИЦЕНЗИИ</div>
|
||||
<div className={styles.fieldLabel}>{t('license.aboutLicenseId')}</div>
|
||||
<div style={{ wordBreak: 'break-all' }}>{snapshot.summary.sub}</div>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>ОКОНЧАНИЕ</div>
|
||||
<div className={styles.fieldLabel}>{t('license.aboutExpiry')}</div>
|
||||
<div>{expText}</div>
|
||||
</div>
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>УСТРОЙСТВО</div>
|
||||
<div className={styles.fieldLabel}>{t('license.aboutDevice')}</div>
|
||||
<div style={{ wordBreak: 'break-all' }}>{snapshot.deviceId}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muted}>Нет данных лицензии.</div>
|
||||
<div className={styles.muted}>{t('license.aboutNoData')}</div>
|
||||
)}
|
||||
<div className={styles.modalFooter}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Закрыть
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorApp } from './EditorApp';
|
||||
import { EditorI18nProvider } from './i18n/EditorI18nContext';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
@@ -11,6 +12,8 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorApp />
|
||||
<EditorI18nProvider>
|
||||
<EditorApp />
|
||||
</EditorI18nProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -58,8 +58,18 @@ function randomId(prefix: string): string {
|
||||
return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
|
||||
}
|
||||
|
||||
export function useProjectState(licenseActive: boolean): readonly [State, Actions] {
|
||||
export type ProjectNoticeCode = 'campaign_audio_empty';
|
||||
|
||||
export type ProjectStateOpts = {
|
||||
onNotice?: (code: ProjectNoticeCode) => void;
|
||||
};
|
||||
|
||||
export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts): readonly [State, Actions] {
|
||||
const api = getDndApi();
|
||||
const onNoticeRef = useRef(opts?.onNotice);
|
||||
useEffect(() => {
|
||||
onNoticeRef.current = opts?.onNotice;
|
||||
}, [opts?.onNotice]);
|
||||
const [state, setState] = useState<State>({
|
||||
projects: [],
|
||||
project: null,
|
||||
@@ -180,7 +190,7 @@ export function useProjectState(licenseActive: boolean): readonly [State, Action
|
||||
const res = await api.invoke(ipcChannels.project.importCampaignAudio, {});
|
||||
if (res.canceled) return;
|
||||
if (res.imported.length === 0) {
|
||||
window.alert('Аудио не добавлено. Проверьте формат файла.');
|
||||
onNoticeRef.current?.('campaign_audio_empty');
|
||||
}
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
|
||||
Reference in New Issue
Block a user