From d44180db6741a96bfb6ffc8e82acbd22aa976a70 Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 29 Sep 2026 13:27:11 +0900 Subject: [PATCH] feat: add Korean-English localization and light theme Localize studio, curriculum, visualization and loading interfaces. Add persistent language and theme settings, refine module loading states, and normalize remote shell locales. --- docs/localization.md | 47 ++ package-lock.json | 90 ++- package.json | 4 + scripts/test-i18n.cjs | 209 ++++++ scripts/test-remote-shell.cjs | 85 +++ scripts/test-studio-theme.cjs | 48 ++ src/main/index.ts | 31 +- src/main/remoteShell.ts | 28 + src/renderer/public/slam-viewer/index.html | 61 +- src/renderer/public/slam-viewer/locale.js | 72 ++ src/renderer/public/splash.html | 14 +- src/renderer/src/App.tsx | 102 +-- .../src/components/AppStatusModals.tsx | 84 +-- src/renderer/src/components/ChatWidgets.tsx | 15 +- .../src/components/DockerSetupNotice.tsx | 31 +- .../src/components/PlatformLoadingOverlay.tsx | 6 +- src/renderer/src/components/Sidebar.tsx | 26 +- src/renderer/src/components/TitleBar.tsx | 108 ++- src/renderer/src/features/AiGuideView.css | 131 ++++ src/renderer/src/features/AiGuideView.tsx | 47 +- .../curriculum/CourseSelectionScreen.tsx | 35 +- .../curriculum/CurriculumDetailPanel.tsx | 22 +- .../curriculum/CurriculumHomeScreen.tsx | 12 +- .../InstructorStudioNoticeModal.tsx | 16 +- .../features/curriculum/LearningInfoCard.tsx | 24 +- .../features/curriculum/LearningStatsCard.tsx | 16 +- .../features/curriculum/MaterialsModal.tsx | 10 +- .../src/features/curriculum/WeekCard.tsx | 4 +- src/renderer/src/features/help/contextHelp.ts | 14 +- .../src/features/platforms/PlatformCards.tsx | 23 +- .../features/platforms/PlatformFilterTabs.tsx | 4 +- .../platforms/PlatformSelectModal.tsx | 12 +- .../platforms/PlatformSelectionPanel.tsx | 16 +- .../platforms/PlatformSettingsPanel.tsx | 34 +- .../features/platforms/RecentSessionModal.tsx | 18 +- .../workspace/ModuleDeleteConfirm.tsx | 27 +- .../workspace/ModuleListEmptyState.tsx | 6 +- .../features/workspace/ModuleListHeader.tsx | 16 +- .../src/features/workspace/ModuleListRow.tsx | 37 +- .../features/workspace/ModuleListToolbar.tsx | 12 +- .../src/features/workspace/ModuleListView.tsx | 188 +++--- .../workspace/ModuleParameterDialog.tsx | 28 +- .../workspace/WorkspaceActivityHeader.tsx | 10 +- .../workspace/WorkspaceCameraPanel.tsx | 24 +- .../workspace/WorkspaceEditorPane.tsx | 14 +- .../workspace/WorkspaceEditorPathBar.tsx | 4 +- .../WorkspaceEditorTabContextMenu.tsx | 4 +- .../workspace/WorkspaceEditorTabs.tsx | 4 +- .../workspace/WorkspaceEmptyModuleState.tsx | 10 +- .../features/workspace/WorkspaceFilePanel.tsx | 45 +- .../workspace/WorkspaceGuidePanel.tsx | 56 +- .../features/workspace/WorkspaceLogView.tsx | 10 +- .../features/workspace/WorkspaceModals.tsx | 102 +-- .../WorkspaceModuleTabContextMenu.tsx | 6 +- .../workspace/WorkspaceModuleTabs.tsx | 8 +- .../src/features/workspace/WorkspaceNav.tsx | 18 +- .../features/workspace/WorkspaceQaPanel.tsx | 48 +- .../workspace/WorkspaceRosConnectionsView.tsx | 73 +- .../workspace/WorkspaceSettingsView.tsx | 136 ++-- .../workspace/WorkspaceSourceControlView.tsx | 70 +- .../WorkspaceTerminalContextMenu.tsx | 10 +- .../workspace/WorkspaceTerminalPanel.tsx | 6 +- .../features/workspace/WorkspaceTopBar.tsx | 33 +- .../workspace/WorkspaceVisualizationPanel.tsx | 48 +- .../features/workspace/WorldEditorModal.tsx | 52 +- .../workspace/WorldMapSelectorModal.tsx | 62 +- .../src/features/workspace/studioDialogs.css | 16 + .../src/features/workspace/studioPalette.css | 44 ++ .../src/features/workspace/studioTheme.ts | 27 + .../workspace/useWorkspaceModuleList.ts | 11 +- .../workspace/useWorkspaceTerminal.ts | 39 +- src/renderer/src/i18n/LanguageSelect.tsx | 20 + src/renderer/src/i18n/en.json | 631 ++++++++++++++++++ src/renderer/src/i18n/index.ts | 63 ++ 74 files changed, 2519 insertions(+), 998 deletions(-) create mode 100644 docs/localization.md create mode 100644 scripts/test-i18n.cjs create mode 100644 scripts/test-remote-shell.cjs create mode 100644 scripts/test-studio-theme.cjs create mode 100644 src/main/remoteShell.ts create mode 100644 src/renderer/public/slam-viewer/locale.js create mode 100644 src/renderer/src/features/AiGuideView.css create mode 100644 src/renderer/src/features/workspace/studioDialogs.css create mode 100644 src/renderer/src/features/workspace/studioPalette.css create mode 100644 src/renderer/src/features/workspace/studioTheme.ts create mode 100644 src/renderer/src/i18n/LanguageSelect.tsx create mode 100644 src/renderer/src/i18n/en.json create mode 100644 src/renderer/src/i18n/index.ts diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 0000000..5fcb8dc --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,47 @@ +# Korean and English UI + +Use the language selector at the top of the studio settings screen (local or +remote workspace). Select a language and press Save to apply it. Unsaved choices +do not change the UI or stored preference. Korean is the default. The choice is +stored under `autonomy-studio.lms.language` in local storage and applies on the +next launch. Switching language updates React subscribers without remounting the +workspace, terminal, editor, or visualization iframe. + +## Scope + +- App navigation, curriculum UI, studio controls, settings, dialogs and help. +- Known application status/error messages and NAV2/SLAM viewer controls. +- Course titles, learning content, materials, module names, source code, terminal + output and server-authored notifications remain in their original language. +- Unrecognized backend/SSH/Docker errors are preserved verbatim for diagnosis. +- Embedded text in guide images and external pages is not translated. +- This does not change ROS, Docker, SSH, the OS locale, or module parameters. + +## Adding Text + +Use `t('Korean source text')` from `src/renderer/src/i18n` at the display boundary +and add the English value to `en.json`. Call `useLocaleRender()` in components +which render translations so an open screen reacts to language changes. + +Keep identifiers and state values unchanged. Do not translate stored form values, +parameter strings, module identifiers, or values used in conditions. Use whole +sentences with named interpolation placeholders instead of joining translated +fragments. Korean falls back to the source key; unknown messages retain the source. + +The standalone SLAM viewer uses `public/slam-viewer/locale.js` and explicitly +marked text/attributes. It follows the same saved preference through storage +events, without reconnecting the bridge or resetting the scene. + +## Verification + +```sh +npm run test:i18n +npx tsc --noEmit -p tsconfig.web.json +npm run build +``` + +The tests cover language persistence, storage failure, cross-window updates, +interpolation, source fallback and literal translation-key coverage. Browser +checks should additionally cover both languages, narrow studio panels, preserving +unsaved edits, and language changes with the viewer open. `npm run build` does not +package an AppImage. diff --git a/package-lock.json b/package-lock.json index 7c1cd04..b98a3da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lms", - "version": "1.0.23", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lms", - "version": "1.0.23", + "version": "1.2.1", "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", @@ -16,9 +16,11 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "driver.js": "^1.4.0", + "i18next": "^26.4.2", "lucide-react": "^1.14.0", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", + "react-i18next": "^17.0.15", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "socket.io-client": "^4.8.3", @@ -289,6 +291,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -5269,6 +5280,15 @@ "dev": true, "license": "ISC" }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -5336,6 +5356,34 @@ "ms": "^2.0.0" } }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -7933,6 +7981,33 @@ "react": "^19.2.6" } }, + "node_modules/react-i18next": { + "version": "17.0.15", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.15.tgz", + "integrity": "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -8923,7 +8998,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9102,6 +9177,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", diff --git a/package.json b/package.json index 2fc240f..78d7421 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "email": "hooladhoon@gmail.com" }, "scripts": { + "test:i18n": "node --test scripts/test-i18n.cjs", + "test:theme": "node --test scripts/test-studio-theme.cjs", "dev": "electron-vite dev", "build": "electron-vite build", "build:linux": "npm run build && electron-builder --linux" @@ -55,9 +57,11 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "driver.js": "^1.4.0", + "i18next": "^26.4.2", "lucide-react": "^1.14.0", "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", + "react-i18next": "^17.0.15", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "socket.io-client": "^4.8.3", diff --git a/scripts/test-i18n.cjs b/scripts/test-i18n.cjs new file mode 100644 index 0000000..01d655a --- /dev/null +++ b/scripts/test-i18n.cjs @@ -0,0 +1,209 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const { test } = require('node:test') +const ts = require('typescript') +const { createInstance } = require('i18next') + +const root = path.resolve(__dirname, '../src/renderer/src') +const dictionary = JSON.parse(fs.readFileSync(path.join(root, 'i18n/en.json'), 'utf8')) + +test('all execution guide page titles have English translations', () => { + const source = fs.readFileSync(path.join(root, 'features/workspace/ModuleListView.tsx'), 'utf8') + const titles = [...source.matchAll(/environment: '([^']+)'/g)].map(match => match[1]) + assert.equal(titles.length, 4) + for (const title of titles) assert.ok(dictionary[title], title) +}) + +test('visualization progress uses exact runtime translation keys', () => { + const { api } = setup('en') + assert.equal(api.t('맵 환경 준비 중'), 'Preparing map environment') + assert.equal(api.t('시작 중'), 'Starting') + assert.equal(api.t('종료 중'), 'Stopping') + const panel = fs.readFileSync(path.join(root, 'features/workspace/WorkspaceVisualizationPanel.tsx'), 'utf8') + assert.ok(panel.includes('busy ? t(statusMessage)')) +}) + +test('viewer translates before 3D initialization and accepts language only from its parent', () => { + const events = new Map() + const sent = [] + const parent = { postMessage: message => sent.push(message) } + const label = { dataset: { i18n: '응답을 기다리는 중입니다.' }, textContent: '' } + const document = { + documentElement: {}, + querySelectorAll: selector => selector === '[data-i18n]' ? [label] : [] + } + const source = fs.readFileSync(path.join(root, '../public/slam-viewer/locale.js'), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, allowJs: true + } }).outputText + vm.runInNewContext(code, { + exports: {}, document, + localStorage: { getItem: () => 'ko' }, + window: { parent, addEventListener: (type, listener) => events.set(type, listener) } + }) + assert.equal(sent[0].type, 'lms:language:ready') + events.get('message')({ source: {}, data: { type: 'lms:language', language: 'en' } }) + assert.equal(label.textContent, '응답을 기다리는 중입니다.') + events.get('message')({ source: parent, data: { type: 'lms:language', language: 'en' } }) + assert.equal(label.textContent, 'Waiting for a response.') + events.get('message')({ source: parent, data: { type: 'lms:language', language: 'ko' } }) + assert.equal(label.textContent, '응답을 기다리는 중입니다.') + const html = fs.readFileSync(path.join(root, '../public/slam-viewer/index.html'), 'utf8') + assert.ok(html.includes('')) +}) + +function setup(savedLanguage, storageBlocked = false) { + const instance = createInstance() + const storage = new Map([['autonomy-studio.lms.language', savedLanguage]]) + const events = new Map() + const document = { documentElement: { lang: '' } } + const exports = {} + const source = fs.readFileSync(path.join(root, 'i18n/index.ts'), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true + } }).outputText + vm.runInNewContext(code, { + exports, document, + localStorage: { + getItem(key) { if (storageBlocked) throw Error('blocked'); return storage.get(key) }, + setItem(key, value) { if (storageBlocked) throw Error('blocked'); storage.set(key, value) } + }, + window: { addEventListener: (name, listener) => events.set(name, listener) }, + require(name) { + if (name === 'i18next') return instance + if (name === './en.json') return dictionary + return require(name) + } + }) + return { api: exports, storage, document, events } +} + +test('Korean defaults, English persists, interpolation and fallback preserve user content', () => { + const { api, storage, document } = setup('invalid') + assert.equal(api.t('설정'), '설정') + assert.equal(document.documentElement.lang, 'ko') + api.setLanguage('en') + assert.equal(api.t('설정'), 'Settings') + assert.equal(api.t('{{week}}주차', { week: 3 }), 'Week 3') + assert.equal(api.t('강사가 작성한 강좌 제목'), '강사가 작성한 강좌 제목') + assert.equal(api.t('sim_mode:=false'), 'sim_mode:=false') + assert.equal(storage.get(api.LANGUAGE_STORAGE_KEY), 'en') + assert.equal(document.documentElement.lang, 'en') + api.setLanguage('ko') + assert.equal(api.t('{{week}}주차', { week: 3 }), '3주차') +}) + +test('restores preference and synchronizes other renderer windows', () => { + const { api, document, events } = setup('en') + assert.equal(api.t('설정'), 'Settings') + events.get('storage')({ key: api.LANGUAGE_STORAGE_KEY, newValue: 'ko' }) + assert.equal(document.documentElement.lang, 'ko') +}) + +test('blocked storage does not prevent switching language', () => { + const { api } = setup(undefined, true) + api.setLanguage('en') + assert.equal(api.t('설정'), 'Settings') +}) + +test('language selector edits a draft without applying or persisting it', () => { + const { api, storage, document } = setup('ko') + const exports = {} + const source = fs.readFileSync(path.join(root, 'i18n/LanguageSelect.tsx'), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true + } }).outputText + vm.runInNewContext(code, { exports, require: name => name === './index' ? api : require(name) }) + let draft = 'ko' + const element = exports.default({ dark: false, value: draft, onChange: value => { draft = value } }) + const select = element.props.children.find(child => child.type === 'select') + select.props.onChange({ target: { value: 'en' } }) + assert.equal(draft, 'en') + assert.equal(document.documentElement.lang, 'ko') + assert.equal(storage.get(api.LANGUAGE_STORAGE_KEY), 'ko') +}) + +test('built-in map labels follow language without changing map data or saved names', () => { + const { api } = setup('ko') + const exports = {} + const source = fs.readFileSync(path.join(root, 'features/workspace/WorldMapSelectorModal.tsx'), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true + } }).outputText + vm.runInNewContext(code, { exports, require: name => { + if (name === '../../i18n') return api + if (name === './studioTheme') return { useStudioTheme: () => 'dark' } + if (name.endsWith('.css')) return {} + return require(name) + } }) + const map = exports.NAV2_MAP_OPTIONS[0] + assert.equal(exports.getMapDisplayName(map), '기본 월드') + api.setLanguage('en') + assert.equal(exports.getMapDisplayName(map), 'Default world') + assert.equal(exports.getMapDisplayName({ ...map, saved: true }), '기본 월드') + assert.equal(map.name, '기본 월드') + assert.equal(map.worldFile, 'turtlebot3_world.world') + assert.equal(api.t('새 2D 맵'), 'New 2D map') + assert.equal(api.t('새 3D 맵'), 'New 3D map') +}) + +test('translations preserve every interpolation placeholder', () => { + const placeholders = value => [...value.matchAll(/{{\s*([^}]+)\s*}}/g)].map(match => match[1]).sort() + for (const [key, value] of Object.entries(dictionary)) { + assert.ok(value.trim(), `Empty translation: ${key}`) + assert.deepEqual(placeholders(value), placeholders(key), key) + } +}) + +test('learning statistics translate API labels but preserve numeric values', () => { + const { api } = setup('ko') + const exports = {} + const source = fs.readFileSync(path.join(root, 'features/curriculum/LearningStatsCard.tsx'), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true + } }).outputText + vm.runInNewContext(code, { exports, require: name => name === '../../i18n' + ? { ...api, useLocaleRender() {} } : require(name) }) + const stats = [ + { key: 'weeks', label: '완료한 주차', value: '0/3', iconKey: 'check' }, + { key: 'time', label: '학습 시간', value: '02:08', iconKey: 'clock' }, + { key: 'commits', label: '커밋 수', value: '0', iconKey: 'trending' }, + { key: 'score', label: '평균 점수', value: '0', iconKey: 'gauge' } + ] + const { createElement } = require('react') + const { renderToStaticMarkup } = require('react-dom/server') + const render = () => renderToStaticMarkup(createElement(exports.default, { stats })) + assert.ok(render().includes('완료한 주차')) + api.setLanguage('en') + const html = render() + for (const text of ['Completed weeks', 'Learning time', 'Commits', 'Average score', '0/3', '02:08']) { + assert.ok(html.includes(text), text) + } + for (const item of stats) assert.ok(!html.includes(item.label), item.label) + api.setLanguage('ko') + assert.ok(render().includes('평균 점수')) +}) + +test('all literal translation calls have an English entry', () => { + function check(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name) + if (entry.isDirectory()) { check(file); continue } + if (!/\.tsx?$/.test(file)) continue + const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true) + function visit(node) { + if (ts.isCallExpression(node) && node.expression.getText(source) === 't' && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) { + assert.ok(Object.hasOwn(dictionary, node.arguments[0].text), `${file}: ${node.arguments[0].text}`) + } + ts.forEachChild(node, visit) + } + visit(source) + } + } + check(root) +}) diff --git a/scripts/test-remote-shell.cjs b/scripts/test-remote-shell.cjs new file mode 100644 index 0000000..47ba4ec --- /dev/null +++ b/scripts/test-remote-shell.cjs @@ -0,0 +1,85 @@ +const assert = require('node:assert/strict') +const { test } = require('node:test') +const { readFileSync, mkdtempSync, writeFileSync, rmSync } = require('node:fs') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { spawn } = require('node:child_process') +const vm = require('node:vm') +const ts = require('typescript') + +const transpile = (source) => ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } +}).outputText +const helpers = { exports: {} } +vm.runInNewContext(transpile(readFileSync(join(__dirname, '../src/main/remoteShell.ts'), 'utf8')), helpers) +const { remoteShellCommand, remoteShellEnvironment } = helpers.exports +const source = readFileSync(join(__dirname, '../src/main/index.ts'), 'utf8') +const start = source.indexOf('async function runCommandOutput(') +const end = source.indexOf('\nasync function runCommand(', start) +const context = { spawn, process, setTimeout, clearTimeout, createModuleProgressParser: () => () => {} } +vm.runInNewContext(transpile(source.slice(start, end)), context) +const run = context.runCommandOutput + +test('SSH data output excludes locale warnings even when command succeeds', async () => { + const result = await run('/bin/bash', ['--noprofile', '--norc', '-c', 'printf %s "/tmp/module with spaces"'], undefined, + { LC_ALL: 'missing_TEST.UTF-8' }, 5000, 'stdout') + assert.equal(result, '/tmp/module with spaces') +}) + +test('failed commands retain stderr diagnostics and stdout', async () => { + await assert.rejects(run('/bin/sh', ['-c', 'printf context; printf "permission denied" >&2; exit 7'], undefined, undefined, 5000, 'stdout'), + error => error.message.includes('context') && error.message.includes('permission denied')) +}) + +test('non-SSH combined output stays compatible and explicit log merging works', async () => { + const command = 'printf output; printf diagnostic >&2' + const combined = await run('/bin/sh', ['-c', command]) + assert.match(combined, /output/) + assert.match(combined, /diagnostic/) + const logs = await run('/bin/sh', ['-c', `{ ${command}; } 2>&1`], undefined, undefined, 5000, 'stdout') + assert.match(logs, /diagnostic/) +}) + +test('SSH environment neutralizes locale forwarding without losing other settings', () => { + const original = { LANG: 'ko_KR.UTF-8', LC_ALL: 'en_US.UTF-8', LC_CTYPE: 'bad', LANGUAGE: 'ko:en', PATH: '/bin', TERM: 'xterm-256color' } + const env = remoteShellEnvironment(original) + assert.equal(env.LANG, 'C') + assert.equal(env.LC_ALL, 'C') + assert.equal(env.LC_CTYPE, undefined) + assert.equal(env.LANGUAGE, undefined) + assert.equal(env.PATH, '/bin') + assert.equal(env.TERM, 'xterm-256color') + assert.equal(original.LC_CTYPE, 'bad') +}) + +test('remote wrapper preserves multiline commands, Unicode, quotes and exit status', async () => { + const command = `cat <<'END' +/tmp/한글 module's folder +END` + const result = await run('/bin/sh', ['-c', remoteShellCommand(command)], undefined, remoteShellEnvironment(process.env), 5000, 'stdout') + assert.equal(result, "/tmp/한글 module's folder\n") + await assert.rejects(run('/bin/sh', ['-c', remoteShellCommand('echo remote-failure >&2; exit 9')], undefined, undefined, 5000, 'stdout'), /remote-failure/) +}) + +test('locale selection prefers C UTF-8, supports other UTF-8 locales and falls back to C', async () => { + const dir = mkdtempSync(join(tmpdir(), 'lms-locale-test-')) + try { + for (const [locales, expected] of [['en_GB.utf8 C.utf8', 'C.utf8'], ['C en_GB.utf8', 'en_GB.utf8'], ['C POSIX', 'C']]) { + writeFileSync(join(dir, 'locale'), `#!/bin/sh\nprintf '%s\\n' ${locales}\n`, { mode: 0o755 }) + const result = await run('/bin/sh', ['-c', remoteShellCommand('printf "%s|%s" "$LANG" "$LC_ALL"')], undefined, + { PATH: `${dir}:${process.env.PATH}`, LC_ALL: 'C' }, 5000, 'stdout') + assert.equal(result, `${expected}|${expected}`) + } + } finally { rmSync(dir, { recursive: true, force: true }) } +}) + +test('queued SSH, stats probes and remote PTY use normalization; container logs keep stderr', () => { + const ssh = source.slice(source.indexOf('async function runSshOutput('), source.indexOf('async function readRemoteDockerStatsOutput(')) + assert.equal((ssh.match(/remoteShellCommand\(command\)/g) || []).length, 2) + assert.equal((ssh.match(/'stdout'/g) || []).length, 2) + const terminal = source.slice(source.indexOf("ipcMain.handle('remote-workspace:terminal:start'"), source.indexOf("ipcMain.handle('terminal-resize'")) + assert.match(terminal, /remoteShellCommand\(mode === 'container'/) + assert.match(terminal, /remoteShellEnvironment\(terminalEnvironment/) + assert.match(source, /target \? remoteShellCommand\(init\)/) + assert.match(source, /docker logs --timestamps --tail 300.*2>&1/) +}) diff --git a/scripts/test-studio-theme.cjs b/scripts/test-studio-theme.cjs new file mode 100644 index 0000000..7845746 --- /dev/null +++ b/scripts/test-studio-theme.cjs @@ -0,0 +1,48 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') +const root = path.resolve(__dirname, '../src/renderer/src/features/workspace') + +function setup(saved, blocked = false) { + const storage = new Map([['autonomy-studio.lms.studio-theme', saved]]) + const events = new Map() + const exports = {} + const code = ts.transpileModule(fs.readFileSync(path.join(root, 'studioTheme.ts'), 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } + }).outputText + vm.runInNewContext(code, { exports, + require: () => ({ useSyncExternalStore: (_subscribe, snapshot) => snapshot() }), + localStorage: { + getItem(key) { if (blocked) throw Error('blocked'); return storage.get(key) }, + setItem(key, value) { if (blocked) throw Error('blocked'); storage.set(key, value) } + }, + window: { addEventListener: (type, listener) => events.set(type, listener) } + }) + return { api: exports, storage, events } +} + +test('theme defaults dark, persists light, and restores the preference', () => { + const { api, storage } = setup('invalid') + assert.equal(api.useStudioTheme(), 'dark') + api.setStudioTheme('light') + assert.equal(api.useStudioTheme(), 'light') + assert.equal(storage.get(api.STUDIO_THEME_KEY), 'light') + assert.equal(setup('light').api.useStudioTheme(), 'light') +}) +test('theme remains usable when storage is blocked', () => { + const { api } = setup(null, true) + api.setStudioTheme('light') + assert.equal(api.useStudioTheme(), 'light') +}) +test('theme synchronizes across windows without writing runtime settings', () => { + const { api, storage, events } = setup('dark') + storage.set(api.STUDIO_THEME_KEY, 'light') + events.get('storage')({ key: api.STUDIO_THEME_KEY }) + assert.equal(api.useStudioTheme(), 'light') + storage.clear() + events.get('storage')({ key: null }) + assert.equal(api.useStudioTheme(), 'dark') +}) diff --git a/src/main/index.ts b/src/main/index.ts index 6d16a5c..683203e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,6 +3,7 @@ import { checkDockerStatus } from './dockerStatus' import { cameraRuntimeArtifacts } from './cameraRuntime' import { remoteModuleFilesScript } from './remoteModuleFiles' import { resolveContainerTerminal } from './containerTerminal' +import { remoteShellCommand, remoteShellEnvironment } from './remoteShell' import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime' import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress' import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress' @@ -1723,10 +1724,11 @@ function handleModuleOperation(channel: string, handler: Parameters { +async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined'): Promise { const readProgress = createModuleProgressParser() return new Promise((resolvePromise, rejectPromise) => { let output = '' + let stdout = '' let terminated = false const child = spawn(command, args, { cwd, @@ -1756,6 +1758,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e child.stdout.setEncoding('utf8') child.stdout.on('data', (data: string) => { + stdout += data.toString() output += data.toString() readProgress(data.toString()) }) @@ -1769,7 +1772,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e child.on('close', (code) => { clearTimeout(timeout) if (terminated) return - if (code === 0) resolvePromise(output) + if (code === 0) resolvePromise(outputMode === 'stdout' ? stdout : output) else rejectPromise(new Error(output.trim() || `${command} ${args.join(' ')} failed with exit code ${code ?? 'unknown'}`)) }) }) @@ -2135,11 +2138,12 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time '-i', normalized.identityFile, `${normalized.user}@${normalized.host}`, - command + remoteShellCommand(command) ], undefined, - undefined, - timeoutMs + remoteShellEnvironment(process.env), + timeoutMs, + 'stdout' )) } @@ -2167,11 +2171,12 @@ async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: stri '-i', normalized.identityFile, `${normalized.user}@${normalized.host}`, - command, + remoteShellCommand(command), ], undefined, - undefined, + remoteShellEnvironment(process.env), timeoutMs, + 'stdout', ) } @@ -5704,7 +5709,7 @@ async function readRemoteWorkspaceModuleLogs( if (!existsOutput.trim()) return { ok: true, logs: '', message: '생성된 컨테이너가 없습니다.' } try { - const logs = await runSshOutput(target, `docker logs --timestamps --tail 300 ${quoteShellSingle(containerKey)}`, 10000) + const logs = await runSshOutput(target, `docker logs --timestamps --tail 300 ${quoteShellSingle(containerKey)} 2>&1`, 10000) return { ok: true, logs } } catch (error) { return { ok: false, logs: '', message: error instanceof Error ? error.message : String(error) } @@ -5980,7 +5985,8 @@ async function containerTerminalCommand(moduleRoot: string, moduleName: string | const container = resolveContainerTerminal(output, moduleRoot) const shell = buildWorkspaceShellCommand(moduleName, container.cwd, cols, rows, false) const init = 'if [ -n "$ROS_DISTRO" ] && [ -f "/opt/ros/$ROS_DISTRO/setup.bash" ]; then source "/opt/ros/$ROS_DISTRO/setup.bash"; fi; if [ -f ./install/setup.bash ]; then source ./install/setup.bash; fi; exec ' + shell - return `exec docker exec -it -e TERM=xterm-256color -w ${quoteShellSingle(container.cwd)} ${quoteShellSingle(container.id)} /bin/bash --noprofile --norc -c ${quoteShellSingle(init)}` + const containerShell = target ? remoteShellCommand(init) : `/bin/bash --noprofile --norc -c ${quoteShellSingle(init)}` + return `exec docker exec -it -e TERM=xterm-256color -w ${quoteShellSingle(container.cwd)} ${quoteShellSingle(container.id)} ${containerShell}` } function registerTerminalHandlers(): void { @@ -6079,14 +6085,15 @@ function registerTerminalHandlers(): void { '-i', target.identityFile, `${target.user}@${target.host}`, - mode === 'container' + remoteShellCommand(mode === 'container' ? await containerTerminalCommand(resolved.moduleRoot, moduleName, safeCols, safeRows, target) - : buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true) + : buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true)) ], { name: 'xterm-256color', cols: safeCols, rows: safeRows, - env: terminalEnvironment(safeCols, safeRows) + env: Object.fromEntries(Object.entries(remoteShellEnvironment(terminalEnvironment(safeCols, safeRows))) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string')) }) attachTerminalProcess(event.sender, sessionId, terminalKey, proc) diff --git a/src/main/remoteShell.ts b/src/main/remoteShell.ts new file mode 100644 index 0000000..4bd72c2 --- /dev/null +++ b/src/main/remoteShell.ts @@ -0,0 +1,28 @@ +function quote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'` +} + +// SSH may forward LANG/LC_* through the user's SendEnv configuration. +// Undefined entries also remove inherited keys when merged by the command runner. +export function remoteShellEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const result = { ...env } + for (const key of Object.keys(result)) { + if (key === 'LANGUAGE' || key.startsWith('LC_')) result[key] = undefined + } + return { ...result, LANG: 'C', LC_ALL: 'C' } +} + +export function remoteShellCommand(command: string): string { + const script = [ + 'selected_locale=C', + // Probe under C so an inherited, unavailable locale cannot break the probe. + 'available_locales=$(locale -a 2>/dev/null) || available_locales=', + 'for candidate in $available_locales; do', + ' case "$candidate" in C.utf8|C.UTF-8) selected_locale=$candidate; break ;;', + ' *utf8|*UTF-8|*utf-8) if [ "$selected_locale" = C ]; then selected_locale=$candidate; fi ;;', + ' esac', + 'done', + `exec env LANG="$selected_locale" LC_ALL="$selected_locale" LANGUAGE=C /bin/bash --noprofile --norc -c ${quote(command)}`, + ].join('\n') + return `env LANG=C LC_ALL=C LANGUAGE=C /bin/sh -c ${quote(script)}` +} diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index 13313cc..b0c9d81 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -377,50 +377,50 @@
- +
- +
- - - - -
- +
- - 실시간 카메라 화면 + + 실시간 카메라 화면
-
i 전진 · , 후진 · j/l 회전 · k 정지
+
i 전진 · , 후진 · j/l 회전 · k 정지
- -
- 응답을 기다리는 중입니다. + 응답을 기다리는 중입니다.
+ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7bd7dcc..5ea3831 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,3 +1,6 @@ +import { t, useLocaleRender } from './i18n' +import { useStudioTheme } from './features/workspace/studioTheme' +import './features/workspace/studioPalette.css' import { type BeforeMount, type OnMount } from '@monaco-editor/react' import { driver } from 'driver.js' import { CarFront, Laptop, Monitor } from 'lucide-react' @@ -46,7 +49,7 @@ import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleT import ModuleListView from './features/workspace/ModuleListView' import WorkspaceRosConnectionsView from './features/workspace/WorkspaceRosConnectionsView' import WorldEditorModal from './features/workspace/WorldEditorModal' -import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal' +import WorldMapSelectorModal, { getMapDisplayName, NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal' import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel' import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView' import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView' @@ -520,6 +523,7 @@ function LocalPcWorkspaceMockup({ onSubmissionRecorded: () => void onVisualizationHostChange?: (host: WorkspaceVisualizationHost) => void }): React.JSX.Element { + useLocaleRender() const visualizationHostInstanceId = useRef(`${platformId}:${Math.random().toString(36).slice(2)}`).current const [powerActionModalOpen, setPowerActionModalOpen] = useState(false) const [cameraOpen, setCameraOpen] = useState(false) @@ -983,7 +987,7 @@ function LocalPcWorkspaceMockup({ : sourceSettingsDraft.workspaceRoot const currentSelectedTreePath = exampleActive ? selectedExampleTreePath : selectedTreePath const surface = dark ? 'bg-[#202020] text-[#e6e6e6]' : 'bg-[#eef1f6] text-[#151827]' - const topbar = dark ? 'border-white/10 bg-[#303030]' : 'border-[#cfd6e4] bg-[#f9fafc]' + const topbar = dark ? 'border-white/10 bg-[#303030]' : 'border-[#bec9d8] bg-[#dce4ee]' const panel = dark ? 'border-white/10 bg-[#252525]' : 'border-[#cfd6e4] bg-[#e9edf4]' const textStrong = dark ? 'text-white' : 'text-[#111520]' const textMid = dark ? 'text-white/70' : 'text-[#4f596b]' @@ -2690,7 +2694,7 @@ function LocalPcWorkspaceMockup({ setNav2VisualizationMode('slam') setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'slam') } - onTitlebarNotice(`[${map.name}] 맵이 선택되었습니다.`, 'info') + onTitlebarNotice(t('[{{name}}] 맵이 선택되었습니다.', { name: getMapDisplayName(map) }), 'info') } const deleteSavedSlamMap = async (map: WorkspaceMapOption): Promise => { if (!map.savedMapId) return @@ -3443,7 +3447,7 @@ function LocalPcWorkspaceMockup({ const cameraAvailable = Boolean(curriculumWeek.detail.camera?.url?.trim()) return ( -
+
{cameraAvailable && cameraOpen && workspaceVisible && setPowerActionModalOpen(false)} onDisconnectSession={() => { @@ -3612,7 +3616,7 @@ function LocalPcWorkspaceMockup({
startResize('left', event)} @@ -3751,7 +3755,7 @@ function LocalPcWorkspaceMockup({ {!logActive && !settingsActive && !gitActive && !rosConnectionsActive && (
setWorldMapSelectorOpen(false)} onCreate={() => { if (remoteWorkspace) { - onTitlebarNotice('맵 편집은 로컬 시뮬레이션 환경에서 사용할 수 있습니다.', 'warning') + onTitlebarNotice(t("맵 편집은 로컬 시뮬레이션 환경에서 사용할 수 있습니다."), 'warning') return } setWorldEditorOpen(true) @@ -4069,7 +4073,7 @@ function LocalPcWorkspaceMockup({ const customMap = sessionWorldMaps.find((map) => map.key === 'nav2-custom') if (customMap) void applyWorldMapSelection(customMap) if (availableModules.some((module) => module.state === 'running')) { - onTitlebarNotice('커스텀 맵은 시뮬레이터를 다시 실행하면 적용됩니다.', 'warning') + onTitlebarNotice(t("커스텀 맵은 시뮬레이터를 다시 실행하면 적용됩니다."), 'warning') } }} onClose={() => setWorldEditorOpen(false)} @@ -4078,7 +4082,7 @@ function LocalPcWorkspaceMockup({
startResize('right', event)} @@ -4122,8 +4126,8 @@ function LocalPcWorkspaceMockup({ {workspaceRestoreLoading && ( )} @@ -4143,6 +4147,7 @@ function LocalPcWorkspaceMockup({ } export default function App(): React.JSX.Element { + useLocaleRender() const appRootRef = useRef(null) const restoredStudioSessionRef = useRef(readStudioRuntimeSessionState()) const previousWeekStateRef = useRef>({}) @@ -4230,7 +4235,7 @@ export default function App(): React.JSX.Element { description: '선택한 플랫폼의 개발 환경과 권한 상태를 확인하고 있습니다.' }) const [materialDownloadingResourceIds, setMaterialDownloadingResourceIds] = useState([]) - const [ideTheme] = useState<'dark' | 'light'>('dark') + const ideTheme = useStudioTheme() const [platformFilter, setPlatformFilter] = useState('all') const [sessionStatus, setSessionStatus] = useState<'idle' | 'running' | 'ended'>(restoredStudioSession?.sessionStatus ?? 'idle') const [studioEntered, setStudioEntered] = useState(Boolean(restoredStudioSession?.studioEntered)) @@ -4275,12 +4280,12 @@ export default function App(): React.JSX.Element { allowClose: true, animate: true, disableActiveInteraction: true, - doneBtnText: '완료', - nextBtnText: '다음', + doneBtnText: t('완료'), + nextBtnText: t('다음'), overlayColor: '#080a10', overlayOpacity: 0.54, popoverClass: localPcLayoutOpen ? 'lms-help-popover lms-help-popover-light' : 'lms-help-popover', - prevBtnText: '이전', + prevBtnText: t('이전'), progressText: '{{current}} / {{total}}', showButtons: ['next', 'previous', 'close'], showProgress: true, @@ -5019,7 +5024,7 @@ export default function App(): React.JSX.Element { : formatSessionClock(connectedPlatform?.control?.session?.endsAt) const sidebarRunningSessions = runningPlatforms.map((platform) => ({ id: platform.id, - title: platform.title, + title: platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title, runningModuleCount: runningModuleCounts[platform.id] ?? 0, startedAt: platform.platformKey === 'futuredrive-local-pc' ? sessionStartedAt @@ -5184,7 +5189,7 @@ export default function App(): React.JSX.Element { const closeConnectedWorkspace = connectedTarget?.id === platformToClose.id if (closeConnectedWorkspace) { setPlatformLoadingCopy({ - title: `${platformToClose.title} 종료 중`, + title: t('{{platform}} 종료 중', { platform: platformToClose.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformToClose.title }), description: '강사가 플랫폼 사용을 종료하여 개발 스튜디오를 닫고 있습니다.' }) setPlatformLoading(true) @@ -5242,7 +5247,7 @@ export default function App(): React.JSX.Element { if (platformLoading) return setPlatformLoadingCopy({ - title: `${platformToClose.title} 종료 중`, + title: t('{{platform}} 종료 중', { platform: platformToClose.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformToClose.title }), description: '현재 개발 스튜디오 세션을 종료하고 플랫폼 작업 상태를 정리하고 있습니다.' }) setPlatformLoading(true) @@ -5283,7 +5288,7 @@ export default function App(): React.JSX.Element { setSessionConfirmAction(null) setPlatformLoadingCopy({ - title: `${platformToDisconnect.title} 연결종료 중`, + title: t('{{platform}} 연결종료 중', { platform: platformToDisconnect.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformToDisconnect.title }), description: '현재 개발 스튜디오 연결을 종료하고 작업 상태를 정리하고 있습니다.' }) setPlatformLoading(true) @@ -5308,7 +5313,7 @@ export default function App(): React.JSX.Element { const platformToRelease = connectedPlatform setSessionConfirmAction(null) setPlatformLoadingCopy({ - title: options?.expired ? `${platformToRelease.title} 제어 시간 종료 중` : `${platformToRelease.title} 제어권 반납 중`, + title: t(options?.expired ? '{{platform}} 제어 시간 종료 중' : '{{platform}} 제어권 반납 중', { platform: platformToRelease.title }), description: options?.expired ? '제어 시간이 종료되어 워크스페이스를 닫고 플랫폼을 초기화하고 있습니다.' : '현재 제어 세션을 종료하고 플랫폼을 초기화하고 있습니다.' @@ -5508,10 +5513,10 @@ export default function App(): React.JSX.Element { void openPlatformSelectModal() } const terminateRunningStudioAndStartSelectedWeek = async (): Promise => { - const runningWeekTitle = runningWorkspaceWeek ? `${runningWorkspaceWeek.week}주차 ${runningWorkspaceWeek.title}` : '실행 중인 스튜디오' + const runningWeekTitle = runningWorkspaceWeek ? t('{{v0}}주차 {{v1}}', { v0: runningWorkspaceWeek.week, v1: runningWorkspaceWeek.title }) : t('실행 중인 스튜디오') setWeekSwitchBlockedOpen(false) setPlatformLoadingCopy({ - title: `${runningWeekTitle} 종료 중`, + title: t('{{platform}} 종료 중', { platform: runningWeekTitle }), description: '현재 개발 스튜디오 연결을 종료하고 선택한 주차를 시작할 준비를 하고 있습니다.' }) setPlatformLoading(true) @@ -5723,7 +5728,9 @@ export default function App(): React.JSX.Element { return } setPlatformLoadingCopy({ - title: `${platform.title} 연결 준비 중`, + title: t('{{platform}} 연결 준비 중', { + platform: platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title + }), description }) setPlatformLoading(true) @@ -5833,11 +5840,11 @@ export default function App(): React.JSX.Element { } } const bootLoadingMessage = !authReady - ? '로그인 세션 확인 중...' + ? t('로그인 세션 확인 중...') : authSession?.accessToken && (!studentCoursesLoaded || studentCoursesLoading) - ? '강좌 정보 불러오는 중...' + ? t('강좌 정보 불러오는 중...') : authSession?.accessToken && curriculumLoading - ? '커리큘럼 및 플랫폼 정보 가져오는 중...' + ? t('커리큘럼 및 플랫폼 정보 가져오는 중...') : '' const bootLoadingVisible = Boolean(bootLoadingMessage) useEffect(() => { @@ -5894,7 +5901,8 @@ export default function App(): React.JSX.Element { />
{studioEntered && workspaceWeekState && workspacePlatform && ( @@ -6070,7 +6078,7 @@ export default function App(): React.JSX.Element { {platformModalOpen && ( setRecentSessionModalOpen(false)} onOpenSession={() => { if (!platformLoading && sessionPlatform) { - void openPlatformWorkspace(sessionPlatform, '선택한 플랫폼의 개발 환경과 권한 상태를 확인하고 있습니다.') + void openPlatformWorkspace(sessionPlatform, t("선택한 플랫폼의 개발 환경과 권한 상태를 확인하고 있습니다.")) } }} /> @@ -6138,9 +6146,9 @@ export default function App(): React.JSX.Element { overlayClassName={appModalOverlayClass} dark={appModalDark} tone="warning" - title="강좌 변경 제한" - message="실행 중인 세션이 있습니다." - detail="현재 실행 중인 개발 스튜디오 세션을 종료한 뒤 다른 강좌를 선택해주세요." + title={t("강좌 변경 제한")} + message={t("실행 중인 세션이 있습니다.")} + detail={t("현재 실행 중인 개발 스튜디오 세션을 종료한 뒤 다른 강좌를 선택해주세요.")} onConfirm={() => setCourseChangeBlockedOpen(false)} /> )} @@ -6149,9 +6157,9 @@ export default function App(): React.JSX.Element { overlayClassName={appModalOverlayClass} dark={appModalDark} tone="success" - title="학습 종료" - message="해당 주차 학습이 종료되었습니다." - detail={`${completedCurriculumNotice.week}주차 · ${completedCurriculumNotice.title}`} + title={t("학습 종료")} + message={t("해당 주차 학습이 종료되었습니다.")} + detail={t("{{v0}}주차 · {{v1}}", { v0: completedCurriculumNotice.week, v1: completedCurriculumNotice.title })} onConfirm={() => setCompletedCurriculumNotice(null)} /> )} @@ -6160,10 +6168,10 @@ export default function App(): React.JSX.Element { overlayClassName={fullAppModalOverlayClass} dark={appModalDark} tone="clock" - title="제어 시간 안내" - message="제어 시간이 곧 종료됩니다." - detail={`남은 시간 ${formatControlRemainingTime(controlWarningNotice.endsAt)}${formatSessionClock(controlWarningNotice.endsAt) ? ` · 종료 ${formatSessionClock(controlWarningNotice.endsAt)}` : ''}`} - actionLabel={canExtendConnectedPlatform ? '연장' : undefined} + title={t("제어 시간 안내")} + message={t("제어 시간이 곧 종료됩니다.")} + detail={t("남은 시간 {{v0}}{{v1}}", { v0: formatControlRemainingTime(controlWarningNotice.endsAt), v1: formatSessionClock(controlWarningNotice.endsAt) ? ` · 종료 ${formatSessionClock(controlWarningNotice.endsAt)}` : '' })} + actionLabel={canExtendConnectedPlatform ? t("연장") : undefined} onAction={canExtendConnectedPlatform ? () => { setControlWarningNotice(null) setControlExtensionConfirmOpen(true) @@ -6190,8 +6198,8 @@ export default function App(): React.JSX.Element { setWeekSwitchBlockedOpen(false)} onConfirm={() => void terminateRunningStudioAndStartSelectedWeek()} /> @@ -6260,9 +6268,9 @@ export default function App(): React.JSX.Element { overlayClassName={fullAppModalOverlayClass} dark={appModalDark} tone="warning" - title="세션 만료" - message="세션이 없거나 유효하지 않습니다." - detail="다시 로그인하려면 앱을 종료한 뒤 런처에서 다시 실행해주세요." + title={t("세션 만료")} + message={t("세션이 없거나 유효하지 않습니다.")} + detail={t("다시 로그인하려면 앱을 종료한 뒤 런처에서 다시 실행해주세요.")} onConfirm={() => { if (window.appLifecycle?.quit) { window.appLifecycle.quit() diff --git a/src/renderer/src/components/AppStatusModals.tsx b/src/renderer/src/components/AppStatusModals.tsx index 43d7e47..1eb47ba 100644 --- a/src/renderer/src/components/AppStatusModals.tsx +++ b/src/renderer/src/components/AppStatusModals.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../i18n' import { AlertCircle, CheckCircle2, Clock3, LogOut, Power, Trash2 } from 'lucide-react' import type React from 'react' @@ -73,30 +74,29 @@ export function StatusNoticeModal({ onAction?: () => void onConfirm: () => void }): React.JSX.Element { + useLocaleRender() return (
{toneIcon(tone)} - {title} + {t(title)}
-

{message}

+

{t(message)}

{children} {detail && (

- {detail} + {t(detail)}

)}
{actionLabel && onAction && ( )} - +
@@ -117,29 +117,28 @@ export function WeekSwitchBlockedModal({ onCancel: () => void onConfirm: () => void }): React.JSX.Element { + useLocaleRender() return (
- 이미 실행 중인 스튜디오가 있습니다 + {t("이미 실행 중인 스튜디오가 있습니다")}
{[ - ['실행 중', runningLabel], - ['새로 시작', nextLabel] + [t("실행 중"), runningLabel], + [t("새로 시작"), nextLabel] ].map(([label, value], index) => (

{label}

{value}

))} -

- 현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다. -

+

{t("현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다.")}

- - + +
@@ -158,15 +157,16 @@ export function RunningPlatformCloseModal({ onCancel: () => void onConfirm: () => void }): React.JSX.Element { + useLocaleRender() return ( } - title="개발 스튜디오 종료" - message={`${platformTitle} 개발 스튜디오를 종료할까요?`} - detail="현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다." - confirmLabel="종료" + title={t("개발 스튜디오 종료")} + message={t("{{v0}} 개발 스튜디오를 종료할까요?", { v0: platformTitle })} + detail={t("현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.")} + confirmLabel={t("종료")} onCancel={onCancel} onConfirm={onConfirm} /> @@ -186,18 +186,19 @@ export function SessionConfirmModal({ onCancel: () => void onConfirm: () => void }): React.JSX.Element { + useLocaleRender() const release = action === 'release' return ( : } - title={release ? '반납' : '연결종료'} - message={release ? '제어권을 반납할까요?' : '연결을 종료할까요?'} + title={release ? t("반납") : t("연결종료")} + message={release ? t("제어권을 반납할까요?") : t("연결을 종료할까요?")} detail={release - ? `${platformName} 제어권을 반납하면 대기 중인 다음 수강생에게 제어권이 넘어갈 수 있습니다.` - : `${platformName} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.`} - confirmLabel={release ? '반납' : '연결종료'} + ? t("{{v0}} 제어권을 반납하면 대기 중인 다음 수강생에게 제어권이 넘어갈 수 있습니다.", { v0: platformName }) + : t("{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.", { v0: platformName })} + confirmLabel={release ? t("반납") : t("연결종료")} onCancel={onCancel} onConfirm={onConfirm} /> @@ -217,22 +218,23 @@ export function ModuleShutdownConfirmModal({ onKeepRunning: () => void onStopModules: () => void }): React.JSX.Element { + useLocaleRender() const release = action === 'release' return ( } - title="실행 중인 모듈" - message={`${moduleNames.length}개의 모듈이 실행 중입니다. 모듈을 종료할까요?`} + title={t("실행 중인 모듈")} + message={t("{{v0}}개의 모듈이 실행 중입니다. 모듈을 종료할까요?", { v0: moduleNames.length })} detail={moduleNames.join(', ')} - cancelLabel="아니오" - confirmLabel="예" + cancelLabel={t("아니오")} + confirmLabel={t("예")} onCancel={onKeepRunning} onConfirm={onStopModules} >

- {release ? '아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.' : '아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.'} + {release ? t("아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.") : t("아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.")}

) @@ -251,22 +253,21 @@ export function ControlExtensionConfirmModal({ onCancel: () => void onConfirm: () => void }): React.JSX.Element { + useLocaleRender() return (
- 제어 시간 연장 + {t("제어 시간 연장")}
-

{platformName} 제어 시간을 연장할까요?

-

- 대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다. -

+

{platformName}{t("제어 시간을 연장할까요?")}

+

{t("대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.")}

- +
@@ -298,22 +299,23 @@ function ConfirmStatusModal({ onConfirm: () => void children?: React.ReactNode }): React.JSX.Element { + useLocaleRender() return (
{icon} - {title} + {t(title)}
-

{message}

+

{t(message)}

- {detail} + {t(detail)}

{children}
- - + +
diff --git a/src/renderer/src/components/ChatWidgets.tsx b/src/renderer/src/components/ChatWidgets.tsx index fce5a16..2d3e6a0 100644 --- a/src/renderer/src/components/ChatWidgets.tsx +++ b/src/renderer/src/components/ChatWidgets.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../i18n' import { ChevronDown, Send, Square } from 'lucide-react' import type React from 'react' import { memo, useEffect, useState } from 'react' @@ -15,6 +16,7 @@ export function ChatAvatar({ fallback: React.JSX.Element className: string }): React.JSX.Element { + useLocaleRender() const [avatarSource, setAvatarSource] = useState(null) useEffect(() => { @@ -57,6 +59,7 @@ export function ChatAvatar({ } export function MarkdownMessage({ content, dark }: { content: string; dark: boolean }): React.JSX.Element { + useLocaleRender() const textColor = dark ? 'text-white/76' : 'text-[#4f596b]' const headingColor = dark ? 'text-white/92' : 'text-[#111827]' const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]' @@ -130,6 +133,7 @@ export function ExpandableMarkdownMessage({ collapseSignal?: number defaultCollapsed?: boolean }): React.JSX.Element { + useLocaleRender() const [expanded, setExpanded] = useState(!defaultCollapsed) const lineCount = content.split('\n').length const collapsible = content.length > 700 || lineCount > 12 @@ -161,8 +165,8 @@ export function ExpandableMarkdownMessage({
+

{t("로컬 Docker 설정")}

+
-

{messages[result.status]}

-

Docker 없이도 LMS와 원격 플랫폼을 사용할 수 있습니다. 로컬 모듈 빌드와 실행에는 Docker가 필요합니다.

+

{t(messages[result.status])}

+

{t("Docker 없이도 LMS와 원격 플랫폼을 사용할 수 있습니다. 로컬 모듈 빌드와 실행에는 Docker가 필요합니다.")}

{result.status === 'missing' ? ( -

공식 설치 안내에서 운영체제를 선택해 Docker를 설치한 뒤 다시 확인해주세요. Ubuntu에서는 Docker Engine의 apt 저장소 설치 방법을 사용할 수 있습니다.

+

{t("공식 설치 안내에서 운영체제를 선택해 Docker를 설치한 뒤 다시 확인해주세요. Ubuntu에서는 Docker Engine의 apt 저장소 설치 방법을 사용할 수 있습니다.")}

) : <> - {linux && result.status === 'permission' &&

일반 Docker Engine 사용 시 아래 명령으로 권한을 추가한 뒤 OS에서 로그아웃하고 다시 로그인해주세요. Docker 그룹은 관리자 수준의 권한을 부여합니다. Rootless Docker나 별도 Docker 컨텍스트를 사용한다면 연결 설정부터 확인해주세요.

} - {linux && result.status === 'unavailable' &&

일반 Docker Engine은 아래 명령으로 서비스를 시작할 수 있습니다. Docker Desktop이나 Rootless Docker 사용 시 해당 서비스를 실행하고 Docker 컨텍스트를 확인해주세요.

} + {linux && result.status === 'permission' &&

{t("일반 Docker Engine 사용 시 아래 명령으로 권한을 추가한 뒤 OS에서 로그아웃하고 다시 로그인해주세요. Docker 그룹은 관리자 수준의 권한을 부여합니다. Rootless Docker나 별도 Docker 컨텍스트를 사용한다면 연결 설정부터 확인해주세요.")}

} + {linux && result.status === 'unavailable' &&

{t("일반 Docker Engine은 아래 명령으로 서비스를 시작할 수 있습니다. Docker Desktop이나 Rootless Docker 사용 시 해당 서비스를 실행하고 Docker 컨텍스트를 확인해주세요.")}

}
{command}
-
} - {actionError &&

{actionError}

} + onClick={() => { void window.localWorkspace.docker.openGuide().catch(() => setActionError(t("설치 안내: https://docs.docker.com/engine/install/"))) }}> + {t("Docker 공식 설치 안내")} + {actionError &&

{t(actionError || '')}

}
- +
, document.body, diff --git a/src/renderer/src/components/PlatformLoadingOverlay.tsx b/src/renderer/src/components/PlatformLoadingOverlay.tsx index 51ff22b..bf825fb 100644 --- a/src/renderer/src/components/PlatformLoadingOverlay.tsx +++ b/src/renderer/src/components/PlatformLoadingOverlay.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../i18n' import type React from 'react' type PlatformLoadingOverlayProps = { @@ -11,14 +12,15 @@ export default function PlatformLoadingOverlay({ description, className = 'absolute inset-0 z-[60]' }: PlatformLoadingOverlayProps): React.JSX.Element { + useLocaleRender() return (
- {title} -

{description}

+ {t(title)} +

{t(description)}

) diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 129521b..203ea53 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../i18n' import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power } from 'lucide-react' import { useEffect, useRef, useState } from 'react' @@ -36,6 +37,7 @@ export default function Sidebar({ onDisconnectSession?: (sessionId?: string) => void onReleaseControl?: () => void }): React.JSX.Element { + useLocaleRender() const [sessionPopoverOpen, setSessionPopoverOpen] = useState(false) const sessionPopoverRef = useRef(null) const visibleRunningSessions = sessionStatus === 'running' && runningSessions.length ? runningSessions : [] @@ -110,7 +112,7 @@ export default function Sidebar({ {collapsed && ( - {disabled ? '강좌 선택 후 이동 가능' : item.label} + {disabled ? t("강좌 선택 후 이동 가능") : t(item.label)} )} - {item.label} + {t(item.label)} ) @@ -148,13 +150,13 @@ export default function Sidebar({ {(session.startedAt || session.endsAt) && ( {[ - session.startedAt ? `시작 ${session.startedAt}` : null, - session.endsAt ? `종료 ${session.endsAt}` : null + session.startedAt ? t("시작 {{v0}}", { v0: session.startedAt }) : null, + session.endsAt ? t("종료 {{v0}}", { v0: session.endsAt }) : null ].filter(Boolean).join(' · ')} )} {session.runningModuleCount !== undefined && ( - 실행 중인 모듈 {session.runningModuleCount}개 + {t('실행 중인 모듈 {{count}}개', { count: session.runningModuleCount })} )} @@ -166,8 +168,8 @@ export default function Sidebar({ event.stopPropagation() onDisconnectSession?.(session.id) }} - aria-label="세션 연결종료" - title="연결종료" + aria-label={t("세션 연결종료")} + title={t("연결종료")} > @@ -207,13 +209,13 @@ export default function Sidebar({ {(session.startedAt || session.endsAt) && ( {[ - session.startedAt ? `시작 ${session.startedAt}` : null, - session.endsAt ? `종료 ${session.endsAt}` : null + session.startedAt ? t("시작 {{v0}}", { v0: session.startedAt }) : null, + session.endsAt ? t("종료 {{v0}}", { v0: session.endsAt }) : null ].filter(Boolean).join(' · ')} )} {session.runningModuleCount !== undefined && ( - 실행 중인 모듈 {session.runningModuleCount}개 + {t('실행 중인 모듈 {{count}}개', { count: session.runningModuleCount })} )} @@ -226,8 +228,8 @@ export default function Sidebar({ setSessionPopoverOpen(false) onDisconnectSession?.(session.id) }} - aria-label="세션 연결종료" - title="연결종료" + aria-label={t("세션 연결종료")} + title={t("연결종료")} > diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx index 7e584b5..dfabe1b 100644 --- a/src/renderer/src/components/TitleBar.tsx +++ b/src/renderer/src/components/TitleBar.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../i18n' import icon from '/assets/icon.png' import { Bell, BookOpen, Bug, Building2, CheckCheck, ChevronLeft, ChevronRight, Clipboard, HelpCircle, LogOut, Minus, RotateCw, ScrollText, Search, Square, Wrench, X } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -127,6 +128,7 @@ const normalizeNotification = (notification: NotificationMessage): NotificationM }) export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCourseSelection, transientNotice = null }: TitleBarProps): React.JSX.Element { + useLocaleRender() const [notificationOpen, setNotificationOpen] = useState(false) const [accountOpen, setAccountOpen] = useState(false) const [toolsOpen, setToolsOpen] = useState(false) @@ -555,19 +557,19 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo ].join(' ')} > - {activeTransientNotice?.title} + {t(activeTransientNotice?.title ?? '')}
- +
- + + {t("새로고침")} + {t("앱 로그")} + {t("개발자 도구")}
) : null}
- + + {t("모두 읽음")} + >{t("전체 보기")}
@@ -680,7 +672,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo notification.read ? 'bg-white/[0.025] text-white/24' : 'bg-[#2563eb]/15 text-[#7db3ff]' ].join(' ')} > - {notification.categoryLabel ?? notification.category} + {t(notification.categoryLabel ?? notification.category)}
@@ -688,16 +680,16 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo

- {notification.title} + {notification.id.startsWith('local-') ? t(notification.title) : notification.title}

- {notification.body} + {notification.id.startsWith('local-') ? t(notification.body) : notification.body}

)) ) : (
-

표시할 알림이 없습니다.

+

{t("표시할 알림이 없습니다.")}

)}
@@ -763,18 +755,14 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo onClick={handleOpenCourseSelection} className="flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-[12px] text-white/62 transition-colors hover:bg-white/[0.06] hover:text-white" > - - 강좌 선택 - + {t("강좌 선택")} ) : null} + {t("종료")}
) : null} @@ -849,14 +837,14 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
-

전체 알림

-

알림은 최대 하루동안만 저장됩니다.

+

{t("전체 알림")}

+

{t("알림은 최대 하루동안만 저장됩니다.")}

@@ -869,7 +857,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo setNotificationQuery(event.target.value)} - placeholder="알림 검색" + placeholder={t("알림 검색")} className="h-9 w-full rounded-md border border-white/10 bg-white/[0.045] pl-9 pr-3 text-[13px] font-medium text-white outline-none placeholder:text-white/28 transition-colors focus:border-[#3b7ffa]/55 focus:bg-white/[0.065]" />
@@ -879,9 +867,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo disabled={unreadCount === 0} className="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-white/10 px-3 text-[12px] font-semibold text-white/68 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/68" > - - 모두 읽음 - + {t("모두 읽음")}
@@ -896,11 +882,11 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo notificationTab === tab ? 'bg-[#3b7ffa]/22 text-[#b9d4ff]' : 'text-white/45 hover:bg-white/[0.05] hover:text-white/70' ].join(' ')} > - {tab === 'unread' ? `안 읽음 ${sortedNotifications.filter((notification) => !notification.read).length}` : `읽음 ${sortedNotifications.filter((notification) => notification.read).length}`} + {tab === 'unread' ? t("안 읽음 {{v0}}", { v0: sortedNotifications.filter((notification) => !notification.read).length }) : t("읽음 {{v0}}", { v0: sortedNotifications.filter((notification) => notification.read).length })} ))}
- {filteredModalNotifications.length}건 + {filteredModalNotifications.length}{t("건")} @@ -926,7 +912,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo notification.read ? 'bg-white/[0.025] text-white/24' : 'bg-[#2563eb]/15 text-[#7db3ff]' ].join(' ')} > - {notification.categoryLabel ?? notification.category} + {t(notification.categoryLabel ?? notification.category)} @@ -934,17 +920,17 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo

- {notification.title} + {notification.id.startsWith('local-') ? t(notification.title) : notification.title}

- {notification.body} + {notification.id.startsWith('local-') ? t(notification.body) : notification.body}

))} ) : (
-

표시할 알림이 없습니다.

+

{t("표시할 알림이 없습니다.")}

)} @@ -959,7 +945,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo onClick={() => setNotificationPage((page) => Math.max(1, page - 1))} disabled={notificationPage <= 1} className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-white/10 text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/55" - aria-label="이전 페이지" + aria-label={t("이전 페이지")} > @@ -968,7 +954,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo onClick={() => setNotificationPage((page) => Math.min(modalTotalPages, page + 1))} disabled={notificationPage >= modalTotalPages} className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-white/10 text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/55" - aria-label="다음 페이지" + aria-label={t("다음 페이지")} > @@ -986,29 +972,27 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
-

앱 로그

-

{appLogPath || '로그 파일을 불러오는 중입니다.'}

+

{t("앱 로그")}

+

{appLogPath || t("로그 파일을 불러오는 중입니다.")}

{appLogsLoading ? ( -
로그를 불러오는 중입니다.
+
{t("로그를 불러오는 중입니다.")}
) : appLogContent ? (
                   {appLogDisplayContent}
                 
) : ( -
- 아직 기록된 앱 로그가 없습니다. -
+
{t("아직 기록된 앱 로그가 없습니다.")}
)}
@@ -1019,7 +1003,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-semibold text-white/65 transition hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65" > - {appLogsCopied ? '복사됨' : '복사'} + {appLogsCopied ? t("복사됨") : t("복사")} + {t("새로고침")}
@@ -1052,23 +1034,19 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
-

앱을 종료하시겠습니까?

+

{t("앱을 종료하시겠습니까?")}

+ >{t("취소")} + >{t("종료")}
diff --git a/src/renderer/src/features/AiGuideView.css b/src/renderer/src/features/AiGuideView.css new file mode 100644 index 0000000..7c93331 --- /dev/null +++ b/src/renderer/src/features/AiGuideView.css @@ -0,0 +1,131 @@ +[data-ai-guide-theme='light'] { + background: linear-gradient(110deg, #6548ff, #4776ff); + color: #202d43; +} + +[data-ai-guide-theme='light'] > header { + background: linear-gradient(115deg, #8370ff, #607dff 55%, #57a6ff); + border-color: #8ea7ff; +} + +[data-ai-guide-theme='light'] aside { + background: #f5f8ff; +} + +[data-ai-guide-theme='light'] main { + background: #f5f8ff; +} + +/* Keep these overrides inside the guide; shared chat widgets retain their own themes. */ +[data-ai-guide-theme='light'] :is(aside, main, section) [class*='text-white/'] { + color: #647497; +} + +[data-ai-guide-theme='light'] :is(aside, main) :is(strong, h2)[class*='text-white/'] { + color: #203654; +} + +[data-ai-guide-theme='light'] [class*='border-white/'] { + border-color: #b8c7e2; +} + +[data-ai-guide-theme='light'] [class*='text-violet-'], +[data-ai-guide-theme='light'] [class*='text-cyan-'] { + color: #2563eb; +} + +[data-ai-guide-theme='light'] [class*='bg-cyan-400/'], +[data-ai-guide-theme='light'] [class*='bg-violet-400/'] { + background: #edf1ff; + border-color: #bdcbed; +} + +[data-ai-guide-theme='light'] aside [class*='bg-cyan-400/10'] { + background: linear-gradient(110deg, #6548ff, #4776ff); + color: #fff; +} + +[data-ai-guide-theme='light'] aside [class*='bg-cyan-400/10'] [class*='text-white/'] { + color: #dce8fc; +} + +[data-ai-guide-theme='light'] :is(aside, section) button[class*='hover:bg-white/']:hover { + background: #d6e2f5; + color: #153b7c; +} + +[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] { + background: #fff; +} + +[data-ai-guide-theme='light'] [class~='bg-[#5b4aa8]'] { + background: linear-gradient(110deg, #6548ff, #4776ff); + box-shadow: 0 4px 12px rgb(37 99 235 / 12%); +} + +[data-ai-guide-theme='light'] [class~='bg-[#10131b]/78'] { + background: rgb(248 250 255 / 82%); +} + +[data-ai-guide-theme='light'] [class~='bg-[#252238]'], +[data-ai-guide-theme='light'] section[class~='bg-[#1b2a42]'] { + background: #fff; + color: #203654; +} + +[data-ai-guide-theme='light'] section[class~='bg-[#1b2a42]'] p.text-white { + color: #203654; +} + +[data-ai-guide-theme='light'] [class~='bg-[#0e7490]'] { + background: linear-gradient(110deg, #6548ff, #4776ff); +} + +[data-ai-guide-theme='light'] [class~='bg-[#0e7490]']:enabled:hover { + background: #1d4ed8; +} + +[data-ai-guide-theme='light'] > header strong { + font-size: 15px; + color: #fff; +} + +[data-ai-guide-theme='light'] > header button { + color: #fff; +} + +[data-ai-guide-theme='light'] [data-help='ai-guide-context'] > div { + padding: 18px 14px; +} + +[data-ai-guide-theme='light'] [data-help='ai-guide-context'] > div > div:first-child { + padding: 16px; +} + +[data-ai-guide-theme='light'] [data-help='ai-guide-context'] > div > button { + min-height: 44px; + margin-top: 18px; + border: 1px solid #b8c8e8; + color: #4056bb; +} + +[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] { + border-color: #c2cfe6; + padding: 22px 24px; + box-shadow: 0 5px 18px rgb(49 81 170 / 5%); +} + +[data-ai-guide-theme='light'] [class~='bg-[#5b4aa8]'] { + padding: 13px 18px; +} + +[data-ai-guide-theme='light'] [data-help='ai-guide-questions'] > div:first-child { + margin: 0 14px; + padding: 0; + height: 46px; +} + +[data-ai-guide-theme='light'] main .pointer-events-auto > div { + border-color: #b8c8e8; + box-shadow: 0 4px 16px rgb(49 81 170 / 6%); +} diff --git a/src/renderer/src/features/AiGuideView.tsx b/src/renderer/src/features/AiGuideView.tsx index 43d41bc..54bc0c9 100644 --- a/src/renderer/src/features/AiGuideView.tsx +++ b/src/renderer/src/features/AiGuideView.tsx @@ -1,8 +1,11 @@ +import { t, useLocaleRender, dateLocale } from '../i18n' import { BookOpen, ChevronRight, Code2, LoaderCircle, MessageSquareText, Plus, Sparkles, Trash2, X } from 'lucide-react' import type React from 'react' import { useEffect, useRef, useState } from 'react' import { ExpandableMarkdownMessage, QaInputBox } from '../components/ChatWidgets' import type { QaChatMessage, QaChatThread } from './workspace/useQaChat' +import { useStudioTheme } from './workspace/studioTheme' +import './AiGuideView.css' type Props = { courseTitle: string | null; weekNumber: number | null; weekTitle: string | null @@ -21,16 +24,18 @@ const quickQuestions = [ const formatDate = (value?: string): string => { if (!value) return '' const date = new Date(value) - return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('ko-KR', { month: 'numeric', day: 'numeric' }).format(date) + return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat(dateLocale(), { month: 'numeric', day: 'numeric' }).format(date) } const formatTime = (value?: string): string => { if (!value) return '' const date = new Date(value) - return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' }).format(date) + return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat(dateLocale(), { hour: '2-digit', minute: '2-digit' }).format(date) } export default function AiGuideView(props: Props): React.JSX.Element { + useLocaleRender() + const dark = useStudioTheme() === 'dark' const { courseTitle, weekNumber, weekTitle, messages, activeConversation, typingMessageId, history, selectedHistoryId, viewingHistory, loading, busy, disabled, onSubmit, onQuickSubmit, onStop, onClear, onSelectHistory, onDeleteHistory, onClose @@ -74,26 +79,26 @@ export default function AiGuideView(props: Props): React.JSX.Element { } return ( -
+
- AI 가이드 - + {t("AI 가이드")} +
@@ -102,18 +107,18 @@ export default function AiGuideView(props: Props): React.JSX.Element { const target = event.currentTarget followConversationRef.current = target.scrollHeight - target.scrollTop - target.clientHeight < 96 }} className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-32 pt-7"> - {loading ?
이전 채팅을 불러오는 중
: disabled ?
AI 가이드를 준비하고 있습니다
:
- {messages.length === 0 &&

무엇이 궁금한가요?

개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.

} - {messages.map((message) =>
{ questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}>
{message.role === 'user' ?

{message.content}

: <><>{(message.isTyping || message.id === typingMessageId) && 생각 중}}
{message.status === 'CANCELED' &&

응답이 중단됨

}
)} - {busy &&
생각 중
} + {loading ?
{t("이전 채팅을 불러오는 중")}
: disabled ?
{t("AI 가이드를 준비하고 있습니다")}
:
+ {messages.length === 0 &&

{t("무엇이 궁금한가요?")}

{t("개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.")}

} + {messages.map((message) =>
{ questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}>
{message.role === 'user' ?

{message.content}

: <><>{(message.isTyping || message.id === typingMessageId) && {t("생각 중")}}}
{message.status === 'CANCELED' &&

{t("응답이 중단됨")}

}
)} + {busy &&
{t("생각 중")}
}
}
-
- {switchingConversation &&
채팅 불러오는 중
} +
+ {switchingConversation &&
{t("채팅 불러오는 중")}
} - +
- {deleteConfirmThread &&
채팅 삭제

이 채팅 내역을 삭제할까요?

{deleteConfirmThread.title || '이전 채팅'}

삭제한 채팅과 답변은 복구할 수 없습니다.

} + {deleteConfirmThread &&
{t("채팅 삭제")}

{t("이 채팅 내역을 삭제할까요?")}

{deleteConfirmThread.title || t("이전 채팅")}

{t("삭제한 채팅과 답변은 복구할 수 없습니다.")}

}
) } diff --git a/src/renderer/src/features/curriculum/CourseSelectionScreen.tsx b/src/renderer/src/features/curriculum/CourseSelectionScreen.tsx index b272669..fe5e03f 100644 --- a/src/renderer/src/features/curriculum/CourseSelectionScreen.tsx +++ b/src/renderer/src/features/curriculum/CourseSelectionScreen.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { CheckCircle2, LoaderCircle, Search, Sparkles, UserPlus } from 'lucide-react' import { useEffect, useState } from 'react' import type React from 'react' @@ -41,6 +42,7 @@ export default function CourseSelectionScreen({ onEnrollCourse, onBack, }: CourseSelectionScreenProps): React.JSX.Element { + useLocaleRender() const [query, setQuery] = useState('') const [searchResults, setSearchResults] = useState([]) const [searching, setSearching] = useState(false) @@ -105,17 +107,15 @@ export default function CourseSelectionScreen({
-

강좌 선택

-

수강 중인 강좌를 선택하거나 새 강좌를 검색해 수강 신청하세요.

+

{t("강좌 선택")}

+

{t("수강 중인 강좌를 선택하거나 새 강좌를 검색해 수강 신청하세요.")}

{hasSelectedCourse ? ( + >{t("뒤로가기")} ) : null}
@@ -125,7 +125,7 @@ export default function CourseSelectionScreen({ setQuery(event.target.value)} - placeholder="강좌명 또는 강사명 검색" + placeholder={t("강좌명 또는 강사명 검색")} className="h-12 w-full rounded-lg border border-[#cfd8e6] bg-[#fbfcff] pl-11 pr-4 text-[14px] font-semibold text-[#172033] outline-none transition focus:border-[#6f4df6] focus:bg-white focus:ring-2 focus:ring-[#6f4df6]/15" /> @@ -134,8 +134,7 @@ export default function CourseSelectionScreen({
{searching ? (
- 검색 중입니다. -
+ {t("검색 중입니다.")}
) : searchResults.length ? (
{searchResults.map((course) => { @@ -161,7 +160,7 @@ export default function CourseSelectionScreen({

{teacherLabel(course)}

{course.enrolled ? ( - 수강 중 + {t("수강 중")} ) : selected ? : null}
@@ -169,9 +168,9 @@ export default function CourseSelectionScreen({ })} ) : ( -
검색 결과가 없습니다.
+
{t("검색 결과가 없습니다.")}
)} - {searchError ?

{searchError}

: null} + {searchError ?

{t(searchError || '')}

: null} {selectedCatalogCourse ? (
+ {enrolling ? : }{t("수강 신청")}
) : null} @@ -190,7 +187,7 @@ export default function CourseSelectionScreen({
-

수강 중인 강좌

+

{t("수강 중인 강좌")}

{courses.length ? courses.map((course) => { const selected = course.id === selectedCourseId @@ -219,14 +216,14 @@ export default function CourseSelectionScreen({ ) }) : ( -
수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.
+
{t("수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.")}
)}
{trialCourse ? (
-

둘러보기

+

{t("둘러보기")}

- - 체험 - + {t("체험")} diff --git a/src/renderer/src/features/curriculum/CurriculumDetailPanel.tsx b/src/renderer/src/features/curriculum/CurriculumDetailPanel.tsx index 34a0eaa..ab2da40 100644 --- a/src/renderer/src/features/curriculum/CurriculumDetailPanel.tsx +++ b/src/renderer/src/features/curriculum/CurriculumDetailPanel.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { CheckCircle2, FileText } from 'lucide-react' import type React from 'react' import { useState } from 'react' @@ -33,24 +34,25 @@ export default function CurriculumDetailPanel({ allowLearningStepSelection = false, onSelectLearningStep }: CurriculumDetailPanelProps): React.JSX.Element { + useLocaleRender() const [failedImageUrl, setFailedImageUrl] = useState(null) const imageUrl = week.detail.coverImage?.downloadUrl const hasCover = Boolean(imageUrl && failedImageUrl !== imageUrl) return (
-

상세정보

+

{t("상세정보")}

{selected ? ( <>
-

{week.week}주차 개요

+

{t('{{week}}주차 개요', { week: week.week })}

{week.title}

{hasCover && (
- {`${week.week}주차 setFailedImageUrl(imageUrl ?? null)} />

{week.detail.topicDetails[index]} @@ -100,9 +102,7 @@ export default function CurriculumDetailPanel({ {Boolean(week.detail.practiceContents?.length) && (

- - 실습 내용 -

+ {t("실습 내용")}
{week.detail.practiceContents?.map((item, index) => (
@@ -119,17 +119,15 @@ export default function CurriculumDetailPanel({

- - 제출 기준 -

+ {t("제출 기준")}

{week.detail.deliverable}

) : (
- 표시할 상세정보가 없습니다. -

강의에 등록된 주차별 커리큘럼을 불러오지 못했거나 아직 등록된 데이터가 없습니다.

+ {t("표시할 상세정보가 없습니다.")} +

{t("강의에 등록된 주차별 커리큘럼을 불러오지 못했거나 아직 등록된 데이터가 없습니다.")}

)} diff --git a/src/renderer/src/features/curriculum/CurriculumHomeScreen.tsx b/src/renderer/src/features/curriculum/CurriculumHomeScreen.tsx index 557b497..f69b401 100644 --- a/src/renderer/src/features/curriculum/CurriculumHomeScreen.tsx +++ b/src/renderer/src/features/curriculum/CurriculumHomeScreen.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import type React from 'react' import CurriculumDetailPanel from './CurriculumDetailPanel' import LearningInfoCard from './LearningInfoCard' @@ -56,6 +57,7 @@ export default function CurriculumHomeScreen({ onStartStudio, onOpenMaterials }: CurriculumHomeScreenProps): React.JSX.Element { + useLocaleRender() return (
@@ -63,14 +65,14 @@ export default function CurriculumHomeScreen({
Autonomy Studio -

커리큘럼

-

단계별 자율주행 알고리즘 학습 과정

+

{t("커리큘럼")}

+

{t("단계별 자율주행 알고리즘 학습 과정")}

-

주차별 커리큘럼

+

{t("주차별 커리큘럼")}

{curriculumSummary}

@@ -85,9 +87,7 @@ export default function CurriculumHomeScreen({ /> )) ) : ( -
- 등록된 커리큘럼이 없습니다. -
+
{t("등록된 커리큘럼이 없습니다.")}
)}
diff --git a/src/renderer/src/features/curriculum/InstructorStudioNoticeModal.tsx b/src/renderer/src/features/curriculum/InstructorStudioNoticeModal.tsx index 654f7ca..eda13d6 100644 --- a/src/renderer/src/features/curriculum/InstructorStudioNoticeModal.tsx +++ b/src/renderer/src/features/curriculum/InstructorStudioNoticeModal.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { X } from 'lucide-react' import type React from 'react' @@ -10,6 +11,7 @@ export default function InstructorStudioNoticeModal({ sidebarCollapsed, onClose }: InstructorStudioNoticeModalProps): React.JSX.Element { + useLocaleRender() return (
- 개발 스튜디오 -

강사 계정은 이용할 수 없습니다

+ {t("개발 스튜디오")} +

{t("강사 계정은 이용할 수 없습니다")}

-

- 개발 스튜디오는 수강생 실습용 화면입니다. 강사 계정은 강사포털에서 수강생 관리, 채팅, 학습 진행 현황을 확인해 주세요. -

+

{t("개발 스튜디오는 수강생 실습용 화면입니다. 강사 계정은 강사포털에서 수강생 관리, 채팅, 학습 진행 현황을 확인해 주세요.")}

+ >{t("확인")}
diff --git a/src/renderer/src/features/curriculum/LearningInfoCard.tsx b/src/renderer/src/features/curriculum/LearningInfoCard.tsx index 4c78005..c6b7b20 100644 --- a/src/renderer/src/features/curriculum/LearningInfoCard.tsx +++ b/src/renderer/src/features/curriculum/LearningInfoCard.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { BookOpen, Car, Clock3, FileText, Play } from 'lucide-react' import type React from 'react' @@ -24,15 +25,14 @@ export default function LearningInfoCard({ onStartStudio, onOpenMaterials }: LearningInfoCardProps): React.JSX.Element { + useLocaleRender() return (
- - 학습 정보 - + {t("학습 정보")}
- {week.week}주차 + {t('{{week}}주차', { week: week.week })} {week.title}
- {week.tags[1] ?? '미등록'} + {t(week.tags[1] ?? '미등록')}
- 진행률 + {t("진행률")}
{week.progress}%
@@ -57,8 +57,8 @@ export default function LearningInfoCard({
{[ - { label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car }, - { label: '예상 학습 시간', value: week.duration, Icon: Clock3 } + { label: t("플랫폼"), value: week.tags[0] ?? '-', Icon: Car }, + { label: t("예상 학습 시간"), value: week.duration, Icon: Clock3 } ].map(({ label, value, Icon }) => (
- - 개발 스튜디오 시작 - + {t("개발 스튜디오 시작")} + {t("강의 자료")}
) } diff --git a/src/renderer/src/features/curriculum/LearningStatsCard.tsx b/src/renderer/src/features/curriculum/LearningStatsCard.tsx index 168fa3a..928a523 100644 --- a/src/renderer/src/features/curriculum/LearningStatsCard.tsx +++ b/src/renderer/src/features/curriculum/LearningStatsCard.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { CheckCircle2, Clock3, Gauge, TrendingUp } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import type React from 'react' @@ -22,22 +23,21 @@ type LearningStatsCardProps = { } export default function LearningStatsCard({ guideMode = false, stats }: LearningStatsCardProps): React.JSX.Element { + useLocaleRender() return (
- {guideMode ? '체험 강좌' : '내 학습 현황'} + {guideMode ? t("체험 강좌") : t("내 학습 현황")} - {guideMode ? '배정 전 사용법 안내 과정' : '전체 진행 상황 및 통계'} + {guideMode ? t("배정 전 사용법 안내 과정") : t("전체 진행 상황 및 통계")}
{guideMode ? ( -
- 이 강좌는 Autonomy Studio LMS 사용법을 확인하기 위한 공통 안내입니다. 실제 학습 시간, 커밋 수, 제출 현황은 강사가 배정한 강좌에서부터 집계됩니다. -
+
{t("이 강좌는 Autonomy Studio LMS 사용법을 확인하기 위한 공통 안내입니다. 실제 학습 시간, 커밋 수, 제출 현황은 강사가 배정한 강좌에서부터 집계됩니다.")}
) : stats.length ? ( stats.map(({ value, label, iconKey }, index) => { const Icon = STAT_ICONS[iconKey] ?? Gauge @@ -53,16 +53,14 @@ export default function LearningStatsCard({ guideMode = false, stats }: Learning - {label} + {t(label)}
{value}
) }) ) : ( -
- 학습 현황 데이터가 없습니다. -
+
{t("학습 현황 데이터가 없습니다.")}
)}
diff --git a/src/renderer/src/features/curriculum/MaterialsModal.tsx b/src/renderer/src/features/curriculum/MaterialsModal.tsx index b4542b3..76d840f 100644 --- a/src/renderer/src/features/curriculum/MaterialsModal.tsx +++ b/src/renderer/src/features/curriculum/MaterialsModal.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { Download, FileText, X } from 'lucide-react' import type React from 'react' @@ -28,6 +29,7 @@ export default function MaterialsModal({ onClose, onDownload }: MaterialsModalProps): React.JSX.Element { + useLocaleRender() return (
({
- {week.week}주차 -

강의 자료

+ {t('{{week}}주차', { week: week.week })} +

{t("강의 자료")}

{week.title}

) diff --git a/src/renderer/src/features/curriculum/WeekCard.tsx b/src/renderer/src/features/curriculum/WeekCard.tsx index 3835be2..0edb219 100644 --- a/src/renderer/src/features/curriculum/WeekCard.tsx +++ b/src/renderer/src/features/curriculum/WeekCard.tsx @@ -22,6 +22,7 @@ export default function WeekCard({ selected = false, onSelect }: WeekCardProps): React.JSX.Element { + useLocaleRender() const locked = week.state === 'locked' const isDone = week.state === 'done' const isCurrent = week.state === 'current' @@ -87,7 +88,7 @@ export default function WeekCard({ : 'bg-[#e7edf5] text-[#506178]' ].join(' ')} > - {tag} + {index === 1 ? t(tag) : tag} ))}
@@ -104,3 +105,4 @@ export default function WeekCard({ ) } +import { t, useLocaleRender } from '../../i18n' diff --git a/src/renderer/src/features/help/contextHelp.ts b/src/renderer/src/features/help/contextHelp.ts index 3b5f0bd..948615c 100644 --- a/src/renderer/src/features/help/contextHelp.ts +++ b/src/renderer/src/features/help/contextHelp.ts @@ -1,6 +1,18 @@ import type { DriveStep } from 'driver.js' +import { t } from '../../i18n' -export function getContextHelpSteps({ +export function getContextHelpSteps(options: Parameters[0]): DriveStep[] { + return buildContextHelpSteps(options).map(step => ({ + ...step, + popover: step.popover ? { + ...step.popover, + title: step.popover.title ? t(step.popover.title) : undefined, + description: step.popover.description ? t(step.popover.description) : undefined + } : undefined + })) +} + +function buildContextHelpSteps({ platformModalOpen, localPcLayoutOpen, aiGuideOpen diff --git a/src/renderer/src/features/platforms/PlatformCards.tsx b/src/renderer/src/features/platforms/PlatformCards.tsx index 09c9d78..c0459f3 100644 --- a/src/renderer/src/features/platforms/PlatformCards.tsx +++ b/src/renderer/src/features/platforms/PlatformCards.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { AlertCircle, CheckCircle2, Link2, LoaderCircle, Lock, Power, Settings } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import type React from 'react' @@ -22,6 +23,7 @@ export function RunningPlatformCard({ onOpen, onClose }: RunningPlatformCardProps): React.JSX.Element { + useLocaleRender() return (
@@ -29,7 +31,7 @@ export function RunningPlatformCard({
-

{platform.title}

+

{platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title}

{weekTitle}

@@ -38,18 +40,14 @@ export function RunningPlatformCard({ onClick={onOpen} className="flex h-9 min-w-[86px] cursor-pointer items-center justify-center gap-1.5 rounded-md border border-[#714ef6] bg-[#714ef6] px-3 text-[12px] font-bold text-white transition hover:bg-[#6040df]" > - - 연결 - + {t("연결")} + {t("종료")}
) @@ -82,6 +80,7 @@ export function PlatformOptionCard({ onAction, onSettings }: PlatformOptionCardProps): React.JSX.Element { + useLocaleRender() const ready = platform.state === 'ready' const reserved = platform.state === 'reserved' const locked = platform.state === 'locked' @@ -104,7 +103,7 @@ export function PlatformOptionCard({
-

{platform.title}

+

{platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title}

{platform.description}

@@ -117,7 +116,7 @@ export function PlatformOptionCard({ : locked ? : } - {displayStatus} + {t(displayStatus)}
@@ -138,16 +137,14 @@ export function PlatformOptionCard({ ].join(' ')} > {running ? null : } - {running ? '실행중' : primaryAction} + {running ? t("실행중") : t(primaryAction)} + {t("설정")}
) diff --git a/src/renderer/src/features/platforms/PlatformFilterTabs.tsx b/src/renderer/src/features/platforms/PlatformFilterTabs.tsx index 41a6ea4..ade891b 100644 --- a/src/renderer/src/features/platforms/PlatformFilterTabs.tsx +++ b/src/renderer/src/features/platforms/PlatformFilterTabs.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import type React from 'react' export type PlatformFilter = 'all' | 'available' | 'unavailable' @@ -19,6 +20,7 @@ export default function PlatformFilterTabs({ counts, onChange }: PlatformFilterTabsProps): React.JSX.Element { + useLocaleRender() const tabs: PlatformFilterTab[] = [ { id: 'all', label: '전체', count: counts.all }, { id: 'available', label: '사용가능', count: counts.available }, @@ -41,7 +43,7 @@ export default function PlatformFilterTabs({ : 'border-[#d7dde8] bg-white text-[#4f596b] hover:bg-[#f8f9fc] hover:text-[#111520]' ].join(' ')} > - {tab.label} ({tab.count}) + {t(tab.label)} ({tab.count}) ))}
diff --git a/src/renderer/src/features/platforms/PlatformSelectModal.tsx b/src/renderer/src/features/platforms/PlatformSelectModal.tsx index 41719fe..32f2898 100644 --- a/src/renderer/src/features/platforms/PlatformSelectModal.tsx +++ b/src/renderer/src/features/platforms/PlatformSelectModal.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { ChevronLeft, X } from 'lucide-react' import type React from 'react' import PlatformLoadingOverlay from '../../components/PlatformLoadingOverlay' @@ -23,6 +24,7 @@ export default function PlatformSelectModal({ onClose, children }: PlatformSelectModalProps): React.JSX.Element { + useLocaleRender() return (
@@ -33,24 +35,24 @@ export default function PlatformSelectModal({ {mode === 'settings' && ( )} - {mode === 'settings' ? '플랫폼 설정' : '플랫폼 선택'} + {mode === 'settings' ? t("플랫폼 설정") : t("플랫폼 선택")}

{mode === 'settings' - ? '플랫폼별 개발 환경 설정을 관리하세요.' - : '실습을 진행할 플랫폼을 선택하고 제어권을 요청하세요.'} + ? t("플랫폼별 개발 환경 설정을 관리하세요.") + : t("실습을 진행할 플랫폼을 선택하고 제어권을 요청하세요.")}

)} @@ -67,7 +65,7 @@ export default function PlatformSettingsPanel({ ) : ( <> onDraftChange((settings) => ({ ...settings, workspaceRoot: value }))} dark={false} @@ -82,15 +80,15 @@ export default function PlatformSettingsPanel({
onDraftChange((settings) => ({ ...settings, gpuEnabled: checked }))} dark={false} />
-

저장소

+

{t("저장소")}

clone path: {clonePath}
@@ -104,16 +102,14 @@ export default function PlatformSettingsPanel({ onClick={onClose} className="h-9 cursor-pointer rounded-md border border-[#d7dde8] bg-white px-4 text-[12px] font-bold text-[#4f596b] transition hover:bg-[#f8f9fc] hover:text-[#111520]" > - {!localPc ? '확인' : '취소'} + {!localPc ? t("확인") : t("취소")} {localPc && ( + >{t("저장")} )}
diff --git a/src/renderer/src/features/platforms/RecentSessionModal.tsx b/src/renderer/src/features/platforms/RecentSessionModal.tsx index f90c169..4ef3cd1 100644 --- a/src/renderer/src/features/platforms/RecentSessionModal.tsx +++ b/src/renderer/src/features/platforms/RecentSessionModal.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { Clock3, Link2, X } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import type React from 'react' @@ -31,18 +32,19 @@ export default function RecentSessionModal({ onClose, onOpenSession }: RecentSessionModalProps): React.JSX.Element { + useLocaleRender() return (
- 개발 스튜디오 -

최근 세션

-

이전에 연결했던 플랫폼을 선택해 개발 환경에 연결하세요.

+ {t("개발 스튜디오")} +

{t("최근 세션")}

+

{t("이전에 연결했던 플랫폼을 선택해 개발 환경에 연결하세요.")}

+ {t("연결")} )) : (
- 최근 세션 데이터가 없습니다. -

아직 실행한 개발 환경 세션이 없습니다.

+ {t("최근 세션 데이터가 없습니다.")} +

{t("아직 실행한 개발 환경 세션이 없습니다.")}

)} diff --git a/src/renderer/src/features/workspace/ModuleDeleteConfirm.tsx b/src/renderer/src/features/workspace/ModuleDeleteConfirm.tsx index a54e24e..ae9fe0e 100644 --- a/src/renderer/src/features/workspace/ModuleDeleteConfirm.tsx +++ b/src/renderer/src/features/workspace/ModuleDeleteConfirm.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { Trash2 } from 'lucide-react' import { useState } from 'react' import type React from 'react' @@ -22,6 +23,7 @@ export default function ModuleDeleteConfirm({ onCancel, onConfirm }: ModuleDeleteConfirmProps): React.JSX.Element { + useLocaleRender() const [deleteRepository, setDeleteRepository] = useState(false) return ( @@ -34,16 +36,15 @@ export default function ModuleDeleteConfirm({ >
- 삭제 + {t("삭제")}

- {module.name} 삭제할까요? -

+ {module.name}{t("삭제할까요?")}

{module.state === 'uninstalled' - ? '등록된 커스텀 모듈 정보를 삭제합니다.' - : '워크스페이스에 설치된 모듈 폴더가 삭제됩니다.'} + ? t("등록된 커스텀 모듈 정보를 삭제합니다.") + : t("워크스페이스에 설치된 모듈 폴더가 삭제됩니다.")}

{allowRepositoryDeletion && module.state !== 'uninstalled' && }
@@ -74,16 +71,12 @@ export default function ModuleDeleteConfirm({ 'h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition', dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]' ].join(' ')} - > - 취소 - + >{t("취소")} + >{t("삭제")}
diff --git a/src/renderer/src/features/workspace/ModuleListEmptyState.tsx b/src/renderer/src/features/workspace/ModuleListEmptyState.tsx index f42ce46..04f4fff 100644 --- a/src/renderer/src/features/workspace/ModuleListEmptyState.tsx +++ b/src/renderer/src/features/workspace/ModuleListEmptyState.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { RefreshCw } from 'lucide-react' import type React from 'react' @@ -10,15 +11,16 @@ export default function ModuleListEmptyState({ dark, loading = false }: ModuleListEmptyStateProps): React.JSX.Element { + useLocaleRender() return (
{loading ? (
- 모듈 목록 불러오는 중 + {t("모듈 목록 불러오는 중")}
) : ( - '표시할 모듈 없음' + t("표시할 모듈 없음") )}
) diff --git a/src/renderer/src/features/workspace/ModuleListHeader.tsx b/src/renderer/src/features/workspace/ModuleListHeader.tsx index cce9498..d56635a 100644 --- a/src/renderer/src/features/workspace/ModuleListHeader.tsx +++ b/src/renderer/src/features/workspace/ModuleListHeader.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import type React from 'react' type ModuleListHeaderProps = { @@ -5,15 +6,16 @@ type ModuleListHeaderProps = { } export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element { + useLocaleRender() return ( -
- 상태 - 분류 - 모듈명 +
+ {t("상태")} + {t("분류")} + {t("모듈명")} CPU - 메모리 - 가동시간 - 제어 + {t("메모리")} + {t("가동시간")} + {t("제어")}
) } diff --git a/src/renderer/src/features/workspace/ModuleListRow.tsx b/src/renderer/src/features/workspace/ModuleListRow.tsx index f62d691..5eaedf4 100644 --- a/src/renderer/src/features/workspace/ModuleListRow.tsx +++ b/src/renderer/src/features/workspace/ModuleListRow.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { Download, FolderOpen, Hammer, Play, RefreshCw, Square, Trash2 } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { useEffect, useRef, useState } from 'react' @@ -57,7 +58,7 @@ function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean) tone === 'install' ? dark ? 'border-[#2f8dff]/45 bg-[#2f8dff]/18 text-[#9cc8ff] hover:bg-[#2f8dff]/26' - : 'border-[#2f8dff]/35 bg-[#edf6ff] text-[#1d5fae] hover:bg-[#dfefff]' + : 'border-[#7c3aed] bg-[#7c3aed] text-white hover:bg-[#6d28d9]' : tone === 'start' ? dark ? 'border-[#2fbda0]/35 bg-[#2fbda0]/10 text-[#80ddc7] hover:bg-[#2fbda0]/18' @@ -77,6 +78,7 @@ function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean) } function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element { + useLocaleRender() const anchorRef = useRef(null) const dismissTimerRef = useRef(null) const [position, setPosition] = useState<{ left: number; top: number } | null>(null) @@ -180,6 +182,7 @@ export default function ModuleListRow({ onStopModule, onRestartModule }: ModuleListRowProps): React.JSX.Element { + useLocaleRender() const selectable = module.state !== 'uninstalled' const busy = installing || controlling const [progress, setProgress] = useState('') @@ -255,7 +258,7 @@ export default function ModuleListRow({ > - {stateLabel[module.state]} + {t(stateLabel[module.state])} {module.category} @@ -263,14 +266,12 @@ export default function ModuleListRow({ {module.name} {module.sourceType === 'CUSTOM' && ( - - 사용자모듈 - + {t("사용자모듈")} )} {busy ? ( - {progress || (installing ? '모듈 설치 중...' : '모듈 작업 준비 중...')} + {t(progress || (installing ? '모듈 설치 중...' : '모듈 작업 준비 중...'))} ) : <> @@ -330,7 +331,7 @@ export default function ModuleListRow({ event.stopPropagation() onAddModuleTab(module) }} - aria-label="열기" + aria-label={t("열기")} className={[ 'group/action relative grid h-7 w-7 cursor-pointer place-items-center rounded-md border transition', dark @@ -339,7 +340,7 @@ export default function ModuleListRow({ ].join(' ')} > - 열기 + {t("열기")} )} {controlActions.map(({ label, Icon, tone, action }) => ( @@ -355,11 +356,11 @@ export default function ModuleListRow({ event.stopPropagation() action(module) }} - aria-label={label} + aria-label={t(label)} className={actionButtonClass(dark, tone, disabled)} > {busy && !canCancelBuild ? : } - {label} + {t(label)} ) })() @@ -372,11 +373,11 @@ export default function ModuleListRow({ event.stopPropagation() onInstallModule(module) }} - aria-label={installAction.label} + aria-label={t(installAction.label)} className={actionButtonClass(dark, installAction.tone, installActionDisabled)} > {busy ? : } - {installAction.label} + {t(installAction.label)} )} @@ -389,11 +390,11 @@ export default function ModuleListRow({ event.stopPropagation() onDeleteModule(module) }} - aria-label="삭제" + aria-label={t("삭제")} className={actionButtonClass(dark, 'delete', busy)} > - 삭제 + {t("삭제")} ) : ( )} diff --git a/src/renderer/src/features/workspace/ModuleListToolbar.tsx b/src/renderer/src/features/workspace/ModuleListToolbar.tsx index bcfdcd9..60a26d6 100644 --- a/src/renderer/src/features/workspace/ModuleListToolbar.tsx +++ b/src/renderer/src/features/workspace/ModuleListToolbar.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { Download, MapPinned, Play, RefreshCw, Square } from 'lucide-react' import type React from 'react' @@ -39,6 +40,7 @@ export default function ModuleListToolbar({ onStopAll, onOpenWorldEditor }: ModuleListToolbarProps): React.JSX.Element { + useLocaleRender() const installingAll = installingModuleName === '__all__' const startingAll = installingModuleName === '__start_all__' const stoppingAll = installingModuleName === '__stop_all__' @@ -69,7 +71,7 @@ export default function ModuleListToolbar({ ] return ( -
+
{FILTER_TABS.map(({ id, label }) => { const active = filter === id @@ -80,7 +82,7 @@ export default function ModuleListToolbar({ type="button" onClick={() => onFilterChange(id)} className={[ - 'h-8 min-w-[76px] cursor-pointer border-x border-t px-3 text-[12px] font-bold transition first:rounded-tl-md last:rounded-tr-md', + 'h-8 min-w-[76px] whitespace-nowrap cursor-pointer border-x border-t px-3 text-[12px] font-bold transition first:rounded-tl-md last:rounded-tr-md', active ? dark ? 'border-white/10 bg-[#2b2b2b] text-white' @@ -90,7 +92,7 @@ export default function ModuleListToolbar({ : 'border-[#e1e6ef] bg-[#eef1f6] text-[#687284] hover:text-[#111520]' ].join(' ')} > - {label} + {t(label)} ) })} @@ -106,7 +108,7 @@ export default function ModuleListToolbar({ onClick() }} className={[ - 'flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition', + 'flex h-7 whitespace-nowrap items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition', disabled ? 'cursor-default opacity-45' : 'cursor-pointer', dark ? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white' @@ -114,7 +116,7 @@ export default function ModuleListToolbar({ ].join(' ')} > {busy ? : } - {label} + {t(label)} ))}
diff --git a/src/renderer/src/features/workspace/ModuleListView.tsx b/src/renderer/src/features/workspace/ModuleListView.tsx index 877aaac..c9b8138 100644 --- a/src/renderer/src/features/workspace/ModuleListView.tsx +++ b/src/renderer/src/features/workspace/ModuleListView.tsx @@ -1,3 +1,4 @@ +import { t, useLocaleRender } from '../../i18n' import { BookOpen, ChevronLeft, ChevronRight, FolderOpen, PackagePlus, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import type React from 'react' @@ -9,7 +10,7 @@ import ModuleListToolbar, { type ModuleFilter } from './ModuleListToolbar' import ModuleParameterDialog from './ModuleParameterDialog' import { createParameterId, parseModuleParameters } from './moduleParameters' import type { ModuleParameterPair, ModuleRow } from './types' -import { WorkspaceMapPreview, type WorkspaceMapOption } from './WorldMapSelectorModal' +import { getMapDisplayName, WorkspaceMapPreview, type WorkspaceMapOption } from './WorldMapSelectorModal' const MODULE_GUIDE_DISMISSED_STORAGE_KEY = 'autonomy-studio.module-guide.dismissed' @@ -84,6 +85,7 @@ export default function ModuleListView({ onCreateCustomModule, onImportCustomModule }: ModuleListViewProps): React.JSX.Element { + useLocaleRender() const [moduleFilter, setModuleFilter] = useState('all') const [deleteConfirmModule, setDeleteConfirmModule] = useState(null) const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null) @@ -104,7 +106,6 @@ export default function ModuleListView({ const stoppedCount = modules.filter((row) => row.state === 'stopped').length const installingAll = installingModuleName === '__all__' const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__' - const hasModuleRows = modules.length > 0 || parkedModules.length > 0 useEffect(() => { try { @@ -250,16 +251,16 @@ export default function ModuleListView({ return (
-
+
- 모듈 리스트 - 자율주행 알고리즘 모듈 관리 + {t("모듈 리스트")} + {t("자율주행 알고리즘 모듈 관리")}
{showSelectedMapPreview && (
- 현재 맵 - {selectedMap.name} ({selectedMap.saved ? '주행' : 'SLAM'}) + {t("현재 맵")} + {getMapDisplayName(selectedMap)} ({selectedMap.saved ? t("주행") : 'SLAM'})
@@ -285,10 +286,10 @@ export default function ModuleListView({ {(listError || installError) && (
-

{listError || installError}

+

{t(listError || installError || '')}

{deleteConfirmModule && ( @@ -553,6 +546,7 @@ function ModuleGuideDialog({ dark: boolean onDismiss: () => void }): React.JSX.Element { + useLocaleRender() const [pageIndex, setPageIndex] = useState(0) const page = moduleGuidePages[pageIndex] const atFirstPage = pageIndex === 0 @@ -563,7 +557,7 @@ function ModuleGuideDialog({ className="fixed inset-0 z-[200] grid place-items-center bg-black/65 p-5" role="dialog" aria-modal="true" - aria-label="모듈 가이드" + aria-label={t("모듈 가이드")} >