From f462e655816cc00b049eb974aca03dd3d2312479 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Mon, 11 May 2026 22:20:14 +0800 Subject: [PATCH] feat: i18n control, Gitea auto-update CI, license-gated updater, fixes Co-authored-by: Cursor --- .gitea/workflows/release.yml | 153 +++++ app/main/index.ts | 2 + app/main/license/licenseService.ts | 23 + app/main/update/installAutoUpdater.ts | 71 +++ app/main/windows/createWindows.ts | 22 +- app/renderer/control/ControlApp.tsx | 147 ++--- app/renderer/control/ControlScenePreview.tsx | 12 +- .../control/controlApp.effectsPanel.test.ts | 36 +- app/renderer/control/main.tsx | 6 +- app/renderer/editor/EditorApp.module.css | 27 + app/renderer/editor/EditorApp.tsx | 544 +++++++++++++----- app/renderer/editor/graph/SceneGraph.tsx | 274 +++++---- .../editor/i18n/EditorI18nContext.tsx | 55 ++ .../editor/i18n/editorMessages.locale.test.ts | 30 + app/renderer/editor/i18n/editorMessages.ts | 500 ++++++++++++++++ .../editor/license/EditorLicenseModals.tsx | 118 ++-- app/renderer/editor/main.tsx | 5 +- app/renderer/editor/state/projectState.ts | 14 +- docs/GITEA_AUTO_UPDATE.md | 191 ++++++ package-lock.json | 148 ++++- package.json | 7 +- scripts/build.mjs | 2 +- scripts/sync-update-feed.mjs | 102 ++++ 23 files changed, 2049 insertions(+), 440 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 app/main/update/installAutoUpdater.ts create mode 100644 app/renderer/editor/i18n/EditorI18nContext.tsx create mode 100644 app/renderer/editor/i18n/editorMessages.locale.test.ts create mode 100644 app/renderer/editor/i18n/editorMessages.ts create mode 100644 docs/GITEA_AUTO_UPDATE.md create mode 100644 scripts/sync-update-feed.mjs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..a294240 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,153 @@ +# Сборка установщиков по тегу v* и выкладка файлов обновления в публичный репозиторий (ветка `updates`). +# Secrets (имена без GITEA_* — зарезервировано на сервере): DND_UPDATE_FEED_URL, DND_UPDATES_SERVER, UPDATES_REPO, DND_UPDATES_PUSH_TOKEN — см. docs/GITEA_AUTO_UPDATE.md + +name: Release + +on: + push: + tags: + - 'v*' + +env: + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + +jobs: + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Version from tag + shell: bash + run: | + TAG="${GITHUB_REF_NAME:-}" + VERSION="${TAG#v}" + npm version "$VERSION" --allow-same-version --no-git-tag-version + + - run: npm ci + + - run: npm run build + + - run: node scripts/release-win-prep.mjs + + - name: electron-builder (win) + shell: bash + env: + DND_UPDATE_FEED_URL: ${{ secrets.DND_UPDATE_FEED_URL }} + run: | + set -euo pipefail + if [[ -z "${DND_UPDATE_FEED_URL:-}" ]]; then + echo "Secret DND_UPDATE_FEED_URL is not set (generic feed base URL, trailing slash)" >&2 + exit 1 + fi + npx electron-builder --win --publish never \ + --config.publish.provider=generic \ + --config.publish.url="${DND_UPDATE_FEED_URL}" + + - name: Stage win artifacts + shell: bash + run: | + mkdir -p _artifact_win + shopt -s nullglob || true + for f in release/*; do + [[ -f "$f" ]] || continue + base=$(basename "$f") + case "$base" in + *.yml|*.yaml|*.exe|*.blockmap|*.zip) cp -v "$f" _artifact_win/ ;; + esac + done + ls -la _artifact_win + + - uses: actions/upload-artifact@v4 + with: + name: eb-win + path: _artifact_win/ + + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Version from tag + shell: bash + run: | + TAG="${GITHUB_REF_NAME:-}" + VERSION="${TAG#v}" + npm version "$VERSION" --allow-same-version --no-git-tag-version + + - run: npm ci + + - run: npm run build + + - name: electron-builder (mac) + shell: bash + env: + DND_UPDATE_FEED_URL: ${{ secrets.DND_UPDATE_FEED_URL }} + run: | + set -euo pipefail + if [[ -z "${DND_UPDATE_FEED_URL:-}" ]]; then + echo "Secret DND_UPDATE_FEED_URL is not set" >&2 + exit 1 + fi + npx electron-builder --mac --publish never \ + --config.publish.provider=generic \ + --config.publish.url="${DND_UPDATE_FEED_URL}" + + - name: Stage mac artifacts + shell: bash + run: | + mkdir -p _artifact_mac + shopt -s nullglob || true + for f in release/*; do + [[ -f "$f" ]] || continue + base=$(basename "$f") + case "$base" in + *.yml|*.yaml|*.dmg|*.blockmap|*.zip|*.pkg) cp -v "$f" _artifact_mac/ ;; + esac + done + ls -la _artifact_mac + + - uses: actions/upload-artifact@v4 + with: + name: eb-mac + path: _artifact_mac/ + + publish-update-feed: + runs-on: ubuntu-latest + needs: [build-windows, build-macos] + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: eb-win + path: _win + + - uses: actions/download-artifact@v4 + with: + name: eb-mac + path: _mac + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Push to public updates repo + env: + DND_UPDATES_SERVER: ${{ secrets.DND_UPDATES_SERVER }} + UPDATES_REPO: ${{ secrets.UPDATES_REPO }} + DND_UPDATES_PUSH_TOKEN: ${{ secrets.DND_UPDATES_PUSH_TOKEN }} + ARTIFACT_WIN: ${{ github.workspace }}/_win + ARTIFACT_MAC: ${{ github.workspace }}/_mac + GIT_COMMIT_TAG: ${{ github.ref_name }} + run: node scripts/sync-update-feed.mjs diff --git a/app/main/index.ts b/app/main/index.ts index 5b1aebe..068c5de 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -7,6 +7,7 @@ import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/route import { LicenseService } from './license/licenseService'; import { ZipProjectStore } from './project/zipStore'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; +import { installAutoUpdater } from './update/installAutoUpdater'; import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo'; import { VideoPlaybackStore } from './video/videoPlaybackStore'; import { @@ -527,6 +528,7 @@ async function main() { installIpcRouter(); applyDockIconIfNeeded(); + installAutoUpdater(licenseService); await runStartupAfterHandlers(licenseService); app.on('activate', () => { diff --git a/app/main/license/licenseService.ts b/app/main/license/licenseService.ts index fccf9b6..a8eef90 100644 --- a/app/main/license/licenseService.ts +++ b/app/main/license/licenseService.ts @@ -17,6 +17,28 @@ type Preferences = { eulaAcceptedVersion?: number; }; +type LicenseChangeListener = () => void; + +const licenseChangeListeners = new Set(); + +/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */ +export function addLicenseChangeListener(fn: LicenseChangeListener): () => void { + licenseChangeListeners.add(fn); + return () => { + licenseChangeListeners.delete(fn); + }; +} + +function notifyLicenseChangeListeners(): void { + for (const fn of licenseChangeListeners) { + try { + fn(); + } catch (err) { + console.error('[license] change listener failed', err); + } + } +} + function readPreferences(userData: string): Preferences { try { const raw = fs.readFileSync(preferencesPath(userData), 'utf8'); @@ -35,6 +57,7 @@ function emitLicenseStatusChanged(): void { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(ipcChannels.license.statusChanged, {}); } + notifyLicenseChangeListeners(); } export class LicenseService { diff --git a/app/main/update/installAutoUpdater.ts b/app/main/update/installAutoUpdater.ts new file mode 100644 index 0000000..8ec1ee6 --- /dev/null +++ b/app/main/update/installAutoUpdater.ts @@ -0,0 +1,71 @@ +import { app, dialog } from 'electron'; +import { autoUpdater } from 'electron-updater'; + +import { addLicenseChangeListener } from '../license/licenseService'; +import type { LicenseService } from '../license/licenseService'; + +const STARTUP_CHECK_DELAY_MS = 12_000; +/** Не дёргать сервер чаще (смена лицензии / повторные emit). */ +const RE_CHECK_COOLDOWN_MS = 30_000; + +let lastCheckAt = 0; + +function isLicensedForUpdates(licenseService: LicenseService): boolean { + const snap = licenseService.getStatusSync(); + return snap.active; +} + +function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: boolean): void { + if (!app.isPackaged) return; + if (!isLicensedForUpdates(licenseService)) return; + const now = Date.now(); + if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return; + lastCheckAt = now; + void autoUpdater.checkForUpdates().catch(() => undefined); +} + +/** + * Проверка обновлений: только упакованное приложение, только при активной лицензии. + * Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки). + */ +export function installAutoUpdater(licenseService: LicenseService): void { + if (!app.isPackaged) return; + + const feedOverride = process.env.DND_UPDATE_FEED_URL?.trim(); + if (feedOverride) { + const url = feedOverride.endsWith('/') ? feedOverride : `${feedOverride}/`; + autoUpdater.setFeedURL({ provider: 'generic', url }); + } + + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + + autoUpdater.on('update-downloaded', (info) => { + void dialog + .showMessageBox({ + type: 'info', + title: 'DNDGamePlayer', + message: `Доступна новая версия ${info.version}. Установить и перезапустить?`, + buttons: ['Перезапустить сейчас', 'Позже'], + defaultId: 0, + cancelId: 1, + }) + .then((r) => { + if (r.response === 0) { + autoUpdater.quitAndInstall(false, true); + } + }); + }); + + autoUpdater.on('error', () => { + /* без console: в production main минифицируется с drop console */ + }); + + addLicenseChangeListener(() => { + maybeCheckForUpdates(licenseService, false); + }); + + setTimeout(() => { + maybeCheckForUpdates(licenseService, true); + }, STARTUP_CHECK_DELAY_MS); +} diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 486995f..ced9f47 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -13,6 +13,19 @@ const windows = new Map(); let appQuitting = false; +/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */ +function broadcastMultiWindowStateChanged(open: boolean): void { + for (const w of BrowserWindow.getAllWindows()) { + if (w.isDestroyed()) continue; + if (w.webContents.isDestroyed()) continue; + try { + w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open }); + } catch { + /* окно могло закрыться между проверкой и send */ + } + } +} + /** Разрешает реальное закрытие окна редактора (выход из приложения). */ export function markAppQuitting(): void { appQuitting = true; @@ -212,9 +225,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow win.on('closed', () => { if (kind !== 'presentation' && kind !== 'control') return; const open = windows.has('presentation') || windows.has('control'); - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open }); - } + broadcastMultiWindowStateChanged(open); }); windows.set(kind, win); return win; @@ -292,10 +303,7 @@ export function openMultiWindow() { // Keep control window independent on darwin. createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation }); } - const open = true; - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.send(ipcChannels.windows.multiWindowStateChanged, { open }); - } + broadcastMultiWindowStateChanged(true); } export function closeMultiWindow(): void { diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index e5e1aaf..53ada98 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -4,6 +4,7 @@ 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'; +import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { getDndApi } from '../shared/dndApi'; import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay'; import { useEffectsState } from '../shared/effects/useEffectsState'; @@ -44,6 +45,9 @@ function playLightningEffectSound(): void { export function ControlApp() { const api = getDndApi(); + const { t } = useEditorI18n(); + const tRef = useRef(t); + tRef.current = t; const [fxState, fx] = useEffectsState(); const [session, setSession] = useState(null); const historyRef = useRef([]); @@ -278,8 +282,7 @@ export function ControlApp() { const m = sceneAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; sceneAudioMetaRef.current.set(ref.assetId, { ...m, - lastPlayError: - 'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.', + lastPlayError: tRef.current('control.audioAutoplayBlocked'), }); setSceneAudioStateTick((x) => x + 1); try { @@ -382,8 +385,7 @@ export function ControlApp() { const m = campaignAudioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null }; campaignAudioMetaRef.current.set(ref.assetId, { ...m, - lastPlayError: - 'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.', + lastPlayError: tRef.current('control.audioAutoplayBlocked'), }); setCampaignAudioStateTick((x) => x + 1); try { @@ -546,21 +548,21 @@ export function ControlApp() { group === 'scene' ? (sceneAudioElsRef.current.get(assetId) ?? null) : (campaignAudioElsRef.current.get(assetId) ?? null); - if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' }; + if (!el) return { label: t('control.audioNoUrl'), detail: t('control.audioNoUrlDetail') }; const meta = group === 'scene' ? (sceneAudioMetaRef.current.get(assetId) ?? { lastPlayError: null }) : (campaignAudioMetaRef.current.get(assetId) ?? { lastPlayError: null }); - if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError }; + if (meta.lastPlayError) return { label: t('control.audioBlocked'), detail: meta.lastPlayError }; if (el.error) return { - label: 'Ошибка', - detail: `MediaError code=${String(el.error.code)} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)`, + label: t('control.audioError'), + detail: t('control.audioMediaError', { code: String(el.error.code) }), }; - if (el.readyState < 2) return { label: 'Загрузка…' }; - if (!el.paused) return { label: 'Играет' }; - if (el.currentTime > 0) return { label: 'Пауза' }; - return { label: 'Остановлено' }; + if (el.readyState < 2) return { label: t('control.audioLoading') }; + if (!el.paused) return { label: t('control.audioPlaying') }; + if (el.currentTime > 0) return { label: t('control.audioPaused') }; + return { label: t('control.audioStopped') }; } const nextScenes = useMemo(() => { if (!project) return []; @@ -955,21 +957,21 @@ export function ControlApp() { return (
-
ПУЛЬТ УПРАВЛЕНИЯ
+
{t('control.remoteTitle')}
{!isVideoPreviewScene ? ( <> -
ЭФФЕКТЫ
+
{t('control.effects')}
-
Инструменты
+
{t('control.tools')}
-
Эффекты поля
+
{t('control.fieldEffects')}
-
Эффекты действий
+
{t('control.actionEffects')}
-
Радиус кисти
+
{t('control.brushRadius')}
{Math.round(tool.radiusN * 100)}
@@ -1107,7 +1109,7 @@ export function ControlApp() { ) : null}
-
СЮЖЕТНАЯ ЛИНИЯ
+
{t('control.storyLine')}
{history.map((gnId, idx) => { @@ -1122,7 +1124,7 @@ export function ControlApp() { className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : ''] .filter(Boolean) .join(' ')} - title={project && !isCurrent ? 'Перейти к этой сцене' : undefined} + title={project && !isCurrent ? t('control.gotoScene') : undefined} onClick={() => { if (!project) return; if (isCurrent) return; @@ -1132,15 +1134,17 @@ export function ControlApp() { }} > {isCurrent ? ( -
ТЕКУЩАЯ СЦЕНА
+
{t('control.currentSceneBadge')}
) : ( -
Пройдено
+
{t('control.passed')}
)}
{s?.title ?? (gn ? String(gn.sceneId) : gnId)}
); })} - {history.length === 0 ?
Нет активной сцены.
: null} + {history.length === 0 ? ( +
{t('control.noActiveScene')}
+ ) : null}
@@ -1148,20 +1152,15 @@ export function ControlApp() {
-
Предпросмотр экрана
+
{t('control.screenPreview')}
- {isVideoPreviewScene ? ( -
- Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для - изображения). -
- ) : null} + {isVideoPreviewScene ?
{t('control.videoBrushHint')}
: null}
@@ -1258,33 +1257,33 @@ export function ControlApp() { -
Варианты ветвления
+
{t('control.branches')}
{nextScenes.map((o, i) => (
-
ОПЦИЯ {String(i + 1)}
+
{t('control.option', { n: String(i + 1) })}
-
{o.scene.title || 'Без названия'}
+
{o.scene.title || t('control.unnamed')}
))} {nextScenes.length === 0 ? (
-
Нет вариантов перехода.
+
{t('control.noBranches')}
) : null} @@ -1293,13 +1292,13 @@ export function ControlApp() {
-
Музыка
+
{t('control.music')}
-
МУЗЫКА СЦЕНЫ
+
{t('control.sceneMusic')}
{sceneAudios.length === 0 ? ( -
В текущей сцене нет аудио.
+
{t('control.noSceneAudio')}
) : null} {sceneAudios.length > 0 ? (
@@ -1314,8 +1313,8 @@ export function ControlApp() {
{asset.originalName}
-
{ref.autoplay ? 'Авто' : 'Ручн.'}
-
{ref.loop ? 'Цикл' : 'Один раз'}
+
{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}
+
{ref.loop ? t('control.loop') : t('control.once')}
{st.label}
@@ -1344,7 +1343,7 @@ export function ControlApp() { styles.audioScrub, dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault, ].join(' ')} - title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'} + title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')} >
x + 1); }); @@ -1403,10 +1402,10 @@ export function ControlApp() { ) : null}
-
МУЗЫКА ИГРЫ
+
{t('control.gameMusic')}
{campaignAudios.length === 0 ? ( -
В игре нет аудио.
+
{t('control.noGameAudio')}
) : (
{campaignAudios.map(({ ref, asset }) => { @@ -1420,10 +1419,12 @@ export function ControlApp() {
{asset.originalName}
-
{ref.autoplay ? 'Авто' : 'Ручн.'}
-
{ref.loop ? 'Цикл' : 'Один раз'}
+
{ref.autoplay ? t('control.modeAuto') : t('control.modeManual')}
+
{ref.loop ? t('control.loop') : t('control.once')}
{st.label}
- {!allowCampaignAudio ?
Пауза (сцена)
: null} + {!allowCampaignAudio ? ( +
{t('control.pauseSceneMusic')}
+ ) : null}
0 ? styles.audioScrubPointer : styles.audioScrubDefault, ].join(' ')} - title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'} + title={dur > 0 ? t('control.scrubSeek') : t('control.durationUnknown')} >
@@ -150,7 +152,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }: type="button" className={styles.transportBtn} onClick={() => void video.dispatch({ kind: 'pause' })} - title="Pause" + title={t('control.transportPause')} > ❚❚ @@ -161,7 +163,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }: void video.dispatch({ kind: 'stop' }); setTick((x) => x + 1); }} - title="Stop" + title={t('control.transportStop')} > ■ diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index 40b31de..bb42039 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -47,28 +47,28 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', ( void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => { const src = readControlApp(); - assert.ok(src.includes('ЭФФЕКТЫ')); - assert.ok(src.includes('Инструменты')); - assert.ok(src.includes('Эффекты поля')); - assert.ok(src.includes('Эффекты действий')); - assert.ok(src.includes('Луч света')); - assert.ok(src.includes('title="Вода"')); - assert.ok(src.includes('title="Облако яда"')); - assert.ok(src.includes('title="Туман"')); - assert.ok(src.includes('ariaLabel="Туман"')); + assert.ok(src.includes("t('control.effects')")); + assert.ok(src.includes("t('control.tools')")); + assert.ok(src.includes("t('control.fieldEffects')")); + assert.ok(src.includes("t('control.actionEffects')")); + assert.ok(src.includes("t('control.sunbeam')")); + assert.ok(src.includes("title={t('control.water')}")); + assert.ok(src.includes("title={t('control.poisonCloud')}")); + assert.ok(src.includes("title={t('control.fog')}")); + assert.ok(src.includes("ariaLabel={t('control.fog')}")); assert.ok(src.includes('iconOnly')); - assert.ok(src.includes('title="Очистить эффекты"')); - assert.ok(src.includes('ariaLabel="Очистить эффекты"')); + assert.ok(src.includes("title={t('control.clearEffects')}")); + assert.ok(src.includes("ariaLabel={t('control.clearEffects')}")); assert.ok(src.includes('#e5484d')); - const fx = src.indexOf('ЭФФЕКТЫ'); - const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ'); + const fx = src.indexOf("t('control.effects')"); + const story = src.indexOf("t('control.storyLine')"); assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии'); }); void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => { const src = readControlApp(); const css = readControlAppCss(); - const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ'); + const story = src.indexOf("t('control.storyLine')"); assert.ok(story !== -1); assert.ok(src.includes('className={styles.storyScroll}')); assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/); @@ -86,8 +86,8 @@ void test('ControlApp: слой кисти не использует курсо void test('ControlApp: радиус кисти не в блоке предпросмотра', () => { const src = readControlApp(); - const previewLabel = src.indexOf('Предпросмотр экрана'); - const radius = src.indexOf('Радиус кисти'); + const previewLabel = src.indexOf("t('control.screenPreview')"); + const radius = src.indexOf("t('control.brushRadius')"); assert.ok(previewLabel !== -1 && radius !== -1); assert.ok( radius < previewLabel, @@ -97,8 +97,8 @@ void test('ControlApp: радиус кисти не в блоке предпро void test('ControlApp: музыка разделена на сцену и кампанию', () => { const src = readControlApp(); - assert.ok(src.includes('МУЗЫКА СЦЕНЫ')); - assert.ok(src.includes('МУЗЫКА ИГРЫ')); + assert.ok(src.includes("t('control.sceneMusic')")); + assert.ok(src.includes("t('control.gameMusic')")); // при музыке сцены — кампанию ставим на паузу assert.ok(src.includes('allowCampaignAudio')); assert.ok( diff --git a/app/renderer/control/main.tsx b/app/renderer/control/main.tsx index aed9cb8..7cce1ab 100644 --- a/app/renderer/control/main.tsx +++ b/app/renderer/control/main.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import '../shared/ui/globals.css'; +import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext'; + import { ControlApp } from './ControlApp'; const rootEl = document.getElementById('root'); @@ -11,6 +13,8 @@ if (!rootEl) { createRoot(rootEl).render( - + + + , ); diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index 401ad3e..8108cbc 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -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; diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 5dfe636..3888ca6 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -14,8 +14,15 @@ import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './EditorApp.module.css'; import { buildNextSceneCardById } from './graph/sceneCardById'; -import { DND_SCENE_ID_MIME, SceneGraph, type SceneGraphSceneCard } from './graph/SceneGraph'; +import { + DND_SCENE_ID_MIME, + SceneGraph, + type SceneGraphSceneCard, + type SceneGraphUiStrings, +} from './graph/SceneGraph'; +import { useEditorI18n } from './i18n/EditorI18nContext'; import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; +import type { ProjectNoticeCode } from './state/projectState'; import { useProjectState } from './state/projectState'; type SceneCard = { @@ -57,11 +64,13 @@ function useStableSceneCardById(project: Project | null): Record(null); const [query, setQuery] = useState(''); const [fileMenuOpen, setFileMenuOpen] = useState(false); const [projectMenuOpen, setProjectMenuOpen] = useState(false); const [settingsMenuOpen, setSettingsMenuOpen] = useState(false); + const [settingsLangSubOpen, setSettingsLangSubOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); const [previewBusy, setPreviewBusy] = useState(false); @@ -72,8 +81,40 @@ export function EditorApp() { const [aboutLicenseOpen, setAboutLicenseOpen] = useState(false); const [openKeyAfterEula, setOpenKeyAfterEula] = useState(false); const licenseActive = licenseSnap?.active === true; - const [state, actions] = useProjectState(licenseActive); + const [appNotice, setAppNotice] = useState<{ title?: string; message: string } | null>(null); + const onProjectNotice = useCallback( + (code: ProjectNoticeCode) => { + const handlers: Record void> = { + campaign_audio_empty: () => + setAppNotice({ title: t('common.message'), message: t('notice.campaignAudioEmpty') }), + }; + handlers[code](); + }, + [t], + ); + const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice }); const sceneCardById = useStableSceneCardById(state.project); + const graphUi = useMemo( + () => ({ + badgeStart: t('graph.badgeStart'), + untitled: t('graph.untitled'), + videoBadge: t('graph.videoBadge'), + audioBadge: t('graph.audioBadge'), + loop: t('graph.loop'), + autoplay: t('graph.autoplay'), + previewAutostart: t('graph.previewAutostart'), + videoLoop: t('graph.videoLoop'), + zoomBar: t('graph.zoomBar'), + zoomIn: t('graph.zoomIn'), + zoomOut: t('graph.zoomOut'), + fitAll: t('graph.fitAll'), + closeMenu: t('common.closeMenu'), + startScene: t('graph.startScene'), + unsetStartScene: t('graph.unsetStartScene'), + delete: t('common.delete'), + }), + [t], + ); const fileMenuBtnRef = useRef(null); const projectMenuBtnRef = useRef(null); const settingsMenuBtnRef = useRef(null); @@ -217,6 +258,10 @@ export function EditorApp() { return () => window.removeEventListener('mousedown', onDown); }, [settingsMenuOpen]); + useEffect(() => { + if (!settingsMenuOpen) setSettingsLangSubOpen(false); + }, [settingsMenuOpen]); + useEffect(() => { let off: (() => void) | null = null; void (async () => { @@ -271,15 +316,13 @@ export function EditorApp() { const bodyOverlay = licenseSnap === null ? (
-
Проверка лицензии…
-
Подождите.
+
{t('license.checkingTitle')}
+
{t('license.checkingWait')}
) : !licenseSnap.active ? (
-
Требуется лицензия
-
- Укажите ключ в меню «Настройки» → «Указать ключ». До активации доступно только меню «Настройки». -
+
{t('license.requiredTitle')}
+
{t('license.requiredHint')}
) : undefined; @@ -287,12 +330,10 @@ export function EditorApp() { <> {presentationOpen ? createPortal( -
+
-
Презентация запущена
-
- Редактор заблокирован. Закройте окна «Презентация» и «Панель управления», чтобы продолжить. -
+
{t('presentation.title')}
+
{t('presentation.body')}
, document.body, @@ -300,10 +341,10 @@ export function EditorApp() { : null} {state.zipProgress ? createPortal( -
+
- {state.zipProgress.kind === 'import' ? 'Импорт проекта' : 'Экспорт проекта'} + {state.zipProgress.kind === 'import' ? t('zip.importTitle') : t('zip.exportTitle')}
{ void actions.closeProject(); }} - title="К списку проектов" + title={t('top.backToProjects')} >
DNDGamePlayer
@@ -344,10 +385,14 @@ export function EditorApp() { onClick={() => { setFileMenuOpen(false); setProjectMenuOpen(false); - setSettingsMenuOpen((v) => !v); + setSettingsMenuOpen((v) => { + const next = !v; + if (next) setSettingsLangSubOpen(false); + return next; + }); }} > - Настройки + {t('top.settings')} {state.project ? ( ) : null}
{appVersionText ? ( -
+
{appVersionText}
) : null} @@ -397,10 +442,10 @@ export function EditorApp() { disabled={!licenseActive || !graphStartGraphNodeId} title={ !licenseActive - ? 'Доступно после активации лицензии' + ? t('top.afterLicense') : graphStartSceneId ? undefined - : 'Назначьте начальную сцену на графе (ПКМ по узлу)' + : t('top.setStartScene') } onClick={() => { if (!licenseActive || !graphStartGraphNodeId) return; @@ -412,7 +457,7 @@ export function EditorApp() { })(); }} > - Запустить + {t('top.run')} ) : null}
@@ -423,9 +468,9 @@ export function EditorApp() { {state.project ? ( <>
- +
@@ -457,6 +502,7 @@ export function EditorApp() {
{state.project ? ( {state.project ? ( <> -
Свойства игры
+
{t('scenes.inspectorGame')}
-
Свойства сцены
+
{t('scenes.inspectorScene')}
{state.selectedSceneId ? ( (() => { const proj = state.project; @@ -533,7 +582,10 @@ export function EditorApp() { try { await actions.importScenePreview(sid); } catch (e) { - window.alert(e instanceof Error ? e.message : String(e)); + setAppNotice({ + title: t('common.error'), + message: e instanceof Error ? e.message : String(e), + }); } finally { setPreviewBusy(false); } @@ -548,11 +600,11 @@ export function EditorApp() { ); })() ) : ( -
Выберите сцену слева, чтобы редактировать её свойства.
+
{t('scenes.selectHint')}
)} ) : ( -
Откройте проект, чтобы редактировать кампанию и сцены.
+
{t('scenes.openProjectHint')}
)}
@@ -572,6 +624,7 @@ export function EditorApp() { className={styles.fileMenuItem} onClick={() => { setSettingsMenuOpen(false); + setSettingsLangSubOpen(false); if ((licenseSnap?.eulaAcceptedVersion ?? null) === EULA_CURRENT_VERSION) { setLicenseKeyModalOpen(true); } else { @@ -580,7 +633,7 @@ export function EditorApp() { } }} > - Указать ключ + {t('menu.enterKey')} +
+ + {settingsLangSubOpen ? ( +
+ + +
+ ) : null} +
, document.body, ) @@ -639,7 +744,7 @@ export function EditorApp() { void actions.closeProject(); }} > - Начальный экран + {t('projectMenu.home')}
, document.body, @@ -686,7 +791,7 @@ export function EditorApp() { setRenameOpen(true); }} > - Переименовать проект + {t('fileMenu.rename')}
, document.body, @@ -714,6 +819,12 @@ export function EditorApp() { await actions.exportProject(projectId); }} /> + setAppNotice(null)} + /> ); } @@ -733,6 +844,7 @@ function ExportProjectModal({ onClose, onExport, }: ExportProjectModalProps) { + const { t } = useEditorI18n(); const [projectId, setProjectId] = useState(initialProjectId); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -759,17 +871,27 @@ function ExportProjectModal({ return createPortal( <> -
-
ПРОЕКТ
+
{t('export.project')}
-
- Далее откроется окно сохранения: укажите имя и папку для файла .dnd.zip — будет создана копия - архива проекта. -
+
{t('export.hint')}
{error ?
{error}
: null}
- +
+
+ , + document.body, + ); +} + +type SimpleMessageModalProps = { + open: boolean; + title?: string; + message: string; + onClose: () => void; +}; + +function SimpleMessageModal({ open, title, message, onClose }: SimpleMessageModalProps) { + const { t } = useEditorI18n(); + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose, open]); + + if (!open) return null; + + return createPortal( + <> + +
+
+
{message}
+
+
+ + +
+
+ , + document.body, + ); +} + +type ConfirmDeleteProjectModalProps = { + open: boolean; + projectName: string; + busy: boolean; + onCancel: () => void; + onConfirm: () => void | Promise; +}; + +function ConfirmDeleteProjectModal({ + open, + projectName, + busy, + onCancel, + onConfirm, +}: ConfirmDeleteProjectModalProps) { + const { t } = useEditorI18n(); + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !busy) onCancel(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [busy, onCancel, open]); + + if (!open) return null; + + return createPortal( + <> + +
+
+
{t('confirmDelete.body', { name: projectName })}
+
+
+ +
@@ -851,6 +1098,7 @@ function RenameProjectModal({ onClose, onSave, }: RenameProjectModalProps) { + const { t } = useEditorI18n(); const [projectName, setProjectName] = useState(projectNameInitial); const [fileBaseName, setFileBaseName] = useState(fileBaseNameInitial); const [saving, setSaving] = useState(false); @@ -891,45 +1139,49 @@ function RenameProjectModal({ return createPortal( <> -
-
НАЗВАНИЕ ПРОЕКТА
- - {!projectNameOk ?
Минимум 3 символа.
: null} - {projectNameDup ? ( -
Проект с таким названием уже существует.
- ) : null} +
{t('rename.projectName')}
+ + {!projectNameOk ?
{t('rename.projectMin')}
: null} + {projectNameDup ?
{t('rename.projectDup')}
: null}
-
НАЗВАНИЕ ФАЙЛА ПРОЕКТА
+
{t('rename.fileName')}
.dnd.zip
- {!fileNameOk ? ( -
Минимум 3 символа, без символов {'<>:"/\\|?*'}
- ) : null} - {fileNameDup ? ( -
Файл проекта с таким названием уже существует.
- ) : null} + {!fileNameOk ?
{t('rename.fileInvalid')}
: null} + {fileNameDup ?
{t('rename.fileDup')}
: null}
{error ?
{error}
: null}
-
@@ -968,16 +1220,20 @@ type ProjectPickerProps = { }; function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: ProjectPickerProps) { - const [name, setName] = useState('Моя кампания'); + const { t, locale } = useEditorI18n(); + const [name, setName] = useState(() => t('picker.defaultName')); const [rowMenuFor, setRowMenuFor] = useState(null); const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null); + const [pendingDelete, setPendingDelete] = useState<{ id: ProjectId; name: string } | null>(null); + const [deleteSubmitting, setDeleteSubmitting] = useState(false); + const [deleteError, setDeleteError] = useState(null); useEffect(() => { if (!rowMenuFor) return; const onDown = (e: MouseEvent) => { - const t = e.target as HTMLElement | null; - if (!t) return; - if (t.closest('[data-project-row-menu-root="1"]')) return; + const tgt = e.target as HTMLElement | null; + if (!tgt) return; + if (tgt.closest('[data-project-row-menu-root="1"]')) return; setRowMenuFor(null); setRowMenuPos(null); }; @@ -987,28 +1243,26 @@ function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: return (
-
Проекты
+
{t('picker.title')}
- +
-
СУЩЕСТВУЮЩИЕ
+
{t('picker.existing')}
{!licenseActive && projects.length > 0 ? ( <> -
- Открытие и создание — после активации лицензии. Список показывает файлы в папке приложения. -
+
{t('picker.lockedHint')}
) : null} @@ -1024,7 +1278,7 @@ function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: }} role="button" tabIndex={0} - title={!licenseActive ? 'Открытие проекта — после активации лицензии' : undefined} + title={!licenseActive ? t('picker.openDisabled') : undefined} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { if (!licenseActive) return; @@ -1033,17 +1287,19 @@ function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: }} >
{p.name}
-
{new Date(p.updatedAt).toLocaleString('ru-RU')}
+
+ {new Date(p.updatedAt).toLocaleString(locale === 'en' ? 'en-US' : 'ru-RU')} +
))} - {projects.length === 0 ?
Пока нет проектов.
: null} + {projects.length === 0 ?
{t('picker.empty')}
: null}
{rowMenuFor && rowMenuPos @@ -1078,30 +1334,45 @@ function ProjectPicker({ projects, licenseActive, onCreate, onOpen, onDelete }: const proj = projects.find((x) => x.id === id); setRowMenuFor(null); setRowMenuPos(null); - if ( - !id || - !proj || - !window.confirm( - `Удалить проект «${proj.name}» безвозвратно? Файл и кэш будут стёрты с диска.`, - ) - ) { - return; - } - void (async () => { - try { - await onDelete(id); - } catch (e) { - window.alert(e instanceof Error ? e.message : String(e)); - } - })(); + if (!id || !proj) return; + setPendingDelete({ id, name: proj.name }); }} > - Удалить + {t('common.delete')}
, document.body, ) : null} + { + if (deleteSubmitting) return; + setPendingDelete(null); + }} + onConfirm={async () => { + if (!pendingDelete) return; + const { id } = pendingDelete; + setDeleteSubmitting(true); + try { + await onDelete(id); + setPendingDelete(null); + } catch (e) { + setDeleteError(e instanceof Error ? e.message : String(e)); + setPendingDelete(null); + } finally { + setDeleteSubmitting(false); + } + }} + /> + setDeleteError(null)} + />
); } @@ -1139,13 +1410,14 @@ function CampaignInspector({ onAudioRefsChange, onUploadAudio, }: CampaignInspectorProps) { + const { t } = useEditorI18n(); const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]); return (
-
АУДИО ИГРЫ
+
{t('campaign.label')}
{mediaAssets.filter((a) => a.type === 'audio').length === 0 ? ( -
Файлов пока нет. Добавьте аудио.
+
{t('campaign.noFiles')}
) : (
{mediaAssets @@ -1165,7 +1437,7 @@ function CampaignInspector({ onAudioRefsChange(next); }} /> - Авто + {t('campaign.auto')}
)} - +
); @@ -1231,22 +1503,23 @@ function SceneInspector({ onRotatePreview, onUploadMedia, }: SceneInspectorProps) { + const { t } = useEditorI18n(); const previewUrl = useAssetUrl(previewAssetId); const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]); return (
-
НАЗВАНИЕ СЦЕНЫ
+
{t('scene.title')}
-
ОПИСАНИЕ
+
{t('scene.description')}