feat: i18n control, Gitea auto-update CI, license-gated updater, fixes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user