Compare commits

...

2 Commits

Author SHA1 Message Date
chyoo 0a15102f8c version up! 2026-09-29 13:27:46 +09:00
chyoo d44180db67 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.
2026-09-29 13:27:11 +09:00
74 changed files with 2520 additions and 999 deletions
+47
View File
@@ -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.
+87 -3
View File
@@ -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",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lms",
"version": "1.2.1",
"version": "1.2.2",
"description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js",
"author": {
@@ -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",
+209
View File
@@ -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('<script type="module" src="./locale.js"></script>'))
})
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)
})
+85
View File
@@ -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/)
})
+48
View File
@@ -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')
})
+19 -12
View File
@@ -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<typeof ipcMa
})
}
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000): Promise<string> {
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined'): Promise<string> {
const readProgress = createModuleProgressParser()
return new Promise<string>((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)
+28
View File
@@ -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)}`
}
+32 -29
View File
@@ -377,50 +377,50 @@
</head>
<body>
<div id="topLeftControls">
<button id="obstacleToggle" title="벽 배치" aria-label="벽 배치" aria-pressed="false">Wall</button>
<button id="obstacleToggle" title="벽 배치" data-i18n-title="벽 배치" aria-label="벽 배치" data-i18n-aria-label="벽 배치" aria-pressed="false">Wall</button>
</div>
<div id="topRightControls">
<div id="downloadWrap">
<button id="downloadMap" title="맵 저장" aria-label="맵 저장" disabled><span>SAVE</span></button>
<button id="downloadMap" title="맵 저장" data-i18n-title="맵 저장" aria-label="맵 저장" data-i18n-aria-label="맵 저장" disabled><span>SAVE</span></button>
</div>
<button id="pathToggle" title="로봇 경로 표시" aria-label="로봇 경로 표시">Path</button>
<button id="sensorToggle" title="센서 표시" aria-label="센서 표시" aria-pressed="false">Sensor</button>
<button id="poseToggle" title="초기 위치 지정" aria-label="초기 위치 지정">Pose</button>
<button id="goalToggle" title="목표 위치 지정" aria-label="목표 위치 지정">Goal</button>
<select id="cameraPresetSelect" title="카메라 시점" aria-label="카메라 시점">
<button id="pathToggle" title="로봇 경로 표시" data-i18n-title="로봇 경로 표시" aria-label="로봇 경로 표시" data-i18n-aria-label="로봇 경로 표시">Path</button>
<button id="sensorToggle" title="센서 표시" data-i18n-title="센서 표시" aria-label="센서 표시" data-i18n-aria-label="센서 표시" aria-pressed="false">Sensor</button>
<button id="poseToggle" title="초기 위치 지정" data-i18n-title="초기 위치 지정" aria-label="초기 위치 지정" data-i18n-aria-label="초기 위치 지정">Pose</button>
<button id="goalToggle" title="목표 위치 지정" data-i18n-title="목표 위치 지정" aria-label="목표 위치 지정" data-i18n-aria-label="목표 위치 지정">Goal</button>
<select id="cameraPresetSelect" title="카메라 시점" data-i18n-title="카메라 시점" aria-label="카메라 시점" data-i18n-aria-label="카메라 시점">
<option value="default">Default</option>
<option value="top">Top</option>
<option value="follow">Follow</option>
</select>
</div>
<canvas id="miniMap" width="260" height="220"></canvas>
<button id="miniMapToggle" title="미니맵 접기/펼치기" aria-label="미니맵 접기">▾</button>
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
<div id="cameraPreviewWrap">
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
<img id="cameraPreview" alt="실시간 카메라 화면" />
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
<img id="cameraPreview" alt="실시간 카메라 화면" data-i18n-alt="실시간 카메라 화면" />
</div>
<div id="keyGuide">
<div>i 전진 · , 후진 · j/l 회전 · k 정지</div>
<div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div>
</div>
<div id="rosTopicStatusWrap">
<button id="rosTopicStatusToggle" type="button" title="ROS 상태 접기/펼치기" aria-label="ROS 상태 접기" aria-expanded="true">
<button id="rosTopicStatusToggle" type="button" title="ROS 상태 접기/펼치기" data-i18n-title="ROS 상태 접기/펼치기" aria-label="ROS 상태 접기" data-i18n-aria-label="ROS 상태 접기" aria-expanded="true">
<span id="rosTopicStatusChevron" aria-hidden="true">▾</span>
</button>
<aside id="rosTopicStatus" aria-label="ROS 상태">
<div id="rosTopicStatusTitle">ROS 상태</div>
<aside id="rosTopicStatus" aria-label="ROS 상태" data-i18n-aria-label="ROS 상태">
<div id="rosTopicStatusTitle" data-i18n="ROS 상태">ROS 상태</div>
<div id="rosTopicStatusList"></div>
</aside>
</div>
<div id="responseWaitOverlay" role="status" aria-live="polite">
<div class="response-wait-dialog">
<span class="response-wait-spinner" aria-hidden="true"></span>
<span>응답을 기다리는 중입니다.</span>
<span data-i18n="응답을 기다리는 중입니다.">응답을 기다리는 중입니다.</span>
</div>
</div>
<div id="mapSaveOverlay" role="status" aria-live="polite" aria-hidden="true">
<div class="response-wait-dialog">
<span class="response-wait-spinner" aria-hidden="true"></span>
<span>맵을 저장하고 있습니다.</span>
<span data-i18n="맵을 저장하고 있습니다.">맵을 저장하고 있습니다.</span>
</div>
</div>
<script type="importmap">
@@ -431,7 +431,10 @@
}
}
</script>
<script type="module" src="./locale.js"></script>
<script type="module">
import { t, setText } from './locale.js';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
@@ -501,7 +504,7 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList');
if (REAL_TELEOP && !EDITOR_MODE) {
document.body.classList.add('real-teleop');
if (REAL_MKMINI_TELEOP) {
document.querySelector('#keyGuide div').textContent = 'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지';
setText(document.querySelector('#keyGuide div'), 'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지');
}
}
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
@@ -554,7 +557,7 @@ function updateRosTopicStatus(topicStatus = {}) {
rosTopicStatusToggle?.addEventListener('click', () => {
const collapsed = rosTopicStatusWrap?.classList.toggle('is-collapsed') ?? false;
rosTopicStatusToggle.setAttribute('aria-expanded', String(!collapsed));
rosTopicStatusToggle.setAttribute('aria-label', collapsed ? 'ROS 상태 펼치기' : 'ROS 상태 접기');
rosTopicStatusToggle.setAttribute('aria-label', collapsed ? t("ROS 상태 펼치기") : t("ROS 상태 접기"));
});
updateRosTopicStatus();
@@ -858,7 +861,7 @@ window.addEventListener('message', (event) => {
pendingSimulatorPoseRequests.delete(payload.requestId);
window.clearTimeout(request.timeout);
if (payload.ok) request.resolve();
else request.reject(new Error(payload.message || '가제보 로봇 위치를 적용하지 못했습니다.'));
else request.reject(new Error(payload.message || t("가제보 로봇 위치를 적용하지 못했습니다.")));
});
let robotModel = null;
@@ -965,8 +968,8 @@ function syncGoalToggleAvailability() {
goalToggleButton.disabled = goalDisabled;
poseToggleButton.disabled = poseDisabled;
poseToggleButton.title = poseDisabled
? '주행 시뮬레이션에서는 초기 위치를 한 번만 설정할 수 있습니다.'
: '로봇 초기 위치 설정';
? t("주행 시뮬레이션에서는 초기 위치를 한 번만 설정할 수 있습니다.")
: t("로봇 초기 위치 설정");
if (goalDisabled) {
navigationGoalMode = false;
goalToggleButton.classList.remove('active');
@@ -2264,8 +2267,8 @@ function drawMiniMapNow(state) {
miniCtx.textBaseline = 'middle';
miniCtx.fillText(
SHOW_POINT_CLOUD && state?.point_cloud
? '3D 포인트클라우드를 표시 중입니다.'
: '모듈을 연결해주세요.',
? t("3D 포인트클라우드를 표시 중입니다.")
: t("모듈을 연결해주세요."),
w / 2,
h / 2,
);
@@ -2592,10 +2595,10 @@ downloadMapButton.addEventListener('click', async () => {
body: JSON.stringify({})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || payload.message || '맵 저장에 실패했습니다.');
if (!response.ok) throw new Error(payload.detail || payload.message || t("맵 저장에 실패했습니다."));
window.parent.postMessage({ source: 'autonomy-slam-viewer', type: 'map-saved', map: payload.map }, '*');
} catch (error) {
window.parent.postMessage({ source: 'autonomy-slam-viewer', type: 'map-save-failed', message: error instanceof Error ? error.message : '맵 저장에 실패했습니다.' }, '*');
window.parent.postMessage({ source: 'autonomy-slam-viewer', type: 'map-save-failed', message: error instanceof Error ? error.message : t("맵 저장에 실패했습니다.") }, '*');
} finally {
setMapSavePending(false);
downloadMapButton.disabled = !latestMiniMapGrid?.data?.length;
@@ -2616,7 +2619,7 @@ sensorToggleButton.addEventListener('click', () => {
cameraPreviewToggle?.addEventListener('click', () => {
const collapsed = cameraPreviewWrap?.classList.toggle('is-collapsed') ?? false;
cameraPreviewToggle.setAttribute('aria-expanded', String(!collapsed));
cameraPreviewToggle.setAttribute('aria-label', collapsed ? '카메라 화면 펼치기' : '카메라 화면 접기');
cameraPreviewToggle.setAttribute('aria-label', collapsed ? t("카메라 화면 펼치기") : t("카메라 화면 접기"));
});
goalToggleButton.addEventListener('click', () => {
@@ -2677,7 +2680,7 @@ obstacleToggleButton.addEventListener('click', () => {
miniMapToggle.addEventListener('click', () => {
const hidden = miniMap.classList.toggle('hidden');
miniMapToggle.textContent = hidden ? '▴' : '▾';
miniMapToggle.setAttribute('aria-label', hidden ? '미니맵 펼치기' : '미니맵 접기');
miniMapToggle.setAttribute('aria-label', hidden ? t("미니맵 펼치기") : t("미니맵 접기"));
focusViewer();
});
@@ -2911,7 +2914,7 @@ async function sendNavigationGoal(goal) {
const cancelResponse = await fetch(`${BACKEND_URL}/cancel_navigation`, { method:'POST' });
if (!cancelResponse.ok) {
const message = await cancelResponse.text().catch(() => '');
throw new Error(message || '기존 목표를 취소하지 못했습니다.');
throw new Error(message || t("기존 목표를 취소하지 못했습니다."));
}
navigationGoalId = null;
placeNavigationGoalMarker(goal.x, goal.y, goal.yaw);
@@ -2934,7 +2937,7 @@ function moveLocalSimulatorToInitialPose(pose) {
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
pendingSimulatorPoseRequests.delete(requestId);
reject(new Error('가제보 로봇 위치 적용 시간이 초과되었습니다.'));
reject(new Error(t("가제보 로봇 위치 적용 시간이 초과되었습니다.")));
}, 12_000);
pendingSimulatorPoseRequests.set(requestId, { resolve, reject, timeout });
window.parent.postMessage({
+72
View File
@@ -0,0 +1,72 @@
const storageKey = 'autonomy-studio.lms.language';
let language = 'ko';
try { language = localStorage.getItem(storageKey) === 'en' ? 'en' : 'ko'; } catch {}
const english = {
'벽 배치': 'Place walls',
'맵 저장': 'Save map',
'로봇 경로 표시': 'Show robot path',
'센서 표시': 'Show sensors',
'초기 위치 지정': 'Set initial pose',
'목표 위치 지정': 'Set goal',
'카메라 시점': 'Camera view',
'미니맵 접기/펼치기': 'Toggle minimap',
'미니맵 접기': 'Collapse minimap',
'미니맵 펼치기': 'Expand minimap',
'카메라 화면 접기/펼치기': 'Toggle camera preview',
'카메라 화면 접기': 'Collapse camera preview',
'카메라 화면 펼치기': 'Expand camera preview',
'실시간 카메라 화면': 'Live camera preview',
'i 전진 · , 후진 · j/l 회전 · k 정지': 'i Forward · , Reverse · j/l Turn · k Stop',
'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지': 'i Forward · , Reverse · j/l Steer while moving · k Stop',
'ROS 상태 접기/펼치기': 'Toggle ROS status',
'ROS 상태 접기': 'Collapse ROS status',
'ROS 상태 펼치기': 'Expand ROS status',
'ROS 상태': 'ROS status',
'응답을 기다리는 중입니다.': 'Waiting for a response.',
'맵을 저장하고 있습니다.': 'Saving the map.',
'가제보 로봇 위치를 적용하지 못했습니다.': 'Failed to set the Gazebo robot pose.',
'주행 시뮬레이션에서는 초기 위치를 한 번만 설정할 수 있습니다.': 'The initial pose can only be set once during navigation simulation.',
'로봇 초기 위치 설정': 'Set initial robot pose',
'3D 포인트클라우드를 표시 중입니다.': 'Displaying the 3D point cloud.',
'모듈을 연결해주세요.': 'Connect your modules.',
'맵 저장에 실패했습니다.': 'Failed to save the map.',
'기존 목표를 취소하지 못했습니다.': 'Failed to cancel the previous goal.',
'가제보 로봇 위치 적용 시간이 초과되었습니다.': 'Setting the Gazebo robot pose timed out.'
};
export function t(key) { return language === 'en' ? (english[key] ?? key) : key; }
// Only explicitly marked viewer UI is translated. Topic names and telemetry stay intact.
export function setText(element, key) {
element.dataset.i18n = key;
element.textContent = t(key);
}
function refresh() {
document.documentElement.lang = language;
document.querySelectorAll('[data-i18n]').forEach(element => {
element.textContent = t(element.dataset.i18n);
});
for (const attribute of ['title', 'aria-label', 'alt']) {
document.querySelectorAll(`[data-i18n-${attribute}]`).forEach(element => {
element.setAttribute(attribute, t(element.getAttribute(`data-i18n-${attribute}`)));
});
}
}
window.addEventListener('storage', event => {
if (event.key !== storageKey) return;
language = event.newValue === 'en' ? 'en' : 'ko';
refresh();
});
refresh();
// Embedded viewers may not share storage with the app (for example file URLs).
window.addEventListener('message', event => {
if (window.parent === window || event.source !== window.parent) return;
if (event.data?.type !== 'lms:language' || !['ko', 'en'].includes(event.data.language)) return;
language = event.data.language;
refresh();
});
if (window.parent !== window) window.parent.postMessage({ type: 'lms:language:ready' }, '*');
+13 -1
View File
@@ -78,11 +78,23 @@
document.getElementById('splash-image').src = './assets/splash.png'
const el = document.getElementById('loading-text')
const translations = {
'학습 환경 준비 중...': 'Preparing your learning environment...',
'로그인 세션 확인 중...': 'Checking your session...',
'강좌 정보 불러오는 중...': 'Loading courses...',
'커리큘럼 및 플랫폼 정보 가져오는 중...': 'Loading curriculum and platforms...'
}
let language = 'ko'
try {
language = localStorage.getItem('autonomy-studio.lms.language') === 'en' ? 'en' : 'ko'
} catch {}
document.documentElement.lang = language
window.setSplashStatus = (message) => {
if (typeof message === 'string' && message.trim()) {
el.textContent = message
el.textContent = language === 'en' ? (translations[message] ?? message) : message
}
}
window.setSplashStatus(el.textContent)
</script>
</body>
</html>
+55 -47
View File
@@ -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<void> => {
if (!map.savedMapId) return
@@ -3443,7 +3447,7 @@ function LocalPcWorkspaceMockup({
const cameraAvailable = Boolean(curriculumWeek.detail.camera?.url?.trim())
return (
<div ref={workspaceRootRef} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}>
<div ref={workspaceRootRef} data-studio-theme={theme} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}>
{cameraAvailable && cameraOpen && workspaceVisible && <WorkspaceCameraPanel
key={`${courseKey}:${weekNumber}`}
containerRef={visualizationContainerRef}
@@ -3459,7 +3463,7 @@ function LocalPcWorkspaceMockup({
textStrong={textStrong}
textMid={textMid}
hoverSoft={hoverSoft}
platformName={platformName}
platformName={platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName}
weekNumber={weekNumber}
title={title}
leftPanelMinimized={leftPanelMinimized}
@@ -3551,7 +3555,7 @@ function LocalPcWorkspaceMockup({
borderSoft={borderSoft}
textStrong={textStrong}
textDim={textDim}
platformName={platformName}
platformName={platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName}
canReleaseControl={canReleaseControl}
onClose={() => setPowerActionModalOpen(false)}
onDisconnectSession={() => {
@@ -3612,7 +3616,7 @@ function LocalPcWorkspaceMockup({
<div
role="separator"
aria-label="학습가이드 패널 크기 조절"
aria-label={t("학습가이드 패널 크기 조절")}
className={[resizeHandleClass, leftPanelMinimized ? 'pointer-events-none opacity-0' : ''].join(' ')}
style={workspaceActive ? { gridColumn: 2, gridRow: '1 / span 2' } : { gridColumn: 2, gridRow: 1 }}
onMouseDown={(event) => startResize('left', event)}
@@ -3751,7 +3755,7 @@ function LocalPcWorkspaceMockup({
{!logActive && !settingsActive && !gitActive && !rosConnectionsActive && (
<div
role="separator"
aria-label="프로젝트 트리 크기 조절"
aria-label={t("프로젝트 트리 크기 조절")}
className={[
'group grid cursor-col-resize place-items-center',
workspaceActive ? '' : 'pointer-events-none opacity-0',
@@ -4036,7 +4040,7 @@ function LocalPcWorkspaceMockup({
onClose={() => 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({
<div
role="separator"
aria-label="질의응답 패널 크기 조절"
aria-label={t("질의응답 패널 크기 조절")}
className={[resizeHandleClass, rightPanelMinimized ? 'pointer-events-none opacity-0' : ''].join(' ')}
style={workspaceActive ? { gridColumn: 7, gridRow: '1 / span 2' } : { gridColumn: 7, gridRow: 1 }}
onMouseDown={(event) => startResize('right', event)}
@@ -4122,8 +4126,8 @@ function LocalPcWorkspaceMockup({
{workspaceRestoreLoading && (
<PlatformLoadingOverlay
title={`${platformName} 화면 불러오는 중`}
description="마지막으로 작업하던 개발 스튜디오 화면을 불러오고 있습니다."
title={t("{{v0}} 화면 불러오는 중", { v0: platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName })}
description={t("마지막으로 작업하던 개발 스튜디오 화면을 불러오고 있습니다.")}
className="absolute inset-0 z-[80]"
/>
)}
@@ -4143,6 +4147,7 @@ function LocalPcWorkspaceMockup({
}
export default function App(): React.JSX.Element {
useLocaleRender()
const appRootRef = useRef<HTMLDivElement>(null)
const restoredStudioSessionRef = useRef<StudioRuntimeSessionState | null>(readStudioRuntimeSessionState())
const previousWeekStateRef = useRef<Record<number, CurriculumWeek['state']>>({})
@@ -4230,7 +4235,7 @@ export default function App(): React.JSX.Element {
description: '선택한 플랫폼의 개발 환경과 권한 상태를 확인하고 있습니다.'
})
const [materialDownloadingResourceIds, setMaterialDownloadingResourceIds] = useState<string[]>([])
const [ideTheme] = useState<'dark' | 'light'>('dark')
const ideTheme = useStudioTheme()
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('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<void> => {
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 {
/>
<main
className={[
'relative min-w-0 flex-1 rounded-t-[28px] [scrollbar-gutter:stable]',
'relative min-w-0 flex-1 rounded-t-[28px]',
outerView === 'guide' ? '[scrollbar-gutter:auto] [clip-path:inset(0_round_28px_28px_0_0)]' : '[scrollbar-gutter:stable]',
outerView === 'guide'
? 'overflow-hidden bg-[#202020] p-0 text-white'
: localPcLayoutOpen
@@ -5905,7 +5913,7 @@ export default function App(): React.JSX.Element {
'px-[22px] pb-8 pt-[22px] text-[#06091a]',
'bg-[radial-gradient(circle_at_36%_15%,rgba(111,77,246,0.08),transparent_30%),linear-gradient(180deg,#fbfbfc_0%,#f3f4f7_100%)]'
].join(' '),
platformModalOpen || recentSessionModalOpen ? 'overflow-hidden' : 'overflow-auto'
outerView === 'guide' || platformModalOpen || recentSessionModalOpen ? 'overflow-hidden' : 'overflow-auto'
].join(' ')}
>
{studioEntered && workspaceWeekState && workspacePlatform && (
@@ -6070,7 +6078,7 @@ export default function App(): React.JSX.Element {
{platformModalOpen && (
<PlatformSelectModal
mode={platformModalMode}
weekLabel={`${selectedWeek.week}주차 ${selectedWeek.title}`}
weekLabel={t("{{v0}}주차 {{v1}}", { v0: selectedWeek.week, v1: selectedWeek.title })}
loading={platformLoading}
loadingTitle={platformLoadingCopy.title}
loadingDescription={platformLoadingCopy.description}
@@ -6127,7 +6135,7 @@ export default function App(): React.JSX.Element {
onClose={() => 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 {
<WeekSwitchBlockedModal
overlayClassName={fullAppModalOverlayClass}
dark={appModalDark}
runningLabel={runningWorkspaceWeek ? `${runningWorkspaceWeek.week}주차 ${runningWorkspaceWeek.title}` : '실행 중인 주차'}
nextLabel={selectedWeekState ? `${selectedWeekState.week}주차 ${selectedWeekState.title}` : '선택한 주차'}
runningLabel={runningWorkspaceWeek ? t("{{v0}}주차 {{v1}}", { v0: runningWorkspaceWeek.week, v1: runningWorkspaceWeek.title }) : t("실행 중인 주차")}
nextLabel={selectedWeekState ? t("{{v0}}주차 {{v1}}", { v0: selectedWeekState.week, v1: selectedWeekState.title }) : t("선택한 주차")}
onCancel={() => 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()
+43 -41
View File
@@ -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 (
<div className={overlayClassName}>
<section className={modalClass(dark)}>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
{toneIcon(tone)}
<strong className="text-[13px] font-bold">{title}</strong>
<strong className="text-[13px] font-bold">{t(title)}</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{message}</p>
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{t(message)}</p>
{children}
{detail && (
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
{detail}
{t(detail)}
</p>
)}
<div className="mt-4 flex justify-end gap-2">
{actionLabel && onAction && (
<button type="button" onClick={onAction} className={cancelButtonClass(dark)}>
{actionLabel}
{t(actionLabel)}
</button>
)}
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>
확인
</button>
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>{t("확인")}</button>
</div>
</div>
</section>
@@ -117,29 +117,28 @@ export function WeekSwitchBlockedModal({
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
useLocaleRender()
return (
<div className={overlayClassName}>
<section className={modalClass(dark, true)}>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
<AlertCircle size={15} className="text-[#f59e0b]" />
<strong className="text-[13px] font-bold">이미 실행 중인 스튜디오가 있습니다</strong>
<strong className="text-[13px] font-bold">{t("이미 실행 중인 스튜디오가 있습니다")}</strong>
</div>
<div className="p-4">
{[
['실행 중', runningLabel],
['새로 시작', nextLabel]
[t("실행 중"), runningLabel],
[t("새로 시작"), nextLabel]
].map(([label, value], index) => (
<div key={label} className={[index ? 'mt-2' : '', 'rounded-md border px-3 py-2', dark ? 'border-white/10 bg-white/5' : 'border-[#e5e8ef] bg-[#f8fafc]'].join(' ')}>
<p className={['text-[11px] font-bold', dimClass(dark)].join(' ')}>{label}</p>
<p className={['mt-1 text-[13px] font-bold', strongClass(dark)].join(' ')}>{value}</p>
</div>
))}
<p className={['mt-3 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다.
</p>
<p className={['mt-3 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>{t("현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다.")}</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>취소</button>
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>종료하고 새로 시작</button>
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{t("취소")}</button>
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{t("종료하고 새로 시작")}</button>
</div>
</div>
</section>
@@ -158,15 +157,16 @@ export function RunningPlatformCloseModal({
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
useLocaleRender()
return (
<ConfirmStatusModal
overlayClassName={overlayClassName}
dark={dark}
icon={<Power size={15} className="text-[#e87979]" />}
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 (
<ConfirmStatusModal
overlayClassName={overlayClassName}
dark={dark}
icon={release ? <Trash2 size={15} className="text-[#e87979]" /> : <LogOut size={15} className="text-[#e87979]" />}
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 (
<ConfirmStatusModal
overlayClassName={overlayClassName}
dark={dark}
icon={<AlertCircle size={15} className="text-[#f59e0b]" />}
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}
>
<p className={['mt-3 text-[11px] font-medium', dimClass(dark)].join(' ')}>
{release ? '아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.' : '아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.'}
{release ? t("아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.") : t("아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.")}
</p>
</ConfirmStatusModal>
)
@@ -251,22 +253,21 @@ export function ControlExtensionConfirmModal({
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
useLocaleRender()
return (
<div className={overlayClassName}>
<section className={modalClass(dark)}>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
<Clock3 size={15} className="text-[#f59e0b]" />
<strong className="text-[13px] font-bold">제어 시간 연장</strong>
<strong className="text-[13px] font-bold">{t("제어 시간 연장")}</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{platformName} 제어 시간을 연장할까요?</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.
</p>
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{platformName}{t("제어 시간을 연장할까요?")}</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>{t("대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.")}</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" disabled={submitting} onClick={onCancel} className={cancelButtonClass(dark)}>취소</button>
<button type="button" disabled={submitting} onClick={onCancel} className={cancelButtonClass(dark)}>{t("취소")}</button>
<button type="button" disabled={submitting} onClick={onConfirm} className={confirmButtonClass('primary')}>
{submitting ? '연장 중' : '연장'}
{submitting ? t("연장 중") : t("연장")}
</button>
</div>
</div>
@@ -298,22 +299,23 @@ function ConfirmStatusModal({
onConfirm: () => void
children?: React.ReactNode
}): React.JSX.Element {
useLocaleRender()
return (
<div className={overlayClassName}>
<section className={modalClass(dark)}>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
{icon}
<strong className="text-[13px] font-bold">{title}</strong>
<strong className="text-[13px] font-bold">{t(title)}</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{message}</p>
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{t(message)}</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
{detail}
{t(detail)}
</p>
{children}
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{cancelLabel}</button>
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{confirmLabel}</button>
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{t(cancelLabel)}</button>
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{t(confirmLabel)}</button>
</div>
</div>
</section>
+10 -5
View File
@@ -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<string | null>(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({
<div className={['relative z-10', collapsed ? '-mt-7' : 'mt-2'].join(' ')}>
<button
type="button"
aria-label={expanded ? '접기' : '자세히 보기'}
title={expanded ? '접기' : '자세히 보기'}
aria-label={expanded ? t("접기") : t("자세히 보기")}
title={expanded ? t("접기") : t("자세히 보기")}
onClick={() => setExpanded((value) => !value)}
className={['grid h-7 w-full cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white/80' : 'text-[#64748b] hover:bg-[#e2e8f0]/70 hover:text-[#2563eb]'].join(' ')}
>
@@ -183,6 +187,7 @@ export function ExpandablePlainMessage({
collapseSignal?: number
content: string
}): React.JSX.Element {
useLocaleRender()
const [expanded, setExpanded] = useState(false)
const lineCount = content.split('\n').length
const collapsible = content.length > 450 || lineCount > 8
@@ -216,8 +221,8 @@ export function ExpandablePlainMessage({
<div className={['relative z-10', collapsed ? '-mt-6' : 'mt-2'].join(' ')}>
<button
type="button"
aria-label={expanded ? '접기' : '자세히 보기'}
title={expanded ? '접기' : '자세히 보기'}
aria-label={expanded ? t("접기") : t("자세히 보기")}
title={expanded ? t("접기") : t("자세히 보기")}
onClick={() => setExpanded((value) => !value)}
className="grid h-7 w-full cursor-pointer place-items-center rounded-md text-white/70 transition hover:bg-white/10 hover:text-white"
>
@@ -271,7 +276,7 @@ export const QaInputBox = memo(function QaInputBox({
submit()
}
}}
placeholder="질문하세요..."
placeholder={t("질문하세요...")}
className={[
'min-h-[56px] min-w-0 flex-1 resize-none bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium',
dark ? 'text-white/80 placeholder:text-white/40' : 'text-[#111520] placeholder:text-[#727b8c]'
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../i18n'
import { Check, Copy, ExternalLink, RefreshCw, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
@@ -15,6 +16,7 @@ const messages = {
}
export default function DockerSetupNotice(): React.JSX.Element | null {
useLocaleRender()
const [result, setResult] = useState<DockerResult | null>(null)
const [dismissed, setDismissed] = useState(() => {
try {
@@ -87,36 +89,35 @@ export default function DockerSetupNotice(): React.JSX.Element | null {
<dialog ref={dialogRef} aria-labelledby="docker-setup-title" onCancel={() => setDismissed(true)}
className="fixed inset-0 m-auto max-h-[calc(100dvh-32px)] w-[520px] max-w-[calc(100vw-32px)] overflow-y-auto rounded-lg border border-white/10 bg-[#252525] p-0 text-white shadow-2xl backdrop:bg-black/60">
<header className="flex items-center justify-between border-b border-white/10 px-5 py-3">
<h2 id="docker-setup-title" className="text-[14px] font-bold">로컬 Docker 설정</h2>
<button type="button" autoFocus title="나중에" aria-label="나중에" onClick={() => setDismissed(true)} className="flex h-7 w-7 cursor-pointer items-center justify-center rounded hover:bg-white/10"><X size={16} /></button>
<h2 id="docker-setup-title" className="text-[14px] font-bold">{t("로컬 Docker 설정")}</h2>
<button type="button" autoFocus title={t("나중에")} aria-label={t("나중에")} onClick={() => setDismissed(true)} className="flex h-7 w-7 cursor-pointer items-center justify-center rounded hover:bg-white/10"><X size={16} /></button>
</header>
<div className="space-y-4 px-5 py-4 text-[13px] leading-relaxed">
<p className="font-semibold">{messages[result.status]}</p>
<p className="text-white/65">Docker 없이도 LMS와 원격 플랫폼을 사용할 수 있습니다. 로컬 모듈 빌드와 실행에는 Docker가 필요합니다.</p>
<p className="font-semibold">{t(messages[result.status])}</p>
<p className="text-white/65">{t("Docker 없이도 LMS와 원격 플랫폼을 사용할 수 있습니다. 로컬 모듈 빌드와 실행에는 Docker가 필요합니다.")}</p>
{result.status === 'missing' ? (
<p>공식 설치 안내에서 운영체제를 선택해 Docker를 설치한 뒤 다시 확인해주세요. Ubuntu에서는 Docker Engine의 apt 저장소 설치 방법을 사용할 수 있습니다.</p>
<p>{t("공식 설치 안내에서 운영체제를 선택해 Docker를 설치한 뒤 다시 확인해주세요. Ubuntu에서는 Docker Engine의 apt 저장소 설치 방법을 사용할 수 있습니다.")}</p>
) : <>
{linux && result.status === 'permission' && <p>일반 Docker Engine 사용 시 아래 명령으로 권한을 추가한 뒤 OS에서 로그아웃하고 다시 로그인해주세요. Docker 그룹은 관리자 수준의 권한을 부여합니다. Rootless Docker나 별도 Docker 컨텍스트를 사용한다면 연결 설정부터 확인해주세요.</p>}
{linux && result.status === 'unavailable' && <p>일반 Docker Engine은 아래 명령으로 서비스를 시작할 수 있습니다. Docker Desktop이나 Rootless Docker 사용 시 해당 서비스를 실행하고 Docker 컨텍스트를 확인해주세요.</p>}
{linux && result.status === 'permission' && <p>{t("일반 Docker Engine 사용 시 아래 명령으로 권한을 추가한 뒤 OS에서 로그아웃하고 다시 로그인해주세요. Docker 그룹은 관리자 수준의 권한을 부여합니다. Rootless Docker나 별도 Docker 컨텍스트를 사용한다면 연결 설정부터 확인해주세요.")}</p>}
{linux && result.status === 'unavailable' && <p>{t("일반 Docker Engine은 아래 명령으로 서비스를 시작할 수 있습니다. Docker Desktop이나 Rootless Docker 사용 시 해당 서비스를 실행하고 Docker 컨텍스트를 확인해주세요.")}</p>}
<div className="relative rounded-md bg-black/25 p-3 pr-12">
<pre className="whitespace-pre-wrap break-all text-[12px]">{command}</pre>
<button type="button" title={copied ? '복사됨' : '명령어 복사'} aria-label={copied ? '복사됨' : '명령어 복사'}
<button type="button" title={copied ? t("복사됨") : t("명령어 복사")} aria-label={copied ? t("복사됨") : t("명령어 복사")}
className="absolute right-2 top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded hover:bg-white/10"
onClick={() => { void navigator.clipboard.writeText(command).then(() => { setCopied(true); setActionError('') }).catch(() => setActionError('복사하지 못했습니다. 명령어를 직접 선택해 복사해주세요.')) }}>
onClick={() => { void navigator.clipboard.writeText(command).then(() => { setCopied(true); setActionError('') }).catch(() => setActionError(t("복사하지 못했습니다. 명령어를 직접 선택해 복사해주세요."))) }}>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
</>}
<button type="button" className="inline-flex cursor-pointer items-center gap-2 text-[#9dbdff] hover:underline"
onClick={() => { void window.localWorkspace.docker.openGuide().catch(() => setActionError('설치 안내: https://docs.docker.com/engine/install/')) }}>
<ExternalLink size={14} />Docker 공식 설치 안내
</button>
{actionError && <p role="status" className="break-words text-[#f2b86b]">{actionError}</p>}
onClick={() => { void window.localWorkspace.docker.openGuide().catch(() => setActionError(t("설치 안내: https://docs.docker.com/engine/install/"))) }}>
<ExternalLink size={14} />{t("Docker 공식 설치 안내")}</button>
{actionError && <p role="status" className="break-words text-[#f2b86b]">{t(actionError || '')}</p>}
</div>
<footer className="flex justify-end gap-2 border-t border-white/10 px-5 py-3">
<button type="button" onClick={() => setDismissed(true)} className="h-8 cursor-pointer rounded-md border border-white/20 px-3 text-[12px] hover:bg-white/10">나중에</button>
<button type="button" onClick={() => setDismissed(true)} className="h-8 cursor-pointer rounded-md border border-white/20 px-3 text-[12px] hover:bg-white/10">{t("나중에")}</button>
<button type="button" disabled={busy} onClick={() => void retry()} className="flex h-8 cursor-pointer items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[12px] disabled:cursor-wait disabled:opacity-50 hover:bg-[#1d4ed8]">
<RefreshCw size={14} className={busy ? 'animate-spin' : ''} />{busy ? '확인 중' : '다시 확인'}
<RefreshCw size={14} className={busy ? 'animate-spin' : ''} />{busy ? t("확인 중") : t("다시 확인")}
</button>
</footer>
</dialog>, document.body,
@@ -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 (
<div className={`${className} grid place-items-center bg-[#080a10]/55 p-6 backdrop-blur-[1px]`}>
<div className="w-[390px] rounded-xl border border-white/10 bg-[#171a22] px-9 py-8 text-center shadow-[0_20px_50px_rgba(8,10,16,0.35)]">
<div className="py-2">
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-4 border-white/25 border-t-white" />
</div>
<strong className="mt-5 block text-[16px] font-bold text-white">{title}</strong>
<p className="mt-3 text-[13px] font-medium leading-relaxed text-white/60">{description}</p>
<strong className="mt-5 block text-[16px] font-bold text-white">{t(title)}</strong>
<p className="mt-3 text-[13px] font-medium leading-relaxed text-white/60">{t(description)}</p>
</div>
</div>
)
+14 -12
View File
@@ -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<HTMLDivElement>(null)
const visibleRunningSessions = sessionStatus === 'running' && runningSessions.length ? runningSessions : []
@@ -110,7 +112,7 @@ export default function Sidebar({
<Icon size={18} />
{collapsed && (
<span className="pointer-events-none absolute left-[46px] top-1/2 z-50 hidden -translate-y-1/2 whitespace-nowrap rounded-md border border-white/10 bg-[#303030] px-2 py-1 text-[11px] font-semibold text-white/78 shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block">
{disabled ? '강좌 선택 후 이동 가능' : item.label}
{disabled ? t("강좌 선택 후 이동 가능") : t(item.label)}
</span>
)}
<span
@@ -119,7 +121,7 @@ export default function Sidebar({
collapsed ? 'pointer-events-none w-0 opacity-0' : 'w-[132px] opacity-100 delay-100'
].join(' ')}
>
{item.label}
{t(item.label)}
</span>
</button>
)
@@ -148,13 +150,13 @@ export default function Sidebar({
{(session.startedAt || session.endsAt) && (
<span className="mt-1 block truncate text-[11px] text-white/38">
{[
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(' · ')}
</span>
)}
{session.runningModuleCount !== undefined && (
<span className="mt-0.5 block truncate text-[11px] text-white/38">실행 중인 모듈 {session.runningModuleCount}개</span>
<span className="mt-0.5 block truncate text-[11px] text-white/38">{t('실행 중인 모듈 {{count}}개', { count: session.runningModuleCount })}</span>
)}
</span>
</button>
@@ -166,8 +168,8 @@ export default function Sidebar({
event.stopPropagation()
onDisconnectSession?.(session.id)
}}
aria-label="세션 연결종료"
title="연결종료"
aria-label={t("세션 연결종료")}
title={t("연결종료")}
>
<Power size={15} />
</button>
@@ -207,13 +209,13 @@ export default function Sidebar({
{(session.startedAt || session.endsAt) && (
<span className="mt-1 block truncate text-[11px] text-white/38">
{[
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(' · ')}
</span>
)}
{session.runningModuleCount !== undefined && (
<span className="mt-0.5 block truncate text-[11px] text-white/38">실행 중인 모듈 {session.runningModuleCount}개</span>
<span className="mt-0.5 block truncate text-[11px] text-white/38">{t('실행 중인 모듈 {{count}}개', { count: session.runningModuleCount })}</span>
)}
</span>
</button>
@@ -226,8 +228,8 @@ export default function Sidebar({
setSessionPopoverOpen(false)
onDisconnectSession?.(session.id)
}}
aria-label="세션 연결종료"
title="연결종료"
aria-label={t("세션 연결종료")}
title={t("연결종료")}
>
<Power size={15} />
</button>
+43 -65
View File
@@ -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(' ')}
>
<span className="block whitespace-nowrap px-3 text-[11px] font-bold">
{activeTransientNotice?.title}
{t(activeTransientNotice?.title ?? '')}
</span>
</div>
<Tooltip label="도움말">
<Tooltip label={t("도움말")}>
<button type="button" aria-label="Help" onClick={onHelpClick} className={winButton}>
<HelpCircle size={16} />
</button>
</Tooltip>
<div className="relative" ref={toolsRef}>
<Tooltip label="도구 보기" disabled={toolsOpen}>
<Tooltip label={t("도구 보기")} disabled={toolsOpen}>
<button
type="button"
aria-label="도구 보기"
aria-label={t("도구 보기")}
aria-expanded={toolsOpen}
onClick={() => {
setToolsOpen((current) => !current)
@@ -586,9 +588,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
onClick={() => window.location.reload()}
>
<RotateCw size={15} />
새로고침
</button>
<RotateCw size={15} />{t("새로고침")}</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
@@ -598,9 +598,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
void loadAppLogs()
}}
>
<ScrollText size={15} />
앱 로그
</button>
<ScrollText size={15} />{t("앱 로그")}</button>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
@@ -611,14 +609,12 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
}).catch((error) => console.error('[titlebar] open DevTools request failed:', error))
}}
>
<Bug size={15} />
개발자 도구
</button>
<Bug size={15} />{t("개발자 도구")}</button>
</div>
) : null}
</div>
<div className="relative" ref={notificationRef}>
<Tooltip label="알림" disabled={notificationOpen}>
<Tooltip label={t("알림")} disabled={notificationOpen}>
<button
type="button"
aria-label="Notifications"
@@ -635,10 +631,10 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
<div className="absolute right-0 top-[34px] z-50 w-[360px] overflow-hidden rounded-xl border border-white/10 bg-[rgba(18,23,35,0.94)] text-white shadow-[0_22px_70px_rgba(0,0,0,0.48)] backdrop-blur-xl">
<div className="border-b border-white/10 px-4 py-3">
<div>
<p className="text-[13px] font-bold text-white">알림</p>
<p className="text-[13px] font-bold text-white">{t("알림")}</p>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold text-white/45">안읽음 {unreadCount}건</span>
<span className="text-[11px] font-semibold text-white/45">{t('안읽음 {{count}}건', { count: unreadCount })}</span>
<div className="flex items-center gap-2">
<button
type="button"
@@ -646,16 +642,12 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
disabled={unreadCount === 0}
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-white/10 px-2.5 text-[11px] font-semibold text-white/65 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
>
<CheckCheck size={13} />
모두 읽음
</button>
<CheckCheck size={13} />{t("모두 읽음")}</button>
<button
type="button"
onClick={handleOpenNotificationModal}
className="h-7 cursor-pointer rounded-md border border-[#3b7ffa]/30 bg-[#3b7ffa]/12 px-2.5 text-[11px] font-semibold text-[#9ec2ff] transition-colors hover:bg-[#3b7ffa]/20 hover:text-white"
>
전체 보기
</button>
>{t("전체 보기")}</button>
</div>
</div>
</div>
@@ -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)}
</span>
</div>
<span className={`shrink-0 text-[11px] font-semibold ${notification.read ? 'text-white/22' : 'text-white/45'}`}>
@@ -688,16 +680,16 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
</span>
</div>
<p className={`text-[13px] leading-5 ${notification.read ? 'font-medium text-white/30' : 'font-bold text-white/90'}`}>
{notification.title}
{notification.id.startsWith('local-') ? t(notification.title) : notification.title}
</p>
<p className={`mt-1 text-[12px] font-medium leading-5 ${notification.read ? 'text-white/24' : 'text-white/58'}`}>
{notification.body}
{notification.id.startsWith('local-') ? t(notification.body) : notification.body}
</p>
</button>
))
) : (
<div className="px-4 py-8 text-center">
<p className="text-[13px] font-semibold text-white/50">표시할 알림이 없습니다.</p>
<p className="text-[13px] font-semibold text-white/50">{t("표시할 알림이 없습니다.")}</p>
</div>
)}
</div>
@@ -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"
>
<BookOpen size={14} />
강좌 선택
</button>
<BookOpen size={14} />{t("강좌 선택")}</button>
) : null}
<button
type="button"
onClick={handleLogout}
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"
>
<LogOut size={14} />
종료
</button>
<LogOut size={14} />{t("종료")}</button>
</div>
</div>
) : null}
@@ -849,14 +837,14 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
<section className="flex h-[540px] w-full max-w-[680px] flex-col overflow-hidden rounded-xl border border-[rgba(255,255,255,0.15)] bg-[rgba(14,18,28,0.88)] text-white shadow-[0_24px_80px_rgba(0,0,0,0.56),inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-2xl">
<header className="flex h-[70px] shrink-0 items-center justify-between px-5">
<div>
<h2 className="text-[16px] font-bold text-white/90">전체 알림</h2>
<p className="mt-1 text-[11px] font-medium text-white/42">알림은 최대 하루동안만 저장됩니다.</p>
<h2 className="text-[16px] font-bold text-white/90">{t("전체 알림")}</h2>
<p className="mt-1 text-[11px] font-medium text-white/42">{t("알림은 최대 하루동안만 저장됩니다.")}</p>
</div>
<button
type="button"
onClick={() => setNotificationModalOpen(false)}
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white"
aria-label="닫기"
aria-label={t("닫기")}
>
<X size={18} />
</button>
@@ -869,7 +857,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
<input
value={notificationQuery}
onChange={(event) => 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]"
/>
</div>
@@ -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"
>
<CheckCheck size={14} />
모두 읽음
</button>
<CheckCheck size={14} />{t("모두 읽음")}</button>
</div>
<div className="mt-3 flex items-center justify-between">
@@ -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 })}
</button>
))}
</div>
<span className="text-[11px] font-semibold text-white/32">{filteredModalNotifications.length}건</span>
<span className="text-[11px] font-semibold text-white/32">{filteredModalNotifications.length}{t("건")}</span>
</div>
</div>
@@ -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)}
</span>
</div>
<span className={`shrink-0 text-[11px] font-semibold ${notification.read ? 'text-white/22' : 'text-white/45'}`}>
@@ -934,17 +920,17 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
</span>
</div>
<h3 className={`text-[13px] leading-5 ${notification.read ? 'font-medium text-white/30' : 'font-bold text-white/90'}`}>
{notification.title}
{notification.id.startsWith('local-') ? t(notification.title) : notification.title}
</h3>
<p className={`mt-1 text-[12px] font-medium leading-5 ${notification.read ? 'text-white/24' : 'text-white/58'}`}>
{notification.body}
{notification.id.startsWith('local-') ? t(notification.body) : notification.body}
</p>
</button>
))}
</div>
) : (
<div className="flex h-full items-center justify-center rounded-lg border border-white/10 bg-white/[0.025]">
<p className="text-[13px] font-semibold text-white/48">표시할 알림이 없습니다.</p>
<p className="text-[13px] font-semibold text-white/48">{t("표시할 알림이 없습니다.")}</p>
</div>
)}
</div>
@@ -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("이전 페이지")}
>
<ChevronLeft size={16} />
</button>
@@ -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("다음 페이지")}
>
<ChevronRight size={16} />
</button>
@@ -986,29 +972,27 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
<section className="flex h-[min(680px,calc(100vh-100px))] w-[min(920px,calc(100vw-80px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#171a21] shadow-[0_28px_90px_rgba(0,0,0,0.58)]">
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-white/10 px-5">
<div className="min-w-0">
<h2 className="text-[14px] font-bold text-white/92">앱 로그</h2>
<p className="mt-0.5 truncate text-[11px] font-medium text-white/42">{appLogPath || '로그 파일을 불러오는 중입니다.'}</p>
<h2 className="text-[14px] font-bold text-white/92">{t("앱 로그")}</h2>
<p className="mt-0.5 truncate text-[11px] font-medium text-white/42">{appLogPath || t("로그 파일을 불러오는 중입니다.")}</p>
</div>
<button
type="button"
onClick={() => setAppLogsOpen(false)}
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white"
aria-label="앱 로그 닫기"
aria-label={t("앱 로그 닫기")}
>
<X size={18} />
</button>
</header>
<div className="min-h-0 flex-1 bg-[#101217] p-4">
{appLogsLoading ? (
<div className="flex h-full items-center justify-center text-[13px] font-medium text-white/48">로그를 불러오는 중입니다.</div>
<div className="flex h-full items-center justify-center text-[13px] font-medium text-white/48">{t("로그를 불러오는 중입니다.")}</div>
) : appLogContent ? (
<pre className="h-full overflow-auto whitespace-pre-wrap break-words rounded-md border border-white/[0.07] bg-black/20 p-3 font-mono text-[11px] leading-5 text-white/70">
{appLogDisplayContent}
</pre>
) : (
<div className="flex h-full items-center justify-center rounded-md border border-dashed border-white/10 text-[13px] font-medium text-white/42">
아직 기록된 앱 로그가 없습니다.
</div>
<div className="flex h-full items-center justify-center rounded-md border border-dashed border-white/10 text-[13px] font-medium text-white/42">{t("아직 기록된 앱 로그가 없습니다.")}</div>
)}
</div>
<footer className="flex h-[58px] shrink-0 items-center justify-end gap-2 border-t border-white/10 px-5">
@@ -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"
>
<Clipboard size={14} />
{appLogsCopied ? '복사됨' : '복사'}
{appLogsCopied ? t("복사됨") : t("복사")}
</button>
<button
type="button"
@@ -1027,9 +1011,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
disabled={appLogsLoading}
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"
>
<RotateCw size={14} className={appLogsLoading ? 'animate-spin' : ''} />
새로고침
</button>
<RotateCw size={14} className={appLogsLoading ? 'animate-spin' : ''} />{t("새로고침")}</button>
</footer>
</section>
</div>
@@ -1052,23 +1034,19 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
</button>
</header>
<div className="px-5 py-5">
<p className="text-[13px] leading-relaxed text-white/62">앱을 종료하시겠습니까?</p>
<p className="text-[13px] leading-relaxed text-white/62">{t("앱을 종료하시겠습니까?")}</p>
</div>
<footer className="flex h-[58px] items-center justify-end gap-2 px-5">
<button
type="button"
onClick={() => setShowCloseConfirm(false)}
className="cursor-pointer rounded-md border border-[rgba(255,255,255,0.12)] px-5 py-2 text-[12px] font-semibold text-white/62 transition-colors hover:bg-white/[0.06] hover:text-white"
>
취소
</button>
>{t("취소")}</button>
<button
type="button"
onClick={handleConfirmClose}
className="cursor-pointer rounded-md border border-red-400/35 bg-red-500/15 px-5 py-2 text-[12px] font-bold text-red-100 transition-colors hover:border-red-400/50 hover:bg-red-500/24"
>
종료
</button>
>{t("종료")}</button>
</footer>
</div>
</div>
+131
View File
@@ -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%);
}
+26 -21
View File
@@ -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 (
<section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#10131b] text-white">
<section data-ai-guide-theme={dark ? 'dark' : 'light'} className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#10131b] text-white">
<header className="flex h-10 shrink-0 items-center justify-between border-b border-violet-200/15 bg-[#2a1d3f] px-3">
<strong className="truncate pl-4 text-[13px] font-bold text-white/90">AI 가이드</strong>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded text-white/55 transition hover:bg-white/[0.08] hover:text-white"><X size={16} /></button>
<strong className="flex min-w-0 items-center gap-2 pl-2 text-[13px] font-bold text-white/90"><Sparkles size={16} className="shrink-0" /><span className="translate-y-px truncate">{t("AI 가이드")}</span></strong>
<button type="button" aria-label={t("닫기")} title={t("닫기")} onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded text-white/55 transition hover:bg-white/[0.08] hover:text-white"><X size={16} /></button>
</header>
<div className="flex min-h-0 flex-1">
<aside data-help="ai-guide-context" className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#191b2a]">
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<div className="rounded-lg border border-cyan-300/18 bg-cyan-400/[0.07] px-3 pb-3 pt-2">
<span className="text-[10px] font-bold tracking-[0.08em] text-cyan-200">현재 학습</span>
<span className="text-[10px] font-bold tracking-[0.08em] text-cyan-200">{t("현재 학습")}</span>
<strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong>
<p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p>
<p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? t("강좌를 선택하면 학습 내용에 맞춰 안내합니다.")}</p>
</div>
<button type="button" disabled={busy || disabled} onClick={onClear} className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Plus size={15} className="shrink-0 text-violet-300" /><span className="min-w-0 flex-1">새 채팅</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>
<div className="mt-5"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">빠른 질문</span><div className="mt-2 grid gap-1">
{quickQuestions.map(({ Icon, label, prompt }) => <button key={label} type="button" disabled={disabled || busy || !weekNumber} onClick={() => onQuickSubmit(prompt)} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Icon size={15} className="shrink-0 text-violet-300" /><span className="min-w-0 flex-1">{label}</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>)}
<button type="button" disabled={busy || disabled} onClick={onClear} className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Plus size={15} className="shrink-0 text-violet-300" /><span className="min-w-0 flex-1">{t("새 채팅")}</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>
<div className="mt-5"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">{t("빠른 질문")}</span><div className="mt-2 grid gap-1">
{quickQuestions.map(({ Icon, label, prompt }) => <button key={label} type="button" disabled={disabled || busy || !weekNumber} onClick={() => onQuickSubmit(prompt)} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Icon size={15} className="shrink-0 text-violet-300" /><span className="min-w-0 flex-1">{t(label)}</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>)}
</div></div>
<div className="mt-6 border-t border-white/[0.08] pt-4"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">최근 채팅</span><div className="mt-2 grid gap-1">
{activeConversation ? <button type="button" onClick={() => selectConversation(null)} className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">현재 채팅</span></button> : null}
{history.map((thread) => <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><button type="button" onClick={() => selectConversation(thread.id)} className="min-w-0 flex-1 cursor-pointer px-2 py-2 text-left"><span className="block truncate text-[11px] font-semibold">{thread.title || '이전 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">{formatDate(thread.lastMessageAt)}</span></button><button type="button" title="채팅 삭제" aria-label="채팅 삭제" onClick={() => setDeleteConfirmThread(thread)} className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"><Trash2 size={13} /></button></div>)}
<div className="mt-6 border-t border-white/[0.08] pt-4"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">{t("최근 채팅")}</span><div className="mt-2 grid gap-1">
{activeConversation ? <button type="button" onClick={() => selectConversation(null)} className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><span className="block truncate text-[11px] font-semibold">{activeConversation.title || t("새 채팅")}</span><span className="mt-0.5 block text-[10px] text-white/32">{t("현재 채팅")}</span></button> : null}
{history.map((thread) => <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><button type="button" onClick={() => selectConversation(thread.id)} className="min-w-0 flex-1 cursor-pointer px-2 py-2 text-left"><span className="block truncate text-[11px] font-semibold">{thread.title || t("이전 채팅")}</span><span className="mt-0.5 block text-[10px] text-white/32">{formatDate(thread.lastMessageAt)}</span></button><button type="button" title={t("채팅 삭제")} aria-label={t("채팅 삭제")} onClick={() => setDeleteConfirmThread(thread)} className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"><Trash2 size={13} /></button></div>)}
</div></div>
</div>
</aside>
@@ -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 ? <div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 채팅을 불러오는 중</span></div> : disabled ? <div className="grid h-full min-h-[300px] place-items-center text-center"><div><span className="mx-auto grid size-10 place-items-center rounded-lg bg-white/[0.06] text-white/42"><MessageSquareText size={19} /></span><strong className="mt-3 block text-[14px] text-white/75">AI 가이드를 준비하고 있습니다</strong></div></div> : <div className="mx-auto flex w-full max-w-[860px] flex-col gap-5">
{messages.length === 0 && <div className="py-9 text-center"><span className="mx-auto grid size-11 place-items-center rounded-xl border border-violet-300/25 bg-violet-400/10 text-violet-200"><Sparkles size={20} /></span><h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2><p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p></div>}
{messages.map((message) => <article key={message.id} ref={message.role === 'user' ? (node) => { questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}><div className={message.role === 'user' ? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#5b4aa8] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(63,49,139,0.28)]' : 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3'}>{message.role === 'user' ? <p className="whitespace-pre-wrap">{message.content}</p> : <><>{(message.isTyping || message.id === typingMessageId) && <span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-violet-200"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>}</><ExpandableMarkdownMessage content={message.content || '응답을 작성하고 있습니다.'} dark defaultCollapsed={!message.keepExpanded && message.id !== latestAssistantMessageId} /></>}</div>{message.status === 'CANCELED' && <p className="mt-1 text-[10px] font-medium text-white/32">응답이 중단됨</p>}</article>)}
{busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3 text-[12px] font-semibold text-violet-200"><LoaderCircle size={15} className="animate-spin" /> 생각 중</div></article>}
{loading ? <div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" />{t("이전 채팅을 불러오는 중")}</span></div> : disabled ? <div className="grid h-full min-h-[300px] place-items-center text-center"><div><span className="mx-auto grid size-10 place-items-center rounded-lg bg-white/[0.06] text-white/42"><MessageSquareText size={19} /></span><strong className="mt-3 block text-[14px] text-white/75">{t("AI 가이드를 준비하고 있습니다")}</strong></div></div> : <div className="mx-auto flex w-full max-w-[860px] flex-col gap-5">
{messages.length === 0 && <div className="py-9 text-center"><span className="mx-auto grid size-11 place-items-center rounded-xl border border-violet-300/25 bg-violet-400/10 text-violet-200"><Sparkles size={20} /></span><h2 className="mt-4 text-[16px] font-bold text-white/86">{t("무엇이 궁금한가요?")}</h2><p className="mt-1 text-[12px] text-white/42">{t("개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.")}</p></div>}
{messages.map((message) => <article key={message.id} ref={message.role === 'user' ? (node) => { questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}><div className={message.role === 'user' ? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#5b4aa8] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(63,49,139,0.28)]' : 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3'}>{message.role === 'user' ? <p className="whitespace-pre-wrap">{message.content}</p> : <><>{(message.isTyping || message.id === typingMessageId) && <span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-violet-200"><LoaderCircle size={13} className="animate-spin" />{t("생각 중")}</span>}</><ExpandableMarkdownMessage content={message.content || t("응답을 작성하고 있습니다.")} dark={dark} defaultCollapsed={!message.keepExpanded && message.id !== latestAssistantMessageId} /></>}</div>{message.status === 'CANCELED' && <p className="mt-1 text-[10px] font-medium text-white/32">{t("응답이 중단됨")}</p>}</article>)}
{busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3 text-[12px] font-semibold text-violet-200"><LoaderCircle size={15} className="animate-spin" />{t("생각 중")}</div></article>}
</div>}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark accent="cyan" busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div>
{switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#10131b]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252238] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-violet-200" /> 채팅 불러오는 중</span></div>}
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark={dark} accent="cyan" busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div>
{switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#10131b]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252238] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-violet-200" />{t("채팅 불러오는 중")}</span></div>}
</main>
<aside data-help="ai-guide-questions" className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold text-white/44">질문 목록</span></div><div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside>
<aside data-help="ai-guide-questions" className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold text-white/44">{t("질문 목록")}</span></div><div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">{t("질문을 보내면 이곳에 표시됩니다.")}</div>}</div></aside>
</div>
{deleteConfirmThread && <div className="absolute inset-0 z-50 grid place-items-center bg-black/36"><section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#1b2a42] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]"><div className="flex items-center gap-2 border-b border-white/10 px-4 py-3"><Trash2 size={15} className="text-[#e87979]" /><strong className="text-[13px] font-bold">채팅 삭제</strong></div><div className="p-4"><p className="text-[13px] font-semibold text-white">이 채팅 내역을 삭제할까요?</p><p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 채팅'}</p><p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 채팅과 답변은 복구할 수 없습니다.</p><div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setDeleteConfirmThread(null)} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" onClick={() => { onDeleteHistory(deleteConfirmThread.id); setDeleteConfirmThread(null) }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]">삭제</button></div></div></section></div>}
{deleteConfirmThread && <div className="absolute inset-0 z-50 grid place-items-center bg-black/36"><section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#1b2a42] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]"><div className="flex items-center gap-2 border-b border-white/10 px-4 py-3"><Trash2 size={15} className="text-[#e87979]" /><strong className="text-[13px] font-bold">{t("채팅 삭제")}</strong></div><div className="p-4"><p className="text-[13px] font-semibold text-white">{t("이 채팅 내역을 삭제할까요?")}</p><p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || t("이전 채팅")}</p><p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">{t("삭제한 채팅과 답변은 복구할 수 없습니다.")}</p><div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setDeleteConfirmThread(null)} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/8 hover:text-white">{t("취소")}</button><button type="button" onClick={() => { onDeleteHistory(deleteConfirmThread.id); setDeleteConfirmThread(null) }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]">{t("삭제")}</button></div></div></section></div>}
</section>
)
}
@@ -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<CourseCatalogOption[]>([])
const [searching, setSearching] = useState(false)
@@ -105,17 +107,15 @@ export default function CourseSelectionScreen({
<div className="w-full rounded-xl border border-[#d5dbea] bg-white p-6 shadow-[0_18px_42px_rgba(20,27,44,0.12)]">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-[24px] font-black text-[#111520]">강좌 선택</h1>
<p className="mt-2 text-[13px] font-semibold text-[#64748b]">수강 중인 강좌를 선택하거나 새 강좌를 검색해 수강 신청하세요.</p>
<h1 className="text-[24px] font-black text-[#111520]">{t("강좌 선택")}</h1>
<p className="mt-2 text-[13px] font-semibold text-[#64748b]">{t("수강 중인 강좌를 선택하거나 새 강좌를 검색해 수강 신청하세요.")}</p>
</div>
{hasSelectedCourse ? (
<button
type="button"
onClick={onBack}
className="shrink-0 cursor-pointer border-0 bg-transparent px-0 py-1 text-[13px] font-black text-[#6f4df6] transition hover:text-[#4f35c8]"
>
뒤로가기
</button>
>{t("뒤로가기")}</button>
) : null}
</div>
@@ -125,7 +125,7 @@ export default function CourseSelectionScreen({
<input
value={query}
onChange={(event) => 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"
/>
</label>
@@ -134,8 +134,7 @@ export default function CourseSelectionScreen({
<div className="mt-3">
{searching ? (
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-[13px] font-bold text-[#64748b]">
<LoaderCircle size={17} className="animate-spin" /> 검색 중입니다.
</div>
<LoaderCircle size={17} className="animate-spin" />{t("검색 중입니다.")}</div>
) : searchResults.length ? (
<div className="grid gap-2">
{searchResults.map((course) => {
@@ -161,7 +160,7 @@ export default function CourseSelectionScreen({
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{teacherLabel(course)}</p>
</div>
{course.enrolled ? (
<span className="shrink-0 rounded-md bg-[#edf7f2] px-2 py-1 text-[11px] font-black text-[#16845b]">수강 중</span>
<span className="shrink-0 rounded-md bg-[#edf7f2] px-2 py-1 text-[11px] font-black text-[#16845b]">{t("수강 중")}</span>
) : selected ? <CheckCircle2 size={18} className="shrink-0 text-[#6f4df6]" /> : null}
</div>
</button>
@@ -169,9 +168,9 @@ export default function CourseSelectionScreen({
})}
</div>
) : (
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-center text-[13px] font-bold text-[#64748b]">검색 결과가 없습니다.</div>
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-center text-[13px] font-bold text-[#64748b]">{t("검색 결과가 없습니다.")}</div>
)}
{searchError ? <p className="mt-2 text-[12px] font-bold text-[#d74d54]">{searchError}</p> : null}
{searchError ? <p className="mt-2 text-[12px] font-bold text-[#d74d54]">{t(searchError || '')}</p> : null}
{selectedCatalogCourse ? (
<div className="mt-3 flex justify-end">
<button
@@ -180,9 +179,7 @@ export default function CourseSelectionScreen({
disabled={enrolling}
className="inline-flex h-10 items-center gap-2 rounded-md bg-[#6f4df6] px-4 text-[13px] font-black text-white transition hover:bg-[#5f42d7] disabled:cursor-wait disabled:opacity-60"
>
{enrolling ? <LoaderCircle size={16} className="animate-spin" /> : <UserPlus size={16} />}
수강 신청
</button>
{enrolling ? <LoaderCircle size={16} className="animate-spin" /> : <UserPlus size={16} />}{t("수강 신청")}</button>
</div>
) : null}
</div>
@@ -190,7 +187,7 @@ export default function CourseSelectionScreen({
</div>
<div className="mt-6 border-t border-[#edf0f5] pt-5">
<p className="text-[12px] font-black text-[#64748b]">수강 중인 강좌</p>
<p className="text-[12px] font-black text-[#64748b]">{t("수강 중인 강좌")}</p>
<div className="mt-3 grid gap-3">
{courses.length ? courses.map((course) => {
const selected = course.id === selectedCourseId
@@ -219,14 +216,14 @@ export default function CourseSelectionScreen({
</button>
)
}) : (
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-10 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.</div>
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-10 text-center text-[13px] font-bold text-[#64748b]">{t("수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.")}</div>
)}
</div>
</div>
{trialCourse ? (
<div className="mt-6 border-t border-[#edf0f5] pt-5">
<p className="text-[12px] font-black text-[#64748b]">둘러보기</p>
<p className="text-[12px] font-black text-[#64748b]">{t("둘러보기")}</p>
<button
type="button"
onClick={() => onSelectCourse(trialCourse.id)}
@@ -246,9 +243,7 @@ export default function CourseSelectionScreen({
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{trialCourse.teacher?.primaryOrganizationName ?? trialCourse.organizationName ?? 'Autonomy Studio'}</p>
</div>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-[#ded4ff] bg-[#f4f0ff] px-2.5 py-1 text-[11px] font-black text-[#6944d8]">
<Sparkles size={12} strokeWidth={2.5} />
체험
</span>
<Sparkles size={12} strokeWidth={2.5} />{t("체험")}</span>
</div>
</button>
</div>
@@ -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<string | null>(null)
const imageUrl = week.detail.coverImage?.downloadUrl
const hasCover = Boolean(imageUrl && failedImageUrl !== imageUrl)
return (
<div data-help="curriculum-detail" className="min-w-0 px-7 pb-7 pt-5">
<h2 className="text-[18px] font-bold leading-tight">상세정보</h2>
<h2 className="text-[18px] font-bold leading-tight">{t("상세정보")}</h2>
<div className="mt-5 min-h-[548px] pb-6">
{selected ? (
<>
<section className={`relative overflow-hidden rounded-lg border border-[#dde2ec] shadow-[0_10px_24px_rgba(20,27,44,0.05)] ${hasCover ? 'bg-white' : 'bg-[#fbfcff] p-5'}`}>
<div>
<div className={hasCover ? 'px-5 pb-3 pt-5' : undefined}>
<h3 className="text-[13px] font-semibold text-[#5f6878]">{week.week}주차 개요</h3>
<h3 className="text-[13px] font-semibold text-[#5f6878]">{t('{{week}}주차 개요', { week: week.week })}</h3>
<h3 className="mt-1 text-[22px] font-bold leading-tight text-[#111520]">{week.title}</h3>
</div>
{hasCover && (
<div className="relative h-[240px] overflow-hidden">
<img key={imageUrl} src={imageUrl} alt={`${week.week}주차 대표이미지`}
<img key={imageUrl} src={imageUrl} alt={t("{{v0}}주차 대표이미지", { v0: week.week })}
className="block h-full w-full object-cover object-center"
onError={() => setFailedImageUrl(imageUrl ?? null)} />
<div aria-hidden="true" className="pointer-events-none absolute inset-0"
@@ -87,7 +89,7 @@ export default function CurriculumDetailPanel({
<div className="flex items-center gap-2.5">
<span className={`grid h-7 w-7 shrink-0 place-items-center rounded-md text-[12px] font-bold ${active ? 'bg-[#714ef6] text-white' : 'bg-[#ede9ff] text-[#714ef6]'}`}>{index + 1}</span>
<h3 className="min-w-0 text-[15px] font-bold text-[#111520]">{topic}</h3>
{active ? <span className="rounded bg-[#ede9ff] px-2 py-1 text-[11px] font-bold text-[#714ef6]">진행중</span> : null}
{active ? <span className="rounded bg-[#ede9ff] px-2 py-1 text-[11px] font-bold text-[#714ef6]">{t("진행중")}</span> : null}
</div>
<p className="mt-1 pl-[38px] text-[14px] font-medium leading-[1.5] text-[#4f596b]">
{week.detail.topicDetails[index]}
@@ -100,9 +102,7 @@ export default function CurriculumDetailPanel({
{Boolean(week.detail.practiceContents?.length) && (
<section className="mt-4 rounded-lg border border-[#dde2ec] bg-white p-4 shadow-[0_10px_22px_rgba(20,27,44,0.04)]">
<h3 className="flex items-center gap-2 text-[14px] font-bold text-[#111520]">
<FileText size={15} className="text-[#714ef6]" />
실습 내용
</h3>
<FileText size={15} className="text-[#714ef6]" />{t("실습 내용")}</h3>
<div className="mt-3 grid">
{week.detail.practiceContents?.map((item, index) => (
<div key={item.title} className="flex gap-3 rounded-lg bg-[#f8f9fc] p-3">
@@ -119,17 +119,15 @@ export default function CurriculumDetailPanel({
<section className="mt-4 rounded-lg border border-[#d9d7ce] bg-[#f7f5ef] p-4">
<h3 className="flex items-center gap-2 text-[14px] font-bold text-[#111520]">
<CheckCircle2 size={15} className="text-[#7a6a2e]" />
제출 기준
</h3>
<CheckCircle2 size={15} className="text-[#7a6a2e]" />{t("제출 기준")}</h3>
<p className="mt-2 text-[14px] font-medium leading-relaxed text-[#4f596b]">{week.detail.deliverable}</p>
</section>
</>
) : (
<div className="grid min-h-[548px] place-items-center rounded-lg border border-dashed border-[#d9deea] bg-[#fbfcff] px-6 text-center">
<div>
<strong className="block text-[15px] font-bold text-[#111520]">표시할 상세정보가 없습니다.</strong>
<p className="mt-2 text-[13px] font-medium text-[#687284]">강의에 등록된 주차별 커리큘럼을 불러오지 못했거나 아직 등록된 데이터가 없습니다.</p>
<strong className="block text-[15px] font-bold text-[#111520]">{t("표시할 상세정보가 없습니다.")}</strong>
<p className="mt-2 text-[13px] font-medium text-[#687284]">{t("강의에 등록된 주차별 커리큘럼을 불러오지 못했거나 아직 등록된 데이터가 없습니다.")}</p>
</div>
</div>
)}
@@ -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<Week extends CurriculumHomeWeek>({
onStartStudio,
onOpenMaterials
}: CurriculumHomeScreenProps<Week>): React.JSX.Element {
useLocaleRender()
return (
<div className="relative mx-auto grid max-w-[1360px] grid-cols-[minmax(0,1fr)_260px] gap-[24px] px-1">
<div className="min-w-0">
@@ -63,14 +65,14 @@ export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
<div className="absolute inset-0 bg-black/20" />
<div className="relative z-[1] max-w-[390px]">
<span className="mb-2 inline-block text-[14px] font-bold text-[#ffb85f]">Autonomy Studio</span>
<h1 className="max-w-[360px] text-[30px] font-bold uppercase leading-[1.04] text-white">커리큘럼</h1>
<p className="mt-2 max-w-[310px] text-[13px] leading-relaxed text-white/75">단계별 자율주행 알고리즘 학습 과정</p>
<h1 className="max-w-[360px] text-[30px] font-bold uppercase leading-[1.04] text-white">{t("커리큘럼")}</h1>
<p className="mt-2 max-w-[310px] text-[13px] leading-relaxed text-white/75">{t("단계별 자율주행 알고리즘 학습 과정")}</p>
</div>
</div>
<section className="grid min-h-[680px] grid-cols-[300px_minmax(0,1fr)] overflow-hidden rounded-xl border border-[#d5dbea] bg-white shadow-[0_18px_42px_rgba(20,27,44,0.12)]">
<div className="border-r border-[#e4e7f0] bg-[#f6f3ff] p-5">
<div>
<h3 className="text-[19px] font-bold">주차별 커리큘럼</h3>
<h3 className="text-[19px] font-bold">{t("주차별 커리큘럼")}</h3>
<p className="mt-1 text-[11px] font-medium text-[#5f6878]">{curriculumSummary}</p>
</div>
<div data-help="curriculum-weeks" className="mt-8 grid gap-2.5">
@@ -85,9 +87,7 @@ export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
/>
))
) : (
<div className="rounded-lg border border-[#e2e6ef] bg-white/70 px-4 py-6 text-center text-[13px] font-semibold text-[#687284]">
등록된 커리큘럼이 없습니다.
</div>
<div className="rounded-lg border border-[#e2e6ef] bg-white/70 px-4 py-6 text-center text-[13px] font-semibold text-[#687284]">{t("등록된 커리큘럼이 없습니다.")}</div>
)}
</div>
</div>
@@ -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 (
<div
className={[
@@ -20,12 +22,12 @@ export default function InstructorStudioNoticeModal({
<section className="w-full max-w-[430px] overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.30)]">
<header className="flex items-start justify-between gap-4 border-b border-[#e5e8ef] px-6 pb-4 pt-5">
<div>
<span className="text-[12px] font-medium text-[#6f4df6]">개발 스튜디오</span>
<h2 className="mt-1 text-[21px] font-bold leading-tight text-[#111520]">강사 계정은 이용할 수 없습니다</h2>
<span className="text-[12px] font-medium text-[#6f4df6]">{t("개발 스튜디오")}</span>
<h2 className="mt-1 text-[21px] font-bold leading-tight text-[#111520]">{t("강사 계정은 이용할 수 없습니다")}</h2>
</div>
<button
type="button"
aria-label="닫기"
aria-label={t("닫기")}
onClick={onClose}
className="grid h-8 w-8 shrink-0 cursor-pointer place-items-center rounded-md text-[#697386] transition hover:bg-[#f0f2f7] hover:text-[#111520]"
>
@@ -34,16 +36,12 @@ export default function InstructorStudioNoticeModal({
</header>
<div className="px-6 py-5">
<p className="text-[14px] font-medium leading-relaxed text-[#4f596b]">
개발 스튜디오는 수강생 실습용 화면입니다. 강사 계정은 강사포털에서 수강생 관리, 채팅, 학습 진행 현황을 확인해 주세요.
</p>
<p className="text-[14px] font-medium leading-relaxed text-[#4f596b]">{t("개발 스튜디오는 수강생 실습용 화면입니다. 강사 계정은 강사포털에서 수강생 관리, 채팅, 학습 진행 현황을 확인해 주세요.")}</p>
<button
type="button"
onClick={onClose}
className="mt-5 flex h-10 w-full cursor-pointer items-center justify-center rounded-md bg-[#714ef6] text-[14px] font-bold text-white transition hover:bg-[#6040df]"
>
확인
</button>
>{t("확인")}</button>
</div>
</section>
</div>
@@ -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 (
<article className="rounded-lg bg-white p-4">
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-[#626b7a]">
<BookOpen size={13} className="text-[#714ef6]" />
학습 정보
</span>
<BookOpen size={13} className="text-[#714ef6]" />{t("학습 정보")}</span>
<div className="mt-1.5 flex items-start justify-between gap-2.5">
<div className="min-w-0">
<strong className="block truncate text-[18px] font-bold">{week.week}주차</strong>
<strong className="block truncate text-[18px] font-bold">{t('{{week}}주차', { week: week.week })}</strong>
<span className="mt-1 block text-[12px] font-medium leading-snug text-[#4f596b]">{week.title}</span>
</div>
<em
@@ -41,12 +41,12 @@ export default function LearningInfoCard({
week.tags[1] === '완료' ? 'bg-[#2fbda0]/15 text-[#178c78]' : 'bg-[#6f4df6]/15 text-[#6f4df6]'
].join(' ')}
>
{week.tags[1] ?? '미등록'}
{t(week.tags[1] ?? '미등록')}
</em>
</div>
<div className="mt-4">
<span className="block text-[12px] font-medium text-[#505b70]">진행률</span>
<span className="block text-[12px] font-medium text-[#505b70]">{t("진행률")}</span>
<div className="mt-1 flex items-center gap-2.5">
<span className="w-9 shrink-0 text-right text-[13px] font-bold">{week.progress}%</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[#d7dae3]">
@@ -57,8 +57,8 @@ export default function LearningInfoCard({
<div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff] py-1">
{[
{ 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 }) => (
<div
key={label}
@@ -90,9 +90,7 @@ export default function LearningInfoCard({
: 'cursor-pointer bg-[#714ef6] text-white shadow-[0_8px_18px_rgba(113,78,246,0.20)] hover:-translate-y-px hover:bg-[#6040df] hover:shadow-[0_10px_20px_rgba(113,78,246,0.26)] active:translate-y-0 active:shadow-[0_6px_14px_rgba(113,78,246,0.18)]'
].join(' ')}
>
<Play size={15} fill="currentColor" strokeWidth={2.4} />
개발 스튜디오 시작
</button>
<Play size={15} fill="currentColor" strokeWidth={2.4} />{t("개발 스튜디오 시작")}</button>
<button
type="button"
data-help="curriculum-materials"
@@ -105,9 +103,7 @@ export default function LearningInfoCard({
: 'cursor-not-allowed border-[#d8dce5] bg-[#f3f4f7] text-[#9aa1af]'
].join(' ')}
>
<FileText size={15} strokeWidth={2.4} />
강의 자료
</button>
<FileText size={15} strokeWidth={2.4} />{t("강의 자료")}</button>
</article>
)
}
@@ -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 (
<section className="rounded-lg border border-[#d6dce8] bg-white/95 p-4 shadow-[0_16px_36px_rgba(20,27,44,0.10)]">
<div>
<div>
<strong className="block font-bold">{guideMode ? '체험 강좌' : '내 학습 현황'}</strong>
<strong className="block font-bold">{guideMode ? t("체험 강좌") : t("내 학습 현황")}</strong>
<span className="mt-1 block text-[12px] font-medium text-[#5f6878]">
{guideMode ? '배정 전 사용법 안내 과정' : '전체 진행 상황 및 통계'}
{guideMode ? t("배정 전 사용법 안내 과정") : t("전체 진행 상황 및 통계")}
</span>
</div>
</div>
<div className="mt-4 overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff] shadow-[0_10px_24px_rgba(20,27,44,0.06)]">
{guideMode ? (
<div className="px-4 py-5 text-[12px] font-medium leading-relaxed text-[#5f6878]">
이 강좌는 Autonomy Studio LMS 사용법을 확인하기 위한 공통 안내입니다. 실제 학습 시간, 커밋 수, 제출 현황은 강사가 배정한 강좌에서부터 집계됩니다.
</div>
<div className="px-4 py-5 text-[12px] font-medium leading-relaxed text-[#5f6878]">{t("이 강좌는 Autonomy Studio LMS 사용법을 확인하기 위한 공통 안내입니다. 실제 학습 시간, 커밋 수, 제출 현황은 강사가 배정한 강좌에서부터 집계됩니다.")}</div>
) : 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
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-[#e9e3ff] text-[#5b48c9]">
<Icon size={15} strokeWidth={2.4} />
</span>
<span className="truncate text-[12px] font-medium text-[#4f596b]">{label}</span>
<span className="truncate text-[12px] font-medium text-[#4f596b]">{t(label)}</span>
</div>
<strong className="shrink-0 text-right text-[15px] font-bold text-[#151827]">{value}</strong>
</div>
)
})
) : (
<div className="px-4 py-6 text-center text-[12px] font-semibold text-[#687284]">
학습 현황 데이터가 없습니다.
</div>
<div className="px-4 py-6 text-center text-[12px] font-semibold text-[#687284]">{t("학습 현황 데이터가 없습니다.")}</div>
)}
</div>
</section>
@@ -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<Resource extends DownloadResource>({
onClose,
onDownload
}: MaterialsModalProps<Resource>): React.JSX.Element {
useLocaleRender()
return (
<div
className="fixed bottom-0 left-0 right-0 top-[38px] z-[70] grid place-items-center bg-[#080a10]/40 p-6 backdrop-blur-[3px]"
@@ -35,13 +37,13 @@ export default function MaterialsModal<Resource extends DownloadResource>({
<section className="flex max-h-[620px] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.30)]">
<header className="flex items-start justify-between gap-4 border-b border-[#e5e8ef] px-6 pb-4 pt-5">
<div className="min-w-0">
<span className="text-[12px] font-medium text-[#6f4df6]">{week.week}주차</span>
<h2 className="mt-1 text-[22px] font-bold leading-tight text-[#111520]">강의 자료</h2>
<span className="text-[12px] font-medium text-[#6f4df6]">{t('{{week}}주차', { week: week.week })}</span>
<h2 className="mt-1 text-[22px] font-bold leading-tight text-[#111520]">{t("강의 자료")}</h2>
<p className="mt-2 truncate text-[13px] font-medium text-[#5f6878]">{week.title}</p>
</div>
<button
type="button"
aria-label="닫기"
aria-label={t("닫기")}
onClick={onClose}
className="grid h-8 w-8 shrink-0 cursor-pointer place-items-center rounded-md text-[#697386] transition hover:bg-[#f0f2f7] hover:text-[#111520]"
>
@@ -75,7 +77,7 @@ export default function MaterialsModal<Resource extends DownloadResource>({
className="flex h-9 shrink-0 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] disabled:cursor-not-allowed disabled:opacity-60"
>
<Download size={14} />
{downloading ? '다운로드 중' : '다운로드'}
{downloading ? t("다운로드 중") : t("다운로드")}
</button>
</article>
)
@@ -22,6 +22,7 @@ export default function WeekCard<Week extends WeekCardWeek>({
selected = false,
onSelect
}: WeekCardProps<Week>): 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<Week extends WeekCardWeek>({
: 'bg-[#e7edf5] text-[#506178]'
].join(' ')}
>
{tag}
{index === 1 ? t(tag) : tag}
</span>
))}
</div>
@@ -104,3 +105,4 @@ export default function WeekCard<Week extends WeekCardWeek>({
</button>
)
}
import { t, useLocaleRender } from '../../i18n'
+13 -1
View File
@@ -1,6 +1,18 @@
import type { DriveStep } from 'driver.js'
import { t } from '../../i18n'
export function getContextHelpSteps({
export function getContextHelpSteps(options: Parameters<typeof buildContextHelpSteps>[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
@@ -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 (
<article className="flex items-center gap-4 rounded-lg border border-[#dfe3ed] bg-white p-4 shadow-[0_10px_24px_rgba(20,27,44,0.06)]">
<div className="relative grid h-12 w-12 shrink-0 place-items-center rounded-lg bg-[#ede9ff] text-[#714ef6]">
@@ -29,7 +31,7 @@ export function RunningPlatformCard({
<span className={['absolute right-2.5 top-2.5 h-2 w-2 rounded-full ring-2 ring-white', dotClassName].join(' ')} />
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.title}</h3>
<h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title}</h3>
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">{weekTitle}</p>
</div>
<div className="grid shrink-0 gap-2">
@@ -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]"
>
<Link2 size={14} />
연결
</button>
<Link2 size={14} />{t("연결")}</button>
<button
type="button"
disabled={disabled}
onClick={onClose}
className="flex h-8 min-w-[86px] cursor-pointer items-center justify-center gap-1.5 rounded-md border border-[#e58b8b] bg-white px-3 text-[12px] font-bold text-[#c24141] shadow-[0_1px_2px_rgba(20,27,44,0.08),inset_0_1px_0_rgba(255,255,255,0.70)] transition hover:border-[#d95f5f] hover:bg-[#fff7f7] hover:text-[#b91c1c] disabled:cursor-not-allowed disabled:border-[#c9d0dc] disabled:bg-[#f3f4f7] disabled:text-[#8a91a0]"
>
<Power size={14} />
종료
</button>
<Power size={14} />{t("종료")}</button>
</div>
</article>
)
@@ -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({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.title}</h3>
<h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platform.title}</h3>
</div>
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">{platform.description}</p>
<div className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-[#5f6878]">
@@ -117,7 +116,7 @@ export function PlatformOptionCard({
: locked
? <Lock size={13} className="text-[#7a8090]" />
: <AlertCircle size={13} className="text-[#c58a1d]" />}
{displayStatus}
{t(displayStatus)}
</div>
</div>
@@ -138,16 +137,14 @@ export function PlatformOptionCard({
].join(' ')}
>
{running ? null : <Link2 size={14} />}
{running ? '실행중' : primaryAction}
{running ? t("실행중") : t(primaryAction)}
</button>
<button
type="button"
onClick={onSettings}
className="flex h-8 w-[86px] cursor-pointer items-center justify-center gap-1.5 rounded-md border border-[#94a3b8] bg-white px-3 text-[12px] font-bold text-[#4f596b] shadow-[0_1px_2px_rgba(20,27,44,0.08),inset_0_1px_0_rgba(255,255,255,0.70)] transition hover:border-[#64748b] hover:bg-[#f8f9fc] hover:text-[#111520]"
>
<Settings size={14} />
설정
</button>
<Settings size={14} />{t("설정")}</button>
</div>
</article>
)
@@ -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})
</button>
))}
</div>
@@ -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 (
<div className="fixed bottom-0 left-0 right-0 top-[38px] z-50 grid place-items-center bg-[#080a10]/35 p-6 backdrop-blur-[2px]">
<section className="flex h-[min(660px,calc(100vh-86px))] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.28)]">
@@ -33,24 +35,24 @@ export default function PlatformSelectModal({
{mode === 'settings' && (
<button
type="button"
aria-label="플랫폼 선택으로 돌아가기"
aria-label={t("플랫폼 선택으로 돌아가기")}
onClick={onBack}
className="grid h-7 w-7 cursor-pointer place-items-center rounded-md text-[#697386] transition hover:bg-[#f0f2f7] hover:text-[#111520]"
>
<ChevronLeft size={18} />
</button>
)}
{mode === 'settings' ? '플랫폼 설정' : '플랫폼 선택'}
{mode === 'settings' ? t("플랫폼 설정") : t("플랫폼 선택")}
</h2>
<p className="mt-2 text-[13px] font-medium text-[#5f6878]">
{mode === 'settings'
? '플랫폼별 개발 환경 설정을 관리하세요.'
: '실습을 진행할 플랫폼을 선택하고 제어권을 요청하세요.'}
? t("플랫폼별 개발 환경 설정을 관리하세요.")
: t("실습을 진행할 플랫폼을 선택하고 제어권을 요청하세요.")}
</p>
</div>
<button
type="button"
aria-label="닫기"
aria-label={t("닫기")}
onClick={onClose}
className="grid h-8 w-8 shrink-0 cursor-pointer place-items-center rounded-md text-[#697386] transition hover:bg-[#f0f2f7] hover:text-[#111520]"
>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { CarFront, Laptop, Monitor } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type React from 'react'
@@ -46,11 +47,12 @@ export default function PlatformSelectionPanel({
onPlatformAction,
onOpenSettings
}: PlatformSelectionPanelProps): React.JSX.Element {
useLocaleRender()
return (
<div className={['h-full min-h-0 w-1/2 shrink-0 overflow-y-auto px-6 pb-5 pt-3 pr-5 transition-opacity duration-300', mode === 'settings' ? 'opacity-0' : 'opacity-100'].join(' ')}>
{runningPlatforms.length > 0 && (
<div className="mb-4 pr-1">
<p className="mb-2 text-[12px] font-black text-[#2563eb]">실행중인 플랫폼</p>
<p className="mb-2 text-[12px] font-black text-[#2563eb]">{t("실행중인 플랫폼")}</p>
<div className="grid gap-3">
{runningPlatforms.map((platform) => {
const Icon = PLATFORM_ICONS[platform.iconKey] ?? Monitor
@@ -89,12 +91,12 @@ export default function PlatformSelectionPanel({
const actionDisabled = statusPending || locked || unavailable
const runningInSelectionList = runningPlatforms.some((runningPlatform) => runningPlatform.id === platform.id)
const displayStatus = statusPending
? '확인 중'
: unavailable && !status.includes('연결 끊김') && !status.includes('연결 불가')
? '연결 끊김'
? t("확인 중")
: unavailable && !status.includes(t("연결 끊김")) && !status.includes(t("연결 불가"))
? t("연결 끊김")
: status
const hasControl = platform.control?.status === 'ACTIVE'
const primaryAction = statusPending ? '확인 중' : hasControl ? '연결' : action
const primaryAction = statusPending ? t("확인 중") : hasControl ? t("연결") : action
const platformActionDisabled = runningInSelectionList || actionDisabled
return (
@@ -119,8 +121,8 @@ export default function PlatformSelectionPanel({
}) : (
<div className="grid min-h-[220px] place-items-center rounded-lg border border-dashed border-[#d9deea] bg-[#fbfcff] px-6 text-center">
<div>
<strong className="block text-[15px] font-bold text-[#111520]">사용 가능한 플랫폼 데이터가 없습니다.</strong>
<p className="mt-2 text-[13px] font-medium text-[#687284]">강의에 배정된 플랫폼을 불러오지 못했거나 아직 등록된 데이터가 없습니다.</p>
<strong className="block text-[15px] font-bold text-[#111520]">{t("사용 가능한 플랫폼 데이터가 없습니다.")}</strong>
<p className="mt-2 text-[13px] font-medium text-[#687284]">{t("강의에 배정된 플랫폼을 불러오지 못했거나 아직 등록된 데이터가 없습니다.")}</p>
</div>
</div>
)}
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import type React from 'react'
import { StudioSettingsField, StudioSettingsSwitch } from '../workspace/StudioSettingsControls'
import type { PlatformOption, StudioSourceSettings } from './types'
@@ -25,28 +26,25 @@ export default function PlatformSettingsPanel({
onClose,
onSave
}: PlatformSettingsPanelProps): React.JSX.Element {
useLocaleRender()
const localPc = target?.platformKey === 'futuredrive-local-pc'
return (
<div className="h-full overflow-y-auto pr-1">
<div className="mb-5 border-b border-[#e5e8ef] pb-3">
<h3 className="text-[15px] font-black text-[#4f596b]">{target?.title ?? '플랫폼'}</h3>
<h3 className="text-[15px] font-black text-[#4f596b]">{target?.title ?? t("플랫폼")}</h3>
</div>
<div className="rounded-lg border border-[#dfe3ed] bg-[#fbfcff] p-4">
{!localPc ? (
<div>
<p className="text-[13px] font-black text-[#111520]">공용 플랫폼 환경</p>
<p className="mt-2 text-[12px] font-semibold leading-relaxed text-[#5f6878]">
원격 플랫폼의 워크스페이스, 저장소, GPU 설정은 강사/관리자 플랫폼 설정을 사용합니다.
</p>
<div className="mt-4 rounded-md bg-white px-3 py-2 text-[12px] font-semibold text-[#4f596b]">
실행 방식: 세션 단위 임시 런타임
</div>
<p className="text-[13px] font-black text-[#111520]">{t("공용 플랫폼 환경")}</p>
<p className="mt-2 text-[12px] font-semibold leading-relaxed text-[#5f6878]">{t("원격 플랫폼의 워크스페이스, 저장소, GPU 설정은 강사/관리자 플랫폼 설정을 사용합니다.")}</p>
<div className="mt-4 rounded-md bg-white px-3 py-2 text-[12px] font-semibold text-[#4f596b]">{t("실행 방식: 세션 단위 임시 런타임")}</div>
{canResetRosSession && (
<div className="mt-4 flex items-center justify-between gap-4 rounded-md border border-[#dfe3ed] bg-white px-3 py-3">
<div>
<p className="text-[12px] font-black text-[#273142]">ROS 연결 초기화</p>
<p className="mt-1 text-[11px] font-medium leading-relaxed text-[#697386]">실행 중인 모듈과 이전 피어 설정을 정리합니다.</p>
<p className="text-[12px] font-black text-[#273142]">{t("ROS 연결 초기화")}</p>
<p className="mt-1 text-[11px] font-medium leading-relaxed text-[#697386]">{t("실행 중인 모듈과 이전 피어 설정을 정리합니다.")}</p>
</div>
<button
type="button"
@@ -59,7 +57,7 @@ export default function PlatformSettingsPanel({
: 'cursor-pointer border-[#d7dde8] bg-white text-[#4f596b] hover:border-[#2563eb] hover:text-[#2563eb]'
].join(' ')}
>
{resettingRosSession ? '초기화 중' : '초기화'}
{resettingRosSession ? t("초기화 중") : t("초기화")}
</button>
</div>
)}
@@ -67,7 +65,7 @@ export default function PlatformSettingsPanel({
) : (
<>
<StudioSettingsField
label="사용자 워크스페이스 루트"
label={t("사용자 워크스페이스 루트")}
value={draft.workspaceRoot}
onChange={(value) => onDraftChange((settings) => ({ ...settings, workspaceRoot: value }))}
dark={false}
@@ -82,15 +80,15 @@ export default function PlatformSettingsPanel({
</div>
<div className="mt-4">
<StudioSettingsSwitch
label="GPU 가속"
description="ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다."
label={t("GPU 가속")}
description={t("ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
checked={draft.gpuEnabled}
onChange={(checked) => onDraftChange((settings) => ({ ...settings, gpuEnabled: checked }))}
dark={false}
/>
</div>
<div className="mt-4 rounded-lg border border-[#e1e6ef] bg-white p-4">
<p className="text-[12px] font-black text-[#4f596b]">저장소</p>
<p className="text-[12px] font-black text-[#4f596b]">{t("저장소")}</p>
<div className="mt-3 rounded-md bg-[#f8fafc] px-3 py-2 font-mono text-[12px] text-[#4f596b]">
clone path: {clonePath}
</div>
@@ -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("취소")}
</button>
{localPc && (
<button
type="button"
onClick={onSave}
className="h-9 cursor-pointer rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8]"
>
저장
</button>
>{t("저장")}</button>
)}
</div>
</div>
@@ -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 (
<div className="fixed bottom-0 left-0 right-0 top-[38px] z-50 grid place-items-center bg-[#080a10]/35 p-6 backdrop-blur-[2px]">
<section className="w-full max-w-[600px] overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.28)]">
<header className="flex items-start justify-between gap-4 border-b border-[#e5e8ef] px-6 pb-4 pt-5">
<div>
<span className="text-[12px] font-medium text-[#6f4df6]">개발 스튜디오</span>
<h2 className="mt-1 text-[22px] font-bold leading-tight text-[#111520]">최근 세션</h2>
<p className="mt-2 text-[13px] font-medium text-[#5f6878]">이전에 연결했던 플랫폼을 선택해 개발 환경에 연결하세요.</p>
<span className="text-[12px] font-medium text-[#6f4df6]">{t("개발 스튜디오")}</span>
<h2 className="mt-1 text-[22px] font-bold leading-tight text-[#111520]">{t("최근 세션")}</h2>
<p className="mt-2 text-[13px] font-medium text-[#5f6878]">{t("이전에 연결했던 플랫폼을 선택해 개발 환경에 연결하세요.")}</p>
</div>
<button
type="button"
aria-label="닫기"
aria-label={t("닫기")}
onClick={onClose}
className="grid h-8 w-8 shrink-0 cursor-pointer place-items-center rounded-md text-[#697386] transition hover:bg-[#f0f2f7] hover:text-[#111520]"
>
@@ -77,15 +79,13 @@ export default function RecentSessionModal({
onClick={onOpenSession}
className="flex h-9 shrink-0 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] disabled:cursor-default disabled:opacity-55"
>
<Link2 size={14} />
연결
</button>
<Link2 size={14} />{t("연결")}</button>
</article>
)) : (
<div className="grid min-h-[180px] place-items-center rounded-lg border border-dashed border-[#d9deea] bg-[#fbfcff] px-6 text-center">
<div>
<strong className="block text-[15px] font-bold text-[#111520]">최근 세션 데이터가 없습니다.</strong>
<p className="mt-2 text-[13px] font-medium text-[#687284]">아직 실행한 개발 환경 세션이 없습니다.</p>
<strong className="block text-[15px] font-bold text-[#111520]">{t("최근 세션 데이터가 없습니다.")}</strong>
<p className="mt-2 text-[13px] font-medium text-[#687284]">{t("아직 실행한 개발 환경 세션이 없습니다.")}</p>
</div>
</div>
)}
@@ -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<Module extends DeletableModule>({
onCancel,
onConfirm
}: ModuleDeleteConfirmProps<Module>): React.JSX.Element {
useLocaleRender()
const [deleteRepository, setDeleteRepository] = useState(false)
return (
@@ -34,16 +36,15 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
>
<div className={['flex items-center gap-2 border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#d8dde8]'].join(' ')}>
<Trash2 size={15} className="text-[#e87979]" />
<strong className="text-[13px] font-bold">삭제</strong>
<strong className="text-[13px] font-bold">{t("삭제")}</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>
{module.name} 삭제할까요?
</p>
{module.name}{t("삭제할까요?")}</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>
{module.state === 'uninstalled'
? '등록된 커스텀 모듈 정보를 삭제합니다.'
: '워크스페이스에 설치된 모듈 폴더가 삭제됩니다.'}
? t("등록된 커스텀 모듈 정보를 삭제합니다.")
: t("워크스페이스에 설치된 모듈 폴더가 삭제됩니다.")}
</p>
{allowRepositoryDeletion && module.state !== 'uninstalled' && <label
className={[
@@ -58,12 +59,8 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
className="mt-[2px] h-4 w-4 cursor-pointer accent-[#d14b4b]"
/>
<span>
<span className={['block text-[12px] font-bold', dark ? 'text-white/82' : 'text-[#1f2937]'].join(' ')}>
학생 저장소도 함께 삭제
</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/42' : 'text-[#7a8494]'].join(' ')}>
제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다.
</span>
<span className={['block text-[12px] font-bold', dark ? 'text-white/82' : 'text-[#1f2937]'].join(' ')}>{t("학생 저장소도 함께 삭제")}</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/42' : 'text-[#7a8494]'].join(' ')}>{t("제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다.")}</span>
</span>
</label>}
<div className="mt-4 flex justify-end gap-2">
@@ -74,16 +71,12 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
'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(' ')}
>
취소
</button>
>{t("취소")}</button>
<button
type="button"
onClick={() => onConfirm(module, { deleteRepository: allowRepositoryDeletion && deleteRepository })}
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
>
삭제
</button>
>{t("삭제")}</button>
</div>
</div>
</section>
@@ -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 (
<div className={['grid min-h-0 flex-1 place-items-center text-[12px] font-semibold', dark ? 'text-white/42' : 'text-[#7b8494]'].join(' ')}>
{loading ? (
<div className="grid justify-items-center gap-3">
<RefreshCw size={22} className={['animate-spin', dark ? 'text-white/55' : 'text-[#64748b]'].join(' ')} />
<span>모듈 목록 불러오는 중</span>
<span>{t("모듈 목록 불러오는 중")}</span>
</div>
) : (
'표시할 모듈 없음'
t("표시할 모듈 없음")
)}
</div>
)
@@ -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 (
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
<span className="text-center">상태</span>
<span className="text-center">분류</span>
<span>모듈명</span>
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#cbd5e1] bg-[#e2e8f0] text-[#334155]'].join(' ')}>
<span className="text-center">{t("상태")}</span>
<span className="text-center">{t("분류")}</span>
<span>{t("모듈명")}</span>
<span className="text-center">CPU</span>
<span className="text-center">메모리</span>
<span className="text-center">가동시간</span>
<span className="col-span-2 text-center">제어</span>
<span className="text-center">{t("메모리")}</span>
<span className="text-center">{t("가동시간")}</span>
<span className="col-span-2 text-center">{t("제어")}</span>
</div>
)
}
@@ -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<HTMLSpanElement>(null)
const dismissTimerRef = useRef<number | null>(null)
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
@@ -180,6 +182,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
onStopModule,
onRestartModule
}: ModuleListRowProps<Module>): 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<Module extends ModuleListRowItem>({
>
<span className="text-center">
<span className={['inline-flex h-6 items-center rounded px-2 text-[11px] font-bold', stateStyle[module.state]].join(' ')}>
{stateLabel[module.state]}
{t(stateLabel[module.state])}
</span>
</span>
<span className={['text-center', dark ? 'text-white/52' : 'text-[#687284]'].join(' ')}>{module.category}</span>
@@ -263,14 +266,12 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
<span className="flex min-w-0 items-center gap-2">
<span className={['truncate text-[12px] font-semibold', dark ? 'text-white/86' : 'text-[#111520]'].join(' ')}>{module.name}</span>
{module.sourceType === 'CUSTOM' && (
<span className={['shrink-0 rounded border px-1.5 py-[1px] text-[9px] font-bold', dark ? 'border-[#a78bfa]/35 bg-[#a78bfa]/12 text-[#c4b5fd]' : 'border-[#8b5cf6]/22 bg-[#f3efff] text-[#7350c8]'].join(' ')}>
사용자모듈
</span>
<span className={['shrink-0 rounded border px-1.5 py-[1px] text-[9px] font-bold', dark ? 'border-[#a78bfa]/35 bg-[#a78bfa]/12 text-[#c4b5fd]' : 'border-[#8b5cf6]/22 bg-[#f3efff] text-[#7350c8]'].join(' ')}>{t("사용자모듈")}</span>
)}
</span>
<button
type="button"
title="파라미터 설정"
title={t("파라미터 설정")}
onClick={(event) => {
event.stopPropagation()
onOpenParameters(module)
@@ -281,13 +282,13 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
dark ? 'text-white/32 hover:text-white/55' : 'text-[#8a94a5] hover:text-[#4f596b]'
].join(' ')}
>
{module.parameters.length ? module.parameters.join(' ') : '파라미터 설정'}
{module.parameters.length ? module.parameters.join(' ') : t("파라미터 설정")}
</button>
</span>
{busy ? (
<span role="status" className={['col-span-3 flex min-w-0 items-center justify-center gap-2 px-2 text-[11px]', dark ? 'text-white/65' : 'text-[#687284]'].join(' ')}>
<RefreshCw size={12} className="shrink-0 animate-spin" />
<span className="truncate" title={progress}>{progress || (installing ? '모듈 설치 중...' : '모듈 작업 준비 중...')}</span>
<span className="truncate" title={t(progress || '')}>{t(progress || (installing ? '모듈 설치 중...' : '모듈 작업 준비 중...'))}</span>
</span>
) : <>
<span className="flex items-center justify-center gap-1.5">
@@ -330,7 +331,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
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<Module extends ModuleListRowItem>({
].join(' ')}
>
<FolderOpen size={14} />
<TooltipLabel dark={dark}>열기</TooltipLabel>
<TooltipLabel dark={dark}>{t("열기")}</TooltipLabel>
</button>
)}
{controlActions.map(({ label, Icon, tone, action }) => (
@@ -355,11 +356,11 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
event.stopPropagation()
action(module)
}}
aria-label={label}
aria-label={t(label)}
className={actionButtonClass(dark, tone, disabled)}
>
{busy && !canCancelBuild ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
<TooltipLabel dark={dark}>{label}</TooltipLabel>
<TooltipLabel dark={dark}>{t(label)}</TooltipLabel>
</button>
)
})()
@@ -372,11 +373,11 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
event.stopPropagation()
onInstallModule(module)
}}
aria-label={installAction.label}
aria-label={t(installAction.label)}
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
>
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
<TooltipLabel dark={dark}>{t(installAction.label)}</TooltipLabel>
</button>
)}
</span>
@@ -389,11 +390,11 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
event.stopPropagation()
onDeleteModule(module)
}}
aria-label="삭제"
aria-label={t("삭제")}
className={actionButtonClass(dark, 'delete', busy)}
>
<Trash2 size={14} />
<TooltipLabel dark={dark}>삭제</TooltipLabel>
<TooltipLabel dark={dark}>{t("삭제")}</TooltipLabel>
</button>
) : (
<button
@@ -407,11 +408,11 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
}
onDeleteModule(module)
}}
aria-label={installAction.label}
aria-label={t(installAction.label)}
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
>
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
<TooltipLabel dark={dark}>{t(installAction.label)}</TooltipLabel>
</button>
)}
</span>
@@ -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 (
<div className={['flex h-10 items-end justify-between px-3', dark ? 'bg-[#252525]' : 'bg-[#f7f8fb]'].join(' ')}>
<div className={['flex min-h-10 flex-wrap items-end justify-between gap-x-3 gap-y-2 px-3 pt-2', dark ? 'bg-[#252525]' : 'bg-[#edf1f7]'].join(' ')}>
<div className="flex items-end">
{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)}
</button>
)
})}
@@ -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 ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
{label}
{t(label)}
</button>
))}
</div>
@@ -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<ModuleFilter>('all')
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(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 (
<section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<div className={['flex h-[69px] items-center justify-between gap-4 p-3', dark ? 'bg-[#252525]' : 'bg-[#eef1f6]'].join(' ')}>
<div className={['flex h-[69px] items-center justify-between gap-4 p-3', dark ? 'bg-[#252525]' : 'border-b border-[#cbd5e1] bg-[#e8edf5]'].join(' ')}>
<div>
<strong className={['block text-[13px] font-bold', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>모듈 리스트</strong>
<span className={['mt-1 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#727b8c]'].join(' ')}>자율주행 알고리즘 모듈 관리</span>
<strong className={['block text-[13px] font-bold', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>{t("모듈 리스트")}</strong>
<span className={['mt-1 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#727b8c]'].join(' ')}>{t("자율주행 알고리즘 모듈 관리")}</span>
</div>
{showSelectedMapPreview && (
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 text-right">
<span className={['block text-[10px] font-bold', dark ? 'text-white/40' : 'text-[#788396]'].join(' ')}>현재 맵</span>
<strong className={['block max-w-[176px] truncate text-[12px] font-bold', dark ? 'text-white/85' : 'text-[#263247]'].join(' ')}>{selectedMap.name} ({selectedMap.saved ? '주행' : 'SLAM'})</strong>
<span className={['block text-[10px] font-bold', dark ? 'text-white/40' : 'text-[#788396]'].join(' ')}>{t("현재 맵")}</span>
<strong className={['block max-w-[176px] truncate text-[12px] font-bold', dark ? 'text-white/85' : 'text-[#263247]'].join(' ')}>{getMapDisplayName(selectedMap)} ({selectedMap.saved ? t("주행") : 'SLAM'})</strong>
</div>
<WorkspaceMapPreview map={selectedMap} selected={false} compact borderless />
</div>
@@ -285,10 +286,10 @@ export default function ModuleListView({
<ModuleListHeader dark={dark} />
{(listError || installError) && (
<div className={['flex items-start justify-between gap-3 border-b px-3 py-2 text-[12px] font-semibold', dark ? 'border-white/10 bg-[#3a2525] text-[#ffb4b4]' : 'border-[#ffd4d4] bg-[#fff4f4] text-[#b42323]'].join(' ')}>
<p className="min-w-0 break-words">{listError || installError}</p>
<p className="min-w-0 break-words">{t(listError || installError || '')}</p>
<button
type="button"
aria-label="오류 닫기"
aria-label={t("오류 닫기")}
onClick={onDismissError}
className={['mt-0.5 shrink-0 cursor-pointer transition hover:opacity-100', dark ? 'text-[#ffb4b4]/60' : 'text-[#b42323]/60'].join(' ')}
>
@@ -313,8 +314,8 @@ export default function ModuleListView({
finishDrag()
}}
>
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'flex min-h-full flex-col'].join(' ')}>
{loading && !hasModuleRows ? (
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#c9bfd8]', 'flex min-h-full flex-col'].join(' ')}>
{loading ? (
<ModuleListEmptyState dark={dark} loading />
) : filteredModuleRows.length === 0 ? (
<ModuleListEmptyState dark={dark} />
@@ -342,15 +343,13 @@ export default function ModuleListView({
<div
role="separator"
aria-orientation="horizontal"
aria-label="보관 영역 크기 조절"
aria-label={t("보관 영역 크기 조절")}
onPointerDown={beginParkingResize}
className={['absolute -top-2 left-0 right-0 z-10 h-5 cursor-row-resize', dropZone === 'parking' ? (dark ? 'bg-[#3b82f6]/16' : 'bg-[#3b82f6]/10') : ''].join(' ')}
/>
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'thin-y-scrollbar h-full overflow-y-auto'].join(' ')}>
{filteredParkedRows.length > 0 ? filteredParkedRows.map((row) => renderModuleRow(row, true)) : (
<div className={['grid h-full place-items-center px-4 text-center text-[12px] font-medium', dark ? 'text-white/28' : 'text-[#7b8799]/55'].join(' ')}>
모듈을 이곳으로 드래그하면 전체 제어 대상에서 제외됩니다
</div>
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#c9bfd8]', 'thin-y-scrollbar h-full overflow-y-auto'].join(' ')}>
{loading ? null : filteredParkedRows.length > 0 ? filteredParkedRows.map((row) => renderModuleRow(row, true)) : (
<div className={['grid h-full place-items-center px-4 text-center text-[12px] font-medium', dark ? 'text-white/60' : 'text-[#687284]'].join(' ')}>{t("모듈을 이곳으로 드래그하면 전체 제어 대상에서 제외됩니다")}</div>
)}
</div>
</div>
@@ -360,7 +359,7 @@ export default function ModuleListView({
<div ref={floatingActionMenuRef} className="absolute -left-7 bottom-6 z-30 h-14 w-14">
<button
type="button"
aria-label="실행 방법 메뉴"
aria-label={t("실행 방법 메뉴")}
aria-expanded={floatingActionMenuOpen}
onClick={() => setFloatingActionMenuOpen((open) => !open)}
className={[
@@ -372,7 +371,7 @@ export default function ModuleListView({
</button>
<button
type="button"
aria-label="실행 가이드"
aria-label={t("실행 가이드")}
onClick={() => {
setModuleGuideOpen(true)
setFloatingActionMenuOpen(false)
@@ -383,13 +382,11 @@ export default function ModuleListView({
].join(' ')}
>
<BookOpen size={17} />
<span className="pointer-events-none absolute left-[calc(100%+8px)] whitespace-nowrap rounded-md bg-[#2563eb] px-2 py-1 text-[11px] font-bold text-white shadow-[0_6px_18px_rgba(20,54,104,0.2)]">
실행 가이드
</span>
<span className="pointer-events-none absolute left-[calc(100%+8px)] whitespace-nowrap rounded-md bg-[#2563eb] px-2 py-1 text-[11px] font-bold text-white shadow-[0_6px_18px_rgba(20,54,104,0.2)]">{t("실행 가이드")}</span>
</button>
<button
type="button"
aria-label="새 모듈"
aria-label={t("새 모듈")}
disabled={!allowCustomModuleCreation}
onClick={() => {
setModuleAddDialogOpen(true)
@@ -407,13 +404,11 @@ export default function ModuleListView({
<span className={[
'pointer-events-none absolute left-[calc(100%+8px)] whitespace-nowrap rounded-md px-2 py-1 text-[11px] font-bold shadow-[0_6px_18px_rgba(20,54,104,0.2)]',
allowCustomModuleCreation ? 'bg-[#2563eb] text-white' : 'bg-[#3b4250] text-white/35 shadow-none'
].join(' ')}>
새 모듈
</span>
].join(' ')}>{t("새 모듈")}</span>
</button>
<button
type="button"
aria-label="모듈 불러오기"
aria-label={t("모듈 불러오기")}
disabled={!allowCustomModuleCreation}
onClick={() => {
setModuleImportDialogOpen(true)
@@ -431,9 +426,7 @@ export default function ModuleListView({
<span className={[
'pointer-events-none absolute left-[calc(100%+8px)] whitespace-nowrap rounded-md px-2 py-1 text-[11px] font-bold shadow-[0_6px_18px_rgba(20,54,104,0.2)]',
allowCustomModuleCreation ? 'bg-[#2563eb] text-white' : 'bg-[#3b4250] text-white/35 shadow-none'
].join(' ')}>
모듈 불러오기
</span>
].join(' ')}>{t("모듈 불러오기")}</span>
</button>
</div>
{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("모듈 가이드")}
>
<section
className={['relative w-full max-w-[780px] overflow-hidden rounded-lg px-7 pb-7 pt-[52px] shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'bg-[#353535] text-white' : 'bg-white text-[#111520]'].join(' ')}
@@ -572,8 +566,8 @@ function ModuleGuideDialog({
type="button"
onClick={onDismiss}
className={['absolute right-[14px] top-3 z-10 grid h-7 w-7 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
aria-label="닫기"
title="닫기"
aria-label={t("닫기")}
title={t("닫기")}
>
<X size={16} aria-hidden="true" />
</button>
@@ -584,7 +578,7 @@ function ModuleGuideDialog({
<div className="mt-5 min-h-[266px] pb-3">
<div className={dark ? 'border-b border-white/10 pb-3' : 'border-b border-[#dfe4ec] pb-3'}>
<h2 className="text-[18px] font-bold">{page.environment}</h2>
<h2 className="text-[18px] font-bold">{t(page.environment)}</h2>
</div>
<div className="mt-4 grid grid-cols-3 gap-4">
<section>
@@ -594,19 +588,19 @@ function ModuleGuideDialog({
</ul>
</section>
<section className={dark ? 'border-l border-white/10 pl-4' : 'border-l border-[#dfe4ec] pl-4'}>
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>자율주행</h3>
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>{t("자율주행")}</h3>
<ul className={dark ? 'mt-1.5 grid list-disc gap-0.5 pl-4 text-[11px] font-semibold leading-5 text-white/78' : 'mt-1.5 grid list-disc gap-0.5 pl-4 text-[11px] font-semibold leading-5 text-[#445065]'}>
{page.navigationModules.map((module) => <li key={module}>{module}</li>)}
</ul>
</section>
<section className={dark ? 'border-l border-white/10 pl-4' : 'border-l border-[#dfe4ec] pl-4'}>
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>파라미터</h3>
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>{t("파라미터")}</h3>
<div className="mt-2 grid gap-2">
{page.parameters.map((parameter) => {
const [label, value] = parameter
return (
<fieldset key={label} className={dark ? 'min-w-0 rounded-md border border-white/12 px-2 pb-1.5' : 'min-w-0 rounded-md border border-[#d7dee9] px-2 pb-1.5'}>
<legend className={dark ? 'ml-1 px-1 font-sans text-[10px] font-bold text-white/56' : 'ml-1 px-1 font-sans text-[10px] font-bold text-[#6d7788]'}>{label}</legend>
<legend className={dark ? 'ml-1 px-1 font-sans text-[10px] font-bold text-white/56' : 'ml-1 px-1 font-sans text-[10px] font-bold text-[#6d7788]'}>{t(label)}</legend>
<code className={dark ? 'block pt-1 break-all text-[10px] leading-4 text-white/82' : 'block pt-1 break-all text-[10px] leading-4 text-[#374151]'}>{value}</code>
</fieldset>
)
@@ -623,10 +617,8 @@ function ModuleGuideDialog({
onClick={() => setPageIndex((current) => Math.max(0, current - 1))}
className={['inline-flex h-9 w-fit cursor-pointer items-center gap-1 rounded-md px-3 text-[12px] font-bold transition disabled:cursor-not-allowed disabled:opacity-35', dark ? 'bg-white/14 text-white hover:bg-white/22' : 'bg-[#eef1f6] text-[#445065] hover:bg-[#e1e6ee]'].join(' ')}
>
<ChevronLeft size={15} aria-hidden="true" />
이전
</button>
<div className="flex items-center justify-center gap-1.5" aria-label={`가이드 ${pageIndex + 1} / ${moduleGuidePages.length}`}>
<ChevronLeft size={15} aria-hidden="true" />{t("이전")}</button>
<div className="flex items-center justify-center gap-1.5" aria-label={t("가이드 {{v0}} / {{v1}}", { v0: pageIndex + 1, v1: moduleGuidePages.length })}>
{moduleGuidePages.map((guidePage, index) => (
<button
key={guidePage.environment}
@@ -636,7 +628,7 @@ function ModuleGuideDialog({
'h-1.5 w-1.5 cursor-pointer rounded-full transition',
index === pageIndex ? 'bg-[#2f8dff]' : (dark ? 'bg-white/18 hover:bg-white/35' : 'bg-[#c9ced9] hover:bg-[#9ea7b7]')
].join(' ')}
aria-label={`${index + 1}페이지`}
aria-label={t("{{v0}}페이지", { v0: index + 1 })}
aria-current={index === pageIndex ? 'page' : undefined}
/>
))}
@@ -652,7 +644,7 @@ function ModuleGuideDialog({
}}
className="inline-flex h-9 w-fit cursor-pointer justify-self-end items-center gap-1 rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8]"
>
{atLastPage ? '닫기' : '다음'}
{atLastPage ? t("닫기") : t("다음")}
{atLastPage ? <X size={14} aria-hidden="true" /> : <ChevronRight size={15} aria-hidden="true" />}
</button>
</footer>
@@ -682,6 +674,7 @@ function ModuleAddDialog({
onClose: () => void
onCreate: (input: CustomModuleInput) => Promise<void>
}): React.JSX.Element {
useLocaleRender()
const directDockerImageOption = '__direct_input__'
const [name, setName] = useState('')
const [category, setCategory] = useState('')
@@ -722,14 +715,14 @@ function ModuleAddDialog({
}, [])
return (
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label="모듈 정보 입력">
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label={t("모듈 정보 입력")}>
<section className={['w-full max-w-[440px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'].join(' ')}>
<header className={['flex items-center justify-between border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#d8dde8]'].join(' ')}>
<div>
<h2 className="text-[14px] font-bold">모듈 정보 입력</h2>
<p className={['mt-1 text-[11px] font-medium', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>추가할 ROS2 모듈의 기본 정보를 입력합니다.</p>
<h2 className="text-[14px] font-bold">{t("모듈 정보 입력")}</h2>
<p className={['mt-1 text-[11px] font-medium', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>{t("추가할 ROS2 모듈의 기본 정보를 입력합니다.")}</p>
</div>
<button type="button" onClick={onClose} className={['grid h-7 w-7 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')} aria-label="닫기">
<button type="button" onClick={onClose} className={['grid h-7 w-7 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')} aria-label={t("닫기")}>
<X size={15} aria-hidden="true" />
</button>
</header>
@@ -739,17 +732,13 @@ function ModuleAddDialog({
setSubmitting(true)
setError(null)
void onCreate({ name: name.trim(), category, dockerImage: dockerImage.trim(), packageName: packageName.trim(), launchFile: launchFile.trim() })
.catch((reason) => setError(reason instanceof Error ? reason.message : '모듈을 추가하지 못했습니다.'))
.catch((reason) => setError(reason instanceof Error ? reason.message : t("모듈을 추가하지 못했습니다.")))
.finally(() => setSubmitting(false))
}}>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
모듈 이름
<input value={name} onChange={(event) => setName(event.target.value)} className={inputClassName} placeholder="예: NAV2-MyPlanner" autoFocus />
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("모듈 이름")}<input value={name} onChange={(event) => setName(event.target.value)} className={inputClassName} placeholder={t("예: NAV2-MyPlanner")} autoFocus />
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
카테고리
<select value={category} onChange={(event) => setCategory(event.target.value)} className={inputClassName}>
<option value="">선택</option>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("카테고리")}<select value={category} onChange={(event) => setCategory(event.target.value)} className={inputClassName}>
<option value="">{t("선택")}</option>
<option value="Vehicle">Vehicle</option>
<option value="Sensing">Sensing</option>
<option value="Camera">Camera</option>
@@ -763,9 +752,7 @@ function ModuleAddDialog({
<option value="Gui">Gui</option>
</select>
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
실행 이미지
<select
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("실행 이미지")}<select
value={dockerImageSelection}
onChange={(event) => {
const selection = event.target.value
@@ -774,33 +761,27 @@ function ModuleAddDialog({
}}
className={inputClassName}
>
<option value="" disabled>선택</option>
<option value={directDockerImageOption}>직접 입력</option>
{dockerImagesLoading && <option value="__loading__" disabled>로컬 Docker 이미지 불러오는 중</option>}
<option value="" disabled>{t("선택")}</option>
<option value={directDockerImageOption}>{t("직접 입력")}</option>
{dockerImagesLoading && <option value="__loading__" disabled>{t("로컬 Docker 이미지 불러오는 중")}</option>}
{dockerImages.map((image) => <option key={image} value={image}>{image}</option>)}
</select>
</label>
{dockerImageSelection === directDockerImageOption && (
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
직접 입력
<input value={dockerImage} onChange={(event) => setDockerImage(event.target.value)} className={inputClassName} placeholder="예: future-nav2:humble" autoFocus />
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("직접 입력")}<input value={dockerImage} onChange={(event) => setDockerImage(event.target.value)} className={inputClassName} placeholder={t("예: future-nav2:humble")} autoFocus />
</label>
)}
{dockerImagesMessage && <p className={dark ? 'text-[11px] font-medium text-white/42' : 'text-[11px] font-medium text-[#7b8494]'}>{dockerImagesMessage}</p>}
<div className="grid grid-cols-2 gap-2">
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
패키지명
<input value={packageName} onChange={(event) => setPackageName(event.target.value)} className={inputClassName} placeholder="my_planner_pkg" />
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("패키지명")}<input value={packageName} onChange={(event) => setPackageName(event.target.value)} className={inputClassName} placeholder="my_planner_pkg" />
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
Launch 파일
<input value={launchFile} onChange={(event) => setLaunchFile(event.target.value)} className={inputClassName} placeholder="planner.launch.py" />
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("Launch 파일")}<input value={launchFile} onChange={(event) => setLaunchFile(event.target.value)} className={inputClassName} placeholder="planner.launch.py" />
</label>
</div>
{error && <p className={dark ? 'text-[11px] font-semibold text-[#ff9c9c]' : 'text-[11px] font-semibold text-[#b42323]'}>{error}</p>}
{error && <p className={dark ? 'text-[11px] font-semibold text-[#ff9c9c]' : 'text-[11px] font-semibold text-[#b42323]'}>{t(error || '')}</p>}
<footer className="mt-1 flex justify-end gap-2">
<button type="button" disabled={submitting} onClick={onClose} className={['h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition disabled:cursor-not-allowed', 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(' ')}>취소</button>
<button type="submit" disabled={submitting || !name.trim() || !category || !packageName.trim() || !launchFile.trim()} className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45">{submitting ? '추가 중' : '확인'}</button>
<button type="button" disabled={submitting} onClick={onClose} className={['h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition disabled:cursor-not-allowed', 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("취소")}</button>
<button type="submit" disabled={submitting || !name.trim() || !category || !packageName.trim() || !launchFile.trim()} className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45">{submitting ? t("추가 중") : t("확인")}</button>
</footer>
</form>
</section>
@@ -817,6 +798,7 @@ function ModuleImportDialog({
onClose: () => void
onImport: (input: CustomModuleImportInput) => Promise<void>
}): React.JSX.Element {
useLocaleRender()
const directDockerImageOption = '__direct_input__'
const [source, setSource] = useState<{
sourcePath: string
@@ -878,14 +860,14 @@ function ModuleImportDialog({
}
return (
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label="모듈 불러오기">
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label={t("모듈 불러오기")}>
<section className={['w-full max-w-[480px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'].join(' ')}>
<header className={['flex items-center justify-between border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#d8dde8]'].join(' ')}>
<div>
<h2 className="text-[14px] font-bold">모듈 불러오기</h2>
<p className={['mt-1 text-[11px] font-medium', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>ROS2 패키지 소스를 검증한 뒤 현재 워크스페이스로 복사합니다.</p>
<h2 className="text-[14px] font-bold">{t("모듈 불러오기")}</h2>
<p className={['mt-1 text-[11px] font-medium', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>{t("ROS2 패키지 소스를 검증한 뒤 현재 워크스페이스로 복사합니다.")}</p>
</div>
<button type="button" onClick={onClose} className={['grid h-7 w-7 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')} aria-label="닫기">
<button type="button" onClick={onClose} className={['grid h-7 w-7 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')} aria-label={t("닫기")}>
<X size={15} aria-hidden="true" />
</button>
</header>
@@ -902,36 +884,32 @@ function ModuleImportDialog({
launchFile,
sourcePath: source.sourcePath,
})
.catch((reason) => setError(reason instanceof Error ? reason.message : '모듈을 불러오지 못했습니다.'))
.catch((reason) => setError(reason instanceof Error ? reason.message : t("모듈을 불러오지 못했습니다.")))
.finally(() => setSubmitting(false))
}}>
<div className="grid gap-1.5">
<span className={['text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>모듈 경로</span>
<span className={['text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("모듈 경로")}</span>
<div className="flex gap-2">
<div className={['w-0 min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap rounded-md border px-2.5 py-2 text-[11px] font-medium', dark ? 'border-white/12 bg-[#1f1f1f] text-white/66' : 'border-[#cfd6e4] bg-[#f8fafc] text-[#596477]'].join(' ')} title={source?.sourcePath}>
{source?.sourcePath ?? '선택한 폴더 없음'}
{source?.sourcePath ?? t("선택한 폴더 없음")}
</div>
<button type="button" onClick={selectSource} disabled={selectingSource || submitting} className="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[#2563eb]/50 px-2.5 text-[12px] font-bold text-[#2f8dff] transition hover:bg-[#2563eb]/10 disabled:cursor-not-allowed disabled:opacity-45">
<FolderOpen size={14} aria-hidden="true" />
{selectingSource ? '확인 중' : '경로 선택'}
{selectingSource ? t("확인 중") : t("경로 선택")}
</button>
</div>
</div>
{source && (
<div className={['grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 rounded-md border px-3 py-2 text-[11px] font-medium', dark ? 'border-[#2f8dff]/25 bg-[#2563eb]/10 text-white/74' : 'border-[#b7d3ff] bg-[#f2f7ff] text-[#40516b]'].join(' ')}>
<span>패키지: <strong className={dark ? 'text-white' : 'text-[#173c70]'}>{source.packageName}</strong></span>
<span>빌드: <strong className={dark ? 'text-white' : 'text-[#173c70]'}>{source.buildType}</strong></span>
<span>{t("패키지:")}<strong className={dark ? 'text-white' : 'text-[#173c70]'}>{source.packageName}</strong></span>
<span>{t("빌드:")}<strong className={dark ? 'text-white' : 'text-[#173c70]'}>{source.buildType}</strong></span>
<span className="col-span-2">Launch: {source.launchFiles.join(', ')}</span>
</div>
)}
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
모듈 이름
<input value={name} onChange={(event) => setName(event.target.value)} className={inputClassName} placeholder="예: NAV2-MyPlanner" disabled={!source} autoFocus />
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("모듈 이름")}<input value={name} onChange={(event) => setName(event.target.value)} className={inputClassName} placeholder={t("예: NAV2-MyPlanner")} disabled={!source} autoFocus />
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
카테고리
<select value={category} onChange={(event) => setCategory(event.target.value)} className={inputClassName} disabled={!source}>
<option value="">선택</option>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("카테고리")}<select value={category} onChange={(event) => setCategory(event.target.value)} className={inputClassName} disabled={!source}>
<option value="">{t("선택")}</option>
<option value="Vehicle">Vehicle</option>
<option value="Sensing">Sensing</option>
<option value="Camera">Camera</option>
@@ -945,9 +923,7 @@ function ModuleImportDialog({
<option value="Gui">Gui</option>
</select>
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
실행 이미지
<select
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("실행 이미지")}<select
value={dockerImageSelection}
disabled={!source}
onChange={(event) => {
@@ -957,36 +933,30 @@ function ModuleImportDialog({
}}
className={inputClassName}
>
<option value="" disabled>선택</option>
<option value={directDockerImageOption}>직접 입력</option>
{dockerImagesLoading && <option value="__loading__" disabled>로컬 Docker 이미지 불러오는 중</option>}
<option value="" disabled>{t("선택")}</option>
<option value={directDockerImageOption}>{t("직접 입력")}</option>
{dockerImagesLoading && <option value="__loading__" disabled>{t("로컬 Docker 이미지 불러오는 중")}</option>}
{dockerImages.map((image) => <option key={image} value={image}>{image}</option>)}
</select>
</label>
{dockerImageSelection === directDockerImageOption && (
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
직접 입력
<input value={dockerImage} onChange={(event) => setDockerImage(event.target.value)} className={inputClassName} placeholder="예: future-nav2:humble" disabled={!source} />
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("직접 입력")}<input value={dockerImage} onChange={(event) => setDockerImage(event.target.value)} className={inputClassName} placeholder={t("예: future-nav2:humble")} disabled={!source} />
</label>
)}
{dockerImagesMessage && <p className={dark ? 'text-[11px] font-medium text-white/42' : 'text-[11px] font-medium text-[#7b8494]'}>{dockerImagesMessage}</p>}
<div className="grid grid-cols-2 gap-2">
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
패키지명
<input value={source?.packageName ?? ''} readOnly className={[inputClassName, 'cursor-default opacity-70'].join(' ')} />
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("패키지명")}<input value={source?.packageName ?? ''} readOnly className={[inputClassName, 'cursor-default opacity-70'].join(' ')} />
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
Launch 파일
<select value={launchFile} onChange={(event) => setLaunchFile(event.target.value)} className={inputClassName} disabled={!source}>
<option value="" disabled>선택</option>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>{t("Launch 파일")}<select value={launchFile} onChange={(event) => setLaunchFile(event.target.value)} className={inputClassName} disabled={!source}>
<option value="" disabled>{t("선택")}</option>
{source?.launchFiles.map((file) => <option key={file} value={file}>{file}</option>)}
</select>
</label>
</div>
{error && <p className={dark ? 'text-[11px] font-semibold text-[#ff9c9c]' : 'text-[11px] font-semibold text-[#b42323]'}>{error}</p>}
{error && <p className={dark ? 'text-[11px] font-semibold text-[#ff9c9c]' : 'text-[11px] font-semibold text-[#b42323]'}>{t(error || '')}</p>}
<footer className="mt-1 flex justify-end gap-2">
<button type="button" disabled={submitting} onClick={onClose} className={['h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition disabled:cursor-not-allowed', 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(' ')}>취소</button>
<button type="submit" disabled={submitting || !source || !name.trim() || !category || !launchFile} className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45">{submitting ? '불러오는 중' : '확인'}</button>
<button type="button" disabled={submitting} onClick={onClose} className={['h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition disabled:cursor-not-allowed', 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("취소")}</button>
<button type="submit" disabled={submitting || !source || !name.trim() || !category || !launchFile} className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45">{submitting ? t("불러오는 중") : t("확인")}</button>
</footer>
</form>
</section>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { FilePlus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import type React from 'react'
@@ -24,18 +25,19 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
onSave,
createParameterId
}: ModuleParameterDialogProps<Module>): React.JSX.Element {
useLocaleRender()
const [parameters, setParameters] = useState(initialParameters)
return (
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
<div className="fixed inset-0 z-[150] grid place-items-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label={t("파라미터 설정")}>
<section
className={[
'w-[500px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
'max-h-full w-full max-w-[500px] overflow-y-auto rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]',
dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'
].join(' ')}
>
<div className={['border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#d8dde8]'].join(' ')}>
<strong className="text-[13px] font-bold">파라미터 설정</strong>
<strong className="text-[13px] font-bold">{t("파라미터 설정")}</strong>
<span className={['mt-1 block text-[11px] font-semibold', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>
{module.name}
</span>
@@ -43,9 +45,9 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
<div className="p-4">
<div className="grid gap-2">
<div className={['grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_38px_32px] gap-2 text-[11px] font-bold', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>
<span>파라미터명</span>
<span>값</span>
<span className="text-center">사용</span>
<span>{t("파라미터명")}</span>
<span>{t("값")}</span>
<span className="text-center">{t("사용")}</span>
<span />
</div>
{parameters.map((parameter) => (
@@ -68,7 +70,7 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
].join(' ')}
placeholder=""
/>
<label className="flex h-8 cursor-pointer items-center justify-center" title="실행에 포함">
<label className="flex h-8 cursor-pointer items-center justify-center" title={t("실행에 포함")}>
<input
type="checkbox"
checked={parameter.enabled !== false}
@@ -96,9 +98,7 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
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(' ')}
>
<FilePlus size={13} />
추가
</button>
<FilePlus size={13} />{t("추가")}</button>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
@@ -108,16 +108,12 @@ export default function ModuleParameterDialog<Module extends ParameterModule>({
'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(' ')}
>
취소
</button>
>{t("취소")}</button>
<button
type="button"
onClick={() => onSave(module, parameters)}
className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8]"
>
저장
</button>
>{t("저장")}</button>
</div>
</div>
</section>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import type React from 'react'
type WorkspaceActivityHeaderProps = {
@@ -22,7 +23,9 @@ function getHeaderCopy(
if (mode === 'git') {
return {
title: '소스 제어',
subtitle: gitPanelTab === 'history' ? `제출 내역 ${gitSubmissionCount}개` : `모듈별 변경 파일 조회 · 변경 ${gitChangedFileCount}개`
subtitle: gitPanelTab === 'history'
? t('제출 내역 {{count}}개', { count: gitSubmissionCount })
: t('모듈별 변경 파일 조회 · 변경 {{count}}개', { count: gitChangedFileCount })
}
}
if (mode === 'empty') return { title: '코드 편집', subtitle: '모듈 소스 편집' }
@@ -38,6 +41,7 @@ export default function WorkspaceActivityHeader({
gitChangedFileCount,
children
}: WorkspaceActivityHeaderProps): React.JSX.Element {
useLocaleRender()
const copy = getHeaderCopy(mode, gitPanelTab, gitSubmissionCount, gitChangedFileCount)
if (!copy) {
@@ -47,9 +51,9 @@ export default function WorkspaceActivityHeader({
return (
<div className="flex h-full items-center px-3">
<div>
<strong className={['block text-[13px] font-bold', textStrong].join(' ')}>{copy.title}</strong>
<strong className={['block text-[13px] font-bold', textStrong].join(' ')}>{t(copy.title)}</strong>
<span className={['mt-0.5 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#727b8c]'].join(' ')}>
{copy.subtitle}
{t(copy.subtitle)}
</span>
</div>
</div>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { Camera, CirclePlay, Maximize2, Minimize2, Square, X } from 'lucide-react'
import { useEffect, useRef, useState, type RefObject } from 'react'
import { createPortal } from 'react-dom'
@@ -23,6 +24,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose, containerR
dark: boolean
onClose: () => void
}): React.JSX.Element | null {
useLocaleRender()
const [viewerUrl, setViewerUrl] = useState('')
const { frameRef, bringToFront, zIndex } = useWorkspaceWindowFocus('overview')
const [loading, setLoading] = useState(false)
@@ -75,7 +77,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose, containerR
const maxWidth = Math.max(0, bounds.width - position.x - 12)
const maxHeight = Math.max(0, bounds.height - position.y - 12)
return createPortal(
<div ref={panelRef} role="dialog" aria-label="오버뷰"
<div ref={panelRef} role="dialog" aria-label={t("오버뷰")}
onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}
className={['absolute flex flex-col overflow-hidden rounded-md border shadow-xl', dark ? 'border-white/20 bg-[#242424] text-white' : 'border-gray-300 bg-white text-gray-800'].join(' ')}
style={large ? { left: 0, right: 0, top: 40, bottom: 0, zIndex } : { zIndex, left: position.x, top: position.y, width: 660, height: 420, minWidth: Math.min(280, maxWidth), minHeight: Math.min(220, maxHeight), maxWidth, maxHeight, resize: 'both' }}>
@@ -93,23 +95,23 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose, containerR
setPosition({ x: Math.max(12, Math.min(container.clientWidth - width - 12, drag.current.left + event.clientX - drag.current.x)), y: Math.max(48, Math.min(container.clientHeight - height - 12, drag.current.top + event.clientY - drag.current.y)) })
}}
onPointerUp={() => { drag.current = null }} onLostPointerCapture={() => { drag.current = null }}>
<Camera size={15} /><strong className="min-w-0 flex-1 text-[13px]">오버뷰</strong>
{viewerUrl && <button type="button" title="영상 중지" aria-label="영상 중지" className={buttonClass} onClick={() => { setViewerUrl(''); setLoading(false) }}><Square size={14} /></button>}
<button type="button" title={large ? '원래 크기' : '확대'} aria-label={large ? '원래 크기' : '확대'} className={buttonClass} onClick={() => setLarge(!large)}>{large ? <Minimize2 size={15} /> : <Maximize2 size={15} />}</button>
<button type="button" title="닫기" aria-label="오버뷰 닫기" className={buttonClass} onClick={onClose}><X size={15} /></button>
<Camera size={15} /><strong className="min-w-0 flex-1 text-[13px]">{t("오버뷰")}</strong>
{viewerUrl && <button type="button" title={t("영상 중지")} aria-label={t("영상 중지")} className={buttonClass} onClick={() => { setViewerUrl(''); setLoading(false) }}><Square size={14} /></button>}
<button type="button" title={large ? t("원래 크기") : t("확대")} aria-label={large ? t("원래 크기") : t("확대")} className={buttonClass} onClick={() => setLarge(!large)}>{large ? <Minimize2 size={15} /> : <Maximize2 size={15} />}</button>
<button type="button" title={t("닫기")} aria-label={t("오버뷰 닫기")} className={buttonClass} onClick={onClose}><X size={15} /></button>
</div>
<div className="relative min-h-0 flex-1 bg-black">
{viewerUrl ? <>
{loading && <div role="status" className="absolute inset-0 grid place-items-center text-sm text-white">영상 연결 중...</div>}
<iframe ref={frameRef} key={viewerUrl} src={viewerUrl} title="주행장 오버뷰 영상" className="relative h-full w-full border-0"
{loading && <div role="status" className="absolute inset-0 grid place-items-center text-sm text-white">{t("영상 연결 중...")}</div>}
<iframe ref={frameRef} key={viewerUrl} src={viewerUrl} title={t("주행장 오버뷰 영상")} className="relative h-full w-full border-0"
allow="autoplay; fullscreen" sandbox="allow-scripts allow-same-origin" referrerPolicy="no-referrer"
onLoad={() => setLoading(false)} />
</> : <div className="absolute inset-0 bg-black/34">
<button type="button" disabled={!camera?.url} onClick={start} className="absolute left-1/2 top-1/2 inline-flex min-h-28 w-64 max-w-full -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 px-3 text-[15px] font-bold text-white/80 transition hover:text-white disabled:cursor-default disabled:opacity-40"><CirclePlay size={40} /><span>실행하기</span></button>
<button type="button" disabled={!camera?.url} onClick={start} className="absolute left-1/2 top-1/2 inline-flex min-h-28 w-64 max-w-full -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 px-3 text-[15px] font-bold text-white/80 transition hover:text-white disabled:cursor-default disabled:opacity-40"><CirclePlay size={40} /><span>{t("실행하기")}</span></button>
<div className="absolute inset-x-0 top-[70%] px-5 text-center">
<p className="text-[13px] font-bold text-white/72">주행장을 외부에서 촬영하는 영상입니다.</p>
{error && <p role="alert" className="text-[13px] font-bold text-red-300">{error}</p>}
{!camera?.url && <p className="text-[13px] font-bold text-white/72">등록된 주행장 카메라가 없습니다.</p>}
<p className="text-[13px] font-bold text-white/72">{t("주행장을 외부에서 촬영하는 영상입니다.")}</p>
{error && <p role="alert" className="text-[13px] font-bold text-red-300">{t(error || '')}</p>}
{!camera?.url && <p className="text-[13px] font-bold text-white/72">{t("등록된 주행장 카메라가 없습니다.")}</p>}
</div>
</div>}
</div>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react'
import { FileText, Loader2, RotateCw } from 'lucide-react'
@@ -44,13 +45,14 @@ function WorkspaceEditorPane({
onMount,
onChange
}: Props): React.JSX.Element {
useLocaleRender()
return (
<div ref={editorPaneRef} className="min-h-0 min-w-0 overflow-hidden">
{!currentActiveEditorFile ? (
<div className={['grid h-full place-items-center', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<div className="px-6 py-5 text-center">
<FileText size={24} className={['mx-auto mb-3', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')} />
<strong className={['block text-[13px] font-bold', textDim].join(' ')}>파일을 선택하세요</strong>
<strong className={['block text-[13px] font-bold', textDim].join(' ')}>{t("파일을 선택하세요")}</strong>
</div>
</div>
) : activeFileIsImage ? (
@@ -70,18 +72,16 @@ function WorkspaceEditorPane({
className="max-h-[82%] max-w-[82%] object-contain shadow-[0_18px_46px_rgba(0,0,0,0.28)]"
/>
) : (
<div className={['rounded-lg border px-5 py-4 text-[13px] font-bold', dark ? 'border-white/10 bg-[#252525] text-white/60' : 'border-[#d8dee9] bg-white text-[#687284]'].join(' ')}>
이미지 미리보기를 사용할 수 없습니다.
</div>
<div className={['rounded-lg border px-5 py-4 text-[13px] font-bold', dark ? 'border-white/10 bg-[#252525] text-white/60' : 'border-[#d8dee9] bg-white text-[#687284]'].join(' ')}>{t("이미지 미리보기를 사용할 수 없습니다.")}</div>
)}
</div>
</div>
) : fileLoading || fileError ? (
<div className={['flex h-full min-w-0 flex-col items-center justify-center gap-3 p-5 text-[13px]', textDim].join(' ')}>
{fileError ? <>
<p role="alert" className="max-w-full break-all">파일을 불러오지 못했습니다.</p>
<button type="button" title="다시 불러오기" aria-label="다시 불러오기" onClick={onRetryFile} className="cursor-pointer p-2"><RotateCw size={18} /></button>
</> : <><Loader2 size={18} className="animate-spin" /><span role="status">파일 불러오는 중...</span></>}
<p role="alert" className="max-w-full break-all">{t("파일을 불러오지 못했습니다.")}</p>
<button type="button" title={t("다시 불러오기")} aria-label={t("다시 불러오기")} onClick={onRetryFile} className="cursor-pointer p-2"><RotateCw size={18} /></button>
</> : <><Loader2 size={18} className="animate-spin" /><span role="status">{t("파일 불러오는 중...")}</span></>}
</div>
) : (
<Editor
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { Save } from 'lucide-react'
import Tooltip from '../../components/Tooltip'
@@ -16,6 +17,7 @@ function WorkspaceEditorPathBar({
isDirtyFile,
onSave
}: Props): React.JSX.Element | null {
useLocaleRender()
if (!currentActiveEditorFile) return null
return (
@@ -31,7 +33,7 @@ function WorkspaceEditorPathBar({
))}
</div>
{!exampleActive && (
<Tooltip label="저장" side="bottom">
<Tooltip label={t("저장")} side="bottom">
<button
type="button"
onClick={onSave}
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
type EditorTabContextMenuState = {
file: string
x: number
@@ -25,6 +26,7 @@ function WorkspaceEditorTabContextMenu({
onCloseAllTabs,
onDismiss
}: Props): React.JSX.Element | null {
useLocaleRender()
if (!menu) return null
const items = [
@@ -57,7 +59,7 @@ function WorkspaceEditorTabContextMenu({
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
{label}
{t(label)}
</button>
))}
</div>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { FileText, X } from 'lucide-react'
type EditorFilePath = string
@@ -33,6 +34,7 @@ function WorkspaceEditorTabs({
onDrop,
onDragEnd
}: Props): React.JSX.Element | null {
useLocaleRender()
if (files.length === 0) return null
return (
@@ -83,7 +85,7 @@ function WorkspaceEditorTabs({
<span
role="button"
tabIndex={0}
aria-label={`${file} 닫기`}
aria-label={t("{{v0}} 닫기", { v0: file })}
onClick={(event) => {
event.stopPropagation()
onCloseFile(file)
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { Code2 } from 'lucide-react'
import type React from 'react'
@@ -14,6 +15,7 @@ export default function WorkspaceEmptyModuleState({
textDim,
onOpenModuleList
}: WorkspaceEmptyModuleStateProps): React.JSX.Element {
useLocaleRender()
return (
<section
className={['grid min-w-0 place-items-center', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
@@ -21,15 +23,13 @@ export default function WorkspaceEmptyModuleState({
>
<div className="px-6 py-5 text-center">
<Code2 size={24} className={['mx-auto mb-3', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')} />
<strong className={['block text-[13px] font-bold', textStrong].join(' ')}>모듈을 선택하세요</strong>
<p className={['mt-1 text-[12px] font-medium', textDim].join(' ')}>모듈 리스트에서 소스를 볼 모듈을 선택하면 알고리즘 코드를 수정할 수 있습니다.</p>
<strong className={['block text-[13px] font-bold', textStrong].join(' ')}>{t("모듈을 선택하세요")}</strong>
<p className={['mt-1 text-[12px] font-medium', textDim].join(' ')}>{t("모듈 리스트에서 소스를 볼 모듈을 선택하면 알고리즘 코드를 수정할 수 있습니다.")}</p>
<button
type="button"
onClick={onOpenModuleList}
className="mt-3 cursor-pointer text-[12px] font-bold text-[#2563eb] underline underline-offset-4 transition hover:text-[#1d4ed8]"
>
리스트로 이동
</button>
>{t("리스트로 이동")}</button>
</div>
</section>
)
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { ChevronDown, FilePlus, FileText, Folder, FolderPlus, ListCollapse, RefreshCw, Search, X } from 'lucide-react'
import { Fragment } from 'react'
import type React from 'react'
@@ -109,6 +110,7 @@ function FileDraftInput({
onCommit: () => void
onCancel: () => void
}): React.JSX.Element {
useLocaleRender()
return (
<input
autoFocus
@@ -139,6 +141,7 @@ function DraftRow({
onCommit: () => void
onCancel: () => void
}): React.JSX.Element {
useLocaleRender()
return (
<div className="flex h-[24px] items-center pr-2" style={{ paddingLeft: `${depth * 10}px` }}>
{draft.kind === 'folder' ? <ChevronDown size={13} className="mr-[3px] shrink-0 text-transparent" /> : <span className="mr-[3px] w-[13px] shrink-0" />}
@@ -196,6 +199,7 @@ export default function WorkspaceFilePanel({
onRenameTreeTarget,
onDeleteTreeTarget
}: WorkspaceFilePanelProps): React.JSX.Element {
useLocaleRender()
return (
<aside
className={['flex min-h-0 flex-col border-r transition-opacity', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
@@ -205,21 +209,21 @@ export default function WorkspaceFilePanel({
<strong className={['text-[11px] font-bold tracking-[0.08em]', textDim].join(' ')}>{searchActive ? 'SEARCH' : 'EXPLORER'}</strong>
{!searchActive && !exampleActive && (
<div className={['flex items-center gap-2', textMid].join(' ')}>
<button type="button" aria-label="파일 만들기" onClick={() => onStartCreateFile('file')} className={['group relative grid h-5 w-4 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<button type="button" aria-label={t("파일 만들기")} onClick={() => onStartCreateFile('file')} className={['group relative grid h-5 w-4 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<FilePlus size={14} />
<span className={toolbarTooltipClass(dark)}>파일 만들기</span>
<span className={toolbarTooltipClass(dark)}>{t("파일 만들기")}</span>
</button>
<button type="button" aria-label="폴더 만들기" onClick={() => onStartCreateFile('folder')} className={['group relative grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<button type="button" aria-label={t("폴더 만들기")} onClick={() => onStartCreateFile('folder')} className={['group relative grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<FolderPlus size={14} />
<span className={toolbarTooltipClass(dark)}>폴더 만들기</span>
<span className={toolbarTooltipClass(dark)}>{t("폴더 만들기")}</span>
</button>
<button type="button" aria-label="목록 새로고침" onClick={onRefreshModuleFiles} className={['group relative grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<button type="button" aria-label={t("목록 새로고침")} onClick={onRefreshModuleFiles} className={['group relative grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<RefreshCw size={14} />
<span className={toolbarTooltipClass(dark)}>목록 새로고침</span>
<span className={toolbarTooltipClass(dark)}>{t("목록 새로고침")}</span>
</button>
<button type="button" aria-label="하위 폴더 모두 닫기" onClick={onCollapseModuleFolders} className={['group relative grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<button type="button" aria-label={t("하위 폴더 모두 닫기")} onClick={onCollapseModuleFolders} className={['group relative grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#e8ebf2] hover:text-[#111520]'].join(' ')}>
<ListCollapse size={14} />
<span className={toolbarTooltipClass(dark)}>하위 폴더 모두 닫기</span>
<span className={toolbarTooltipClass(dark)}>{t("하위 폴더 모두 닫기")}</span>
</button>
</div>
)}
@@ -295,6 +299,7 @@ function SearchPanel({
'dark' | 'textMid' | 'textDim' | 'selectedModuleName' | 'moduleSearchInputRef' | 'moduleSearchQuery' |
'activeModuleSearchQuery' | 'moduleSearchRunning' | 'moduleSearchError' | 'moduleSearchResults' | 'onSearchChange' | 'onOpenSearchResult'
>): React.JSX.Element {
useLocaleRender()
return (
<div className={['flex min-h-0 flex-1 flex-col px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
<div className={['flex h-8 items-center gap-2 rounded-md border px-2', dark ? 'border-white/12 bg-[#1f1f1f]' : 'border-[#cfd6e4] bg-white'].join(' ')}>
@@ -303,11 +308,11 @@ function SearchPanel({
ref={moduleSearchInputRef}
value={moduleSearchQuery}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="검색"
placeholder={t("검색")}
className={['min-w-0 flex-1 bg-transparent text-[12px] font-medium outline-none', dark ? 'text-white/75 placeholder:text-white/30' : 'text-[#111520] placeholder:text-[#8a94a5]'].join(' ')}
/>
{moduleSearchQuery && (
<button type="button" aria-label="검색어 지우기" onClick={() => onSearchChange('')} className={['grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}>
<button type="button" aria-label={t("검색어 지우기")} onClick={() => onSearchChange('')} className={['grid h-5 w-5 cursor-pointer place-items-center rounded transition', dark ? 'hover:bg-white/10 hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}>
<X size={12} />
</button>
)}
@@ -315,17 +320,17 @@ function SearchPanel({
<div className="thin-y-scrollbar mt-3 min-h-0 flex-1 overflow-auto pr-1">
<div className="grid gap-1">
{!moduleSearchQuery.trim() ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색어를 입력하세요.</p>
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>{t("검색어를 입력하세요.")}</p>
) : moduleSearchQuery.trim().length < 2 ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>2글자 이상 입력하세요.</p>
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>{t("2글자 이상 입력하세요.")}</p>
) : moduleSearchQuery.trim() !== activeModuleSearchQuery ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 중...</p>
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>{t("검색 중...")}</p>
) : moduleSearchRunning ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 중...</p>
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>{t("검색 중...")}</p>
) : moduleSearchError ? (
<p role="alert" className="px-3 py-2 text-[12px] text-red-500">{moduleSearchError}</p>
<p role="alert" className="px-3 py-2 text-[12px] text-red-500">{t(moduleSearchError || '')}</p>
) : moduleSearchResults.length === 0 ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 결과가 없습니다.</p>
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>{t("검색 결과가 없습니다.")}</p>
) : (
moduleSearchResults.map((result) => {
const query = activeModuleSearchQuery
@@ -368,6 +373,7 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
'activeModuleSearchQuery' | 'moduleSearchRunning' | 'moduleSearchResults' | 'onStartCreateFile' | 'onRefreshModuleFiles' |
'onCollapseModuleFolders' | 'onSearchChange' | 'onOpenSearchResult'
>): React.JSX.Element {
useLocaleRender()
const {
dark,
textMid,
@@ -428,7 +434,7 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
<DraftRow dark={dark} draft={newFileDraft} depth={newFileDraft.depth} onChange={onDraftChange} onCommit={onCommitCreateFile} onCancel={onCancelCreateFile} />
)}
{moduleFileTreeLoading && (
<div className="grid gap-1 py-1 pl-6 pr-2" aria-label="파일 트리 로딩 중">
<div className="grid gap-1 py-1 pl-6 pr-2" aria-label={t("파일 트리 로딩 중")}>
{[0, 1, 2].map((item) => (
<div key={item} className="flex h-[22px] items-center gap-2">
<span className={['h-3 w-3 shrink-0 animate-pulse rounded-sm', dark ? 'bg-white/10' : 'bg-[#cfd6e4]'].join(' ')} />
@@ -522,6 +528,7 @@ function FolderRow({
onCommitCreateFile: () => void
onCancelCreateFile: () => void
}): React.JSX.Element {
useLocaleRender()
return (
<Fragment>
<button
@@ -578,6 +585,7 @@ function FileRow({
onCommitCreateFile: () => void
onCancelCreateFile: () => void
}): React.JSX.Element {
useLocaleRender()
return (
<Fragment>
<button
@@ -638,6 +646,7 @@ function TreeContextMenu({
onRename: () => void
onDelete: () => void
}): React.JSX.Element {
useLocaleRender()
const menuItems = [
{ label: '열기', action: onOpen, disabled: false, separatorAfter: true },
{ label: '자르기', action: onCut, disabled: menu.path === workspaceRootPath },
@@ -677,7 +686,7 @@ function TreeContextMenu({
: dark ? 'cursor-pointer hover:bg-[#37373d] hover:text-white' : 'cursor-pointer hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
{label}
{t(label)}
</button>
{separatorAfter && <div className={dark ? 'my-1 h-px bg-white/10' : 'my-1 h-px bg-[#e5e8ef]'} />}
</div>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { BookOpen, ChevronDown, Download, FileText, Minus, Play } from 'lucide-react'
import type React from 'react'
@@ -95,29 +96,26 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
onToggleMaterial,
onDownloadResource
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
useLocaleRender()
return (
<aside
data-help="workspace-guide"
className={['flex min-w-0 flex-col border-r transition-opacity', minimized ? 'pointer-events-none overflow-hidden border-r-0 opacity-0' : '', panelClassName].join(' ')}
style={workspaceActive ? { gridColumn: 1, gridRow: '1 / span 2' } : { gridColumn: 1, gridRow: 1 }}
>
<div className={['border-b p-3', borderSoft].join(' ')}>
<div className={['border-b p-3', borderSoft, dark ? '' : 'bg-[#edf1f7]'].join(' ')}>
<div className="flex items-center justify-between gap-2">
<h3 className={['flex items-center gap-2 text-[13px] font-bold', textStrong].join(' ')}>
<BookOpen size={14} className={textDim} />
학습가이드
</h3>
<button type="button" aria-label="학습가이드 최소화" onClick={onMinimize} className={[iconButtonClass, 'group relative'].join(' ')}>
<BookOpen size={14} className={textDim} />{t("학습가이드")}</h3>
<button type="button" aria-label={t("학습가이드 최소화")} onClick={onMinimize} className={[iconButtonClass, 'group relative'].join(' ')}>
<Minus size={15} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
최소화
</span>
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span>
</button>
</div>
<div className={['mt-3 grid grid-cols-2 overflow-hidden rounded-md border', borderSoft].join(' ')}>
{[
{ id: 'material', label: '강의내용', Icon: BookOpen },
{ id: 'reference', label: '참고자료', Icon: FileText }
{ id: 'material', label: t("강의내용"), Icon: BookOpen },
{ id: 'reference', label: t("참고자료"), Icon: FileText }
].map(({ id, label, Icon }) => (
<button
key={id}
@@ -133,12 +131,12 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
].join(' ')}
>
<Icon size={14} />
{label}
{t(label)}
</button>
))}
</div>
</div>
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain p-3">
<div className={['thin-y-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain p-3', !dark && resourceMode === 'material' ? 'bg-[#f4f4f5]' : ''].join(' ')}>
{resourceMode === 'material' ? (
<div className="grid gap-3">
{materialItems.length ? materialItems.map((item, index) => {
@@ -165,25 +163,23 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
expanded ? 'px-2.5 pb-4 pt-2.5' : 'p-3',
canToggle ? 'cursor-pointer' : '',
active
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#2563eb]/55 bg-white'
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-[#f5f9ff]'
: canToggle
? dark ? 'border-white/12 bg-[#292929] opacity-92 hover:border-white/28 hover:bg-[#303030] hover:opacity-100' : 'border-[#dbe2ee] bg-[#f4f6fa] opacity-95 hover:border-[#bfc8d9] hover:bg-white hover:opacity-100'
: dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#e1e6ef] bg-[#f4f6fa] opacity-60'
? dark ? 'border-white/12 bg-[#292929] opacity-92 hover:border-white/28 hover:bg-[#303030] hover:opacity-100' : 'border-[#c5cfdd] bg-[#dce5f0] hover:border-[#9cacc2] hover:bg-[#edf3fb]'
: dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#cbd4e0] bg-[#dde3ec]'
].join(' ')}
>
<div className={['flex gap-2.5', expanded ? 'items-start' : 'items-center'].join(' ')}>
<span className={[
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold',
active ? 'bg-[#2563eb]/18 text-[#9dbdff]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-white text-[#4f596b]' : dark ? 'bg-white/8 text-white/38' : 'bg-white text-[#9aa3b2]'
active ? dark ? 'bg-[#2563eb]/18 text-[#9dbdff]' : 'bg-[#dbeafe] text-[#1e40af]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-[#e2e8f0] text-[#334155]' : dark ? 'bg-white/8 text-white/38' : 'bg-[#e2e8f0] text-[#475569]'
].join(' ')}>{index + 1}</span>
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
<div className="flex min-w-0 items-center justify-between gap-2">
<strong title={item.title} className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
<span className="flex shrink-0 items-center gap-1.5">
{active && (
<span className="rounded border border-[#60a5fa]/24 bg-[#2563eb]/16 px-2 py-0.5 text-[10px] font-bold text-[#bfdbfe]">
진행중
</span>
<span className={['rounded border px-2 py-0.5 text-[10px] font-bold', dark ? 'border-[#60a5fa]/24 bg-[#2563eb]/16 text-[#bfdbfe]' : 'border-[#b8cef3] bg-[#e3edfc] text-[#1e40af]'].join(' ')}>{t("진행중")}</span>
)}
{canToggle && (
<ChevronDown
@@ -206,7 +202,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
className={['group relative aspect-video w-full cursor-pointer overflow-hidden rounded-md border bg-black text-left', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')}
onClick={(event) => {
event.stopPropagation()
void window.learningVideo.open(youtubeEmbedUrl, `${item.title} 동영상강의`)
void window.learningVideo.open(youtubeEmbedUrl, t("{{v0}} 동영상강의", { v0: item.title }))
}}
>
<img
@@ -221,19 +217,17 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
<Play size={22} fill="currentColor" />
</span>
</span>
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">
동영상강의 보기
</span>
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">{t("동영상강의 보기")}</span>
</button>
)}
<section>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>학습 내용</span>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("학습 내용")}</span>
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
{item.description || topicDetails[index] || overview}
</p>
</section>
<section className={['border-b pb-2.5', borderSoft].join(' ')}>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>핵심 개념</span>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("핵심 개념")}</span>
<div className="mt-2 grid gap-1.5">
{(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => (
<div key={concept} className="flex items-center gap-2">
@@ -250,9 +244,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
</section>
)
}) : (
<div className={['rounded-lg border px-3 py-6 text-center text-[12px] font-semibold', dark ? 'border-white/10 bg-white/[0.03] text-white/42' : 'border-[#e1e6ef] bg-[#f7f8fb] text-[#7b8494]'].join(' ')}>
등록된 강의자료가 없습니다.
</div>
<div className={['rounded-lg border px-3 py-6 text-center text-[12px] font-semibold', dark ? 'border-white/10 bg-white/[0.03] text-white/42' : 'border-[#e1e6ef] bg-[#f7f8fb] text-[#7b8494]'].join(' ')}>{t("등록된 강의자료가 없습니다.")}</div>
)}
</div>
) : (
@@ -268,11 +260,11 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
className={[
'flex min-h-10 cursor-pointer items-center justify-between gap-2 rounded-md border px-3 py-2 text-left transition disabled:cursor-default',
downloading ? 'opacity-60' : '',
dark ? 'border-white/10 bg-[#292929] text-white/70 hover:bg-white/8 hover:text-white' : 'border-[#e1e6ef] bg-[#f7f8fb] text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
dark ? 'border-white/10 bg-[#292929] text-white/70 hover:bg-white/8 hover:text-white' : 'border-[#d4c9e4] bg-[#eee8f6] text-[#3f3553] hover:border-[#b69adf] hover:bg-[#e4d9f2] hover:text-[#38205b]'
].join(' ')}
>
<span className="min-w-0">
<span className="block truncate text-[12px] font-bold">{downloading ? '다운로드 중...' : resource.title}</span>
<span className="block truncate text-[12px] font-bold">{downloading ? t("다운로드 중...") : resource.title}</span>
{resource.fileName ? (
<span className={['mt-0.5 block truncate text-[10px] font-medium', textDim].join(' ')}>
{resource.fileName} · {getResourceSizeLabel(resource)}
@@ -287,9 +279,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
</button>
)
}) : (
<div className={['rounded-lg border px-3 py-6 text-center text-[12px] font-semibold', dark ? 'border-white/10 bg-white/[0.03] text-white/42' : 'border-[#e1e6ef] bg-[#f7f8fb] text-[#7b8494]'].join(' ')}>
등록된 참고자료가 없습니다.
</div>
<div className={['rounded-lg border px-3 py-6 text-center text-[12px] font-semibold', dark ? 'border-white/10 bg-white/[0.03] text-white/42' : 'border-[#e1e6ef] bg-[#f7f8fb] text-[#7b8494]'].join(' ')}>{t("등록된 참고자료가 없습니다.")}</div>
)}
</div>
)}
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { Terminal } from 'lucide-react'
type ModuleLogEntry = {
@@ -30,6 +31,7 @@ function WorkspaceLogView({
moduleLogScrollRef,
onScroll
}: Props): React.JSX.Element {
useLocaleRender()
return (
<section
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
@@ -39,7 +41,7 @@ function WorkspaceLogView({
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft].join(' ')}>
<div className="flex items-center gap-2">
<Terminal size={15} className={dark ? 'text-white/58' : 'text-[#687284]'} />
<span className={['text-[12px] font-bold', textMid].join(' ')}>실시간 로그</span>
<span className={['text-[12px] font-bold', textMid].join(' ')}>{t("실시간 로그")}</span>
</div>
<span className={['rounded px-2 py-1 font-mono text-[10px]', dark ? 'bg-white/7 text-white/45' : 'bg-[#e8ebf2] text-[#687284]'].join(' ')}>
{selectedModuleState === 'running' ? 'STREAMING' : 'IDLE'}
@@ -51,12 +53,10 @@ function WorkspaceLogView({
className={['thin-y-scrollbar min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-5', dark ? 'bg-[#171717] text-white/68' : 'bg-[#f9fafc] text-[#374151]'].join(' ')}
>
{moduleLogsLoading ? (
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', dark ? 'text-white/42' : 'text-[#7b8494]'].join(' ')}>
로그 불러오는 중
</div>
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', dark ? 'text-white/42' : 'text-[#7b8494]'].join(' ')}>{t("로그 불러오는 중")}</div>
) : visibleModuleLogEntries.length === 0 ? (
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', dark ? 'text-white/42' : 'text-[#7b8494]'].join(' ')}>
{moduleLogMessage ?? '표시할 로그 없음'}
{moduleLogMessage ?? t("표시할 로그 없음")}
</div>
) : visibleModuleLogEntries.map((entry, index) => (
<div key={`${entry.time}-${entry.source}-${index}`} className="flex min-w-0 gap-1.5">
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { FileText, LogOut, Power, Trash2, Upload, X } from 'lucide-react'
import type React from 'react'
@@ -35,6 +36,7 @@ export function TreeTargetDialog({
onDelete,
onCancel
}: TreeTargetDialogProps): React.JSX.Element {
useLocaleRender()
return (
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
<section
@@ -50,7 +52,7 @@ export function TreeTargetDialog({
<FileText size={15} className={textDim} />
)}
<strong className="text-[13px] font-bold">
{dialog.type === 'delete' ? '삭제' : '이름 변경'}
{dialog.type === 'delete' ? t("삭제") : t("이름 변경")}
</strong>
</div>
@@ -62,9 +64,7 @@ export function TreeTargetDialog({
onRename(dialog.path, dialog.value)
}}
>
<label className={['block text-[12px] font-semibold', textDim].join(' ')} htmlFor="tree-rename-input">
새 이름
</label>
<label className={['block text-[12px] font-semibold', textDim].join(' ')} htmlFor="tree-rename-input">{t("새 이름")}</label>
<input
id="tree-rename-input"
autoFocus
@@ -85,7 +85,7 @@ export function TreeTargetDialog({
</p>
<ModalButtonRow
dark={dark}
confirmLabel="변경"
confirmLabel={t("변경")}
confirmClassName="bg-[#2563eb] hover:bg-[#1d5ed8]"
onCancel={onCancel}
/>
@@ -93,11 +93,8 @@ export function TreeTargetDialog({
) : (
<div className="p-4">
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>
{getPathName(dialog.path)} 삭제할까요?
</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
선택한 {dialog.kind === 'folder' ? '폴더와 하위 항목' : '파일'}이 현재 작업 트리에서 제거됩니다.
</p>
{getPathName(dialog.path)}{t("삭제할까요?")}</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>{t("선택한")}{dialog.kind === 'folder' ? t("폴더와 하위 항목") : t("파일")}{t("이 현재 작업 트리에서 제거됩니다.")}</p>
<p className={['mt-2 truncate text-[11px] font-medium', textDim].join(' ')}>
{getDisplayPath(dialog.path)}
</p>
@@ -106,16 +103,12 @@ export function TreeTargetDialog({
type="button"
onClick={onCancel}
className={cancelButtonClass(dark)}
>
취소
</button>
>{t("취소")}</button>
<button
type="button"
onClick={() => onDelete(dialog.path)}
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
>
삭제
</button>
>{t("삭제")}</button>
</div>
</div>
)}
@@ -139,6 +132,7 @@ export function SourceSettingsSaveConfirmModal({
onCancel,
onConfirm
}: SourceSettingsSaveConfirmModalProps): React.JSX.Element {
useLocaleRender()
return (
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
<section
@@ -149,26 +143,20 @@ export function SourceSettingsSaveConfirmModal({
>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
<Power size={15} className="text-[#e87979]" />
<strong className="text-[13px] font-bold">모듈 종료</strong>
<strong className="text-[13px] font-bold">{t("모듈 종료")}</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>
실행 중인 모듈을 종료하고 설정을 저장할까요?
</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
워크스페이스 경로가 변경되면 현재 경로에서 실행 중인 모듈을 먼저 종료해야 합니다.
</p>
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>{t("실행 중인 모듈을 종료하고 설정을 저장할까요?")}</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>{t("워크스페이스 경로가 변경되면 현재 경로에서 실행 중인 모듈을 먼저 종료해야 합니다.")}</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>
취소
</button>
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{t("취소")}</button>
<button
type="button"
disabled={saving}
onClick={onConfirm}
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838] disabled:cursor-default disabled:opacity-60"
>
{saving ? '종료 중' : '종료하고 저장'}
{saving ? t("종료 중") : t("종료하고 저장")}
</button>
</div>
</div>
@@ -192,6 +180,7 @@ export function GitFileContextMenu({
y,
onRestore
}: GitFileContextMenuProps): React.JSX.Element {
useLocaleRender()
return (
<div
ref={menuRef}
@@ -208,9 +197,7 @@ export function GitFileContextMenu({
'flex h-8 w-full cursor-pointer items-center px-3 text-left transition',
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
되돌리기
</button>
>{t("되돌리기")}</button>
</div>
)
}
@@ -233,11 +220,10 @@ function ModalButtonRow({
confirmClassName: string
onCancel: () => void
}): React.JSX.Element {
useLocaleRender()
return (
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>
취소
</button>
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{t("취소")}</button>
<button
type="submit"
className={['h-8 cursor-pointer rounded-md px-3 text-[12px] font-bold text-white transition', confirmClassName].join(' ')}
@@ -263,6 +249,7 @@ export function SubmitConfirmModal({
onCancel,
onConfirm
}: SubmitConfirmModalProps): React.JSX.Element {
useLocaleRender()
return (
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
<section
@@ -273,15 +260,11 @@ export function SubmitConfirmModal({
>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
<Upload size={15} className="text-[#7aa7ff]" />
<strong className="text-[13px] font-bold">학습 완료</strong>
<strong className="text-[13px] font-bold">{t("학습 완료")}</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>
현재 주차의 학습을 완료할까요?
</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
완료 후에는 이 주차의 학습을 다시 완료 처리할 수 없습니다.
</p>
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>{t("현재 주차의 학습을 완료할까요?")}</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>{t("완료 후에는 이 주차의 학습을 다시 완료 처리할 수 없습니다.")}</p>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
@@ -290,17 +273,13 @@ export function SubmitConfirmModal({
'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(' ')}
>
취소
</button>
>{t("취소")}</button>
<button
type="button"
disabled={submitting}
onClick={onConfirm}
className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:opacity-60"
>
학습 완료
</button>
>{t("학습 완료")}</button>
</div>
</div>
</section>
@@ -326,6 +305,7 @@ export function PowerActionModal({
onDisconnectSession,
onReleaseControl
}: PowerActionModalProps): React.JSX.Element {
useLocaleRender()
return (
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
<section
@@ -337,11 +317,11 @@ export function PowerActionModal({
<div className={['flex items-center justify-between gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
<div className="flex items-center gap-2">
<Power size={15} className={dark ? 'text-white/62' : 'text-[#687284]'} />
<strong className="text-[13px] font-bold">종료</strong>
<strong className="text-[13px] font-bold">{t("종료")}</strong>
</div>
<button
type="button"
aria-label="닫기"
aria-label={t("닫기")}
onClick={onClose}
className={[
'grid h-7 w-7 cursor-pointer place-items-center rounded-md transition',
@@ -353,8 +333,7 @@ export function PowerActionModal({
</div>
<div className="p-4">
<p className={['mb-5 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
{platformName} 세션에 적용할 작업을 선택하세요.
</p>
{t("{{platform}} 세션에 적용할 작업을 선택하세요.", { platform: platformName })}</p>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
@@ -365,7 +344,7 @@ export function PowerActionModal({
].join(' ')}
>
<LogOut size={30} />
<span>연결종료</span>
<span>{t("연결종료")}</span>
</button>
<button
type="button"
@@ -378,7 +357,7 @@ export function PowerActionModal({
].join(' ')}
>
<Trash2 size={30} />
<span>반납</span>
<span>{t("반납")}</span>
</button>
</div>
</div>
@@ -400,6 +379,7 @@ export function UnsavedEditorCloseModal({
onCancel,
onConfirm
}: UnsavedEditorCloseModalProps): React.JSX.Element {
useLocaleRender()
return (
<div className="absolute inset-0 z-[90] grid place-items-center bg-black/36">
<section
@@ -410,15 +390,11 @@ export function UnsavedEditorCloseModal({
>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderSoft].join(' ')}>
<Trash2 size={15} className="text-[#e87979]" />
<strong className="text-[13px] font-bold">나가기</strong>
<strong className="text-[13px] font-bold">{t("나가기")}</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>
저장하지 않고 나갈까요?
</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
저장하지 않고 닫으면 현재 편집 내용이 사라집니다.
</p>
<p className={['text-[13px] font-semibold', textStrong].join(' ')}>{t("저장하지 않고 나갈까요?")}</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>{t("저장하지 않고 닫으면 현재 편집 내용이 사라집니다.")}</p>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
@@ -427,16 +403,12 @@ export function UnsavedEditorCloseModal({
'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(' ')}
>
취소
</button>
>{t("취소")}</button>
<button
type="button"
onClick={onConfirm}
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
>
나가기
</button>
>{t("나가기")}</button>
</div>
</div>
</section>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import type { ModuleRow } from './types'
type ModuleControlAction = 'start' | 'stop' | 'restart'
@@ -35,6 +36,7 @@ function WorkspaceModuleTabContextMenu({
onCloseAllModuleTabs,
onDismiss
}: Props): React.JSX.Element | null {
useLocaleRender()
if (!menu) return null
const contextModule = moduleTabs.find((module) => module.name === menu.moduleName)
@@ -85,7 +87,7 @@ function WorkspaceModuleTabContextMenu({
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
{label}
{t(label)}
</button>
))}
{controlItems.length > 0 && <div className={dark ? 'my-1 h-px bg-white/10' : 'my-1 h-px bg-[#e5e8ef]'} />}
@@ -103,7 +105,7 @@ function WorkspaceModuleTabContextMenu({
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
{label}
{t(label)}
</button>
))}
</div>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { RefreshCw, X } from 'lucide-react'
import type React from 'react'
import type { WorkspaceActivity } from './WorkspaceNav'
@@ -30,6 +31,7 @@ export default function WorkspaceModuleTabs({
onClose,
onOpenContextMenu
}: WorkspaceModuleTabsProps): React.JSX.Element {
useLocaleRender()
return (
<div className="thin-x-scrollbar flex h-full min-w-0 items-stretch overflow-x-auto overflow-y-hidden">
{modules.length > 0 ? (
@@ -82,7 +84,7 @@ export default function WorkspaceModuleTabs({
<span
role="button"
tabIndex={0}
aria-label={`${module.name} 닫기`}
aria-label={t("{{v0}} 닫기", { v0: module.name })}
onClick={(event) => {
event.stopPropagation()
onClose(module.name)
@@ -106,9 +108,7 @@ export default function WorkspaceModuleTabs({
)
})
) : (
<div className={['flex h-full items-center px-3 text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#7b8494]'].join(' ')}>
열린 모듈 없음
</div>
<div className={['flex h-full items-center px-3 text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#7b8494]'].join(' ')}>{t("열린 모듈 없음")}</div>
)}
</div>
)
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { Code2, FileCode2, GitBranch, Network, Radio, Search, Settings, Terminal } from 'lucide-react'
import type React from 'react'
@@ -43,6 +44,7 @@ export default function WorkspaceNav({
workspaceNavFlash,
onSelect
}: WorkspaceNavProps): React.JSX.Element {
useLocaleRender()
const tooltipClassName = [
'pointer-events-none absolute left-11 top-1/2 z-50 hidden -translate-y-1/2 whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block',
dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'
@@ -51,7 +53,7 @@ export default function WorkspaceNav({
return (
<nav
data-help="workspace-nav"
className={['relative z-30 flex flex-col items-center gap-3 overflow-visible border-r py-3', dark ? 'border-white/10 bg-[#303030] text-white/65' : 'border-[#cfd6e4] bg-[#f3f5f9] text-[#687284]'].join(' ')}
className={['relative z-30 flex flex-col items-center gap-3 overflow-visible border-r py-3', dark ? 'border-white/10 bg-[#303030] text-white/65' : 'border-[#bec9d8] bg-[#e2e8f0] text-[#475569]'].join(' ')}
style={workspaceActive ? { gridColumn: 3, gridRow: '1 / span 2' } : { gridColumn: 3, gridRow: 1 }}
>
{WORKSPACE_NAV_ITEMS.map(({ id, Icon, label }) => {
@@ -68,7 +70,7 @@ export default function WorkspaceNav({
<button
key={id}
type="button"
aria-label={label}
aria-label={t(label)}
onClick={() => {
if (!selectable) return
onSelect(id)
@@ -79,16 +81,16 @@ export default function WorkspaceNav({
active
? dark
? 'bg-[#2563eb]/32 text-white'
: 'bg-[#edf3ff] text-[#1d4ed8]'
: 'bg-[#c7d9f8] text-[#1e40af]'
: dark
? selectable ? 'hover:bg-white/10 hover:text-white' : ''
: selectable ? 'hover:bg-[#eef1f7] hover:text-[#111520]' : ''
: selectable ? 'hover:bg-[#d1dae7] hover:text-[#111520]' : ''
].join(' ')}
>
{id === 'workspace' && workspaceNavFlash && (
<>
<span className="pointer-events-none absolute inset-[-3px] rounded-xl border border-white/70 shadow-[0_0_18px_rgba(255,255,255,0.28)] animate-ping" />
<span className="pointer-events-none absolute inset-0 rounded-lg bg-[#60a5fa]/18 shadow-[0_0_18px_rgba(96,165,250,0.38)] animate-pulse" />
<span className={['pointer-events-none absolute inset-[-3px] rounded-xl border animate-ping', dark ? 'border-white/70 shadow-[0_0_18px_rgba(255,255,255,0.28)]' : 'border-[#7c3aed]/70 shadow-[0_0_12px_rgba(124,58,237,0.22)]'].join(' ')} />
<span className={['pointer-events-none absolute inset-0 rounded-lg animate-pulse', dark ? 'bg-[#60a5fa]/18 shadow-[0_0_18px_rgba(96,165,250,0.38)]' : 'bg-[#7c3aed]/15 shadow-[0_0_12px_rgba(124,58,237,0.18)]'].join(' ')} />
</>
)}
<Icon
@@ -96,7 +98,7 @@ export default function WorkspaceNav({
className={[
'relative z-10',
id === 'modules' ? 'translate-x-[0.5px]' : '',
id === 'workspace' && workspaceNavFlash ? 'text-[#b8d7ff]' : ''
id === 'workspace' && workspaceNavFlash ? (dark ? 'text-[#b8d7ff]' : 'text-[#5b21b6]') : ''
].join(' ')}
/>
{id === 'workspace' && moduleTabCount > 0 && (
@@ -110,7 +112,7 @@ export default function WorkspaceNav({
</span>
)}
<span className={tooltipClassName}>
{label}
{t(label)}
</span>
</button>
)
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { Bot, MessageCircle, Minus, User } from 'lucide-react'
import type React from 'react'
import { useEffect, useState } from 'react'
@@ -71,6 +72,7 @@ export default function WorkspaceQaPanel({
onSubmit,
onClearConversation
}: WorkspaceQaPanelProps): React.JSX.Element {
useLocaleRender()
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1]
@@ -149,23 +151,19 @@ export default function WorkspaceQaPanel({
className={['flex min-w-0 flex-col border-l transition-opacity', minimized ? 'pointer-events-none overflow-hidden border-l-0 opacity-0' : '', panelClassName].join(' ')}
style={workspaceActive ? { gridColumn: 8, gridRow: '1 / span 2' } : { gridColumn: 8, gridRow: 1 }}
>
<div className={['border-b p-3', borderSoft].join(' ')}>
<div className={['border-b p-3', borderSoft, dark ? '' : 'bg-[#edf1f7]'].join(' ')}>
<div className="flex items-center justify-between gap-2">
<h3 className={['flex items-center gap-2 text-[13px] font-bold', textStrong].join(' ')}>
<MessageCircle size={14} className={textDim} />
질의응답
</h3>
<button type="button" aria-label="질의응답 최소화" onClick={onMinimize} className={[iconButtonClass, 'group relative'].join(' ')}>
<MessageCircle size={14} className={textDim} />{t("질의응답")}</h3>
<button type="button" aria-label={t("질의응답 최소화")} onClick={onMinimize} className={[iconButtonClass, 'group relative'].join(' ')}>
<Minus size={15} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
최소화
</span>
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span>
</button>
</div>
<div className={['mt-3 grid grid-cols-2 overflow-hidden rounded-md border', borderSoft].join(' ')}>
{[
{ id: 'ai' as const, label: 'AI', Icon: Bot },
{ id: 'teacher' as const, label: '강사', Icon: User }
{ id: 'teacher' as const, label: t("강사"), Icon: User }
].map(({ id, label, Icon }) => {
const disabled = id === 'teacher' && teacherQaDisabled
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
@@ -197,7 +195,7 @@ export default function WorkspaceQaPanel({
].join(' ')}
>
<Icon size={14} className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')} />
<span className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')}>{label}</span>
<span className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')}>{t(label)}</span>
</button>
)
})}
@@ -208,7 +206,7 @@ export default function WorkspaceQaPanel({
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}>
<div>
<span className={['mx-auto mb-3 block h-6 w-6 animate-spin rounded-full border-2 border-t-transparent', dark ? 'border-white/45' : 'border-[#94a3b8]'].join(' ')} />
<span>채팅 내역을 불러오는 중입니다</span>
<span>{t("채팅 내역을 불러오는 중입니다")}</span>
</div>
</div>
) : qaMode === 'ai' ? (
@@ -241,9 +239,7 @@ export default function WorkspaceQaPanel({
setContextMenu(null)
onClearConversation()
}}
>
대화내역 삭제
</button>
>{t("대화내역 삭제")}</button>
</div>
)}
<QaInputBox
@@ -268,14 +264,11 @@ function AiMessages({
pending: boolean
collapseSignal: number
}): React.JSX.Element {
useLocaleRender()
if (!messages.length && !pending) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<span>
AI에게 질문할
<br />
내용을 입력하세요
</span>
<span>{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}</span>
</div>
)
}
@@ -319,7 +312,7 @@ function AiMessages({
<Bot size={15} />
</span>
<div className={['inline-flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] font-medium', dark ? 'bg-[#303030] text-white/55' : 'bg-white text-[#727b8c]'].join(' ')}>
<span>답변을 작성 중입니다...</span>
<span>{t("답변을 작성 중입니다...")}</span>
<span className={['h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-t-transparent', dark ? 'border-white/45' : 'border-[#94a3b8]'].join(' ')} />
</div>
</div>
@@ -341,14 +334,11 @@ function TeacherMessages({
threadStatus: string | null
collapseSignal: number
}): React.JSX.Element {
useLocaleRender()
if (!messages.length) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<span>
강사에게 문의할
<br />
내용을 입력하세요
</span>
<span>{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}</span>
</div>
)
}
@@ -375,7 +365,7 @@ function TeacherMessages({
fallback={<User size={15} />}
/>
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg px-3 py-2 text-[12px] font-medium leading-relaxed', dark ? 'bg-[#303030] text-white/75' : 'bg-white text-[#4f596b]'].join(' ')}>
<strong className={['mb-1 block text-[11px]', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>강사</strong>
<strong className={['mb-1 block text-[11px]', dark ? 'text-white' : 'text-[#111520]'].join(' ')}>{t("강사")}</strong>
<MarkdownMessage content={message.content} dark={dark} />
</div>
</div>
@@ -384,10 +374,8 @@ function TeacherMessages({
{threadStatus === 'WAITING_INSTRUCTOR' && (
<div data-qa-message="true" className={['rounded-lg border px-3 py-2', dark ? 'border-[#b78b28]/35 bg-[#2b261b]' : 'border-[#ead7a3] bg-[#fff8e8]'].join(' ')}>
<div className="flex items-center gap-1.5 text-[11px] font-bold text-[#c58a1d]">
<MessageCircle size={13} />
답변 대기 중
</div>
<p className={['mt-1 text-[12px] font-medium', dark ? 'text-white/55' : 'text-[#6d6470]'].join(' ')}>강사가 확인하는 대로 답변을 드립니다</p>
<MessageCircle size={13} />{t("답변 대기 중")}</div>
<p className={['mt-1 text-[12px] font-medium', dark ? 'text-white/55' : 'text-[#6d6470]'].join(' ')}>{t("강사가 확인하는 대로 답변을 드립니다")}</p>
</div>
)}
</div>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { Activity, ArrowRight, CircleDot, ListChecks, ListX, Radio, RefreshCw, Send, Waypoints, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import type React from 'react'
@@ -151,6 +152,7 @@ function RuntimeLane({
onSelectAll: () => void
onClearSelection: () => void
}): React.JSX.Element {
useLocaleRender()
const hasSelectedModules = modules.some(({ module }) => selectedModuleKeys[module.folderName ?? module.name] !== false)
const hasUnselectedModules = modules.some(({ module }) => selectedModuleKeys[module.folderName ?? module.name] === false)
return (
@@ -167,8 +169,8 @@ function RuntimeLane({
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
aria-label="전체 선택"
title="전체 선택"
aria-label={t("전체 선택")}
title={t("전체 선택")}
onClick={onSelectAll}
disabled={!hasUnselectedModules}
className={['grid h-6 w-6 place-items-center rounded transition', hasUnselectedModules ? 'cursor-pointer' : 'cursor-default opacity-35', dark ? 'text-white/52 hover:bg-white/10 hover:text-white disabled:hover:bg-transparent disabled:hover:text-white/52' : 'text-[#6c7890] hover:bg-[#edf2f8] hover:text-[#1e293b] disabled:hover:bg-transparent disabled:hover:text-[#6c7890]'].join(' ')}
@@ -177,8 +179,8 @@ function RuntimeLane({
</button>
<button
type="button"
aria-label="전체 해제"
title="전체 해제"
aria-label={t("전체 해제")}
title={t("전체 해제")}
onClick={onClearSelection}
disabled={!hasSelectedModules}
className={['grid h-6 w-6 place-items-center rounded transition', hasSelectedModules ? 'cursor-pointer' : 'cursor-default opacity-35', dark ? 'text-white/52 hover:bg-white/10 hover:text-white disabled:hover:bg-transparent disabled:hover:text-white/52' : 'text-[#6c7890] hover:bg-[#edf2f8] hover:text-[#1e293b] disabled:hover:bg-transparent disabled:hover:text-[#6c7890]'].join(' ')}
@@ -214,7 +216,7 @@ function RuntimeLane({
<div className="mt-3 space-y-2 text-[10px]">
{publishers.length > 0 && (
<div>
<span className={dark ? 'text-[#7ee7d3]' : 'text-[#087f6a]'}>발행</span>
<span className={dark ? 'text-[#7ee7d3]' : 'text-[#087f6a]'}>{t("발행")}</span>
<div className={['mt-1 flex flex-wrap gap-1', dark ? 'text-white/62' : 'text-[#556176]'].join(' ')}>
{publishers.map((item) => <code key={item.name} className={['rounded px-1.5 py-0.5', dark ? 'bg-[#ffffff0d]' : 'bg-[#eef2f7]'].join(' ')}>{item.name}</code>)}
</div>
@@ -222,7 +224,7 @@ function RuntimeLane({
)}
{subscribers.length > 0 && (
<div>
<span className={dark ? 'text-[#9fc6ff]' : 'text-[#2563a8]'}>구독</span>
<span className={dark ? 'text-[#9fc6ff]' : 'text-[#2563a8]'}>{t("구독")}</span>
<div className={['mt-1 flex flex-wrap gap-1', dark ? 'text-white/62' : 'text-[#556176]'].join(' ')}>
{subscribers.map((item) => <code key={item.name} className={['rounded px-1.5 py-0.5', dark ? 'bg-[#ffffff0d]' : 'bg-[#eef2f7]'].join(' ')}>{item.name}</code>)}
</div>
@@ -230,14 +232,12 @@ function RuntimeLane({
)}
</div>
) : (
<p className={['mt-3 text-[10px]', dark ? 'text-white/38' : 'text-[#8690a2]'].join(' ')}>토픽 정의가 아직 없습니다.</p>
<p className={['mt-3 text-[10px]', dark ? 'text-white/38' : 'text-[#8690a2]'].join(' ')}>{t("토픽 정의가 아직 없습니다.")}</p>
)}
</button>
)
}) : (
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>
실행 중인 모듈이 없습니다.
</div>
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>{t("실행 중인 모듈이 없습니다.")}</div>
)}
</div>
</section>
@@ -257,11 +257,12 @@ function TopicDetailDrawer({
onClose: () => void
onResizeStart: (event: React.MouseEvent<HTMLDivElement>) => void
}): React.JSX.Element {
useLocaleRender()
return (
<section className={['relative grid min-h-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-t', dark ? 'border-white/12 bg-[#242424]' : 'border-[#cfd8e6] bg-white'].join(' ')}>
<div
role="separator"
aria-label="토픽 상세 패널 크기 조절"
aria-label={t("토픽 상세 패널 크기 조절")}
onMouseDown={onResizeStart}
className={['group absolute inset-x-0 top-0 z-10 grid h-2 cursor-ns-resize items-start justify-items-center', dark ? 'hover:bg-white/6' : 'hover:bg-[#edf2f8]'].join(' ')}
>
@@ -271,12 +272,12 @@ function TopicDetailDrawer({
<div className="flex min-w-0 items-center gap-2 self-center">
<span className={['grid h-7 w-7 shrink-0 place-items-center rounded-md', dark ? 'bg-[#2563eb]/18 text-[#9fc6ff]' : 'bg-[#edf4ff] text-[#2563a8]'].join(' ')}><Send size={14} /></span>
<div className="min-w-0">
<strong className={['block text-[12px]', dark ? 'text-white/90' : 'text-[#1e293b]'].join(' ')}>토픽 상세</strong>
<strong className={['block text-[12px]', dark ? 'text-white/90' : 'text-[#1e293b]'].join(' ')}>{t("토픽 상세")}</strong>
</div>
</div>
<button
type="button"
aria-label="토픽 상세 닫기"
aria-label={t("토픽 상세 닫기")}
onClick={onClose}
className={['grid h-7 w-7 shrink-0 cursor-pointer place-items-center self-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#718096] hover:bg-[#edf2f8] hover:text-[#1e293b]'].join(' ')}
>
@@ -288,36 +289,36 @@ function TopicDetailDrawer({
<div>
<div className={['rounded-md border p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#e0e6ee] bg-[#f8fafc]'].join(' ')}>
<dl className={['grid grid-cols-2 gap-x-3 gap-y-2 text-[10px]', dark ? 'text-white/62' : 'text-[#4f5d74]'].join(' ')}>
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>토픽</dt><dd className="mt-0.5"><code className={['block break-all font-semibold', dark ? 'text-[#9fc6ff]' : 'text-[#1d4ed8]'].join(' ')}>{connection.topic.name}</code></dd></div>
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>메시지 타입</dt><dd className="mt-0.5"><code className="block break-all font-semibold">{connection.topic.type}</code></dd></div>
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>상태</dt><dd className={['mt-0.5 inline-flex items-center gap-1 font-semibold', connection.active ? 'text-[#22a06b]' : dark ? 'text-white/40' : 'text-[#8a95a8]'].join(' ')}><span className={['h-1.5 w-1.5 rounded-full', connection.active ? 'bg-[#31c48d]' : 'bg-[#a9b3c2]'].join(' ')} />{connection.active ? '발행·구독 연결됨' : '연결 대기'}</dd></div>
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>발행자</dt><dd className="mt-0.5 font-semibold">{connection.publishers.length}</dd></div>
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>구독자</dt><dd className="mt-0.5 font-semibold">{connection.subscribers.length}</dd></div>
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>QoS</dt><dd className="mt-0.5 font-semibold">기본값</dd></div>
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>{t("토픽")}</dt><dd className="mt-0.5"><code className={['block break-all font-semibold', dark ? 'text-[#9fc6ff]' : 'text-[#1d4ed8]'].join(' ')}>{connection.topic.name}</code></dd></div>
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>{t("메시지 타입")}</dt><dd className="mt-0.5"><code className="block break-all font-semibold">{connection.topic.type}</code></dd></div>
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>{t("상태")}</dt><dd className={['mt-0.5 inline-flex items-center gap-1 font-semibold', connection.active ? 'text-[#22a06b]' : dark ? 'text-white/40' : 'text-[#8a95a8]'].join(' ')}><span className={['h-1.5 w-1.5 rounded-full', connection.active ? 'bg-[#31c48d]' : 'bg-[#a9b3c2]'].join(' ')} />{connection.active ? t("발행·구독 연결됨") : t("연결 대기")}</dd></div>
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>{t("발행자")}</dt><dd className="mt-0.5 font-semibold">{connection.publishers.length}</dd></div>
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>{t("구독자")}</dt><dd className="mt-0.5 font-semibold">{connection.subscribers.length}</dd></div>
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>QoS</dt><dd className="mt-0.5 font-semibold">{t("기본값")}</dd></div>
<div><dt className={dark ? 'text-white/35' : 'text-[#8994a7]'}>Domain</dt><dd className="mt-0.5 font-semibold">{rosDomainId}</dd></div>
</dl>
</div>
</div>
<div>
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#718096]'].join(' ')}>발행 노드</h3>
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#718096]'].join(' ')}>{t("발행 노드")}</h3>
<div className="mt-2 space-y-1.5">
{connection.publishers.length ? connection.publishers.map((endpoint) => (
<div key={endpoint.node} className={['rounded-md border px-2.5 py-2 text-[11px]', dark ? 'border-white/10 bg-white/[0.03] text-white/72' : 'border-[#e0e6ee] bg-[#fbfcfe] text-[#42516a]'].join(' ')}>
<strong className="block truncate font-semibold">{endpoint.node}</strong>
<code className={['mt-0.5 block truncate text-[10px]', dark ? 'text-white/38' : 'text-[#8995a8]'].join(' ')}>{endpoint.type || connection.topic.type}</code>
</div>
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#8792a5]'].join(' ')}>발행 노드 없음</p>}
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#8792a5]'].join(' ')}>{t("발행 노드 없음")}</p>}
</div>
</div>
<div>
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#718096]'].join(' ')}>구독 노드</h3>
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#718096]'].join(' ')}>{t("구독 노드")}</h3>
<div className="mt-2 space-y-1.5">
{connection.subscribers.length ? connection.subscribers.map((endpoint) => (
<div key={endpoint.node} className={['rounded-md border px-2.5 py-2 text-[11px]', dark ? 'border-white/10 bg-white/[0.03] text-white/72' : 'border-[#e0e6ee] bg-[#fbfcfe] text-[#42516a]'].join(' ')}>
<strong className="block truncate font-semibold">{endpoint.node}</strong>
<code className={['mt-0.5 block truncate text-[10px]', dark ? 'text-white/38' : 'text-[#8995a8]'].join(' ')}>{endpoint.type || connection.topic.type}</code>
</div>
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#8792a5]'].join(' ')}>구독 노드 없음</p>}
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#8792a5]'].join(' ')}>{t("구독 노드 없음")}</p>}
</div>
</div>
</div>
@@ -334,6 +335,7 @@ export default function WorkspaceRosConnectionsView({
onRefreshModules,
loadRosGraph,
}: WorkspaceRosConnectionsViewProps): React.JSX.Element {
useLocaleRender()
const runningRosModules = useMemo<RosModule[]>(() => modules
.filter((module) => module.state === 'running')
.map((module) => ({ module, ...moduleTopics(module) })), [modules])
@@ -452,13 +454,13 @@ export default function WorkspaceRosConnectionsView({
<div className={['flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className="flex flex-wrap items-center gap-2">
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-[#31c48d]/25 bg-[#31c48d]/10 text-[#8fe8bf]' : 'border-[#bcebd5] bg-[#effcf5] text-[#168254]'].join(' ')}>
<Activity size={13} /> 실행 모듈 {runningModules}
<Activity size={13} />{t("실행 모듈")}{runningModules}
</span>
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#dbe1eb] bg-[#f6f8fb] text-[#536075]'].join(' ')}>
<Waypoints size={13} /> 토픽 {connections.length}
<Waypoints size={13} />{t("토픽")}{connections.length}
</span>
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#dbe1eb] bg-[#f6f8fb] text-[#536075]'].join(' ')}>
<CircleDot size={13} /> 활성 연결 {activeConnections}
<CircleDot size={13} />{t("활성 연결")}{activeConnections}
</span>
{rosGraph.message && (
<span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}>
@@ -475,8 +477,7 @@ export default function WorkspaceRosConnectionsView({
disabled={rosGraphLoading}
className={['inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-semibold transition', rosGraphLoading ? 'cursor-wait opacity-60' : 'cursor-pointer', dark ? 'border-white/12 text-white/72 hover:bg-white/10 hover:text-white' : 'border-[#d5dce8] text-[#526078] hover:bg-[#eef3f9] hover:text-[#1e293b]'].join(' ')}
>
<RefreshCw size={13} className={rosGraphLoading ? 'animate-spin' : ''} /> 새로고침
</button>
<RefreshCw size={13} className={rosGraphLoading ? 'animate-spin' : ''} />{t("새로고침")}</button>
</div>
<div
@@ -487,7 +488,7 @@ export default function WorkspaceRosConnectionsView({
<div className="grid min-h-full min-w-[520px] grid-cols-[minmax(200px,0.9fr)_minmax(300px,1.35fr)]">
<RuntimeLane
dark={dark}
title={remoteWorkspace ? '원격 플랫폼' : '로컬 PC'}
title={remoteWorkspace ? t("원격 플랫폼") : t("로컬 PC")}
modules={runningRosModules}
selectedModuleKeys={selectedModuleKeys}
onToggleModule={toggleModuleConnection}
@@ -498,10 +499,10 @@ export default function WorkspaceRosConnectionsView({
<section className={['min-h-0 border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#dbe1eb] bg-[#f7f9fc]'].join(' ')}>
<div className="mb-3 flex items-center justify-between px-1">
<div>
<strong className={['block text-[12px]', dark ? 'text-white/88' : 'text-[#1f2937]'].join(' ')}>토픽 연결</strong>
<span className={['mt-0.5 block text-[10px]', dark ? 'text-white/42' : 'text-[#758196]'].join(' ')}>발행 노드에서 구독 노드로 전달</span>
<strong className={['block text-[12px]', dark ? 'text-white/88' : 'text-[#1f2937]'].join(' ')}>{t("토픽 연결")}</strong>
<span className={['mt-0.5 block text-[10px]', dark ? 'text-white/42' : 'text-[#758196]'].join(' ')}>{t("발행 노드에서 구독 노드로 전달")}</span>
</div>
<span className={['text-[10px] font-semibold', dark ? 'text-white/42' : 'text-[#7a8597]'].join(' ')}>{activeConnections}/{connections.length} 활성</span>
<span className={['text-[10px] font-semibold', dark ? 'text-white/42' : 'text-[#7a8597]'].join(' ')}>{activeConnections}/{connections.length}{t("활성")}</span>
</div>
<div className="space-y-2">
{connections.length ? connections.map((connection) => {
@@ -526,23 +527,21 @@ export default function WorkspaceRosConnectionsView({
].join(' ')}
>
<div className="min-w-0">
<span className={['block truncate text-[10px] font-semibold', dark ? 'text-[#7ee7d3]' : 'text-[#087f6a]'].join(' ')}>발행 {connection.publishers.length}</span>
<span className={['block truncate text-[10px] font-semibold', dark ? 'text-[#7ee7d3]' : 'text-[#087f6a]'].join(' ')}>{t("발행")}{connection.publishers.length}</span>
<span className={['mt-0.5 block truncate text-[11px]', dark ? 'text-white/72' : 'text-[#4b5870]'].join(' ')} title={connection.publishers.map((endpoint) => endpoint.node).join(', ')}>{summarizeEndpoints(connection.publishers)}</span>
</div>
<span className={['grid h-7 w-7 place-items-center rounded-full border', connection.active ? (dark ? 'border-[#31c48d]/40 bg-[#31c48d]/12 text-[#7ee7b5]' : 'border-[#9ae4bf] bg-[#ebfff4] text-[#1d9b62]') : (dark ? 'border-white/12 text-white/32' : 'border-[#dce3ed] text-[#98a3b4]')].join(' ')}>
<ArrowRight size={14} />
</span>
<div className="min-w-0 text-right">
<span className={['block truncate text-[10px] font-semibold', dark ? 'text-[#9fc6ff]' : 'text-[#2563a8]'].join(' ')}>구독 {connection.subscribers.length}</span>
<span className={['block truncate text-[10px] font-semibold', dark ? 'text-[#9fc6ff]' : 'text-[#2563a8]'].join(' ')}>{t("구독")}{connection.subscribers.length}</span>
<span className={['mt-0.5 block truncate text-[11px]', dark ? 'text-white/72' : 'text-[#4b5870]'].join(' ')} title={connection.subscribers.map((endpoint) => endpoint.node).join(', ')}>{summarizeEndpoints(connection.subscribers)}</span>
</div>
<code className={['col-span-3 mt-1 block truncate text-center text-[10px]', dark ? 'text-white/44' : 'text-[#7e899b]'].join(' ')}>{connection.topic.name}</code>
</button>
)
}) : (
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>
실행 모듈에서 확인된 토픽이 없습니다.
</div>
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#cdd6e4] text-[#7b8799]'].join(' ')}>{t("실행 모듈에서 확인된 토픽이 없습니다.")}</div>
)}
</div>
</section>
@@ -1,3 +1,6 @@
import { t, useLanguage } from '../../i18n'
import LanguageSelect from '../../i18n/LanguageSelect'
import { setStudioTheme, useStudioTheme, type StudioTheme } from './studioTheme'
import type { StudioSourceSettings } from '../platforms/types'
import type { PlatformOption } from '../platforms/types'
import { useEffect, useRef, useState } from 'react'
@@ -30,9 +33,20 @@ function WorkspaceSettingsView({
onUpdateSourceSetting,
onSaveSourceSettings
}: Props): React.JSX.Element {
const { language, setLanguage } = useLanguage()
const [languageDraft, setLanguageDraft] = useState(language)
const theme = useStudioTheme()
const [themeDraft, setThemeDraft] = useState<StudioTheme>(theme)
const themeDirty = themeDraft !== theme
const languageDirty = languageDraft !== language
const sourceSettingsDirty = !remoteWorkspace && !saveDisabled
const canSave = languageDirty || themeDirty || sourceSettingsDirty
const [saveAcknowledged, setSaveAcknowledged] = useState(false)
const saveAcknowledgedTimerRef = useRef<number | null>(null)
useEffect(() => { setLanguageDraft(language) }, [language])
useEffect(() => { setThemeDraft(theme) }, [theme])
useEffect(() => () => {
if (saveAcknowledgedTimerRef.current !== null) {
window.clearTimeout(saveAcknowledgedTimerRef.current)
@@ -40,9 +54,11 @@ function WorkspaceSettingsView({
}, [])
const handleSave = (): void => {
if (saveDisabled || saveAcknowledged) return
if (!canSave || saveAcknowledged) return
setSaveAcknowledged(true)
onSaveSourceSettings()
if (languageDirty) setLanguage(languageDraft)
if (themeDirty) setStudioTheme(themeDraft)
if (sourceSettingsDirty) onSaveSourceSettings()
saveAcknowledgedTimerRef.current = window.setTimeout(() => {
setSaveAcknowledged(false)
saveAcknowledgedTimerRef.current = null
@@ -57,25 +73,39 @@ function WorkspaceSettingsView({
<div className="thin-y-scrollbar h-full overflow-auto p-6">
<div className="mx-auto max-w-[760px]">
<div className={['rounded-lg border p-5', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className={['mb-5 border-b pb-5', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
<h3 className={['mb-5 text-[16px] font-black', textStrong].join(' ')}>{t('공통')}</h3>
<div className="grid gap-4 sm:grid-cols-2">
<LanguageSelect dark={dark} value={languageDraft} onChange={setLanguageDraft} />
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t('테마')}</span>
<select
aria-label={t('테마')}
value={themeDraft}
onChange={event => setThemeDraft(event.target.value === 'light' ? 'light' : 'dark')}
className={['mt-2 h-10 w-full cursor-pointer rounded-md border px-3 text-[13px] font-semibold outline-none focus:border-[#3b82f6]', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
<option value="dark">{t('다크')}</option>
<option value="light">{t('라이트')}</option>
</select>
</label>
</div>
</div>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>개발환경</h3>
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>{t("개발환경")}</h3>
</div>
</div>
{remoteWorkspace ? (
<div className={['mt-5 rounded-lg border p-4', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#e1e6ef] bg-[#f8fafc]'].join(' ')}>
<p className={['text-[13px] font-black', textStrong].join(' ')}>공용 플랫폼 환경</p>
<p className={['mt-2 text-[12px] font-semibold leading-relaxed', textMid].join(' ')}>
원격 플랫폼의 워크스페이스 설정은 강사/관리자 설정을 사용합니다.
</p>
<p className={['text-[13px] font-black', textStrong].join(' ')}>{t("공용 플랫폼 환경")}</p>
<p className={['mt-2 text-[12px] font-semibold leading-relaxed', textMid].join(' ')}>{t("원격 플랫폼의 워크스페이스 설정은 강사/관리자 설정을 사용합니다.")}</p>
<div className="mt-4 grid gap-2 text-[12px] font-semibold">
<div className={['rounded-md px-3 py-2', dark ? 'bg-white/[0.05] text-white/62' : 'bg-white text-[#4f596b]'].join(' ')}>
플랫폼: {platformName}
</div>
<div className={['rounded-md px-3 py-2', dark ? 'bg-white/[0.05] text-white/62' : 'bg-white text-[#4f596b]'].join(' ')}>
실행 방식: 세션 단위 임시 런타임
<div className={['rounded-md px-3 py-2', dark ? 'bg-white/[0.05] text-white/62' : 'bg-white text-[#4f596b]'].join(' ')}>{t("플랫폼:")}{platformName}
</div>
<div className={['rounded-md px-3 py-2', dark ? 'bg-white/[0.05] text-white/62' : 'bg-white text-[#4f596b]'].join(' ')}>{t("실행 방식: 세션 단위 임시 런타임")}</div>
</div>
</div>
) : (
@@ -83,30 +113,30 @@ function WorkspaceSettingsView({
<div className="mt-5 grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<StudioSettingsField
label="사용자 워크스페이스 루트"
description="모듈 소스와 빌드 결과가 저장되는 기본 경로입니다."
label={t("사용자 워크스페이스 루트")}
description={t("모듈 소스와 빌드 결과가 저장되는 기본 경로입니다.")}
value={configuredDraftWorkspaceRoot}
onChange={(value) => onUpdateSourceSetting('workspaceRoot', value)}
dark={dark}
/>
<StudioSettingsField
label="ROS Domain ID"
description="같은 번호의 ROS 모듈끼리 토픽과 서비스를 공유합니다."
description={t("같은 번호의 ROS 모듈끼리 토픽과 서비스를 공유합니다.")}
value={sourceSettingsDraft.rosDomainId}
onChange={(value) => onUpdateSourceSetting('rosDomainId', value.replace(/[^\d]/g, '').slice(0, 3))}
dark={dark}
/>
</div>
<StudioSettingsSwitch
label="외부 ROS 통신"
description="같은 네트워크의 기체와 통신을 허용합니다. 사용자별 ROS Domain ID를 구분하고, 변경 후 모듈과 시각화를 재실행하세요. 플랫폼 연동 통신은 별도 설정을 따릅니다."
label={t("외부 ROS 통신")}
description={t("같은 네트워크의 기체와 통신을 허용합니다. 사용자별 ROS Domain ID를 구분하고, 변경 후 모듈과 시각화를 재실행하세요. 플랫폼 연동 통신은 별도 설정을 따릅니다.")}
checked={sourceSettingsDraft.allowExternalRos === true}
onChange={(checked) => onUpdateSourceSetting('allowExternalRos', checked)}
dark={dark}
/>
<StudioSettingsSwitch
label="GPU 가속"
description="ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다."
label={t("GPU 가속")}
description={t("ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
checked={sourceSettingsDraft.gpuEnabled}
onChange={(checked) => onUpdateSourceSetting('gpuEnabled', checked)}
dark={dark}
@@ -118,56 +148,54 @@ function WorkspaceSettingsView({
dark
? 'border-amber-300/20 bg-[#363328] text-amber-100/80 before:border-amber-300/20 before:bg-[#363328]'
: 'border-amber-300/50 bg-amber-50 text-amber-900/75 before:border-amber-300/50 before:bg-amber-50'
].join(' ')}>
NVIDIA Container Toolkit과 Docker GPU 런타임이 준비된 PC에서만 실행됩니다. 준비되지 않은 경우 GPU 가속을 끄세요.
</div>
].join(' ')}>{t("NVIDIA Container Toolkit과 Docker GPU 런타임이 준비된 PC에서만 실행됩니다. 준비되지 않은 경우 GPU 가속을 끄세요.")}</div>
) : null}
</div>
<div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}>
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>플랫폼</h3>
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>{t("플랫폼")}</h3>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>연동 대상 플랫폼</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>선택한 플랫폼과 같은 ROS Domain으로 모듈을 실행합니다.</span>
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("연동 대상 플랫폼")}</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{t("선택한 플랫폼과 같은 ROS Domain으로 모듈을 실행합니다.")}</span>
<select
value={sourceSettingsDraft.rosLinkedPlatformId ?? ''}
onChange={(event) => onUpdateSourceSetting('rosLinkedPlatformId', event.target.value || null)}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
className={['mt-2 h-10 w-full cursor-pointer disabled:cursor-default rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
<option value="" className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>연동하지 않음</option>
<option value="" className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{t("연동하지 않음")}</option>
{remotePlatforms.map((platform) => (
<option key={platform.id} value={platform.id} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{platform.title}</option>
))}
</select>
</label>
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>시각화 브리지 실행 위치</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>로컬과 플랫폼 모듈을 함께 쓸 때 실행 PC 브리지를 사용하세요.</span>
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("시각화 브리지 실행 위치")}</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{t("로컬과 플랫폼 모듈을 함께 쓸 때 실행 PC 브리지를 사용하세요.")}</span>
<select
value={sourceSettingsDraft.visualizationBridgeLocation ?? 'local'}
onChange={(event) => onUpdateSourceSetting('visualizationBridgeLocation', event.target.value === 'platform' ? 'platform' : 'local')}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
className={['mt-2 h-10 w-full cursor-pointer disabled:cursor-default rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
<option value="local" className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>실행 PC</option>
<option value="platform" disabled={!sourceSettingsDraft.rosLinkedPlatformId} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>연동 플랫폼</option>
<option value="local" className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{t("실행 PC")}</option>
<option value="platform" disabled={!sourceSettingsDraft.rosLinkedPlatformId} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{t("연동 플랫폼")}</option>
</select>
</label>
</div>
</div>
<div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}>
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>에디터</h3>
<h3 className={['text-[16px] font-black', textStrong].join(' ')}>{t("에디터")}</h3>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>글꼴 크기</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>코드 편집기에 표시할 글자 크기입니다.</span>
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("글꼴 크기")}</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{t("코드 편집기에 표시할 글자 크기입니다.")}</span>
<select
value={sourceSettingsDraft.editorFontSize}
onChange={(event) => onUpdateSourceSetting('editorFontSize', Number(event.target.value))}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
className={['mt-2 h-10 w-full cursor-pointer disabled:cursor-default rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
{[12, 13, 14, 15, 16].map((size) => (
@@ -176,32 +204,32 @@ function WorkspaceSettingsView({
</select>
</label>
<label className="block">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>탭 크기</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>탭 입력 시 사용할 공백 칸 수입니다.</span>
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t("탭 크기")}</span>
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{t("탭 입력 시 사용할 공백 칸 수입니다.")}</span>
<select
value={sourceSettingsDraft.editorTabSize}
onChange={(event) => onUpdateSourceSetting('editorTabSize', Number(event.target.value))}
className={['mt-2 h-10 w-full rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
className={['mt-2 h-10 w-full cursor-pointer disabled:cursor-default rounded-md border px-3 text-[13px] font-semibold outline-none', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}
>
{[2, 4, 8].map((size) => (
<option key={size} value={size} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{size}칸</option>
<option key={size} value={size} className={dark ? 'bg-[#252525] text-white' : 'bg-white text-[#273142]'}>{size}{t("칸")}</option>
))}
</select>
</label>
</div>
<div className="mt-4">
<StudioSettingsSwitch
label="줄바꿈"
description="긴 줄을 편집기 너비에 맞춰 다음 줄에 표시합니다."
label={t("줄바꿈")}
description={t("긴 줄을 편집기 너비에 맞춰 다음 줄에 표시합니다.")}
checked={sourceSettingsDraft.editorWordWrap}
onChange={(checked) => onUpdateSourceSetting('editorWordWrap', checked)}
dark={dark}
/>
<div className="mt-4">
<StudioSettingsSwitch
label="미니맵 표시"
description="편집기 오른쪽에 코드 축소 지도를 표시합니다."
label={t("미니맵 표시")}
description={t("편집기 오른쪽에 코드 축소 지도를 표시합니다.")}
checked={sourceSettingsDraft.editorMinimap}
onChange={(checked) => onUpdateSourceSetting('editorMinimap', checked)}
dark={dark}
@@ -210,18 +238,18 @@ function WorkspaceSettingsView({
</div>
</div>
<div className="mt-8 flex justify-end">
<button
type="button"
disabled={saveDisabled || saveAcknowledged}
onClick={handleSave}
className="h-9 rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:border-[#64748b] disabled:bg-[#64748b] disabled:text-white/70 disabled:hover:bg-[#64748b]"
>
{saveAcknowledged ? '저장됨' : '저장'}
</button>
</div>
</>
)}
<div className="mt-8 flex justify-end">
<button
type="button"
disabled={!canSave || saveAcknowledged}
onClick={handleSave}
className="h-9 cursor-pointer rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:border-[#64748b] disabled:bg-[#64748b] disabled:text-white/70 disabled:hover:bg-[#64748b]"
>
{saveAcknowledged ? t("저장됨") : t("저장")}
</button>
</div>
</div>
</div>
</div>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { Download, RefreshCw, Upload } from 'lucide-react'
import type { ModuleRow } from './types'
@@ -137,6 +138,7 @@ function WorkspaceSourceControlView({
getGitStatusTone,
formatGitCommitDate
}: Props): React.JSX.Element {
useLocaleRender()
const refreshing = !teamProject && gitPanelTab === 'history' ? gitSubmissionsLoading : gitLoading
const historyLoading = teamProject ? gitLoading : gitSubmissionsLoading
@@ -149,8 +151,8 @@ function WorkspaceSourceControlView({
<div className={['flex h-10 shrink-0 items-end justify-between px-3', dark ? 'bg-[#252525]' : 'bg-[#f7f8fb]'].join(' ')}>
<div className="flex items-end">
{[
{ key: 'source' as const, label: '소스 제어' },
{ key: 'history' as const, label: teamProject ? '팀 변경 내역' : '제출 내역' }
{ key: 'source' as const, label: t("소스 제어") },
{ key: 'history' as const, label: teamProject ? t("팀 변경 내역") : t("제출 내역") }
].map((tab) => (
<button
key={tab.key}
@@ -167,7 +169,7 @@ function WorkspaceSourceControlView({
: 'border-[#e1e6ef] bg-[#eef1f6] text-[#687284] hover:text-[#111520]'
].join(' ')}
>
{tab.label}
{t(tab.label)}
</button>
))}
</div>
@@ -185,9 +187,7 @@ function WorkspaceSourceControlView({
: 'border-[#93c5fd] bg-[#eff6ff] text-[#1d5fae] hover:bg-[#dbeafe] hover:text-[#174a87]'
].join(' ')}
>
{gitSubmitting ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}
제출
</button>
{gitSubmitting ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}{t("제출")}</button>
)}
{teamProject && (
<>
@@ -203,9 +203,7 @@ function WorkspaceSourceControlView({
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
<Download size={14} />
팀 변경 사항 받기
</button>
<Download size={14} />{t("팀 변경 사항 받기")}</button>
<button
type="button"
onClick={onTeamPublish}
@@ -218,9 +216,7 @@ function WorkspaceSourceControlView({
: 'border-[#93c5fd] bg-[#eff6ff] text-[#1d5fae] hover:bg-[#dbeafe] hover:text-[#174a87]'
].join(' ')}
>
{teamSyncing ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}
팀에 반영
</button>
{teamSyncing ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}{t("팀에 반영")}</button>
</>
)}
<button
@@ -234,11 +230,9 @@ function WorkspaceSourceControlView({
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
aria-label="새로고침"
aria-label={t("새로고침")}
>
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} />
새로고침
</button>
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} />{t("새로고침")}</button>
</div>
</div>
@@ -246,29 +240,23 @@ function WorkspaceSourceControlView({
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
<span className={['truncate text-[12px] font-bold', textMid].join(' ')}>{teamProject ? '내 변경 파일' : '변경 파일'}</span>
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}개</span>
<span className={['truncate text-[12px] font-bold', textMid].join(' ')}>{teamProject ? t("내 변경 파일") : t("변경 파일")}</span>
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitChangedFileCount}{t("개")}</span>
</div>
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
{gitError && (
<div className={['mb-3 rounded-lg border px-3 py-2 text-[12px] font-semibold leading-relaxed', dark ? 'border-[#ef4444]/24 bg-[#ef4444]/10 text-[#fca5a5]' : 'border-[#fecaca] bg-[#fff1f2] text-[#dc2626]'].join(' ')}>
{gitError}
{t(gitError || '')}
</div>
)}
{!hasInstalledGitModules ? (
<div className={['grid min-h-full place-items-center px-6 text-center text-[12px] font-semibold leading-relaxed', textDim].join(' ')}>
공동 저장소는 모듈을 설치하면 생성됩니다.
</div>
<div className={['grid min-h-full place-items-center px-6 text-center text-[12px] font-semibold leading-relaxed', textDim].join(' ')}>{t("공동 저장소는 모듈을 설치하면 생성됩니다.")}</div>
) : gitLoading && !Object.keys(gitModuleStatuses).length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
Git 상태 불러오는 중
</div>
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>{t("Git 상태 불러오는 중")}</div>
) : gitChangedFileCount === 0 ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
변경된 파일 없음
</div>
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>{t("변경된 파일 없음")}</div>
) : (
<div className="grid gap-3">
{gitChangedModules.map((module) => {
@@ -337,16 +325,12 @@ function WorkspaceSourceControlView({
type="button"
onClick={() => onOpenGitChangedFile(selectedGitTarget.moduleName, selectedGitTarget.filePath)}
className={['h-7 cursor-pointer rounded-md border px-3 text-[11px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
>
열기
</button>
>{t("열기")}</button>
)}
</div>
<div className={['thin-y-scrollbar h-[calc(100%-44px)] overflow-auto p-4 font-mono text-[11px] leading-5', dark ? 'bg-[#171717] text-white/68' : 'bg-[#f9fafc] text-[#374151]'].join(' ')}>
{gitLoading && !selectedGitStatus ? (
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', textDim].join(' ')}>
변경 내용 불러오는 중
</div>
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', textDim].join(' ')}>{t("변경 내용 불러오는 중")}</div>
) : selectedGitStatus?.diff ? (
<div className={['overflow-hidden rounded-md border text-[11px]', dark ? 'border-white/10 bg-[#111111]' : 'border-[#e1e6ef] bg-white'].join(' ')}>
{parseDiffViewLines(selectedGitStatus.diff).map(renderDiffLine)}
@@ -361,31 +345,31 @@ function WorkspaceSourceControlView({
<div className={['grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)]', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<aside className={['flex min-h-0 flex-col border-r', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<div className={['flex h-11 items-center justify-between border-b px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
<span className={['text-[12px] font-bold', textMid].join(' ')}>{teamProject ? '팀 변경 내역' : '제출 목록'}</span>
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}개</span>
<span className={['text-[12px] font-bold', textMid].join(' ')}>{teamProject ? t("팀 변경 내역") : t("제출 목록")}</span>
<span className={['text-[11px] font-bold', textDim].join(' ')}>{gitSubmissionGroups.length}{t("개")}</span>
</div>
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
{gitError && (
<div className={['mb-3 rounded-lg border px-3 py-2 text-[12px] font-semibold leading-relaxed', dark ? 'border-[#ef4444]/24 bg-[#ef4444]/10 text-[#fca5a5]' : 'border-[#fecaca] bg-[#fff1f2] text-[#dc2626]'].join(' ')}>
{gitError}
{t(gitError || '')}
</div>
)}
{historyLoading && !gitSubmissions.length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
{teamProject ? '팀 변경 내역 불러오는 중' : '제출 내역 불러오는 중'}
{teamProject ? t("팀 변경 내역 불러오는 중") : t("제출 내역 불러오는 중")}
</div>
) : !gitSubmissionGroups.length ? (
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
{teamProject ? '팀 변경 내역 없음' : '제출 내역 없음'}
{teamProject ? t("팀 변경 내역 없음") : t("제출 내역 없음")}
</div>
) : (
<div className="grid gap-3">
{gitSubmissionGroups.map((group, groupIndex) => (
<div key={group.id} className={['overflow-hidden rounded-lg border', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
<div className={['px-3 py-2', dark ? 'bg-[#1f1f1f]' : 'bg-[#f8fafc]'].join(' ')}>
<p className={['text-[12px] font-black', textStrong].join(' ')}>{teamProject ? '팀 반영' : `제출 ${gitSubmissionGroups.length - groupIndex}`}</p>
<p className={['text-[12px] font-black', textStrong].join(' ')}>{teamProject ? t("팀 반영") : t("제출 {{v0}}", { v0: gitSubmissionGroups.length - groupIndex })}</p>
<p className={['mt-0.5 text-[10px] font-bold', textDim].join(' ')}>
{formatGitCommitDate(group.submittedAt)} · {teamProject ? group.submissions[0]?.commit.author : `${group.submissions.length}개 모듈`}
{formatGitCommitDate(group.submittedAt)} · {teamProject ? group.submissions[0]?.commit.author : t("{{v0}}개 모듈", { v0: group.submissions.length })}
</p>
</div>
<div className="grid gap-1 p-1.5">
@@ -432,9 +416,7 @@ function WorkspaceSourceControlView({
</div>
<div className={['thin-y-scrollbar h-[calc(100%-44px)] overflow-auto p-4 font-mono text-[11px] leading-5', dark ? 'bg-[#171717] text-white/68' : 'bg-[#f9fafc] text-[#374151]'].join(' ')}>
{gitSubmissionDiffLoading ? (
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', textDim].join(' ')}>
변경 내용 불러오는 중
</div>
<div className={['grid h-full place-items-center font-sans text-[12px] font-semibold', textDim].join(' ')}>{t("변경 내용 불러오는 중")}</div>
) : selectedGitSubmissionDiff ? (
<div className={['overflow-hidden rounded-md border text-[11px]', dark ? 'border-white/10 bg-[#111111]' : 'border-[#e1e6ef] bg-white'].join(' ')}>
{parseDiffViewLines(selectedGitSubmissionDiff).map(renderDiffLine)}
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { ClipboardPaste, Copy } from 'lucide-react'
type TerminalContextMenuState = {
@@ -21,6 +22,7 @@ function WorkspaceTerminalContextMenu({
onCopy,
onPaste
}: Props): React.JSX.Element | null {
useLocaleRender()
if (!menu) return null
const hasSelection = Boolean(menu.text.trim())
@@ -42,9 +44,7 @@ function WorkspaceTerminalContextMenu({
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
<Copy size={13} />
복사
</button>
<Copy size={13} />{t("복사")}</button>
)}
<button
type="button"
@@ -54,9 +54,7 @@ function WorkspaceTerminalContextMenu({
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
<ClipboardPaste size={13} />
붙여넣기
</button>
<ClipboardPaste size={13} />{t("붙여넣기")}</button>
</div>
)
}
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { Terminal } from 'lucide-react'
type Props = {
@@ -25,6 +26,7 @@ function WorkspaceTerminalPanel({
terminalHostRef,
onStartResize
}: Props): React.JSX.Element {
useLocaleRender()
const modes: ('host' | 'container')[] = containerAvailable ? ['host', 'container'] : ['host']
return (
<footer className={['relative border-t', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#e9edf4]'].join(' ')}>
@@ -38,7 +40,7 @@ function WorkspaceTerminalPanel({
</div>
<div className={['flex h-10 items-center gap-2 border-b px-3', borderSoft].join(' ')}>
<Terminal size={14} className={textDim} />
<div role="group" aria-label="터미널 환경" className="flex items-center gap-1">
<div role="group" aria-label={t("터미널 환경")} className="flex items-center gap-1">
{modes.map((mode) => (
<button
key={mode}
@@ -49,7 +51,7 @@ function WorkspaceTerminalPanel({
? dark ? 'bg-white/15 text-white' : 'bg-white text-[#253044]'
: `${textMid} ${dark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`].join(' ')}
>
{mode === 'host' ? '호스트' : '컨테이너'}
{mode === 'host' ? t("호스트") : t("컨테이너")}
</button>
))}
</div>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { BookOpen, Camera, Clock3, MessageCircle, PanelLeft, Power, RefreshCw, Upload, X } from 'lucide-react'
import type React from 'react'
import Tooltip from '../../components/Tooltip'
@@ -69,18 +70,17 @@ export default function WorkspaceTopBar({
onRequestControlExtension,
onClose
}: WorkspaceTopBarProps): React.JSX.Element {
useLocaleRender()
return (
<header className={['flex h-10 items-center justify-between border-b px-3', topbarClassName].join(' ')}>
<div className="flex items-center gap-2 pl-4">
<span className="h-1.5 w-1.5 rounded-full bg-[#22c55e] shadow-[0_0_6px_rgba(34,197,94,0.55)]" />
<strong className={['text-[13px] font-bold', textStrong].join(' ')}>{platformName}</strong>
<span className="rounded-md bg-[#2563eb]/18 px-2 py-1 text-[11px] font-bold text-[#9dbdff]">{weekNumber}주차 · {title}</span>
<span className={['rounded-md px-2 py-1 text-[11px] font-bold', dark ? 'bg-[#2563eb]/18 text-[#9dbdff]' : 'bg-[#dbeafe] text-[#1e40af]'].join(' ')}>{t('{{week}}주차', { week: weekNumber })} · {title}</span>
</div>
<div className={['flex items-center gap-2 text-[12px] font-bold', textMid].join(' ')}>
<button type="button" onClick={onToggleLeftPanel} className={topToggleButtonClass(!leftPanelMinimized)}>
<BookOpen size={14} />
학습가이드
</button>
<BookOpen size={14} />{t("학습가이드")}</button>
<button
type="button"
onClick={onToggleRightPanel}
@@ -93,16 +93,13 @@ export default function WorkspaceTopBar({
</>
)}
<MessageCircle size={14} className="relative z-10" />
<span className="relative z-10">질의응답</span>
<span className="relative z-10">{t("질의응답")}</span>
</button>
<button type="button" onClick={onTogglePreview} className={topToggleButtonClass(previewOpen)}>
<PanelLeft size={14} />
시각화
</button>
<PanelLeft size={14} />{t("시각화")}</button>
{cameraAvailable && (
<button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}>
<Camera size={14} />오버뷰
</button>
<Camera size={14} />{t("오버뷰")}</button>
)}
{showControlCountdown && (
<>
@@ -121,18 +118,16 @@ export default function WorkspaceTopBar({
type="button"
onClick={onRequestControlExtension}
className="h-6 cursor-pointer rounded border border-[#ef4444]/45 px-2 text-[11px] font-bold text-[#ff8b8b] transition hover:bg-[#ef4444]/12"
>
연장
</button>
>{t("연장")}</button>
)}
</>
)}
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
{submitVisible && (
<Tooltip label="학습 완료" side="bottom">
<Tooltip label={t("학습 완료")} side="bottom">
<button
type="button"
aria-label="학습 완료"
aria-label={t("학습 완료")}
disabled={submitDisabled}
onClick={onSubmit}
className={[
@@ -144,18 +139,18 @@ export default function WorkspaceTopBar({
</button>
</Tooltip>
)}
<Tooltip label="종료" side="bottom">
<Tooltip label={t("종료")} side="bottom">
<button
type="button"
onClick={onOpenPowerActions}
className={['grid h-7 w-7 cursor-pointer place-items-center rounded transition', hoverSoft].join(' ')}
aria-label="종료"
aria-label={t("종료")}
>
<Power size={15} />
</button>
</Tooltip>
<Tooltip label="닫기" side="bottom">
<button type="button" aria-label="닫기" onClick={onClose} className={['grid h-7 w-7 cursor-pointer place-items-center rounded transition', hoverSoft].join(' ')}>
<Tooltip label={t("닫기")} side="bottom">
<button type="button" aria-label={t("닫기")} onClick={onClose} className={['grid h-7 w-7 cursor-pointer place-items-center rounded transition', hoverSoft].join(' ')}>
<X size={16} />
</button>
</Tooltip>
@@ -1,3 +1,4 @@
import { t, useLanguage } from '../../i18n'
import { CirclePlay, LoaderCircle, Minus, PanelLeft, Power, Square, X } from 'lucide-react'
import type React from 'react'
import { useEffect, useState } from 'react'
@@ -50,10 +51,23 @@ export default function WorkspaceVisualizationPanel({
onTerminate,
onResizeStart
}: WorkspaceVisualizationPanelProps): React.JSX.Element {
const { language } = useLanguage()
const [terminateConfirmOpen, setTerminateConfirmOpen] = useState(false)
const { frameRef, bringToFront, zIndex } = useWorkspaceWindowFocus('visualization', !minimized)
const [activeViewerUrl, setActiveViewerUrl] = useState<string | null>(() => (running ? viewerUrl : null))
useEffect(() => {
const sendLanguage = (): void => {
frameRef.current?.contentWindow?.postMessage({ type: 'lms:language', language }, '*')
}
const onMessage = (event: MessageEvent): void => {
if (event.source === frameRef.current?.contentWindow && event.data?.type === 'lms:language:ready') sendLanguage()
}
window.addEventListener('message', onMessage)
sendLanguage()
return () => window.removeEventListener('message', onMessage)
}, [language, frameRef, activeViewerUrl])
useEffect(() => {
if (!running || !viewerUrl) return
setActiveViewerUrl((current) => current === viewerUrl ? current : viewerUrl)
@@ -94,26 +108,22 @@ export default function WorkspaceVisualizationPanel({
].join(' ')}
>
<strong className={['flex items-center gap-2 text-[12px] font-bold', textStrong].join(' ')}>
<PanelLeft size={14} className={textDim} />
시각화
</strong>
<PanelLeft size={14} className={textDim} />{t("시각화")}</strong>
<div className="flex items-center gap-1">
<button
type="button"
aria-label="최소화"
aria-label={t("최소화")}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={onMinimize}
className={[iconButtonClass, 'group relative'].join(' ')}
>
<Minus size={15} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
최소화
</span>
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span>
</button>
<button
type="button"
aria-label={large ? '원래 크기' : '최대화'}
aria-label={large ? t("원래 크기") : t("최대화")}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={onToggleLarge}
@@ -121,12 +131,12 @@ export default function WorkspaceVisualizationPanel({
>
<Square size={13} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
{large ? '원래 크기' : '최대화'}
{large ? t("원래 크기") : t("최대화")}
</span>
</button>
<button
type="button"
aria-label={running ? '시각화 종료' : '닫기'}
aria-label={running ? t("시각화 종료") : t("닫기")}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => {
@@ -137,7 +147,7 @@ export default function WorkspaceVisualizationPanel({
>
<X size={15} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>
{running ? '종료' : '닫기'}
{running ? t("종료") : t("닫기")}
</span>
</button>
</div>
@@ -166,16 +176,16 @@ export default function WorkspaceVisualizationPanel({
className="absolute left-1/2 top-1/2 inline-flex min-h-28 w-64 max-w-full -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 px-3 text-[15px] font-bold text-white/80 transition hover:text-white disabled:cursor-default"
>
{busy ? <LoaderCircle size={40} className="animate-spin" aria-hidden="true" /> : <CirclePlay size={40} />}
<span role="status" aria-live="polite">{busy ? statusMessage : '실행하기'}</span>
<span role="status" aria-live="polite">{busy ? t(statusMessage) : t("실행하기")}</span>
</button>
<div className="absolute inset-x-0 top-[70%] px-5 text-center">
<p className="text-[13px] font-bold text-white/72">지도와 차량 상태가 표시됩니다.</p>
<p className="text-[13px] font-bold text-white/72">{t("지도와 차량 상태가 표시됩니다.")}</p>
</div>
</>
) : (
<div className="grid h-full place-items-center px-5 text-center">
<div>
<p className="text-[13px] font-bold text-white/72">시각화 준비 중입니다.</p>
<p className="text-[13px] font-bold text-white/72">{t("시각화 준비 중입니다.")}</p>
</div>
</div>
)}
@@ -201,14 +211,14 @@ export default function WorkspaceVisualizationPanel({
<section className={['w-[360px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#dfe3ed] bg-white text-[#111520]'].join(' ')}>
<div className={['flex items-center gap-2 border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#e5e8ef]'].join(' ')}>
<Power size={15} className="text-[#e87979]" />
<strong className="text-[13px] font-bold">시각화 종료</strong>
<strong className="text-[13px] font-bold">{t("시각화 종료")}</strong>
</div>
<div className="p-4">
<p className="text-[13px] font-semibold">시각화를 종료할까요?</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>종료 후에는 실행 버튼으로 다시 시작할 수 있습니다.</p>
<p className="text-[13px] font-semibold">{t("시각화를 종료할까요?")}</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>{t("종료 후에는 실행 버튼으로 다시 시작할 수 있습니다.")}</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" disabled={busy} onClick={() => setTerminateConfirmOpen(false)} className={['h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition disabled:cursor-default disabled:opacity-45', 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(' ')}>취소</button>
<button type="button" disabled={busy} onClick={() => { setTerminateConfirmOpen(false); void onTerminate() }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838] disabled:cursor-default disabled:opacity-45">종료</button>
<button type="button" disabled={busy} onClick={() => setTerminateConfirmOpen(false)} className={['h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition disabled:cursor-default disabled:opacity-45', 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("취소")}</button>
<button type="button" disabled={busy} onClick={() => { setTerminateConfirmOpen(false); void onTerminate() }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838] disabled:cursor-default disabled:opacity-45">{t("종료")}</button>
</div>
</div>
</section>
@@ -1,3 +1,4 @@
import { t, useLocaleRender } from '../../i18n'
import { Box, Cylinder, LoaderCircle, Maximize2, Move3D, PanelsTopLeft, Rotate3D, RotateCcw, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type React from 'react'
@@ -173,6 +174,7 @@ async function loadWorldSdf(parent: THREE.Group): Promise<void> {
}
export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
useLocaleRender()
const canvasHostRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<HTMLIFrameElement>(null)
const worldRef = useRef<WorldLayout>({ version: 1, bounds: { width: 10, depth: 10 }, objects: [] })
@@ -438,27 +440,27 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
<section className="flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#111a2a] shadow-[0_24px_72px_rgba(0,0,0,0.5)]">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
<div>
<h2 className="text-[13px] font-bold text-white">맵 설정</h2>
<p className="mt-0.5 text-[10px] font-medium text-white/42">주차 워크스페이스에 월드 배치를 저장합니다.</p>
<h2 className="text-[13px] font-bold text-white">{t("맵 설정")}</h2>
<p className="mt-0.5 text-[10px] font-medium text-white/42">{t("주차 워크스페이스에 월드 배치를 저장합니다.")}</p>
</div>
<button type="button" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white" aria-label="닫기"><X size={16} /></button>
<button type="button" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white" aria-label={t("닫기")}><X size={16} /></button>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-[214px] shrink-0 flex-col border-r border-white/10 bg-[#101a2a]">
<div className="border-b border-white/10 px-3 py-3">
<p className="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
<p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p>
<p className="mt-1 text-[11px] font-semibold text-white/66">{t("월드 오브젝트")}</p>
</div>
<div className="border-b border-white/10 px-3 py-3">
<p className="mb-2 text-[10px] font-bold tracking-[0.08em] text-white/38">맵 크기</p>
<p className="mb-2 text-[10px] font-bold tracking-[0.08em] text-white/38">{t("맵 크기")}</p>
<div className="grid grid-cols-2 gap-1.5">
<label className="min-w-0">
<span className="mb-1 block text-[10px] font-bold text-white/38">가로 (m)</span>
<span className="mb-1 block text-[10px] font-bold text-white/38">{t("가로 (m)")}</span>
<input type="number" min="2" max="40" step="0.5" value={layout.bounds.width} onChange={(event) => updateBounds('width', event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" />
</label>
<label className="min-w-0">
<span className="mb-1 block text-[10px] font-bold text-white/38">세로 (m)</span>
<span className="mb-1 block text-[10px] font-bold text-white/38">{t("세로 (m)")}</span>
<input type="number" min="2" max="40" step="0.5" value={layout.bounds.depth} onChange={(event) => updateBounds('depth', event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" />
</label>
</div>
@@ -474,17 +476,17 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
<span className="text-[10px] text-white/30">{item.id.split('-').at(-1)}</span>
</button>
)
}) : <div className="px-3 py-8 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 추가해<br />맵을 구성하세요.</div>}
}) : <div className="px-3 py-8 text-center text-[11px] leading-relaxed text-white/35">{t("오브젝트를 추가해")}<br />{t("맵을 구성하세요.")}</div>}
</div>
<div className="border-t border-white/10 p-2">
<p className="mb-2 px-1 text-[10px] font-bold tracking-[0.08em] text-white/35">ADD OBJECT</p>
<div className="grid grid-cols-3 gap-1">
{([
['pillar', Cylinder, '기둥'],
['wall', PanelsTopLeft, '벽'],
['box', Box, '박스'],
['pillar', Cylinder, t("기둥")],
['wall', PanelsTopLeft, t("벽")],
['box', Box, t("박스")],
] as const).map(([type, Icon, label]) => (
<button key={type} type="button" onClick={() => addObject(type)} title={`${label} 추가`} className="flex h-12 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-white/10 text-[10px] font-semibold text-white/60 transition hover:border-[#60a5fa]/55 hover:bg-[#1a2c49] hover:text-white"><Icon size={14} />{label}</button>
<button key={type} type="button" onClick={() => addObject(type)} title={t("{{v0}} 추가", { v0: label })} className="flex h-12 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-white/10 text-[10px] font-semibold text-white/60 transition hover:border-[#60a5fa]/55 hover:bg-[#1a2c49] hover:text-white"><Icon size={14} />{t(label)}</button>
))}
</div>
</div>
@@ -492,38 +494,38 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
<main className="relative min-w-0 flex-1 bg-[#0b1220]">
<div ref={canvasHostRef} className="pointer-events-none absolute inset-0 opacity-0" />
<iframe ref={viewerRef} title="TurtleBot3 World Editor" src="./slam-viewer/index.html?editor=1&mapKey=nav2-custom" className="absolute inset-0 z-10 size-full border-0" />
{loading && <div className="absolute inset-0 z-20 grid place-items-center bg-[#0b1220] text-[12px] font-semibold text-white/58"><span className="flex items-center gap-2"><LoaderCircle size={16} className="animate-spin" />월드 불러오는 중</span></div>}
{loading && <div className="absolute inset-0 z-20 grid place-items-center bg-[#0b1220] text-[12px] font-semibold text-white/58"><span className="flex items-center gap-2"><LoaderCircle size={16} className="animate-spin" />{t("월드 불러오는 중")}</span></div>}
<div className="absolute left-3 top-3 z-20 flex overflow-hidden rounded-md border border-white/15 bg-[#15223a]/95 shadow">
{([
['translate', Move3D, '이동'],
['rotate', Rotate3D, '회전'],
['scale', Maximize2, '크기'],
['translate', Move3D, t("이동")],
['rotate', Rotate3D, t("회전")],
['scale', Maximize2, t("크기")],
] as const).map(([mode, Icon, label]) => (
<button key={mode} type="button" onClick={() => selectTransformMode(mode)} title={label} aria-label={label} className={`flex h-8 cursor-pointer items-center gap-1.5 px-2.5 text-[11px] font-bold transition ${transformMode === mode ? 'bg-[#3b7ffa] text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}><Icon size={14} />{label}</button>
<button key={mode} type="button" onClick={() => selectTransformMode(mode)} title={t(label)} aria-label={t(label)} className={`flex h-8 cursor-pointer items-center gap-1.5 px-2.5 text-[11px] font-bold transition ${transformMode === mode ? 'bg-[#3b7ffa] text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}><Icon size={14} />{t(label)}</button>
))}
</div>
</main>
<aside className="flex w-[270px] shrink-0 flex-col border-l border-white/10 bg-[#131e31]">
<div className="border-b border-white/10 px-3 py-3"><span className="text-[11px] font-bold text-white/70">선택한 오브젝트</span></div>
<div className="border-b border-white/10 px-3 py-3"><span className="text-[11px] font-bold text-white/70">{t("선택한 오브젝트")}</span></div>
{selected ? (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-4 flex items-center justify-between"><span className="rounded bg-[#3b7ffa]/18 px-2 py-1 text-[11px] font-bold text-[#a8c7ff]">{objectLabels[selected.type]}</span><button type="button" onClick={removeSelected} className="grid size-7 cursor-pointer place-items-center rounded border border-red-400/25 text-red-300/80 transition hover:bg-red-500/12 hover:text-red-200" title="삭제"><Trash2 size={14} /></button></div>
<div className="mb-4 flex items-center justify-between"><span className="rounded bg-[#3b7ffa]/18 px-2 py-1 text-[11px] font-bold text-[#a8c7ff]">{objectLabels[selected.type]}</span><button type="button" onClick={removeSelected} className="grid size-7 cursor-pointer place-items-center rounded border border-red-400/25 text-red-300/80 transition hover:bg-red-500/12 hover:text-red-200" title={t("삭제")}><Trash2 size={14} /></button></div>
{(['position', 'rotation', 'size'] as const).map((key) => {
const indexes = key === 'position' ? [0, 1] : key === 'rotation' ? [2] : [0, 1, 2]
return <div key={key} className="mb-4"><p className="mb-1.5 text-[10px] font-bold tracking-[0.08em] text-white/38">{key === 'position' ? '위치' : key === 'rotation' ? '회전' : '크기'}</p><div className={`grid gap-1.5 ${indexes.length === 1 ? 'grid-cols-1' : indexes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>{indexes.map((index) => <label key={index} className="min-w-0"><span className="mb-1 block text-[10px] font-bold text-white/38">{key === 'rotation' ? '각도 (Z)' : ['X', 'Y', '높이'][index]}</span><input type="number" step="0.1" value={Number(selected[key][index].toFixed(2))} onChange={(event) => updateTuple(key, index, event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" /></label>)}</div></div>
return <div key={key} className="mb-4"><p className="mb-1.5 text-[10px] font-bold tracking-[0.08em] text-white/38">{key === 'position' ? t("위치") : key === 'rotation' ? t("회전") : t("크기")}</p><div className={`grid gap-1.5 ${indexes.length === 1 ? 'grid-cols-1' : indexes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>{indexes.map((index) => <label key={index} className="min-w-0"><span className="mb-1 block text-[10px] font-bold text-white/38">{key === 'rotation' ? t("각도 (Z)") : ['X', 'Y', t("높이")][index]}</span><input type="number" step="0.1" value={Number(selected[key][index].toFixed(2))} onChange={(event) => updateTuple(key, index, event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" /></label>)}</div></div>
})}
</div>
) : <div className="grid min-h-0 flex-1 place-items-center px-5 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 선택하거나 추가하세요.</div>}
<div className="border-t border-white/10 p-3"><button type="button" onClick={resetLayout} className="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border border-white/12 text-[11px] font-semibold text-white/54 transition hover:bg-white/8 hover:text-white"><RotateCcw size={13} />모두 비우기</button></div>
) : <div className="grid min-h-0 flex-1 place-items-center px-5 text-center text-[11px] leading-relaxed text-white/35">{t("오브젝트를 선택하거나 추가하세요.")}</div>}
<div className="border-t border-white/10 p-3"><button type="button" onClick={resetLayout} className="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border border-white/12 text-[11px] font-semibold text-white/54 transition hover:bg-white/8 hover:text-white"><RotateCcw size={13} />{t("모두 비우기")}</button></div>
</aside>
</div>
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-white/10 bg-[#162238] px-4">
<span className="text-[10px] font-medium text-white/38">저장 위치: world-layout.json</span>
<div className="flex items-center gap-2"><button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md border border-white/12 bg-[#1c2a42] px-3 text-[11px] font-semibold text-white/72 transition hover:bg-[#263a59] hover:text-white">취소</button><button type="button" disabled={saving || loading} onClick={() => void save()} className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#3b7ffa] px-3 text-[11px] font-bold text-white transition hover:bg-[#4a89ff] disabled:cursor-default disabled:opacity-55">{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}저장</button></div>
<span className="text-[10px] font-medium text-white/38">{t("저장 위치: world-layout.json")}</span>
<div className="flex items-center gap-2"><button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md border border-white/12 bg-[#1c2a42] px-3 text-[11px] font-semibold text-white/72 transition hover:bg-[#263a59] hover:text-white">{t("취소")}</button><button type="button" disabled={saving || loading} onClick={() => void save()} className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#3b7ffa] px-3 text-[11px] font-bold text-white transition hover:bg-[#4a89ff] disabled:cursor-default disabled:opacity-55">{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}{t("저장")}</button></div>
</footer>
{error && <div className="absolute bottom-14 left-4 rounded border border-red-400/25 bg-[#3a1f29] px-3 py-2 text-[11px] font-semibold text-red-200">{error}</div>}
{error && <div className="absolute bottom-14 left-4 rounded border border-red-400/25 bg-[#3a1f29] px-3 py-2 text-[11px] font-semibold text-red-200">{t(error)}</div>}
</section>
</div>
)
@@ -1,3 +1,6 @@
import { t, useLocaleRender, dateLocale } from '../../i18n'
import { useStudioTheme } from './studioTheme'
import './studioDialogs.css'
import { AlertTriangle, Check, House, Map, PencilRuler, Radio, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
@@ -61,6 +64,10 @@ export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [
}
]
export function getMapDisplayName(map: WorkspaceMapOption): string {
return map.saved ? map.name : t(map.name)
}
export function WorkspaceMapPreview({
map,
selected,
@@ -72,6 +79,7 @@ export function WorkspaceMapPreview({
compact?: boolean
borderless?: boolean
}): React.JSX.Element {
useLocaleRender()
const Icon = map.remoteSlam ? Radio : map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
const slamLabel = map.saved
? null
@@ -212,6 +220,8 @@ type WorldMapSelectorModalProps = {
}
export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
useLocaleRender()
const theme = useStudioTheme()
const [scope, setScope] = useState<WorkspaceMapScope>(initialScope)
const maps = scope === 'simulation' ? simulationMaps : platformMaps
const selectedMap = maps.find((map) => (map.selectionKey ?? map.key) === selectedMapKey) ?? null
@@ -226,20 +236,20 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
}, [initialScope])
return (
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
<div data-studio-dialog-theme={theme} className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label={t("맵 선택")}>
<section className="flex w-full max-w-[560px] max-h-[82vh] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<h2 className="text-[16px] font-bold">맵 설정</h2>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차 학습 세션 공통 맵</p>
<h2 className="text-[16px] font-bold">{t("맵 설정")}</h2>
<p className="mt-1 text-[12px] text-white/50">{t('{{week}}주차 학습 세션 공통 맵', { week: weekNumber })}</p>
</div>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<button type="button" aria-label={t("닫기")} title={t("닫기")} onClick={onClose} className="grid size-8 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<X className="size-4" />
</button>
</header>
<div className="flex gap-1 border-b border-white/10 px-5 pt-3">
<button type="button" onClick={() => setScope('simulation')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'simulation' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>시뮬레이션</button>
<button type="button" onClick={() => setScope('platform')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'platform' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>플랫폼</button>
<button type="button" onClick={() => setScope('simulation')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'simulation' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>{t("시뮬레이션")}</button>
<button type="button" onClick={() => setScope('platform')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'platform' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>{t("플랫폼")}</button>
</div>
<div className="thin-y-scrollbar min-h-[400px] max-h-[52vh] space-y-2 overflow-y-auto p-5">
{maps.map((map) => {
@@ -252,7 +262,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
const mapDimension = map.saved
? (map.runtimeKind === 'PLATFORM_3D' ? '3D' : '2D')
: null
const mapUsage = map.saved ? '주행' : 'SLAM'
const mapUsage = map.saved ? t("주행") : 'SLAM'
return (
<button
key={map.key}
@@ -266,7 +276,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
<WorkspaceMapPreview map={map} selected={pending} />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-[14px] font-bold">{map.name}</span>
<span className="truncate text-[14px] font-bold">{getMapDisplayName(map)}</span>
{mapDimension && (
<span className={[
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold',
@@ -274,38 +284,32 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
].join(' ')}>{mapDimension}</span>
)}
</span>
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.saved ? map.description : t(map.description)}</span>
<span className={[
'mt-1.5 inline-flex rounded px-1.5 py-0.5 text-[10px] font-bold',
mapUsage === '주행' ? 'bg-emerald-400/15 text-emerald-200' : 'bg-sky-400/15 text-sky-200',
map.saved ? 'bg-emerald-400/15 text-emerald-200' : 'bg-sky-400/15 text-sky-200',
].join(' ')}>{mapUsage}</span>
</span>
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString(dateLocale(), { dateStyle: 'short', timeStyle: 'short' })}</span>}
{selected && <Check className="absolute right-4 top-3 size-4 text-[#7eabff]" />}
{map.saved && !selected && onDelete && (
<span role="button" tabIndex={0} title="삭제" aria-label={`${map.name} 삭제`} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100">
<span role="button" tabIndex={0} title={t("삭제")} aria-label={t("{{v0}} 삭제", { v0: map.name })} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100">
<Trash2 className="size-3.5" />
</span>
)}
</button>
)
})}
{!maps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">{scope === 'platform' ? '저장된 플랫폼 SLAM 맵이 없습니다.' : '사용 가능한 시뮬레이션 맵이 없습니다.'}</div>}
{!maps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">{scope === 'platform' ? t("저장된 플랫폼 SLAM 맵이 없습니다.") : t("사용 가능한 시뮬레이션 맵이 없습니다.")}</div>}
</div>
<footer className="flex items-center justify-between border-t border-white/10 px-5 py-3">
{scope === 'simulation' && onCreate && (
<button type="button" onClick={onCreate} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
<PencilRuler className="size-3.5" />
커스텀 맵
</button>
<PencilRuler className="size-3.5" />{t("커스텀 맵")}</button>
)}
<div className="ml-auto flex items-center gap-2">
<button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
취소
</button>
<button type="button" disabled={!pendingMap} onClick={() => pendingMap && onSelect(pendingMap)} className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">
확인
</button>
<button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">{t("취소")}</button>
<button type="button" disabled={!pendingMap} onClick={() => pendingMap && onSelect(pendingMap)} className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">{t("확인")}</button>
</div>
</footer>
</section>
@@ -319,20 +323,20 @@ type WorldMapRestartConfirmModalProps = {
}
export function WorldMapRestartConfirmModal({ map, onConfirm }: WorldMapRestartConfirmModalProps): React.JSX.Element {
useLocaleRender()
const theme = useStudioTheme()
return (
<div className="fixed inset-0 z-[160] grid place-items-center bg-black/65 p-6" role="dialog" aria-modal="true" aria-label="맵 변경 확인">
<div data-studio-dialog-theme={theme} className="fixed inset-0 z-[160] grid place-items-center bg-black/65 p-6" role="dialog" aria-modal="true" aria-label={t("맵 변경 확인")}>
<section className="w-full max-w-[400px] overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center gap-2 border-b border-white/10 px-5 py-4">
<AlertTriangle className="size-4 text-amber-300" />
<h2 className="text-[15px] font-bold">맵 변경 안내</h2>
<h2 className="text-[15px] font-bold">{t("맵 변경 안내")}</h2>
</header>
<div className="px-5 py-4">
<p className="text-[13px] font-semibold">{map.name}으로 변경되었습니다.</p>
<p className="mt-2 text-[12px] leading-5 text-white/55">새 맵을 적용하려면 실행 중인 모듈을 전체 재시작해야 합니다.</p>
<p className="text-[13px] font-semibold">{t('{{name}}으로 변경되었습니다.', { name: getMapDisplayName(map) })}</p>
<p className="mt-2 text-[12px] leading-5 text-white/55">{t("새 맵을 적용하려면 실행 중인 모듈을 전체 재시작해야 합니다.")}</p>
<div className="mt-5 flex justify-end">
<button type="button" onClick={onConfirm} className="inline-flex h-8 cursor-pointer items-center rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8]">
확인
</button>
<button type="button" onClick={onConfirm} className="inline-flex h-8 cursor-pointer items-center rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8]">{t("확인")}</button>
</div>
</div>
</section>
@@ -0,0 +1,16 @@
/* Map dialogs render outside the workspace, so scope their light palette explicitly. */
[data-studio-dialog-theme='light'] {
& .bg-\[\#171b23\] { background-color: #ffffff; color: #111520; }
& .bg-\[\#111520\] { background-color: #f7f8fb; }
& .hover\:bg-white\/\[0\.045\]:hover { background-color: #edf2fa; }
& .hover\:border-white\/25:hover { border-color: #9facbf; }
& .text-white\/50, & .text-white\/55, & .text-white\/45,
& .text-white\/40, & .text-white\/35 { color: #687284; }
& .text-white\/70 { color: #374151; }
& .border-white\/10, & .border-white\/12 { border-color: #d8dee9; }
& .text-\[\#78a4ff\] { color: #245bb8; }
& .text-emerald-200 { color: #166b4a; }
& .text-sky-200 { color: #12638a; }
& .hover\:bg-white\/10:hover { background-color: #eef1f7; }
& .hover\:text-white:hover, & .hover\:text-white\/75:hover { color: #111520; }
}
@@ -0,0 +1,44 @@
/* Accent overrides are scoped to light development studios, never curriculum or media. */
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#2563eb\] { background-color: #7c3aed; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .hover\:bg-\[\#2563eb\]:hover { background-color: #7c3aed; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .text-\[\#2563eb\] { color: #7c3aed; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .border-\[\#2563eb\] { border-color: #7c3aed; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .focus\:border-\[\#2563eb\]:focus { border-color: #7c3aed; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#1d4ed8\] { background-color: #6d28d9; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .hover\:bg-\[\#1d4ed8\]:hover { background-color: #6d28d9; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .text-\[\#1d4ed8\] { color: #6d28d9; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .hover\:text-\[\#1d4ed8\]:hover { color: #6d28d9; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .text-\[\#1e40af\] { color: #5b21b6; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#2158cc\] { background-color: #7c3aed; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#3b82f6\] { background-color: #8b5cf6; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .border-\[\#3b82f6\] { border-color: #8b5cf6; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .focus\:border-\[\#3b82f6\]:focus { border-color: #8b5cf6; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#3b7ffa\] { background-color: #8b5cf6; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .border-\[\#3b7ffa\] { border-color: #8b5cf6; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#1d5ed8\] { background-color: #6d28d9; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .hover\:bg-\[\#1d5ed8\]:hover { background-color: #6d28d9; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .text-\[\#1d5fae\] { color: #6d28d9; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#dbeafe\] { background-color: #ede9fe; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .hover\:bg-\[\#dbeafe\]:hover { background-color: #ede9fe; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#edf3ff\] { background-color: #f3efff; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#edf6ff\] { background-color: #f3efff; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#e3edfc\] { background-color: #ede9fe; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#f5f9ff\] { background-color: #faf8ff; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#dce5f0\] { background-color: #ece7f5; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#dde3ec\] { background-color: #e8e5ed; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#e8edf5\] { background-color: #ece8f3; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#edf1f7\] { background-color: #f1eef6; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#e2e8f0\] { background-color: #e9e5f0; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#dce4ee\] { background-color: #e4dcef; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#c7d9f8\] { background-color: #ddd0fa; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#d1dae7\] { background-color: #ddd5e9; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .hover\:bg-\[\#d1dae7\]:hover { background-color: #ddd5e9; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#f3f5f9\] { background-color: #f3f1f7; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .hover\:bg-\[\#f3f5f9\]:hover { background-color: #f3f1f7; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#eef1f6\] { background-color: #eeebf3; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .border-\[\#eef1f6\] { border-color: #eeebf3; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .border-\[\#b8cef3\] { border-color: #cdb9f1; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .border-\[\#8aade4\] { border-color: #b69adf; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .border-\[\#bec9d8\] { border-color: #c9bfd8; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .bg-\[\#cbd5e1\] { background-color: #d4ccdf; }
:is([data-studio-theme='light'], [data-studio-dialog-theme='light']) .border-\[\#cbd5e1\] { border-color: #d4ccdf; }
@@ -0,0 +1,27 @@
import { useSyncExternalStore } from 'react'
export type StudioTheme = 'dark' | 'light'
export const STUDIO_THEME_KEY = 'autonomy-studio.lms.studio-theme'
const listeners = new Set<() => void>()
function readTheme(): StudioTheme {
try { return localStorage.getItem(STUDIO_THEME_KEY) === 'light' ? 'light' : 'dark' }
catch { return 'dark' }
}
let theme = readTheme()
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
export function setStudioTheme(value: StudioTheme): void {
theme = value
try { localStorage.setItem(STUDIO_THEME_KEY, value) } catch { /* Keep the session preference. */ }
listeners.forEach(listener => listener())
}
export function useStudioTheme(): StudioTheme {
return useSyncExternalStore(subscribe, () => theme)
}
window.addEventListener('storage', event => {
if (event.key !== STUDIO_THEME_KEY && event.key !== null) return
theme = readTheme()
listeners.forEach(listener => listener())
})
@@ -90,7 +90,7 @@ export function useWorkspaceModuleList({
applyModuleParameterOverrides
}: UseWorkspaceModuleListOptions): UseWorkspaceModuleListResult {
const [availableModules, setAvailableModules] = useState<ModuleRow[]>([])
const [modulesLoading, setModulesLoading] = useState(false)
const [modulesLoading, setModulesLoading] = useState(true)
const [moduleListError, setModuleListError] = useState<string | null>(null)
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
const remoteModuleCacheKey = useMemo(
@@ -174,6 +174,7 @@ export function useWorkspaceModuleList({
}, [curriculumModuleKey, remoteTarget])
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
if (!silent) setModulesLoading(true)
// Remote probes open an SSH session. Do not let polling overlap a slow
// probe, otherwise every response becomes stale and loading never settles.
if (moduleLoadInFlightRef.current) {
@@ -182,7 +183,6 @@ export function useWorkspaceModuleList({
}
moduleLoadInFlightRef.current = true
const requestSequence = ++loadSequenceRef.current
if (!silent) setModulesLoading(true)
try {
const { curriculumModules: currentCurriculumModules, applyModuleParameterOverrides: applyOverrides } = moduleInputsRef.current
if (remoteWorkspace) {
@@ -213,7 +213,7 @@ export function useWorkspaceModuleList({
const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.'
if (!silent) {
setModuleListError(message)
setAvailableModules(makeUninstalledModuleRows(moduleInputsRef.current.curriculumModules))
setAvailableModules([])
}
} finally {
moduleLoadInFlightRef.current = false
@@ -256,8 +256,9 @@ export function useWorkspaceModuleList({
return applyPendingModuleStates(moduleInputsRef.current.applyModuleParameterOverrides(modules))
})
setModuleListError(null)
setModulesLoading(false)
if (!(cachedModules && skipInitialModuleLoad)) void loadAvailableModules()
const skipLoad = Boolean(cachedModules && skipInitialModuleLoad)
setModulesLoading(!skipLoad)
if (!skipLoad) void loadAvailableModules()
return () => {
++loadSequenceRef.current
@@ -4,6 +4,31 @@ import { useEffect, useRef, useState } from 'react'
import '@xterm/xterm/css/xterm.css'
import { getModuleTerminalSessionId, stripInternalTerminalCommands } from './workspaceLogs'
function terminalTheme(dark: boolean) {
return {
background: dark ? '#171717' : '#f9fafc',
foreground: dark ? '#d0d0d0' : '#2f3747',
cursor: dark ? '#93c5fd' : '#2563eb',
selectionBackground: 'rgba(59,130,246,0.28)',
black: dark ? '#242424' : '#263244',
red: dark ? '#f87171' : '#b42323',
green: dark ? '#86efac' : '#16733e',
yellow: dark ? '#fde047' : '#896000',
blue: dark ? '#60a5fa' : '#2459b5',
magenta: dark ? '#d8b4fe' : '#8541a0',
cyan: dark ? '#67e8f9' : '#087588',
white: dark ? '#e5e7eb' : '#596477',
brightBlack: '#737d8c',
brightRed: dark ? '#fca5a5' : '#c53030',
brightGreen: dark ? '#bbf7d0' : '#218543',
brightYellow: dark ? '#fef08a' : '#946800',
brightBlue: dark ? '#93c5fd' : '#2563eb',
brightMagenta: dark ? '#e9d5ff' : '#9333aa',
brightCyan: dark ? '#a5f3fc' : '#078197',
brightWhite: dark ? '#ffffff' : '#111520'
}
}
type TerminalContextMenu = {
text: string
x: number
@@ -67,6 +92,11 @@ export function useWorkspaceTerminal({
const terminalContextMenuRef = useRef<HTMLDivElement>(null)
const terminalHostRef = useRef<HTMLDivElement | null>(null)
const terminalRef = useRef<XTerm | null>(null)
const darkRef = useRef(dark)
darkRef.current = dark
useEffect(() => {
if (terminalRef.current) terminalRef.current.options.theme = terminalTheme(dark)
}, [dark])
const terminalFitRef = useRef<FitAddon | null>(null)
const terminalSessionIdRef = useRef<string | null>(null)
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
@@ -201,12 +231,7 @@ export function useWorkspaceTerminal({
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
fontSize: 11,
lineHeight: 1.25,
theme: {
background: dark ? '#171717' : '#f9fafc',
foreground: dark ? 'rgba(255,255,255,0.72)' : '#2f3747',
cursor: '#93c5fd',
selectionBackground: 'rgba(59,130,246,0.28)'
}
theme: terminalTheme(darkRef.current)
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
@@ -342,7 +367,7 @@ export function useWorkspaceTerminal({
terminalFitRef.current = null
terminalSessionIdRef.current = null
}
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive, terminalMode])
}, [clonePathPreview, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive, terminalMode])
return {
terminalMode,
+20
View File
@@ -0,0 +1,20 @@
import { t, type Language } from './index'
export default function LanguageSelect({ dark, value, onChange }: {
dark: boolean
value: Language
onChange: (language: Language) => void
}): React.JSX.Element {
return (
<label className="block max-w-[340px]">
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{t('언어')}</span>
<select aria-label="Language / 언어" value={value}
onChange={event => onChange(event.target.value === 'en' ? 'en' : 'ko')}
className={['mt-2 h-10 w-full cursor-pointer rounded-md border px-3 text-[13px] font-semibold outline-none focus:border-[#3b82f6]', dark ? 'border-white/12 bg-[#1f1f1f] text-white/78' : 'border-[#d7dde8] bg-white text-[#111520]'].join(' ')}
style={{ colorScheme: dark ? 'dark' : 'light' }}>
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
</label>
)
}
+631
View File
@@ -0,0 +1,631 @@
{
"공통": "General",
"완료한 주차": "Completed weeks",
"학습 시간": "Learning time",
"커밋 수": "Commits",
"평균 점수": "Average score",
"[{{name}}] 맵이 선택되었습니다.": "Map selected: [{{name}}].",
"언어": "Language",
"기본 월드": "Default world",
"실내 환경": "Indoor environment",
"새 2D 맵": "New 2D map",
"새 3D 맵": "New 3D map",
"{{name}}으로 변경되었습니다.": "Switched to {{name}}.",
"사용 가능": "Available",
"사용 불가": "Unavailable",
"연결됨": "Connected",
"오프라인": "Offline",
"대기 중": "Waiting",
"제어": "Control",
"채팅": "Chat",
"플랫폼 제어 상태가 변경되었습니다.": "Platform control status changed.",
"강사 채팅에 새 답변이 있습니다.": "You have a new reply from your instructor.",
"완료": "Completed",
"{{week}}주차 학습 세션 공통 맵": "Shared map for week {{week}}",
"실행 중인 모듈 {{count}}개": "Running modules: {{count}}",
"안읽음 {{count}}건": "Unread: {{count}}",
"다운로드 파일 확인 중...": "Verifying download...",
"이미지 파일 확인 중...": "Verifying image file...",
"시각화 시작 중...": "Starting visualization...",
"모듈 실행 중...": "Starting module...",
"학습 맥락과 빠른 질문": "Learning context and quick questions",
"AI 가이드 대화": "AI guide conversation",
"질문 목록": "Questions",
"학습 가이드": "Learning guide",
"스튜디오 메뉴": "Studio navigation",
"작업 화면": "Workspace",
"실습을 실행할 플랫폼을 선택하는 영역입니다. 제어권이 없으면 먼저 제어권 요청을 하고, 제어권이 부여되면 연결 버튼으로 개발 스튜디오를 열 수 있습니다. 설정 버튼에서는 플랫폼별 작업 환경을 확인하거나 조정합니다.": "Select a platform for your practice. Request control first, then connect to open the studio. Use Settings to review or adjust the platform environment.",
"현재 강좌와 주차 정보를 확인하고, 자주 묻는 질문을 바로 선택할 수 있습니다. 이전 대화도 이곳에서 다시 열 수 있습니다.": "Review your current course and week, choose a quick question, or reopen an earlier conversation.",
"학습 중 궁금한 내용을 입력하면 현재 강좌와 주차 맥락을 바탕으로 답변합니다.": "Ask a question to receive an answer based on the current course and week.",
"이전에 입력한 질문 목록입니다. 항목을 누르면 해당 질문과 답변 위치로 바로 이동합니다.": "Select a previous question to jump to its answer.",
"현재 주차의 강의 내용, 진행 중인 학습단계, 참고자료를 확인하는 영역입니다.": "View this week's content, current learning step, and reference materials.",
"모듈 목록, 코드 편집, 로그, 예제 코드, 소스 제어, 설정 화면으로 이동합니다.": "Navigate to modules, the code editor, logs, examples, source control, and settings.",
"선택한 메뉴에 따라 모듈 설치와 실행, 코드 편집, 검색, 로그 확인, 제출 변경사항 확인 같은 실습 작업을 수행합니다.": "Install and run modules, edit and search code, inspect logs, and review changes for submission.",
"사용하지 않는 모듈을 이 영역으로 드래그하면 전체 설치·실행·중지 대상에서 제외됩니다. 보관한 모듈은 개별적으로 제어할 수 있습니다.": "Drag unused modules here to exclude them from bulk install, start, and stop actions. Parked modules can still be controlled individually.",
"AI 챗봇 또는 강사에게 질문할 수 있습니다. 긴 답변은 접어서 화면을 가볍게 유지합니다.": "Ask the AI assistant or your instructor. Long answers can be collapsed.",
"강사의 진행 설정에 따라 현재 진행 주차와 진행률이 반영됩니다. 완료된 주차와 잠긴 주차도 이곳에서 확인합니다.": "View the current week and progress set by your instructor, including completed and locked weeks.",
"선택한 주차의 개요, 학습단계, 실습내용, 제출 기준을 확인합니다.": "Review the selected week's overview, learning steps, practice activities, and submission requirements.",
"강사가 등록한 주차별 참고자료와 예제 파일을 확인합니다. 필요한 자료는 다운로드해서 실습 중 참고할 수 있습니다.": "View and download the reference materials and example files provided by your instructor.",
"해당 주차 실습을 진행할 플랫폼을 선택하고 개발 스튜디오로 이동합니다.": "Select a platform for this week's practice and open the development studio.",
"전체": "All",
"코드 편집": "Code editor",
"로그": "Logs",
"예제 코드": "Examples",
"중지": "Stop",
"실행": "Start",
"빌드": "Build",
"설치": "Install",
"자르기": "Cut",
"경로 복사": "Copy path",
"기본 장애물 환경": "Default obstacle environment",
"방과 복도로 구성된 실내 환경": "Indoor environment with rooms and corridors",
"2D LiDAR로 새 지도를 생성합니다.": "Create a new map using 2D LiDAR.",
"3D LiDAR 포인트 클라우드로 새 지도를 생성합니다.": "Create a new map using a 3D LiDAR point cloud.",
"저장된 SLAM 결과 맵": "Saved SLAM map",
"장애물을 직접 배치해 학습 환경 구성": "Create a practice environment by placing obstacles",
"강사가 플랫폼 사용을 종료하여 개발 스튜디오를 닫고 있습니다.": "Closing the studio because the instructor ended your platform session.",
"현재 개발 스튜디오 세션을 종료하고 플랫폼 작업 상태를 정리하고 있습니다.": "Closing the studio session and cleaning up the platform state.",
"현재 개발 스튜디오 연결을 종료하고 작업 상태를 정리하고 있습니다.": "Disconnecting the studio and cleaning up the workspace.",
"현재 개발 스튜디오 연결을 종료하고 선택한 주차를 시작할 준비를 하고 있습니다.": "Closing the current studio to prepare the selected week.",
"대기열에서 요청을 제거하고 플랫폼 상태를 다시 확인하고 있습니다.": "Removing your request from the queue and checking platform status.",
"플랫폼 사용 가능 여부와 대기열 상태를 확인하고 있습니다.": "Checking platform availability and queue status.",
"다운로드 중...": "Downloading...",
"다운로드 및 이미지 준비 중...": "Downloading and preparing image...",
"모듈 빌드 중...": "Building module...",
"이미지 준비 대기 중...": "Waiting for image preparation...",
"이미지 다운로드 중...": "Downloading image...",
"이미지 불러오는 중...": "Loading image...",
"이미지 검증 중...": "Verifying image...",
"맵 환경 준비 중...": "Preparing map environment...",
"맵 환경 준비 중": "Preparing map environment",
"시작 중": "Starting",
"시작 실패": "Failed to start",
"종료 실패": "Failed to stop",
"대기": "Idle",
"연결 대기": "Waiting for connection",
"예약됨": "Queued",
"대기 취소": "Cancel request",
"다른 수강생 사용 중": "In use by another student",
"대기 예약": "Join queue",
"제어요청": "Request control",
"제어 중": "In control",
"{{week}}주차": "Week {{week}}",
"{{week}}주차 개요": "Week {{week}} overview",
"제출 내역 {{count}}개": "Submissions: {{count}}",
"모듈별 변경 파일 조회 · 변경 {{count}}개": "Changed files by module · {{count}} changes",
"모듈 리스트": "Modules",
"ROS 연결상태": "ROS connections",
"개발스튜디오 설정": "Studio settings",
"워크스페이스 경로와 템플릿 저장소 관리": "Workspace paths and template repositories",
"파일 조회": "Browse files",
"실행 모듈의 토픽 발행·구독 연결": "Topic publishers and subscribers of running modules",
"모듈 소스 편집": "Edit module source",
"실행중": "Running",
"정지됨": "Stopped",
"미설치": "Missing",
"맵설정": "Map settings",
"전체 설치": "Install all",
"전체 실행": "Start all",
"전체 중지": "Stop all",
"다른 탭 닫기": "Close other tabs",
"오른쪽 탭 닫기": "Close tabs to the right",
"모든 탭 닫기": "Close all tabs",
"사용가능": "Available",
"사용불가": "Unavailable",
"재시작": "Restart",
"재실행": "Restart",
"소스 생성": "Create source",
"진행 중": "In progress",
"진행중": "In progress",
"잠김": "Locked",
"예정": "Upcoming",
"미시작": "Not started",
"학습 가능": "Ready",
"테마": "Theme",
"다크": "Dark",
"라이트": "Light",
"로컬 환경": "Local environment",
"원격 플랫폼(Yahboom Car)": "Remote platform (Yahboom Car)",
"원격 플랫폼(MK-mini)": "Remote platform (MK-mini)",
"원격 플랫폼 3D(MK-mini)": "Remote platform 3D (MK-mini)",
"기본": "Default",
"기둥": "Pillar",
"벽": "Wall",
"박스": "Box",
"이동": "Move",
"회전": "Rotation",
"크기": "Size",
"이번 주차 요약": "Summarize this week",
"실습 시작 방법": "How to start",
"Docker 명령을 찾을 수 없습니다.": "Docker was not found.",
"현재 사용자의 Docker 접근 권한을 확인해주세요.": "Check your permission to access Docker.",
"Docker 데몬에 연결할 수 없습니다.": "Cannot connect to the Docker daemon.",
"Docker 상태를 확인하지 못했습니다.": "Could not check Docker status.",
"Docker를 사용할 수 있습니다.": "Docker is ready.",
"로그인 세션 확인 중...": "Checking your session...",
"강좌 정보 불러오는 중...": "Loading courses...",
"커리큘럼 및 플랫폼 정보 가져오는 중...": "Loading curriculum and platforms...",
"플랫폼 연결 준비 중": "Preparing platform connection",
"{{platform}} 연결 준비 중": "Preparing connection to {{platform}}",
"클라이언트 상태와 개발 환경 권한을 확인하고 있습니다.": "Checking client status and development environment permissions.",
"플랫폼 연결 상태를 확인하고 있습니다.": "Checking the platform connection.",
"선택한 플랫폼의 개발 환경과 권한 상태를 확인하고 있습니다.": "Checking the selected platform environment and permissions.",
"설정이 저장되었습니다": "Settings saved",
"맵이 저장되었습니다.": "Map saved.",
"맵 저장에 실패했습니다.": "Failed to save the map.",
"모듈 종료 실패": "Could not stop modules",
"플랫폼 초기화 실패": "Platform reset failed",
"원격 플랫폼 연결 정보를 찾지 못했습니다.": "Remote platform connection details were not found.",
"원격 ROS 연결 초기화에 실패했습니다.": "Failed to reset the remote ROS connection.",
"원격 ROS 연결을 초기화했습니다.": "Remote ROS connection reset.",
"편집 중인 파일을 먼저 저장하세요": "Save your edited files first",
"팀에 반영할 변경사항이 없습니다": "No changes to push to the team",
"팀 저장소에 반영했습니다": "Changes pushed to the team repository",
"팀 저장소 반영에 실패했습니다": "Failed to push changes to the team repository",
"팀 변경사항을 받았습니다": "Team changes pulled",
"팀 변경사항을 받지 못했습니다": "Failed to pull team changes",
"설치된 모듈이 없습니다": "No modules installed",
"학습을 완료했습니다": "Learning completed",
"이미 제출한 주차입니다": "This week has already been submitted",
"제출할 변경사항이 없습니다": "No changes to submit",
"변경사항을 제출했습니다": "Changes submitted",
"변경사항을 반영했습니다": "Changes applied",
"제출에 실패했습니다": "Submission failed",
"변경사항을 되돌렸습니다": "Changes reverted",
"변경사항 되돌리기에 실패했습니다": "Failed to revert changes",
"다운로드 완료": "Download complete",
"파일 다운로드에 실패했습니다.": "File download failed.",
"모듈 삭제 실패": "Module deletion failed",
"커스텀 모듈이 저장되었습니다.": "Custom module saved.",
"모듈을 현재 워크스페이스로 불러왔습니다.": "Module imported into this workspace.",
"플랫폼 제어권이 부여되었습니다": "Platform control granted",
"플랫폼 사용이 강사에 의해 종료되었습니다.": "The instructor ended your platform session.",
"환경 종료 실패": "Failed to close environment",
"모듈 중지 실패": "Failed to stop module",
"제어 시간 연장에 실패했습니다.": "Failed to extend control time.",
"플랫폼 연결 정보를 불러오지 못했습니다": "Failed to load platform connection details",
"2글자 이상 입력하세요.": "Enter at least 2 characters.",
"AI 가이드": "AI Guide",
"AI 가이드를 준비하고 있습니다": "Preparing your AI guide",
"AI에게 질문할": "Ask AI",
"Docker 공식 설치 안내": "Official Docker installation guide",
"Docker 없이도 LMS와 원격 플랫폼을 사용할 수 있습니다. 로컬 모듈 빌드와 실행에는 Docker가 필요합니다.": "You can use LMS and remote platforms without Docker. Docker is required to build and run local modules.",
"GPU 가속": "GPU acceleration",
"Git 상태 불러오는 중": "Loading Git status",
"Launch 파일": "Launch file",
"NVIDIA Container Toolkit과 Docker GPU 런타임이 준비된 PC에서만 실행됩니다. 준비되지 않은 경우 GPU 가속을 끄세요.": "Requires NVIDIA Container Toolkit and a configured Docker GPU runtime. Otherwise, turn off GPU acceleration.",
"ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다.": "Use the NVIDIA GPU when running Docker.",
"ROS 연결 초기화": "Reset ROS connections",
"ROS2 패키지 소스를 검증한 뒤 현재 워크스페이스로 복사합니다.": "Validate ROS2 package sources and copy them into the current workspace.",
"{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.": "Disconnect from the {{v0}} studio only. Platform control is retained.",
"{{v0}} 개발 스튜디오를 종료할까요?": "Close the {{v0}} studio?",
"{{v0}} 닫기": "Close {{v0}}",
"{{v0}} 동영상강의": "{{v0}} video lesson",
"{{v0}} 삭제": "Delete {{v0}}",
"{{v0}} 제어권을 반납하면 대기 중인 다음 수강생에게 제어권이 넘어갈 수 있습니다.": "Releasing control of {{v0}} may transfer it to the next student in the queue.",
"{{v0}} 추가": "Add {{v0}}",
"{{v0}} 화면 불러오는 중": "Loading {{v0}}",
"{{v0}}개 모듈": "{{v0}} modules",
"{{v0}}개의 모듈이 실행 중입니다. 모듈을 종료할까요?": "{{v0}} modules are running. Stop them?",
"{{v0}}주차 {{v1}}": "Week {{v0}} {{v1}}",
"{{v0}}주차 · {{v1}}": "Week {{v0}} · {{v1}}",
"{{v0}}주차 대표이미지": "Week {{v0}} cover image",
"{{v0}}페이지": "Page {{v0}}",
"가동시간": "Uptime",
"가로 (m)": "Width (m)",
"가이드 {{v0}} / {{v1}}": "Guide {{v0}} / {{v1}}",
"각도 (Z)": "Angle (Z)",
"값": "Value",
"강사": "Instructor",
"강사 계정은 이용할 수 없습니다": "Not available for instructor accounts",
"강사가 확인하는 대로 답변을 드립니다": "Your instructor will reply after reviewing your question.",
"강사에게 문의할": "Ask your instructor",
"강의 자료": "Course Materials",
"강의내용": "Course content",
"강의에 등록된 주차별 커리큘럼을 불러오지 못했거나 아직 등록된 데이터가 없습니다.": "The weekly curriculum could not be loaded or has not been added yet.",
"강의에 배정된 플랫폼을 불러오지 못했거나 아직 등록된 데이터가 없습니다.": "Assigned platforms could not be loaded or have not been added yet.",
"강좌 변경 제한": "Cannot switch courses",
"강좌 선택": "Select course",
"강좌 선택 후 이동 가능": "Select a course first",
"강좌를 선택하면 학습 내용에 맞춰 안내합니다.": "Select a course for guidance tailored to its content.",
"강좌명 또는 강사명 검색": "Search courses or instructors",
"같은 네트워크의 기체와 통신을 허용합니다. 사용자별 ROS Domain ID를 구분하고, 변경 후 모듈과 시각화를 재실행하세요. 플랫폼 연동 통신은 별도 설정을 따릅니다.": "Allow communication with robots on the same network. Use a separate ROS Domain ID for each user. Restart modules and visualization after changing this setting. Linked platforms use separate communication settings.",
"같은 번호의 ROS 모듈끼리 토픽과 서비스를 공유합니다.": "ROS modules with the same domain ID share topics and services.",
"개": " items",
"개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.": "Ask about concepts, practical steps, or troubleshooting.",
"개발 스튜디오": "Development Studio",
"개발 스튜디오 시작": "Start studio",
"개발 스튜디오 종료": "Close studio",
"개발 스튜디오는 수강생 실습용 화면입니다. 강사 계정은 강사포털에서 수강생 관리, 채팅, 학습 진행 현황을 확인해 주세요.": "The development studio is for student practice. Instructors can manage students, chat, and review progress in the instructor portal.",
"개발자 도구": "Developer tools",
"개발환경": "Development environment",
"건": " items",
"검색": "Search",
"검색 결과가 없습니다.": "No results found.",
"검색 중...": "Searching...",
"검색 중입니다.": "Searching.",
"검색어 지우기": "Clear search",
"검색어를 입력하세요.": "Enter a search term.",
"경로 선택": "Choose path",
"공동 저장소는 모듈을 설치하면 생성됩니다.": "The shared repository is created when you install a module.",
"공식 설치 안내에서 운영체제를 선택해 Docker를 설치한 뒤 다시 확인해주세요. Ubuntu에서는 Docker Engine의 apt 저장소 설치 방법을 사용할 수 있습니다.": "Install Docker using the official instructions for your OS, then check again. On Ubuntu, you can use the Docker Engine apt repository.",
"공용 플랫폼 환경": "Shared platform environment",
"구독": "Subscribe",
"구독 노드": "Subscriber nodes",
"구독 노드 없음": "No subscriber nodes",
"구독자": "Subscribers",
"글꼴 크기": "Font size",
"기본값": "Default",
"긴 줄을 편집기 너비에 맞춰 다음 줄에 표시합니다.": "Wrap long lines to fit the editor width.",
"나가기": "Leave",
"나중에": "Later",
"남은 시간 {{v0}}{{v1}}": "Time remaining {{v0}}{{v1}}",
"내 변경 파일": "My changes",
"내 학습 현황": "My Progress",
"내용을 입력하세요": "Enter text",
"높이": "Height",
"다시 로그인하려면 앱을 종료한 뒤 런처에서 다시 실행해주세요.": "Close the app and reopen it from the launcher to sign in again.",
"다시 불러오기": "Reload",
"다시 확인": "Check again",
"다운로드": "Download",
"다운로드 중": "Downloading",
"다음": "Next",
"다음 페이지": "Next page",
"단계별 자율주행 알고리즘 학습 과정": "Step-by-step autonomous driving curriculum",
"닫기": "Close",
"답변 대기 중": "Awaiting reply",
"답변을 작성 중입니다...": "Writing a reply...",
"대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.": "Control can be extended once by the current duration when no reservations are waiting.",
"대화내역 삭제": "Delete conversation",
"도구 보기": "Show tools",
"도움말": "Help",
"동영상강의 보기": "Watch video lesson",
"되돌리기": "Revert",
"둘러보기": "Explore",
"뒤로가기": "Back",
"등록된 강의자료가 없습니다.": "No course materials yet.",
"등록된 주행장 카메라가 없습니다.": "No track camera configured.",
"등록된 참고자료가 없습니다.": "No references yet.",
"등록된 커리큘럼이 없습니다.": "No curriculum available.",
"등록된 커스텀 모듈 정보를 삭제합니다.": "Delete the registered custom module information.",
"로그 불러오는 중": "Loading logs",
"로그 파일을 불러오는 중입니다.": "Loading the log file.",
"로그를 불러오는 중입니다.": "Loading logs.",
"로컬 Docker 설정": "Local Docker setup",
"로컬 Docker 이미지 불러오는 중": "Loading local Docker images",
"로컬 PC": "Local PC",
"{{platform}} 종료 중": "Closing {{platform}}",
"{{platform}} 연결종료 중": "Disconnecting {{platform}}",
"{{platform}} 제어 시간 종료 중": "Ending control time for {{platform}}",
"{{platform}} 제어권 반납 중": "Releasing control of {{platform}}",
"실행 중인 스튜디오": "Running studio",
"제어 시간이 종료되어 워크스페이스를 닫고 플랫폼을 초기화하고 있습니다.": "Closing the workspace and resetting the platform because your control time has ended.",
"현재 제어 세션을 종료하고 플랫폼을 초기화하고 있습니다.": "Ending the control session and resetting the platform.",
"로컬과 플랫폼 모듈을 함께 쓸 때 실행 PC 브리지를 사용하세요.": "Use the PC bridge when combining local and platform modules.",
"리스트로 이동": "Back to list",
"마지막으로 작업하던 개발 스튜디오 화면을 불러오고 있습니다.": "Restoring your last development studio session.",
"맵 변경 안내": "Map change",
"맵 변경 확인": "Confirm map change",
"맵 선택": "Select map",
"맵 설정": "Map settings",
"맵 크기": "Map size",
"맵 편집은 로컬 시뮬레이션 환경에서 사용할 수 있습니다.": "Map editing is available in local simulation.",
"맵을 구성하세요.": "Build your map.",
"메모리": "Memory",
"메시지 타입": "Message type",
"명령어 복사": "Copy command",
"모두 비우기": "Clear all",
"모두 읽음": "Mark all as read",
"모듈 가이드": "Module guide",
"모듈 경로": "Module path",
"모듈 리스트에서 소스를 볼 모듈을 선택하면 알고리즘 코드를 수정할 수 있습니다.": "Select a module from the list to view and edit its algorithm code.",
"모듈 목록 불러오는 중": "Loading modules",
"모듈 불러오기": "Import module",
"모듈 설치 중...": "Installing module...",
"모듈 소스와 빌드 결과가 저장되는 기본 경로입니다.": "The default directory for module sources and build output.",
"모듈 이름": "Module name",
"모듈 작업 준비 중...": "Preparing module operation...",
"모듈 정보 입력": "Module details",
"모듈 종료": "Stop module",
"모듈명": "Module name",
"모듈을 불러오지 못했습니다.": "Could not load the module.",
"모듈을 선택하세요": "Select a module",
"모듈을 이곳으로 드래그하면 전체 제어 대상에서 제외됩니다": "Drag modules here to exclude them from bulk actions",
"모듈을 추가하지 못했습니다.": "Could not add the module.",
"목록 새로고침": "Refresh list",
"무엇이 궁금한가요?": "What would you like to know?",
"미니맵 표시": "Show minimap",
"미등록": "Not registered",
"반납": "Release",
"발행": "Publish",
"발행 노드": "Publisher nodes",
"발행 노드 없음": "No publisher nodes",
"발행 노드에서 구독 노드로 전달": "From publisher to subscriber",
"발행·구독 연결됨": "Publisher and subscriber connected",
"발행자": "Publishers",
"배정 전 사용법 안내 과정": "Getting started before course assignment",
"변경": "Change",
"변경 내용 불러오는 중": "Loading changes",
"변경 파일": "Changed files",
"변경된 파일 없음": "No changes",
"보관 영역 크기 조절": "Resize excluded modules area",
"복사": "Copy",
"복사됨": "Copied",
"복사하지 못했습니다. 명령어를 직접 선택해 복사해주세요.": "Could not copy. Select and copy the command manually.",
"분류": "Category",
"불러오는 중": "Loading",
"붙여넣기": "Paste",
"빌드:": "Build:",
"빠른 질문": "Quick questions",
"사용": "Use",
"사용 가능한 시뮬레이션 맵이 없습니다.": "No simulation maps available.",
"사용 가능한 플랫폼 데이터가 없습니다.": "No platforms available.",
"사용자 워크스페이스 루트": "Workspace root",
"사용자모듈": "Custom modules",
"삭제": "Delete",
"삭제한 채팅과 답변은 복구할 수 없습니다.": "Deleted conversations and replies cannot be recovered.",
"삭제할까요?": "Delete?",
"상세정보": "Details",
"상태": "Status",
"새 맵을 적용하려면 실행 중인 모듈을 전체 재시작해야 합니다.": "Restart all running modules to apply the new map.",
"새 모듈": "New module",
"새 이름": "New name",
"새 채팅": "New chat",
"새로 시작": "Start new",
"새로고침": "Refresh",
"생각 중": "Thinking",
"선택": "Select",
"선택한": "Selected",
"선택한 오브젝트": "Selected object",
"선택한 주차": "Selected week",
"선택한 폴더 없음": "No folder selected",
"선택한 플랫폼과 같은 ROS Domain으로 모듈을 실행합니다.": "Run modules in the same ROS domain as the selected platform.",
"설정": "Settings",
"설치 안내: https://docs.docker.com/engine/install/": "Installation: https://docs.docker.com/engine/install/",
"세로 (m)": "Depth (m)",
"세션 만료": "Session expired",
"세션 연결종료": "Disconnect session",
"세션에 적용할 작업을 선택하세요.": "Choose an action for this session.",
"{{platform}} 세션에 적용할 작업을 선택하세요.": "Choose an action for the {{platform}} session.",
"세션이 없거나 유효하지 않습니다.": "The session is missing or invalid.",
"소스 제어": "Source control",
"수강 신청": "Enroll",
"수강 중": "Enrolled",
"수강 중인 강좌": "My courses",
"수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.": "You have no courses yet. Search above to enroll.",
"수강 중인 강좌를 선택하거나 새 강좌를 검색해 수강 신청하세요.": "Select a course or search for a new one to enroll.",
"시각화": "Visualization",
"시각화 브리지 실행 위치": "Visualization bridge location",
"시각화 종료": "Stop visualization",
"시각화 준비 중입니다.": "Preparing visualization.",
"시각화를 종료할까요?": "Stop visualization?",
"시뮬레이션": "Simulation",
"시작 {{v0}}": "Started {{v0}}",
"실습 내용": "Practice",
"실습을 진행할 플랫폼을 선택하고 제어권을 요청하세요.": "Select a platform and request control to start practicing.",
"실시간 로그": "Live logs",
"실행 PC": "This PC",
"실행 가이드": "Run guide",
"실행 모듈": "Running modules",
"실행 모듈에서 확인된 토픽이 없습니다.": "No topics found in running modules.",
"실행 방법 메뉴": "Run options",
"실행 방식: 세션 단위 임시 런타임": "Runtime: temporary per-session environment",
"실행 이미지": "Runtime image",
"실행 중": "Running",
"실행 중인 모듈": "Running modules",
"실행 중인 모듈과 이전 피어 설정을 정리합니다.": "Stop running modules and clear previous peer settings.",
"실행 중인 모듈을 종료하고 설정을 저장할까요?": "Stop running modules and save settings?",
"실행 중인 모듈이 없습니다.": "No modules are running.",
"실행 중인 세션이 있습니다.": "A session is already running.",
"실행 중인 주차": "Active week",
"실행에 포함": "Include in bulk actions",
"실행중인 플랫폼": "Active platforms",
"실행하기": "Run",
"아니오": "No",
"아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.": "Choose No to keep the modules running on the platform.",
"아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.": "Choose No to keep the modules running after disconnecting.",
"아직 기록된 앱 로그가 없습니다.": "No app logs yet.",
"아직 실행한 개발 환경 세션이 없습니다.": "No development sessions yet.",
"안 읽음 {{v0}}": "Unread {{v0}}",
"안읽음": "Unread",
"알림": "Notifications",
"알림 검색": "Search notifications",
"알림은 최대 하루동안만 저장됩니다.": "Notifications are kept for up to one day.",
"앱 로그": "App logs",
"앱 로그 닫기": "Close app logs",
"앱을 종료하시겠습니까?": "Quit the app?",
"에디터": "Editor",
"연결": "Connect",
"연결 끊김": "Disconnected",
"연결 불가": "Unavailable",
"연결을 종료할까요?": "Disconnect?",
"연결종료": "Disconnect",
"연동 대상 플랫폼": "Linked platform",
"연동 플랫폼": "Linked platform",
"연동하지 않음": "Not linked",
"연장": "Extend",
"연장 중": "Extending",
"열기": "Open",
"열린 모듈 없음": "No open modules",
"영상 연결 중...": "Connecting video...",
"영상 중지": "Stop video",
"예": "Yes",
"예: NAV2-MyPlanner": "e.g. NAV2-MyPlanner",
"예: future-nav2:humble": "e.g. future-nav2:humble",
"예상 학습 시간": "Estimated learning time",
"오류 닫기": "Dismiss error",
"오버뷰": "Overview",
"오버뷰 닫기": "Close overview",
"오브젝트를 선택하거나 추가하세요.": "Select or add an object.",
"오브젝트를 추가해": "Add objects to",
"완료 후에는 이 주차의 학습을 다시 완료 처리할 수 없습니다.": "You cannot mark this week complete again once it is completed.",
"외부 ROS 통신": "External ROS communication",
"워크스페이스 경로가 변경되면 현재 경로에서 실행 중인 모듈을 먼저 종료해야 합니다.": "Stop modules in the current workspace before changing its path.",
"워크스페이스에 설치된 모듈 폴더가 삭제됩니다.": "The installed module folder will be deleted from the workspace.",
"원격 플랫폼": "Remote platform",
"원격 플랫폼의 워크스페이스 설정은 강사/관리자 설정을 사용합니다.": "Remote workspaces use settings configured by the instructor or administrator.",
"원격 플랫폼의 워크스페이스, 저장소, GPU 설정은 강사/관리자 플랫폼 설정을 사용합니다.": "Remote workspace, repository, and GPU settings are managed by the instructor or administrator.",
"원래 크기": "Restore size",
"월드 불러오는 중": "Loading world",
"월드 오브젝트": "World objects",
"위치": "Position",
"으로 변경되었습니다.": "is now selected.",
"응답을 작성하고 있습니다.": "Writing a response.",
"응답이 중단됨": "Response interrupted",
"이 강좌는 Autonomy Studio LMS 사용법을 확인하기 위한 공통 안내입니다. 실제 학습 시간, 커밋 수, 제출 현황은 강사가 배정한 강좌에서부터 집계됩니다.": "This course introduces Autonomy Studio LMS. Learning time, commits, and submissions are tracked in courses assigned by your instructor.",
"이 채팅 내역을 삭제할까요?": "Delete this conversation?",
"이 현재 작업 트리에서 제거됩니다.": "will be removed from the working tree.",
"이름 변경": "Rename",
"이미 실행 중인 스튜디오가 있습니다": "A studio is already running",
"이미지 미리보기를 사용할 수 없습니다.": "Image preview is unavailable.",
"이전": "Previous",
"이전 채팅": "Earlier chats",
"이전 채팅을 불러오는 중": "Loading earlier chats",
"이전 페이지": "Previous page",
"이전에 연결했던 플랫폼을 선택해 개발 환경에 연결하세요.": "Select a previously connected platform to reopen its development environment.",
"일반 Docker Engine 사용 시 아래 명령으로 권한을 추가한 뒤 OS에서 로그아웃하고 다시 로그인해주세요. Docker 그룹은 관리자 수준의 권한을 부여합니다. Rootless Docker나 별도 Docker 컨텍스트를 사용한다면 연결 설정부터 확인해주세요.": "For Docker Engine, run the command below, then sign out of your OS and sign back in. The Docker group grants administrator-level privileges. For rootless Docker or another Docker context, check your connection settings first.",
"일반 Docker Engine은 아래 명령으로 서비스를 시작할 수 있습니다. Docker Desktop이나 Rootless Docker 사용 시 해당 서비스를 실행하고 Docker 컨텍스트를 확인해주세요.": "For Docker Engine, start the service with the command below. For Docker Desktop or rootless Docker, start the appropriate service and check your Docker context.",
"읽음 {{v0}}": "Read {{v0}}",
"자세히 보기": "View details",
"자율주행": "Autonomous driving",
"자율주행 알고리즘 모듈 관리": "Autonomous driving modules",
"저장": "Save",
"저장 위치: world-layout.json": "Save to: world-layout.json",
"저장된 플랫폼 SLAM 맵이 없습니다.": "No saved platform SLAM maps.",
"저장됨": "Saved",
"저장소": "Repository",
"저장하지 않고 나갈까요?": "Leave without saving?",
"저장하지 않고 닫으면 현재 편집 내용이 사라집니다.": "Closing without saving will discard your edits.",
"전체 보기": "View all",
"전체 선택": "Select all",
"전체 알림": "All notifications",
"전체 진행 상황 및 통계": "Overall progress and statistics",
"전체 해제": "Deselect all",
"접기": "Collapse",
"제어 시간 안내": "Control time notice",
"제어 시간 연장": "Extend control time",
"제어 시간을 연장할까요?": "Extend control time?",
"제어 시간이 곧 종료됩니다.": "Your control time is about to expire.",
"제어권을 반납할까요?": "Release control?",
"제출": "Submit",
"제출 {{v0}}": "Submission {{v0}}",
"제출 기준": "Submission requirements",
"제출 내역": "Submission history",
"제출 내역 불러오는 중": "Loading submissions",
"제출 내역 없음": "No submissions",
"제출 목록": "Submissions",
"제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다.": "Submitted commits and work history will be deleted. Reinstalling creates a new repository from the template.",
"종료": "Stop",
"종료 {{v0}}": "Ends {{v0}}",
"종료 중": "Stopping",
"종료 후에는 실행 버튼으로 다시 시작할 수 있습니다.": "You can restart it with the Run button.",
"종료하고 새로 시작": "Stop and start new",
"종료하고 저장": "Stop and save",
"주차": " week",
"주차 ·": " ·",
"주차 개요": " · Overview",
"주차 워크스페이스에 월드 배치를 저장합니다.": "Save the world layout in this week's workspace.",
"주차 학습 세션 공통 맵": "Shared map for this week's session",
"주차별 커리큘럼": "Weekly curriculum",
"주행": "Driving",
"주행장 오버뷰 영상": "Track overview video",
"주행장을 외부에서 촬영하는 영상입니다.": "An external camera view of the driving track.",
"줄바꿈": "Word wrap",
"지도와 차량 상태가 표시됩니다.": "View the map and vehicle status.",
"직접 입력": "Enter manually",
"진행률": "Progress",
"질문을 보내면 이곳에 표시됩니다.": "Your questions will appear here.",
"질문하세요...": "Ask a question...",
"질의응답": "Q&A",
"질의응답 최소화": "Minimize Q&A",
"질의응답 패널 크기 조절": "Resize Q&A panel",
"참고자료": "References",
"채팅 내역을 불러오는 중입니다": "Loading conversation history",
"채팅 불러오는 중": "Loading chats",
"채팅 삭제": "Delete chat",
"체험": "Demo",
"체험 강좌": "Demo course",
"초기화": "Reset",
"초기화 중": "Resetting",
"최근 세션": "Recent sessions",
"최근 세션 데이터가 없습니다.": "No recent sessions.",
"최근 채팅": "Recent chats",
"최대화": "Maximize",
"최소화": "Minimize",
"추가": "Add",
"추가 중": "Adding",
"추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.",
"취소": "Cancel",
"카테고리": "Category",
"칸": " spaces",
"커리큘럼": "Curriculum",
"커스텀 맵": "Custom map",
"커스텀 맵은 시뮬레이터를 다시 실행하면 적용됩니다.": "Restart the simulator to apply the custom map.",
"컨테이너": "Container",
"코드 편집기에 표시할 글자 크기입니다.": "Font size used in the code editor.",
"탭 입력 시 사용할 공백 칸 수입니다.": "Number of spaces for each tab.",
"탭 크기": "Tab size",
"터미널 환경": "Terminal environment",
"토픽": "Topics",
"토픽 상세": "Topic details",
"토픽 상세 닫기": "Close topic details",
"토픽 상세 패널 크기 조절": "Resize topic details panel",
"토픽 연결": "Topic connections",
"토픽 정의가 아직 없습니다.": "No topic definitions yet.",
"팀 반영": "Share with team",
"팀 변경 내역": "Team changes",
"팀 변경 내역 불러오는 중": "Loading team changes",
"팀 변경 내역 없음": "No team changes",
"팀 변경 사항 받기": "Pull team changes",
"팀에 반영": "Share with team",
"파라미터": "Parameters",
"파라미터 설정": "Parameter settings",
"파라미터명": "Parameter name",
"파일": "File",
"파일 만들기": "New file",
"파일 불러오는 중...": "Loading file...",
"파일 트리 로딩 중": "Loading file tree",
"파일을 불러오지 못했습니다.": "Could not load the file.",
"파일을 선택하세요": "Select a file",
"패키지:": "Package:",
"패키지명": "Package name",
"편집기 오른쪽에 코드 축소 지도를 표시합니다.": "Show a code minimap on the right side of the editor.",
"폴더 만들기": "New folder",
"폴더와 하위 항목": "The folder and its contents",
"표시할 로그 없음": "No logs to display",
"표시할 모듈 없음": "No modules to display",
"표시할 상세정보가 없습니다.": "No details available.",
"표시할 알림이 없습니다.": "No notifications.",
"프로젝트 트리 크기 조절": "Resize project tree",
"플랫폼": "Platform",
"플랫폼 선택": "Select platform",
"플랫폼 선택으로 돌아가기": "Back to platform selection",
"플랫폼 설정": "Platform settings",
"플랫폼:": "Platform: ",
"플랫폼별 개발 환경 설정을 관리하세요.": "Manage development settings for each platform.",
"하위 폴더 모두 닫기": "Collapse all folders",
"학생 저장소도 함께 삭제": "Also delete the student repository",
"학습 내용": "Learning content",
"학습 완료": "Complete learning",
"학습 정보": "Learning information",
"학습 종료": "Learning complete",
"학습 현황 데이터가 없습니다.": "No learning statistics available.",
"학습가이드": "Learning guide",
"학습가이드 최소화": "Minimize learning guide",
"학습가이드 패널 크기 조절": "Resize learning guide panel",
"해당 주차 학습이 종료되었습니다.": "This week's learning is complete.",
"핵심 개념": "Key concepts",
"현재 맵": "Current map",
"현재 실행 중인 개발 스튜디오 세션을 종료한 뒤 다른 강좌를 선택해주세요.": "Close the running studio session before selecting another course.",
"현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다.": "Stop the current studio before starting the selected week.",
"현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.": "Only the current studio session will close. Platform control is retained.",
"현재 주차의 학습을 완료할까요?": "Mark this week as complete?",
"현재 채팅": "Current chat",
"현재 학습": "Current learning",
"호스트": "Host",
"확대": "Expand",
"확인": "OK",
"확인 중": "Checking",
"활성": "Active",
"활성 연결": "Active connections"
}
+63
View File
@@ -0,0 +1,63 @@
import i18next from 'i18next'
import { initReactI18next, useTranslation } from 'react-i18next'
import english from './en.json'
export type Language = 'ko' | 'en'
export const LANGUAGE_STORAGE_KEY = 'autonomy-studio.lms.language'
function initialLanguage(): Language {
try { return localStorage.getItem(LANGUAGE_STORAGE_KEY) === 'en' ? 'en' : 'ko' }
catch { return 'ko' }
}
void i18next.use(initReactI18next).init({
lng: initialLanguage(),
fallbackLng: 'ko',
supportedLngs: ['ko', 'en'],
resources: {
en: { translation: english },
ko: { translation: Object.fromEntries(Object.keys(english).map(key => [key, key])) }
},
keySeparator: false,
nsSeparator: false,
interpolation: { escapeValue: false },
initAsync: false,
react: { useSuspense: false }
})
export function t(message: string, values?: Record<string, unknown>): string {
return i18next.t(message, { ...values, defaultValue: message }) as string
}
// Subscribe without remounting editors, terminals, or running workspace sessions.
export function useLocaleRender(): void {
useTranslation()
}
export function dateLocale(): string {
return i18next.language === 'en' ? 'en-US' : 'ko-KR'
}
export function useLanguage(): { language: Language; setLanguage: (language: Language) => void } {
const { i18n } = useTranslation()
return { language: i18n.language === 'en' ? 'en' : 'ko', setLanguage }
}
export function setLanguage(language: Language): void {
try { localStorage.setItem(LANGUAGE_STORAGE_KEY, language) } catch { /* Session-only on restricted storage. */ }
void i18next.changeLanguage(language)
}
function updateDocumentLanguage(): void {
document.documentElement.lang = i18next.language === 'en' ? 'en' : 'ko'
}
i18next.on('languageChanged', updateDocumentLanguage)
updateDocumentLanguage()
window.addEventListener('storage', (event) => {
if (event.key === LANGUAGE_STORAGE_KEY) {
void i18next.changeLanguage(event.newValue === 'en' ? 'en' : 'ko')
}
})
export default i18next