Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a15102f8c | |||
| d44180db67 | |||
| 4125f71792 |
@@ -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.
|
||||
Generated
+87
-3
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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' }, '*');
|
||||
@@ -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>
|
||||
|
||||
+56
-55
@@ -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,
|
||||
@@ -5000,12 +5005,6 @@ export default function App(): React.JSX.Element {
|
||||
? 'absolute inset-0'
|
||||
: ['fixed bottom-0 right-0 top-[38px]', sidebarCollapsed ? 'left-[68px]' : 'left-[240px]'].join(' ')
|
||||
].join(' ')
|
||||
const workspaceOnlyModalOverlayClass = [
|
||||
'z-[80] grid place-items-center bg-black/36',
|
||||
localPcLayoutOpen
|
||||
? 'absolute inset-0'
|
||||
: ['fixed bottom-0 right-0 top-[38px]', sidebarCollapsed ? 'left-[68px]' : 'left-[240px]'].join(' ')
|
||||
].join(' ')
|
||||
const fullAppModalOverlayClass = 'fixed bottom-0 left-0 right-0 top-[38px] z-[80] grid place-items-center bg-black/36'
|
||||
const canReleaseConnectedPlatform = connectedPlatform?.control?.status === 'ACTIVE'
|
||||
const controlWarningMatchesConnectedPlatform = Boolean(
|
||||
@@ -5025,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
|
||||
@@ -5190,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)
|
||||
@@ -5248,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)
|
||||
@@ -5289,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)
|
||||
@@ -5314,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
|
||||
? '제어 시간이 종료되어 워크스페이스를 닫고 플랫폼을 초기화하고 있습니다.'
|
||||
: '현재 제어 세션을 종료하고 플랫폼을 초기화하고 있습니다.'
|
||||
@@ -5514,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)
|
||||
@@ -5729,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)
|
||||
@@ -5839,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(() => {
|
||||
@@ -5900,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
|
||||
@@ -5911,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 && (
|
||||
@@ -6064,7 +6066,6 @@ export default function App(): React.JSX.Element {
|
||||
|
||||
{materialModalOpen && (
|
||||
<MaterialsModal
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
week={selectedWeek}
|
||||
resources={selectedWeekDownloadResources}
|
||||
downloadingResourceIds={materialDownloadingResourceIds}
|
||||
@@ -6077,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}
|
||||
@@ -6134,7 +6135,7 @@ export default function App(): React.JSX.Element {
|
||||
onClose={() => setRecentSessionModalOpen(false)}
|
||||
onOpenSession={() => {
|
||||
if (!platformLoading && sessionPlatform) {
|
||||
void openPlatformWorkspace(sessionPlatform, '선택한 플랫폼의 개발 환경과 권한 상태를 확인하고 있습니다.')
|
||||
void openPlatformWorkspace(sessionPlatform, t("선택한 플랫폼의 개발 환경과 권한 상태를 확인하고 있습니다."))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -6145,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)}
|
||||
/>
|
||||
)}
|
||||
@@ -6156,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)}
|
||||
/>
|
||||
)}
|
||||
@@ -6167,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)
|
||||
@@ -6195,10 +6196,10 @@ export default function App(): React.JSX.Element {
|
||||
)}
|
||||
{weekSwitchBlockedOpen && (
|
||||
<WeekSwitchBlockedModal
|
||||
overlayClassName={workspaceOnlyModalOverlayClass}
|
||||
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()}
|
||||
/>
|
||||
@@ -6267,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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -9,7 +10,6 @@ type DownloadResource = {
|
||||
}
|
||||
|
||||
type MaterialsModalProps<Resource extends DownloadResource> = {
|
||||
sidebarCollapsed: boolean
|
||||
week: {
|
||||
week: number
|
||||
title: string
|
||||
@@ -22,7 +22,6 @@ type MaterialsModalProps<Resource extends DownloadResource> = {
|
||||
}
|
||||
|
||||
export default function MaterialsModal<Resource extends DownloadResource>({
|
||||
sidebarCollapsed,
|
||||
week,
|
||||
resources,
|
||||
downloadingResourceIds,
|
||||
@@ -30,23 +29,21 @@ export default function MaterialsModal<Resource extends DownloadResource>({
|
||||
onClose,
|
||||
onDownload
|
||||
}: MaterialsModalProps<Resource>): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'fixed bottom-0 right-0 top-[38px] z-[70] grid place-items-center bg-[#080a10]/40 p-6 backdrop-blur-[3px]',
|
||||
sidebarCollapsed ? 'left-[68px]' : 'left-[240px]'
|
||||
].join(' ')}
|
||||
className="fixed bottom-0 left-0 right-0 top-[38px] z-[70] grid place-items-center bg-[#080a10]/40 p-6 backdrop-blur-[3px]"
|
||||
>
|
||||
<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]"
|
||||
>
|
||||
@@ -80,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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user