feat: support simultaneous Tailscale and LAN communication for Yahboom cameras
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 961 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 961 KiB After Width: | Height: | Size: 628 KiB |
@@ -9,7 +9,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #07111f;
|
||||
background: #ffffff;
|
||||
font-family: NanumSquare, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: radial-gradient(circle at 50% 35%, #11243e 0%, #07111f 54%, #03070d 100%);
|
||||
background: #ffffff;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
@@ -31,22 +31,53 @@
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.fallback-title {
|
||||
.brand-logo {
|
||||
position: absolute;
|
||||
top: 45%;
|
||||
top: 116px;
|
||||
left: calc(50% - 3px);
|
||||
z-index: 1;
|
||||
width: 80px;
|
||||
height: 70px;
|
||||
transform: translateX(-50%);
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
position: absolute;
|
||||
top: 187px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #4d5d76;
|
||||
text-shadow: 0 1px 12px rgba(255, 255, 255, 0.28);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
z-index: 1;
|
||||
width: 290px;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
color: #252d39;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: 6px;
|
||||
text-indent: 6px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
margin-top: 9px;
|
||||
color: #7a8495;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
right: 26px;
|
||||
bottom: 20px;
|
||||
z-index: 2;
|
||||
color: #64738a;
|
||||
text-shadow: 0 1px 10px rgba(255, 255, 255, 0.24);
|
||||
font-size: 12px;
|
||||
@@ -58,6 +89,7 @@
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
z-index: 2;
|
||||
color: #7a8495;
|
||||
text-shadow: 0 1px 8px rgba(255, 255, 255, 0.2);
|
||||
font-size: 11px;
|
||||
@@ -68,15 +100,17 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="splash">
|
||||
<div class="fallback-title">LMS</div>
|
||||
<img class="splash-image" id="splash-image" alt="" />
|
||||
<img class="splash-image" src="./assets/splash.png" alt="" />
|
||||
<img class="brand-logo" src="./assets/splash-logo.png" alt="" />
|
||||
<div class="brand-copy">
|
||||
<div class="brand-title">LMS</div>
|
||||
<div class="brand-subtitle">Learning Management System</div>
|
||||
</div>
|
||||
<div class="copyright">© 2026 Autonomy Studio. All rights reserved.</div>
|
||||
<div class="loading" id="loading-text">학습 환경 준비 중...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('splash-image').src = './assets/splash.png'
|
||||
|
||||
const el = document.getElementById('loading-text')
|
||||
const translations = {
|
||||
'학습 환경 준비 중...': 'Preparing your learning environment...',
|
||||
|
||||
+55
-10
@@ -1,4 +1,5 @@
|
||||
import { t, useLocaleRender } from './i18n'
|
||||
import type { GitModuleImportInput } from '../../shared/gitModuleImport'
|
||||
import { useStudioTheme } from './features/workspace/studioTheme'
|
||||
import './features/workspace/studioPalette.css'
|
||||
import { type BeforeMount, type OnMount } from '@monaco-editor/react'
|
||||
@@ -987,7 +988,9 @@ 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-[#bec9d8] bg-[#dce4ee]'
|
||||
const topbar = dark
|
||||
? 'border-white/10 bg-[#303030]'
|
||||
: 'border-[#cbbdde] bg-[linear-gradient(90deg,#e6ddf1_0%,#e5def1_52%,#dce9f5_100%)]'
|
||||
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]'
|
||||
@@ -1918,14 +1921,14 @@ function LocalPcWorkspaceMockup({
|
||||
} catch (error) {
|
||||
console.error('[git] status before submit failed', error)
|
||||
setGitError(error instanceof Error ? error.message : 'Git 상태를 불러오지 못했습니다.')
|
||||
onTitlebarNotice('제출에 실패했습니다', 'error')
|
||||
onTitlebarNotice('커밋 또는 푸시에 실패했습니다', 'error')
|
||||
setGitSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const changedModules = installedGitModules.filter((module) => statusMap[module.name]?.files.length)
|
||||
if (!changedModules.length) {
|
||||
onTitlebarNotice('제출할 변경사항이 없습니다', 'warning')
|
||||
onTitlebarNotice('커밋할 변경사항이 없습니다', 'warning')
|
||||
setGitSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -1961,7 +1964,7 @@ function LocalPcWorkspaceMockup({
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
module.folderName ?? undefined,
|
||||
`${weekNumber}주차 ${module.name} 제출`,
|
||||
`${weekNumber}주차 ${module.name} 커밋`,
|
||||
undefined,
|
||||
undefined,
|
||||
submissionId,
|
||||
@@ -1971,7 +1974,7 @@ function LocalPcWorkspaceMockup({
|
||||
module.name,
|
||||
clonePathPreview,
|
||||
module.folderName,
|
||||
`${weekNumber}주차 ${module.name} 제출`,
|
||||
`${weekNumber}주차 ${module.name} 커밋`,
|
||||
undefined,
|
||||
undefined,
|
||||
submissionId,
|
||||
@@ -1981,13 +1984,13 @@ function LocalPcWorkspaceMockup({
|
||||
if (result.commit) submittedCommitCount += 1
|
||||
}
|
||||
|
||||
onTitlebarNotice(submittedCommitCount > 0 ? '변경사항을 제출했습니다' : '변경사항을 반영했습니다', 'info')
|
||||
onTitlebarNotice(submittedCommitCount > 0 ? '변경사항을 커밋하고 푸시했습니다' : '변경사항을 반영했습니다', 'info')
|
||||
await refreshGitStatuses(null)
|
||||
await refreshGitSubmissions()
|
||||
} catch (error) {
|
||||
console.error('[git] submission failed', error)
|
||||
setGitError(error instanceof Error ? error.message : '제출에 실패했습니다.')
|
||||
onTitlebarNotice('제출에 실패했습니다', 'error')
|
||||
setGitError(error instanceof Error ? error.message : '커밋 또는 푸시에 실패했습니다')
|
||||
onTitlebarNotice('커밋 또는 푸시에 실패했습니다', 'error')
|
||||
} finally {
|
||||
setGitSubmitting(false)
|
||||
}
|
||||
@@ -2433,6 +2436,21 @@ function LocalPcWorkspaceMockup({
|
||||
const updateSourceSetting = <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]): void => {
|
||||
setSourceSettingsDraft((settings) => ({ ...settings, [key]: value }))
|
||||
}
|
||||
const resetSourceSettingsDraft = (): void => {
|
||||
setSourceSettingsDraft((settings) => normalizeStudioSourceSettings({
|
||||
...settings,
|
||||
workspaceRoot: platformSourceSettingsDefaults.workspaceRoot,
|
||||
gpuEnabled: platformSourceSettingsDefaults.gpuEnabled,
|
||||
allowExternalRos: platformSourceSettingsDefaults.allowExternalRos,
|
||||
editorWordWrap: platformSourceSettingsDefaults.editorWordWrap,
|
||||
editorFontSize: platformSourceSettingsDefaults.editorFontSize,
|
||||
editorTabSize: platformSourceSettingsDefaults.editorTabSize,
|
||||
editorMinimap: platformSourceSettingsDefaults.editorMinimap,
|
||||
rosDomainId: platformSourceSettingsDefaults.rosDomainId,
|
||||
rosLinkedPlatformId: platformSourceSettingsDefaults.rosLinkedPlatformId,
|
||||
visualizationBridgeLocation: platformSourceSettingsDefaults.visualizationBridgeLocation,
|
||||
}))
|
||||
}
|
||||
const applySourceSettings = async (normalizedSettings: StudioSourceSettings, stopRunningModules: boolean): Promise<void> => {
|
||||
const workspaceRootChanged = normalizedSettings.workspaceRoot !== sourceSettings.workspaceRoot
|
||||
const runningModules = availableModules.filter((module) => module.state === 'running')
|
||||
@@ -3424,6 +3442,27 @@ function LocalPcWorkspaceMockup({
|
||||
addCustomModuleToList(customModule)
|
||||
onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info')
|
||||
}
|
||||
const importGitModule = async (input: GitModuleImportInput): Promise<void> => {
|
||||
if (!remoteWorkspace) throw new Error('원격 워크스페이스에서만 사용할 수 있습니다.')
|
||||
const source = await window.localWorkspace.modules.inspectGitSource(localVisualizationWorkspaceRoot, input.sourceFolder)
|
||||
if (source.dirty || !source.pushed) throw new Error('로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.')
|
||||
if (!source.branches.some(branch => branch.name === input.branch && branch.commit === input.commit)) {
|
||||
throw new Error('브랜치가 변경되었습니다. 저장소 정보를 새로고침하세요.')
|
||||
}
|
||||
const customModule = await registerCustomModule({ name: input.name, category: input.category, packageName: input.packageName, launchFile: input.launchFile, dockerImage: input.dockerImage })
|
||||
try {
|
||||
await window.remoteWorkspace.modules.importGit(getRemoteWorkspaceTarget(), localVisualizationWorkspaceRoot, { ...input, folderName: customModule.folderName! })
|
||||
} catch (error) {
|
||||
const rollback = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/custom-modules/${encodeURIComponent(customModule.id)}?courseId=${encodeURIComponent(courseKey)}`, { method: 'DELETE' }).catch(() => null)
|
||||
if (!rollback?.ok) {
|
||||
addCustomModuleToList(customModule)
|
||||
throw new Error(t('가져오기에 실패했고 모듈 등록을 취소하지 못했습니다. 목록에서 해당 모듈을 확인하세요.'))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
addCustomModuleToList(customModule)
|
||||
onTitlebarNotice(t('Git 모듈을 가져왔습니다. 차량에서 빌드한 뒤 실행하세요.'), 'info')
|
||||
}
|
||||
const importCustomModule = async ({ sourcePath, ...input }: CustomModuleImportInput): Promise<void> => {
|
||||
if (remoteWorkspace) throw new Error('모듈 불러오기는 로컬 워크스페이스에서만 사용할 수 있습니다.')
|
||||
const customModule = await registerCustomModule(input)
|
||||
@@ -3794,6 +3833,7 @@ function LocalPcWorkspaceMockup({
|
||||
saveDisabled={!sourceSettingsDirty}
|
||||
remotePlatforms={platformOptions.filter((platform) => platform.platformKey !== 'futuredrive-local-pc')}
|
||||
onUpdateSourceSetting={updateSourceSetting}
|
||||
onResetSourceSettings={resetSourceSettingsDraft}
|
||||
onSaveSourceSettings={saveSourceSettings}
|
||||
/>
|
||||
) : gitActive ? (
|
||||
@@ -4002,7 +4042,12 @@ function LocalPcWorkspaceMockup({
|
||||
selectedMap={selectedWorldMap}
|
||||
showSelectedMapPreview={Boolean(curriculumWeek.maps?.some((map) => map.platformKey === 'NAV2'))}
|
||||
allowRepositoryDeletion={!remoteWorkspace && !publicGuideCourse}
|
||||
allowCustomModuleCreation={!remoteWorkspace}
|
||||
allowCustomModuleCreation={true}
|
||||
allowCustomModuleImport={true}
|
||||
remoteGitImport={remoteWorkspace ? { workspaceRoot: localVisualizationWorkspaceRoot, onImport: importGitModule } : undefined}
|
||||
loadDockerImages={() => remoteWorkspace
|
||||
? window.remoteWorkspace.modules.listDockerImages(getRemoteWorkspaceTarget())
|
||||
: window.localWorkspace.modules.listDockerImages()}
|
||||
onAddModuleTab={addModuleTabOnly}
|
||||
onOpenSource={openModuleSource}
|
||||
onInstallModule={(module) => void installModule(module)}
|
||||
@@ -4173,7 +4218,7 @@ export default function App(): React.JSX.Element {
|
||||
const [workspaceVisualizationOpen, setWorkspaceVisualizationOpen] = useState(false)
|
||||
const [workspaceVisualizationMinimized, setWorkspaceVisualizationMinimized] = useState(false)
|
||||
const [workspaceVisualizationLarge, setWorkspaceVisualizationLarge] = useState(false)
|
||||
const [workspaceVisualizationPosition, setWorkspaceVisualizationPosition] = useState({ x: 1040, y: 72 })
|
||||
const [workspaceVisualizationPosition, setWorkspaceVisualizationPosition] = useState({ x: 1040, y: 86 })
|
||||
const [workspaceVisualizationSize, setWorkspaceVisualizationSize] = useState({ width: 600, height: 600 })
|
||||
const workspaceVisualizationHostRef = useRef<WorkspaceVisualizationHost | null>(null)
|
||||
const [workspaceVisualizationHost, setWorkspaceVisualizationHost] = useState<WorkspaceVisualizationHost | null>(null)
|
||||
|
||||
@@ -526,7 +526,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="relative z-[10020] flex h-[38px] shrink-0 items-center justify-between rounded-t-[18px] bg-[#111520] px-3 pl-5"
|
||||
className="relative z-[10020] flex h-[38px] shrink-0 items-center justify-between bg-[#111520] px-3 pl-5"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
onContextMenuCapture={suppressTitlebarContextMenu}
|
||||
onContextMenu={suppressTitlebarContextMenu}
|
||||
|
||||
Vendored
+4
@@ -111,6 +111,8 @@ interface Window {
|
||||
}>
|
||||
}>
|
||||
listDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||
listGitSources: (root: string) => Promise<import('../../shared/gitModuleImport').LocalGitModule[]>
|
||||
inspectGitSource: (root: string, folder: string) => Promise<import('../../shared/gitModuleImport').GitModuleSource>
|
||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{
|
||||
ok: boolean
|
||||
module: {
|
||||
@@ -333,6 +335,8 @@ interface Window {
|
||||
runtime: string
|
||||
}
|
||||
}>
|
||||
listDockerImages: (target: RemoteWorkspaceTarget) => Promise<{ images: string[]; message?: string }>
|
||||
importGit: (target: RemoteWorkspaceTarget, root: string, input: import('../../shared/gitModuleImport').GitModuleImportInput & { folderName: string }) => Promise<{ ok: boolean; workspacePath: string }>
|
||||
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }>
|
||||
transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => Promise<{ ok: boolean; workspacePath: string }>
|
||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
}
|
||||
|
||||
[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] {
|
||||
background: #fff;
|
||||
background: #faf8ff;
|
||||
}
|
||||
|
||||
[data-ai-guide-theme='light'] [class~='bg-[#5b4aa8]'] {
|
||||
@@ -111,6 +111,7 @@
|
||||
|
||||
[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] {
|
||||
border-color: #c2cfe6;
|
||||
background: #faf8ff;
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 5px 18px rgb(49 81 170 / 5%);
|
||||
}
|
||||
@@ -129,3 +130,21 @@
|
||||
border-color: #b8c8e8;
|
||||
box-shadow: 0 4px 16px rgb(49 81 170 / 6%);
|
||||
}
|
||||
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] button:not([title]):not([class*='bg-cyan-400/10']),
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] div.group:not([class*='bg-cyan-400/10']),
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-questions'] button:not([class*='bg-cyan-400/10']) {
|
||||
border: 1px solid #d5ddef;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] button:not([title]):not([class*='bg-cyan-400/10']):hover,
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] div.group:not([class*='bg-cyan-400/10']):hover,
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-questions'] button:not([class*='bg-cyan-400/10']):hover {
|
||||
background: #f2f5fb;
|
||||
}
|
||||
|
||||
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] div.group > button:first-child {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function PlatformSettingsPanel({
|
||||
<div className="mt-4">
|
||||
<StudioSettingsSwitch
|
||||
label={t("GPU 가속")}
|
||||
description={t("ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
||||
description={t("모듈 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
||||
checked={draft.gpuEnabled}
|
||||
onChange={(checked) => onDraftChange((settings) => ({ ...settings, gpuEnabled: checked }))}
|
||||
dark={false}
|
||||
|
||||
@@ -8,7 +8,7 @@ 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-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'].join(' ')}>
|
||||
<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-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'].join(' ')}>
|
||||
<span className="text-center">{t("상태")}</span>
|
||||
<span className="text-center">{t("분류")}</span>
|
||||
<span>{t("모듈명")}</span>
|
||||
|
||||
@@ -62,18 +62,18 @@ function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean)
|
||||
: tone === 'start'
|
||||
? dark
|
||||
? 'border-[#2fbda0]/35 bg-[#2fbda0]/10 text-[#80ddc7] hover:bg-[#2fbda0]/18'
|
||||
: 'border-[#2fbda0]/28 bg-[#effbf7] text-[#177553] hover:bg-[#e1f7ee]'
|
||||
: 'border-[#439b7b] bg-[#effbf7] text-[#177553] hover:border-[#177553] hover:bg-[#e1f7ee]'
|
||||
: tone === 'warning'
|
||||
? dark
|
||||
? 'border-[#f59e0b]/25 bg-[#f59e0b]/8 text-[#e8b76b] hover:bg-[#f59e0b]/14'
|
||||
: 'border-[#f59e0b]/22 bg-[#fff8e8] text-[#9a6814] hover:bg-[#fff1cf]'
|
||||
: 'border-[#b8872e] bg-[#fff8e8] text-[#9a6814] hover:border-[#9a6814] hover:bg-[#fff1cf]'
|
||||
: tone === 'delete'
|
||||
? dark
|
||||
? 'border-[#ef4444]/20 bg-white/[0.03] text-[#e87979] hover:border-[#ef4444]/40 hover:bg-[#ef4444]/10 hover:text-[#ffb4b4]'
|
||||
: 'border-[#ef4444]/18 bg-white text-[#c25a5a] hover:border-[#ef4444]/32 hover:bg-[#fff4f4] hover:text-[#b42323]'
|
||||
: 'border-[#cc6868] bg-[#fff7f7] text-[#b42323] hover:border-[#b42323] hover:bg-[#ffeded] hover:text-[#b42323]'
|
||||
: dark
|
||||
? 'border-white/15 bg-white/[0.04] text-white/60 hover:bg-white/10 hover:text-white'
|
||||
: 'border-[#cfd6e4] bg-white text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
: 'border-[#919db1] bg-white text-[#526078] hover:border-[#64748b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
@@ -192,8 +192,8 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
useEffect(() => { if (!busy) setProgress('') }, [busy])
|
||||
const stateStyle: Record<ModuleListRowItem['state'], string> = {
|
||||
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
|
||||
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]',
|
||||
uninstalled: dark ? 'bg-[#2d2d2d] text-white/45' : 'bg-[#edf0f5] text-[#687284]'
|
||||
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#dce2eb] text-[#3e4c62]',
|
||||
uninstalled: dark ? 'bg-[#2d2d2d] text-white/45' : 'bg-[#e7deef] text-[#60486f]'
|
||||
}
|
||||
const controlActions: Array<{
|
||||
label: string
|
||||
@@ -249,16 +249,17 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
className={[
|
||||
'grid h-[58px] grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center px-3 text-[12px] transition',
|
||||
'cursor-default',
|
||||
!dark ? 'last:border-b last:border-[#c9bfd8] hover:bg-[#ece6f4]' : '',
|
||||
dragging ? 'opacity-45' : '',
|
||||
dropTarget ? (dark ? 'bg-[#2563eb]/14 outline outline-1 outline-[#3b82f6]/50' : 'bg-[#edf6ff] outline outline-1 outline-[#2563eb]/35') : '',
|
||||
selectable
|
||||
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
|
||||
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b]'
|
||||
: dark ? 'text-white/42' : 'text-[#475569]'
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-center">
|
||||
<span className={['inline-flex h-6 items-center rounded px-2 text-[11px] font-bold', stateStyle[module.state]].join(' ')}>
|
||||
{t(stateLabel[module.state])}
|
||||
<span className="relative top-px leading-none">{t(stateLabel[module.state])}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={['text-center', dark ? 'text-white/52' : 'font-medium text-[#334155]'].join(' ')}>{module.category}</span>
|
||||
@@ -299,7 +300,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
|
||||
{module.cpu.toFixed(module.cpu >= 10 ? 0 : 1)}%
|
||||
</span>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#aeb9cb]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2f8dff]" style={{ width: `${Math.min(100, module.cpu)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
@@ -313,7 +314,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
|
||||
{module.memory.toFixed(module.memory >= 10 ? 0 : 1)}%
|
||||
</span>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
|
||||
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#aeb9cb]'].join(' ')}>
|
||||
<span className="block h-full rounded-full bg-[#2fbda0]" style={{ width: `${Math.min(100, module.memory)}%` }} />
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -46,9 +46,11 @@ export default function ModuleListToolbar({
|
||||
const stoppingAll = installingModuleName === '__stop_all__'
|
||||
|
||||
const bulkActions = [
|
||||
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: onOpenWorldEditor },
|
||||
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: onOpenWorldEditor,
|
||||
lightClass: 'border-[#b59bd8] bg-[#e9def7] text-[#553080] enabled:hover:border-[#9370bd] enabled:hover:bg-[#ddccef]' },
|
||||
{
|
||||
label: '전체 설치',
|
||||
lightClass: 'border-[#a99bc9] bg-[#e8e2f3] text-[#59417f] enabled:hover:border-[#8b77b4] enabled:hover:bg-[#dcd2ee]',
|
||||
Icon: Download,
|
||||
busy: installingAll,
|
||||
disabled: loading || installingModuleName !== null || uninstalledCount === 0,
|
||||
@@ -56,6 +58,7 @@ export default function ModuleListToolbar({
|
||||
},
|
||||
{
|
||||
label: '전체 실행',
|
||||
lightClass: 'border-[#78ab94] bg-[#e0f1e8] text-[#216545] enabled:hover:border-[#439b7b] enabled:hover:bg-[#cde8da]',
|
||||
Icon: Play,
|
||||
busy: startingAll,
|
||||
disabled: loading || installingModuleName !== null || stoppedCount === 0,
|
||||
@@ -63,6 +66,7 @@ export default function ModuleListToolbar({
|
||||
},
|
||||
{
|
||||
label: '전체 중지',
|
||||
lightClass: 'border-[#b8872e] bg-[#fff0d0] text-[#855b13] enabled:hover:border-[#9a6814] enabled:hover:bg-[#ffe5ae]',
|
||||
Icon: Square,
|
||||
busy: stoppingAll,
|
||||
disabled: loading || installingModuleName !== null || runningCount === 0,
|
||||
@@ -86,7 +90,7 @@ export default function ModuleListToolbar({
|
||||
active
|
||||
? dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: dark
|
||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
@@ -98,7 +102,7 @@ export default function ModuleListToolbar({
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
{bulkActions.map(({ label, Icon, busy, disabled, onClick }) => (
|
||||
{bulkActions.map(({ label, Icon, busy, disabled, onClick, lightClass }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
@@ -109,10 +113,10 @@ export default function ModuleListToolbar({
|
||||
}}
|
||||
className={[
|
||||
'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',
|
||||
disabled ? (dark ? 'cursor-default opacity-45' : 'cursor-default') : 'cursor-pointer',
|
||||
dark
|
||||
? '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]'
|
||||
: disabled ? 'border-[#d2ccdc] bg-[#e9e6ee] text-[#8b8498]' : lightClass
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import RemoteModuleImportDialog from './RemoteModuleImportDialog'
|
||||
import type { GitModuleImportInput } from '../../../../shared/gitModuleImport'
|
||||
import { BookOpen, ChevronLeft, ChevronRight, FolderOpen, PackagePlus, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type React from 'react'
|
||||
@@ -28,6 +30,9 @@ type ModuleListViewProps = {
|
||||
showSelectedMapPreview?: boolean
|
||||
allowRepositoryDeletion?: boolean
|
||||
allowCustomModuleCreation?: boolean
|
||||
allowCustomModuleImport?: boolean
|
||||
remoteGitImport?: { workspaceRoot: string; onImport: (input: GitModuleImportInput) => Promise<void> }
|
||||
loadDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||
onAddModuleTab: (module: ModuleRow) => void
|
||||
onOpenSource: (module: ModuleRow) => void
|
||||
onInstallModule: (module: ModuleRow) => void
|
||||
@@ -64,6 +69,9 @@ export default function ModuleListView({
|
||||
showSelectedMapPreview = true,
|
||||
allowRepositoryDeletion = true,
|
||||
allowCustomModuleCreation = true,
|
||||
allowCustomModuleImport = allowCustomModuleCreation,
|
||||
remoteGitImport,
|
||||
loadDockerImages,
|
||||
onAddModuleTab,
|
||||
onOpenSource,
|
||||
onInstallModule,
|
||||
@@ -409,14 +417,14 @@ export default function ModuleListView({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("모듈 불러오기")}
|
||||
disabled={!allowCustomModuleCreation}
|
||||
disabled={!allowCustomModuleImport}
|
||||
onClick={() => {
|
||||
setModuleImportDialogOpen(true)
|
||||
setFloatingActionMenuOpen(false)
|
||||
}}
|
||||
className={[
|
||||
'absolute bottom-[124px] left-1/2 grid h-12 w-12 -translate-x-1/2 place-items-center rounded-full transition-all duration-200 ease-out',
|
||||
allowCustomModuleCreation
|
||||
allowCustomModuleImport
|
||||
? 'cursor-pointer bg-[#2563eb] text-white shadow-[0_8px_20px_rgba(20,54,104,0.24)] hover:scale-105 hover:bg-[#1d4ed8]'
|
||||
: 'cursor-not-allowed bg-[#3b4250] text-white/35 shadow-none',
|
||||
floatingActionMenuOpen ? 'pointer-events-auto translate-y-0 scale-100 opacity-100 delay-75' : 'pointer-events-none translate-y-4 scale-50 opacity-0'
|
||||
@@ -425,7 +433,7 @@ export default function ModuleListView({
|
||||
<FolderOpen size={18} />
|
||||
<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'
|
||||
allowCustomModuleImport ? 'bg-[#2563eb] text-white' : 'bg-[#3b4250] text-white/35 shadow-none'
|
||||
].join(' ')}>{t("모듈 불러오기")}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -457,6 +465,7 @@ export default function ModuleListView({
|
||||
{allowCustomModuleCreation && moduleAddDialogOpen && (
|
||||
<ModuleAddDialog
|
||||
dark={dark}
|
||||
loadDockerImages={loadDockerImages}
|
||||
onClose={() => setModuleAddDialogOpen(false)}
|
||||
onCreate={async (input) => {
|
||||
await onCreateCustomModule(input)
|
||||
@@ -464,8 +473,14 @@ export default function ModuleListView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{allowCustomModuleCreation && moduleImportDialogOpen && (
|
||||
<ModuleImportDialog
|
||||
{allowCustomModuleImport && moduleImportDialogOpen && (
|
||||
remoteGitImport ? <RemoteModuleImportDialog
|
||||
dark={dark}
|
||||
workspaceRoot={remoteGitImport.workspaceRoot}
|
||||
loadDockerImages={loadDockerImages}
|
||||
onImport={remoteGitImport.onImport}
|
||||
onClose={() => setModuleImportDialogOpen(false)}
|
||||
/> : <ModuleImportDialog
|
||||
dark={dark}
|
||||
onClose={() => setModuleImportDialogOpen(false)}
|
||||
onImport={async (input) => {
|
||||
@@ -667,10 +682,12 @@ export type CustomModuleImportInput = CustomModuleInput & {
|
||||
|
||||
function ModuleAddDialog({
|
||||
dark,
|
||||
loadDockerImages,
|
||||
onClose,
|
||||
onCreate
|
||||
}: {
|
||||
dark: boolean
|
||||
loadDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||
onClose: () => void
|
||||
onCreate: (input: CustomModuleInput) => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
@@ -695,7 +712,7 @@ function ModuleAddDialog({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void window.localWorkspace.modules.listDockerImages()
|
||||
void loadDockerImages()
|
||||
.then(({ images, message }) => {
|
||||
if (cancelled) return
|
||||
setDockerImages(images)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RefreshCw, X } from 'lucide-react'
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import type { GitModuleImportInput, GitModuleSource, LocalGitModule } from '../../../../shared/gitModuleImport'
|
||||
|
||||
export default function RemoteModuleImportDialog({ dark, workspaceRoot, loadDockerImages, onImport, onClose }: {
|
||||
dark: boolean
|
||||
workspaceRoot: string
|
||||
loadDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||
onImport: (input: GitModuleImportInput) => Promise<void>
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const [loadImages] = useState(() => loadDockerImages)
|
||||
const [modules, setModules] = useState<LocalGitModule[]>([])
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [folder, setFolder] = useState('')
|
||||
const [source, setSource] = useState<GitModuleSource | null>(null)
|
||||
const [branch, setBranch] = useState('')
|
||||
const [image, setImage] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [inspecting, setInspecting] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const selected = modules.find(module => module.folderName === folder)
|
||||
const selectedBranch = source?.branches.find(item => item.name === branch)
|
||||
const ready = selected && source && !source.dirty && source.pushed && selectedBranch && image.trim() && name.trim()
|
||||
const inputClass = ['h-9 w-full rounded-md border px-2 text-[12px] disabled:opacity-60', dark ? 'border-white/20 bg-[#202020] text-white' : 'border-[#aebace] bg-white text-[#334155]'].join(' ')
|
||||
const buttonClass = ['h-8 cursor-pointer rounded-md border px-3 text-[12px] disabled:cursor-default disabled:opacity-50', dark ? 'border-white/20 hover:bg-white/10' : 'border-[#aebace] hover:bg-[#eee8f6]'].join(' ')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
void Promise.all([window.localWorkspace.modules.listGitSources(workspaceRoot), loadImages()])
|
||||
.then(([list, result]) => {
|
||||
if (cancelled) return
|
||||
setModules(list)
|
||||
setImages(result.images)
|
||||
if (result.message) setError(result.message)
|
||||
}).catch(reason => { if (!cancelled) setError(String(reason.message || reason)) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [workspaceRoot, loadImages])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setSource(null)
|
||||
setBranch('')
|
||||
if (!folder) {
|
||||
setInspecting(false)
|
||||
return
|
||||
}
|
||||
setInspecting(true)
|
||||
setError(null)
|
||||
void window.localWorkspace.modules.inspectGitSource(workspaceRoot, folder)
|
||||
.then(result => {
|
||||
if (cancelled) return
|
||||
setSource(result)
|
||||
setBranch(result.branches.some(item => item.name === result.currentBranch) ? result.currentBranch : result.branches[0]?.name ?? '')
|
||||
}).catch(reason => { if (!cancelled) setError(String(reason.message || reason)) })
|
||||
.finally(() => { if (!cancelled) setInspecting(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [workspaceRoot, folder, revision])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label={t('로컬 모듈 Git 가져오기')}>
|
||||
<section className={['w-full max-w-[500px] rounded-lg border shadow-xl', dark ? 'border-white/20 bg-[#252525] text-white' : 'border-[#aebace] bg-white text-[#1e293b]'].join(' ')}>
|
||||
<header className="flex items-center justify-between border-b border-current/15 p-4">
|
||||
<h2 className="text-[14px] font-bold">{t('로컬 모듈 Git 가져오기')}</h2>
|
||||
<button type="button" disabled={submitting} onClick={onClose} aria-label={t('닫기')} className="cursor-pointer rounded p-1 disabled:opacity-50"><X size={16} /></button>
|
||||
</header>
|
||||
<form className="grid gap-3 p-4" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
if (!ready || submitting || inspecting || !selected || !selectedBranch) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
void onImport({ sourceFolder: folder, branch, commit: selectedBranch.commit, name: name.trim(), category: selected.category, packageName: selected.packageName, launchFile: selected.launchFile, dockerImage: image.trim() })
|
||||
.then(onClose)
|
||||
.catch(reason => setError(String(reason.message || reason)))
|
||||
.finally(() => setSubmitting(false))
|
||||
}}>
|
||||
<label className="grid gap-1 text-[12px] font-semibold">{t('로컬 사용자 모듈')}
|
||||
<select className={inputClass + ' cursor-pointer'} value={folder} disabled={loading || submitting} onChange={event => {
|
||||
setFolder(event.target.value)
|
||||
setSource(null)
|
||||
setBranch('')
|
||||
setName(modules.find(module => module.folderName === event.target.value)?.name ?? '')
|
||||
}}>
|
||||
<option value="">{loading ? t('조회 중...') : t('선택')}</option>
|
||||
{modules.map(module => <option key={module.folderName} value={module.folderName}>{module.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{!loading && !modules.length && <p className="text-[12px]">{t('현재 강좌·주차의 로컬 사용자 모듈이 없습니다.')}</p>}
|
||||
<label className="grid gap-1 text-[12px] font-semibold">{t('모듈 이름')}<input required className={inputClass} value={name} disabled={submitting} onChange={event => setName(event.target.value)} /></label>
|
||||
<div className="flex items-end gap-2">
|
||||
<label className="grid min-w-0 flex-1 gap-1 text-[12px] font-semibold">{t('브랜치')}
|
||||
<select className={inputClass + ' cursor-pointer'} value={branch} disabled={inspecting || submitting || !source} onChange={event => setBranch(event.target.value)}>
|
||||
<option value="">{inspecting ? t('조회 중...') : t('선택')}</option>
|
||||
{source?.branches.map(item => <option key={item.name} value={item.name}>{item.name} ({item.commit.slice(0, 8)})</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className={buttonClass} disabled={!folder || inspecting || submitting} onClick={() => setRevision(value => value + 1)} aria-label={t('새로고침')}><RefreshCw size={14} className={inspecting ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
{source && <p className="break-all text-[11px]">{source.repository}</p>}
|
||||
{source && (source.dirty || !source.pushed) && <p className={dark ? 'text-[12px] text-[#facc15]' : 'text-[12px] text-[#855b13]'}>{t('로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.')}</p>}
|
||||
<label className="grid gap-1 text-[12px] font-semibold">{t('실행 이미지')}
|
||||
<input required list="remote-git-module-images" className={inputClass} value={image} disabled={submitting} onChange={event => setImage(event.target.value)} placeholder="future-nav2:humble-yahboom" />
|
||||
<datalist id="remote-git-module-images">{images.map(value => <option key={value} value={value} />)}</datalist>
|
||||
</label>
|
||||
{error && <p className={['break-words text-[12px]', dark ? 'text-[#ff9c9c]' : 'text-[#b42323]'].join(' ')}>{t(error)}</p>}
|
||||
<footer className="mt-1 flex justify-end gap-2">
|
||||
<button type="button" className={buttonClass} disabled={submitting} onClick={onClose}>{t('취소')}</button>
|
||||
<button type="submit" disabled={!ready || submitting || inspecting} className="flex h-8 cursor-pointer items-center gap-2 rounded-md bg-[#7c3aed] px-3 text-[12px] font-semibold text-white disabled:cursor-default disabled:opacity-50">{submitting && <RefreshCw size={14} className="animate-spin" />}{t('가져오기')}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function StudioSettingsField({
|
||||
<label className="block">
|
||||
<span className={['block text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{label}</span>
|
||||
{description && (
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{description}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{description}</span>
|
||||
)}
|
||||
<input
|
||||
value={value}
|
||||
@@ -62,7 +62,7 @@ export function StudioSettingsSwitch({
|
||||
<div className="min-w-0">
|
||||
<p className={['text-[12px] font-black', dark ? 'text-white/70' : 'text-[#4f596b]'].join(' ')}>{label}</p>
|
||||
{description && (
|
||||
<p className={['mt-1 text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{description}</p>
|
||||
<p className={['mt-1 text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -24,7 +24,7 @@ function getHeaderCopy(
|
||||
return {
|
||||
title: '소스 제어',
|
||||
subtitle: gitPanelTab === 'history'
|
||||
? t('제출 내역 {{count}}개', { count: gitSubmissionCount })
|
||||
? t('커밋 내역 {{count}}개', { count: gitSubmissionCount })
|
||||
: t('모듈별 변경 파일 조회 · 변경 {{count}}개', { count: gitChangedFileCount })
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export default function WorkspaceActivityHeader({
|
||||
<div className="flex h-full items-center px-3">
|
||||
<div>
|
||||
<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(' ')}>
|
||||
<span className={['mt-0.5 block text-[11px] font-medium', dark ? 'text-white/40' : 'text-[#526078]'].join(' ')}>
|
||||
{t(copy.subtitle)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose, containerR
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [large, setLarge] = useState(false)
|
||||
const [position, setPosition] = useState({ x: 32, y: 90 })
|
||||
const [position, setPosition] = useState({ x: 96, y: 90 })
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null)
|
||||
const [bounds, setBounds] = useState({ width: 0, height: 0 })
|
||||
@@ -81,7 +81,7 @@ export default function WorkspaceCameraPanel({ camera, dark, onClose, containerR
|
||||
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' }}>
|
||||
<div className="flex h-10 shrink-0 touch-none items-center gap-2 border-b border-gray-500/25 px-3 select-none"
|
||||
<div className={['flex h-10 shrink-0 touch-none items-center gap-2 border-b px-3 select-none', dark ? 'border-white/10 bg-[#252525]' : 'border-[#bec9d8] bg-[#e2e8f0]'].join(' ')}
|
||||
style={{ cursor: large ? 'default' : 'move' }}
|
||||
onPointerDown={(event) => {
|
||||
if (large || event.button !== 0 || (event.target as HTMLElement).closest('button')) return
|
||||
|
||||
@@ -65,7 +65,7 @@ function WorkspaceEditorTabs({
|
||||
activeFile === file
|
||||
? dark
|
||||
? 'border-white/10 bg-[#1f1f1f] text-white'
|
||||
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white/45 hover:text-white/70'
|
||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
|
||||
@@ -173,8 +173,8 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
<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 ? 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>
|
||||
active ? dark ? 'bg-[#2563eb]/18 text-[#9dbdff]' : 'bg-[#ddd0fa] text-[#5b21b6]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-[#cbd5e1] text-[#334155]' : dark ? 'bg-white/8 text-white/38' : 'bg-[#d5dbe5] text-[#64748b]'
|
||||
].join(' ')}><span className="relative top-px leading-none">{index + 1}</span></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>
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function WorkspaceModuleTabs({
|
||||
className={[
|
||||
'group flex h-full min-w-[170px] max-w-[210px] shrink-0 cursor-pointer items-center gap-2 rounded-none border-r px-3 text-[12px] font-bold transition',
|
||||
active
|
||||
? dark ? 'border-white/10 bg-[#1f1f1f] text-white/90' : 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||
? dark ? 'border-white/10 bg-[#1f1f1f] text-white/90' : 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: dark ? 'border-white/10 bg-[#252525] text-white/45 hover:bg-[#2b2b2b] hover:text-white/75' : 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -53,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-[#bec9d8] bg-[#e2e8f0] text-[#475569]'].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-[#cbbdde] bg-[linear-gradient(180deg,#e6ddf1_0%,#e8e1f2_55%,#e4eef7_100%)] text-[#475569]'].join(' ')}
|
||||
style={workspaceActive ? { gridColumn: 3, gridRow: '1 / span 2' } : { gridColumn: 3, gridRow: 1 }}
|
||||
>
|
||||
{WORKSPACE_NAV_ITEMS.map(({ id, Icon, label }) => {
|
||||
|
||||
@@ -291,7 +291,7 @@ function AiMessages({
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
|
||||
<Bot size={15} />
|
||||
</span>
|
||||
<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(' ')}>
|
||||
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg border px-3 py-2 text-[12px] font-medium leading-relaxed', dark ? 'border-transparent bg-[#303030] text-white/75' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||
<ExpandableMarkdownMessage
|
||||
collapseOnSignal={index < messages.length - 1}
|
||||
collapseSignal={collapseSignal}
|
||||
@@ -311,7 +311,7 @@ function AiMessages({
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
|
||||
<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(' ')}>
|
||||
<div className={['inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-[12px] font-medium', dark ? 'border-transparent bg-[#303030] text-white/55' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#475569]'].join(' ')}>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import { Activity, ArrowRight, CircleDot, Radio, RefreshCw, Send, Waypoints, X } from 'lucide-react'
|
||||
import { Activity, ArrowRight, Check, CircleDot, Radio, RefreshCw, Send, Waypoints, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import type { ModuleRow } from './types'
|
||||
@@ -80,8 +80,8 @@ function RuntimeLane({
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
return (
|
||||
<section className={['grid min-h-0 min-w-[200px] grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-r last:border-r-0', dark ? 'border-white/10 bg-[#222222]' : 'border-[#dbe1eb] bg-[#f8f9fc]'].join(' ')}>
|
||||
<header className={['sticky top-0 z-10 flex items-center justify-between gap-2 border-b px-4 py-3', dark ? 'border-white/10 bg-[#252525]' : 'border-[#dbe1eb] bg-white'].join(' ')}>
|
||||
<section className={['grid min-h-0 min-w-[200px] grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-r last:border-r-0', dark ? 'border-white/10 bg-[#222222]' : 'border-[#aebace] bg-[#f8f9fc]'].join(' ')}>
|
||||
<header className={['sticky top-0 z-10 flex items-center justify-between gap-2 border-b px-4 py-3', dark ? 'border-white/10 bg-[#252525]' : 'border-[#aebace] bg-white'].join(' ')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={['grid h-6 w-6 place-items-center rounded-md', dark ? 'bg-[#0f766e]/25 text-[#7ee7d3]' : 'bg-[#e7f8f5] text-[#0f766e]'].join(' ')}>
|
||||
<Radio size={14} />
|
||||
@@ -99,7 +99,7 @@ function RuntimeLane({
|
||||
key={moduleKey}
|
||||
className={[
|
||||
'w-full rounded-md border p-3 text-left shadow-sm',
|
||||
dark ? 'border-white/10 bg-[#2a2a2a]' : 'border-[#dbe1eb] bg-white'
|
||||
dark ? 'border-white/10 bg-[#2a2a2a]' : 'border-[#aebace] bg-white'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -111,7 +111,7 @@ function RuntimeLane({
|
||||
</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 className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#aebace] text-[#526078]'].join(' ')}>{t("실행 중인 모듈이 없습니다.")}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -133,7 +133,7 @@ function TopicDetailDrawer({
|
||||
}): 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(' ')}>
|
||||
<section className={['relative grid min-h-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-t', dark ? 'border-white/12 bg-[#242424]' : 'border-[#98a8bf] bg-white'].join(' ')}>
|
||||
<div
|
||||
role="separator"
|
||||
aria-label={t("토픽 상세 패널 크기 조절")}
|
||||
@@ -142,7 +142,7 @@ function TopicDetailDrawer({
|
||||
>
|
||||
<span className={['mt-0.5 h-px w-10 rounded-full transition', dark ? 'bg-white/22 group-hover:bg-white/55' : 'bg-[#9daabc] group-hover:bg-[#64748b]'].join(' ')} />
|
||||
</div>
|
||||
<header className={['flex min-h-12 items-center justify-between border-b px-4 py-2', dark ? 'border-white/10' : 'border-[#e0e6ee]'].join(' ')}>
|
||||
<header className={['flex min-h-12 items-center justify-between border-b px-4 py-2', dark ? 'border-white/10' : 'border-[#b0bdd0]'].join(' ')}>
|
||||
<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">
|
||||
@@ -153,7 +153,7 @@ function TopicDetailDrawer({
|
||||
type="button"
|
||||
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(' ')}
|
||||
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-[#475569] hover:bg-[#edf2f8] hover:text-[#1e293b]'].join(' ')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -161,38 +161,38 @@ function TopicDetailDrawer({
|
||||
<div className="thin-y-scrollbar min-h-0 overflow-auto p-4">
|
||||
<div className="grid min-w-[660px] grid-cols-[minmax(230px,0.8fr)_minmax(250px,1fr)_minmax(250px,1fr)] gap-4">
|
||||
<div>
|
||||
<div className={['rounded-md border p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#e0e6ee] bg-[#f8fafc]'].join(' ')}>
|
||||
<div className={['rounded-md border p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#b0bdd0] 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]'}>{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>
|
||||
<div className="col-span-2"><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>{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-[#526078]'}>{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-[#526078]'}>{t("상태")}</dt><dd className={['mt-0.5 inline-flex items-center gap-1 font-semibold', connection.active ? (dark ? 'text-[#22a06b]' : 'text-[#177553]') : dark ? 'text-white/40' : 'text-[#526078]'].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-[#526078]'}>{t("발행자")}</dt><dd className="mt-0.5 font-semibold">{connection.publishers.length}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>{t("구독자")}</dt><dd className="mt-0.5 font-semibold">{connection.subscribers.length}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>QoS</dt><dd className="mt-0.5 font-semibold">{t("기본값")}</dd></div>
|
||||
<div><dt className={dark ? 'text-white/35' : 'text-[#526078]'}>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(' ')}>{t("발행 노드")}</h3>
|
||||
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#475569]'].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(' ')}>
|
||||
<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-[#b0bdd0] 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>
|
||||
<code className={['mt-0.5 block truncate text-[10px]', dark ? 'text-white/38' : 'text-[#526078]'].join(' ')}>{endpoint.type || connection.topic.type}</code>
|
||||
</div>
|
||||
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#8792a5]'].join(' ')}>{t("발행 노드 없음")}</p>}
|
||||
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#526078]'].join(' ')}>{t("발행 노드 없음")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#718096]'].join(' ')}>{t("구독 노드")}</h3>
|
||||
<h3 className={['text-[10px] font-bold', dark ? 'text-white/46' : 'text-[#475569]'].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(' ')}>
|
||||
<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-[#b0bdd0] 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>
|
||||
<code className={['mt-0.5 block truncate text-[10px]', dark ? 'text-white/38' : 'text-[#526078]'].join(' ')}>{endpoint.type || connection.topic.type}</code>
|
||||
</div>
|
||||
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#8792a5]'].join(' ')}>{t("구독 노드 없음")}</p>}
|
||||
)) : <p className={['text-[11px]', dark ? 'text-white/38' : 'text-[#526078]'].join(' ')}>{t("구독 노드 없음")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,10 +215,12 @@ export default function WorkspaceRosConnectionsView({
|
||||
const [rosGraphLoading, setRosGraphLoading] = useState(false)
|
||||
|
||||
const connections = useMemo(() => rosGraphConnections(rosGraph), [rosGraph])
|
||||
const [activeOnly, setActiveOnly] = useState(false)
|
||||
const visibleConnections = useMemo(() => connections.filter((connection) => !activeOnly || connection.active), [connections, activeOnly])
|
||||
const [selectedTopicName, setSelectedTopicName] = useState<string | null>(null)
|
||||
const [topicDetailOpen, setTopicDetailOpen] = useState(false)
|
||||
const [topicDetailHeight, setTopicDetailHeight] = useState(320)
|
||||
const selectedConnection = connections.find((connection) => connection.topic.name === selectedTopicName) ?? null
|
||||
const selectedConnection = visibleConnections.find((connection) => connection.topic.name === selectedTopicName) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTopicName) return
|
||||
@@ -268,23 +270,34 @@ export default function WorkspaceRosConnectionsView({
|
||||
className={['grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
||||
style={{ gridColumn: '4 / span 3', gridRow: 2 }}
|
||||
>
|
||||
<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 justify-between gap-3 border-b px-4 py-3', dark ? 'border-white/10 bg-[#242424]' : 'border-[#a6b4c9] 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(' ')}>
|
||||
<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-[#79b99a] bg-[#effcf5] text-[#168254]'].join(' ')}>
|
||||
<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(' ')}>
|
||||
<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-[#aebace] bg-[#f6f8fb] text-[#536075]'].join(' ')}>
|
||||
<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} />{t("활성 연결")}{activeConnections}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeOnly}
|
||||
title={t("활성 연결만 보기")}
|
||||
onClick={() => setActiveOnly((value) => !value)}
|
||||
className={[
|
||||
'inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8b5cf6]',
|
||||
activeOnly
|
||||
? dark ? 'border-[#a78bfa] bg-[#6d28d9] text-white hover:bg-[#7c3aed]' : 'border-[#b59bd8] bg-[#e7daf5] text-[#5b21a3] hover:bg-[#dac5ee]'
|
||||
: dark ? 'border-white/10 bg-white/5 text-white/70 hover:bg-white/10' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075] hover:bg-[#ece6f4]'
|
||||
].join(' ')}
|
||||
>
|
||||
{activeOnly ? <Check size={13} /> : <CircleDot size={13} />}{t("활성 연결")} {activeConnections}
|
||||
</button>
|
||||
{rosGraph.message && (
|
||||
<span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}>
|
||||
{rosGraph.message}
|
||||
</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/62' : 'border-[#dbe1eb] bg-[#f6f8fb] text-[#657187]'].join(' ')}>
|
||||
<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/62' : 'border-[#aebace] bg-[#f6f8fb] text-[#657187]'].join(' ')}>
|
||||
ROS Domain {rosDomainId}
|
||||
</span>
|
||||
</div>
|
||||
@@ -292,7 +305,7 @@ export default function WorkspaceRosConnectionsView({
|
||||
type="button"
|
||||
onClick={() => void refreshRosGraph(true)}
|
||||
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(' ')}
|
||||
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-[#a6b4c9] text-[#526078] hover:bg-[#eef3f9] hover:text-[#1e293b]'].join(' ')}
|
||||
>
|
||||
<RefreshCw size={13} className={rosGraphLoading ? 'animate-spin' : ''} />{t("새로고침")}</button>
|
||||
</div>
|
||||
@@ -309,16 +322,16 @@ export default function WorkspaceRosConnectionsView({
|
||||
modules={runningRosModules}
|
||||
/>
|
||||
|
||||
<section className={['grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#dbe1eb] bg-[#f7f9fc]'].join(' ')}>
|
||||
<section className={['grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] overflow-hidden border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#aebace] 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(' ')}>{t("토픽 연결")}</strong>
|
||||
<span className={['mt-0.5 block text-[10px]', dark ? 'text-white/42' : 'text-[#758196]'].join(' ')}>{t("발행 노드에서 구독 노드로 전달")}</span>
|
||||
<span className={['mt-0.5 block text-[10px]', dark ? 'text-white/42' : 'text-[#526078]'].join(' ')}>{t("발행 노드에서 구독 노드로 전달")}</span>
|
||||
</div>
|
||||
<span className={['text-[10px] font-semibold', dark ? 'text-white/42' : 'text-[#7a8597]'].join(' ')}>{activeConnections}/{connections.length}{t("활성")}</span>
|
||||
<span className={['text-[10px] font-semibold', dark ? 'text-white/42' : 'text-[#526078]'].join(' ')}>{activeConnections}/{connections.length}{t("활성")}</span>
|
||||
</div>
|
||||
<div className="thin-y-scrollbar min-h-0 space-y-2 overflow-y-auto overscroll-contain">
|
||||
{connections.length ? connections.map((connection) => {
|
||||
{visibleConnections.length ? visibleConnections.map((connection) => {
|
||||
const selected = topicDetailOpen && selectedConnection?.topic.name === connection.topic.name
|
||||
return (
|
||||
<button
|
||||
@@ -335,26 +348,26 @@ export default function WorkspaceRosConnectionsView({
|
||||
className={[
|
||||
'grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_28px_minmax(0,1fr)] items-center gap-2 rounded-md border p-2.5 text-left transition',
|
||||
selected
|
||||
? dark ? 'border-[#60a5fa]/55 bg-[#2563eb]/14' : 'border-[#82b3ff] bg-[#edf5ff]'
|
||||
: dark ? 'border-white/10 bg-[#282828] hover:border-white/22' : 'border-[#dbe2ec] bg-white hover:border-[#b8c9e5]'
|
||||
? dark ? 'border-[#60a5fa]/55 bg-[#2563eb]/14' : 'border-[#5289dc] bg-[#edf5ff]'
|
||||
: dark ? 'border-white/10 bg-[#282828] hover:border-white/22' : 'border-[#aebace] bg-white hover:border-[#7f99bd]'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<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(' ')}>
|
||||
<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-[#79b99a] bg-[#ebfff4] text-[#1d9b62]') : (dark ? 'border-white/12 text-white/32' : 'border-[#aebace] 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(' ')}>{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>
|
||||
<code className={['col-span-3 mt-1 block truncate text-center text-[10px]', dark ? 'text-white/44' : 'text-[#475569]'].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(' ')}>{rosGraphLoading ? t("조회 중...") : rosGraph.message ? t("ROS 그래프 조회에 실패했습니다.") : t("조회된 ROS 토픽이 없습니다.")}</div>
|
||||
<div className={['rounded-md border border-dashed px-3 py-8 text-center text-[11px]', dark ? 'border-white/12 text-white/38' : 'border-[#aebace] text-[#526078]'].join(' ')}>{rosGraphLoading ? t("조회 중...") : rosGraph.message ? t("ROS 그래프 조회에 실패했습니다.") : activeOnly ? t("활성 연결이 없습니다.") : t("조회된 ROS 토픽이 없습니다.")}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -17,6 +17,7 @@ type Props = {
|
||||
saveDisabled: boolean
|
||||
remotePlatforms: PlatformOption[]
|
||||
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
|
||||
onResetSourceSettings: () => void
|
||||
onSaveSourceSettings: () => void
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ function WorkspaceSettingsView({
|
||||
saveDisabled,
|
||||
remotePlatforms,
|
||||
onUpdateSourceSetting,
|
||||
onResetSourceSettings,
|
||||
onSaveSourceSettings
|
||||
}: Props): React.JSX.Element {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
@@ -43,6 +45,8 @@ function WorkspaceSettingsView({
|
||||
const canSave = languageDirty || themeDirty || sourceSettingsDirty
|
||||
const [saveAcknowledged, setSaveAcknowledged] = useState(false)
|
||||
const saveAcknowledgedTimerRef = useRef<number | null>(null)
|
||||
const saveButtonClass = '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]'
|
||||
const resetButtonClass = ['h-9 cursor-pointer rounded-md border px-4 text-[12px] font-bold transition disabled:cursor-default disabled:opacity-50', dark ? 'border-white/15 bg-white/[0.04] text-white/75 hover:bg-white/[0.08]' : 'border-[#cbd5e1] bg-white text-[#475569] hover:bg-[#f1f5f9]'].join(' ')
|
||||
|
||||
useEffect(() => { setLanguageDraft(language) }, [language])
|
||||
useEffect(() => { setThemeDraft(theme) }, [theme])
|
||||
@@ -65,6 +69,13 @@ function WorkspaceSettingsView({
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleReset = (): void => {
|
||||
if (saveAcknowledged) return
|
||||
setLanguageDraft('ko')
|
||||
setThemeDraft('dark')
|
||||
if (!remoteWorkspace) onResetSourceSettings()
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
|
||||
@@ -74,7 +85,27 @@ function WorkspaceSettingsView({
|
||||
<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="relative mb-5">
|
||||
<h3 className={['pr-40 text-[16px] font-black', textStrong].join(' ')}>{t('공통')}</h3>
|
||||
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saveAcknowledged}
|
||||
onClick={handleReset}
|
||||
className={resetButtonClass}
|
||||
>
|
||||
{t("초기화")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSave || saveAcknowledged}
|
||||
onClick={handleSave}
|
||||
className={saveButtonClass}
|
||||
>
|
||||
{saveAcknowledged ? t("저장됨") : t("저장")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LanguageSelect dark={dark} value={languageDraft} onChange={setLanguageDraft} />
|
||||
<label className="block">
|
||||
@@ -129,14 +160,14 @@ function WorkspaceSettingsView({
|
||||
</div>
|
||||
<StudioSettingsSwitch
|
||||
label={t("외부 ROS 통신")}
|
||||
description={t("같은 네트워크의 기체와 통신을 허용합니다. 사용자별 ROS Domain ID를 구분하고, 변경 후 모듈과 시각화를 재실행하세요. 플랫폼 연동 통신은 별도 설정을 따릅니다.")}
|
||||
description={t("같은 네트워크의 기체와 통신을 허용합니다. 플랫폼 연동 통신은 별도 설정을 따릅니다.")}
|
||||
checked={sourceSettingsDraft.allowExternalRos === true}
|
||||
onChange={(checked) => onUpdateSourceSetting('allowExternalRos', checked)}
|
||||
dark={dark}
|
||||
/>
|
||||
<StudioSettingsSwitch
|
||||
label={t("GPU 가속")}
|
||||
description={t("ON이면 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
||||
description={t("모듈 Docker 실행 시 NVIDIA GPU를 사용합니다.")}
|
||||
checked={sourceSettingsDraft.gpuEnabled}
|
||||
onChange={(checked) => onUpdateSourceSetting('gpuEnabled', checked)}
|
||||
dark={dark}
|
||||
@@ -157,7 +188,7 @@ function WorkspaceSettingsView({
|
||||
<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(' ')}>{t("연동 대상 플랫폼")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{t("선택한 플랫폼과 같은 ROS Domain으로 모듈을 실행합니다.")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("선택한 플랫폼과 같은 ROS Domain으로 모듈을 실행합니다.")}</span>
|
||||
<select
|
||||
value={sourceSettingsDraft.rosLinkedPlatformId ?? ''}
|
||||
onChange={(event) => onUpdateSourceSetting('rosLinkedPlatformId', event.target.value || null)}
|
||||
@@ -172,7 +203,7 @@ function WorkspaceSettingsView({
|
||||
</label>
|
||||
<label className="block">
|
||||
<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>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("로컬과 플랫폼 모듈을 함께 쓸 때 실행 PC 브리지를 사용하세요.")}</span>
|
||||
<select
|
||||
value={sourceSettingsDraft.visualizationBridgeLocation ?? 'local'}
|
||||
onChange={(event) => onUpdateSourceSetting('visualizationBridgeLocation', event.target.value === 'platform' ? 'platform' : 'local')}
|
||||
@@ -186,12 +217,12 @@ function WorkspaceSettingsView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}>
|
||||
<div className={['mt-5 border-y py-5', dark ? 'border-white/10' : 'border-[#e1e6ef]'].join(' ')}>
|
||||
<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(' ')}>{t("글꼴 크기")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#7a8393]'].join(' ')}>{t("코드 편집기에 표시할 글자 크기입니다.")}</span>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("코드 편집기에 표시할 글자 크기입니다.")}</span>
|
||||
<select
|
||||
value={sourceSettingsDraft.editorFontSize}
|
||||
onChange={(event) => onUpdateSourceSetting('editorFontSize', Number(event.target.value))}
|
||||
@@ -205,7 +236,7 @@ function WorkspaceSettingsView({
|
||||
</label>
|
||||
<label className="block">
|
||||
<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>
|
||||
<span className={['mt-1 block text-[11px] font-medium leading-relaxed', dark ? 'text-white/38' : 'text-[#687284]'].join(' ')}>{t("탭 입력 시 사용할 공백 칸 수입니다.")}</span>
|
||||
<select
|
||||
value={sourceSettingsDraft.editorTabSize}
|
||||
onChange={(event) => onUpdateSourceSetting('editorTabSize', Number(event.target.value))}
|
||||
@@ -245,7 +276,7 @@ function WorkspaceSettingsView({
|
||||
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]"
|
||||
className={saveButtonClass}
|
||||
>
|
||||
{saveAcknowledged ? t("저장됨") : t("저장")}
|
||||
</button>
|
||||
|
||||
@@ -152,7 +152,7 @@ function WorkspaceSourceControlView({
|
||||
<div className="flex items-end">
|
||||
{[
|
||||
{ key: 'source' as const, label: t("소스 제어") },
|
||||
{ key: 'history' as const, label: teamProject ? t("팀 변경 내역") : t("제출 내역") }
|
||||
{ key: 'history' as const, label: teamProject ? t("팀 변경 내역") : t("커밋 내역") }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -163,7 +163,7 @@ function WorkspaceSourceControlView({
|
||||
gitPanelTab === tab.key
|
||||
? dark
|
||||
? 'border-white/10 bg-[#2b2b2b] text-white'
|
||||
: 'border-[#c4b5df] bg-[#e4d8f5] text-[#38205b]'
|
||||
: 'border-[#c4b5df] bg-[#e2e8f0] text-[#38205b]'
|
||||
: dark
|
||||
? 'border-white/8 bg-[#202020] text-white/45 hover:text-white/70'
|
||||
: 'border-[#d4c9e4] bg-[#eee8f6] text-[#475569] hover:bg-[#e8def3] hover:text-[#38205b]'
|
||||
@@ -184,10 +184,10 @@ function WorkspaceSourceControlView({
|
||||
learningSubmitted || gitSubmitting || !hasInstalledGitModules || gitChangedFileCount === 0 ? 'cursor-default opacity-45' : 'cursor-pointer',
|
||||
dark
|
||||
? 'border-[#60a5fa]/45 bg-[#2563eb]/15 text-[#bfdbfe] hover:bg-[#2563eb]/26 hover:text-white'
|
||||
: 'border-[#93c5fd] bg-[#eff6ff] text-[#1d5fae] hover:bg-[#dbeafe] hover:text-[#174a87]'
|
||||
: 'border-[#b59bd8] bg-[#f1eafa] text-[#7138bf] hover:bg-[#e7daf5] hover:text-[#5b21a3]'
|
||||
].join(' ')}
|
||||
>
|
||||
{gitSubmitting ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}{t("제출")}</button>
|
||||
{gitSubmitting ? <RefreshCw size={14} className="animate-spin" /> : <Upload size={14} />}{t("커밋")}</button>
|
||||
)}
|
||||
{teamProject && (
|
||||
<>
|
||||
@@ -239,7 +239,7 @@ function WorkspaceSourceControlView({
|
||||
{gitPanelTab === 'source' ? (
|
||||
<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(' ')}>
|
||||
<div className={['flex h-11 items-center justify-between border-y px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
|
||||
<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>
|
||||
@@ -344,8 +344,8 @@ 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 ? t("팀 변경 내역") : t("제출 목록")}</span>
|
||||
<div className={['flex h-11 items-center justify-between border-y px-4', borderSoft, dark ? 'bg-[#2b2b2b]' : 'bg-[#f3f5f9]'].join(' ')}>
|
||||
<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">
|
||||
@@ -356,18 +356,18 @@ function WorkspaceSourceControlView({
|
||||
)}
|
||||
{historyLoading && !gitSubmissions.length ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
{teamProject ? t("팀 변경 내역 불러오는 중") : t("제출 내역 불러오는 중")}
|
||||
{teamProject ? t("팀 변경 내역 불러오는 중") : t("커밋 내역 불러오는 중")}
|
||||
</div>
|
||||
) : !gitSubmissionGroups.length ? (
|
||||
<div className={['grid min-h-full place-items-center text-[12px] font-semibold', textDim].join(' ')}>
|
||||
{teamProject ? t("팀 변경 내역 없음") : t("제출 내역 없음")}
|
||||
{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 ? t("팀 반영") : t("제출 {{v0}}", { v0: 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 : t("{{v0}}개 모듈", { v0: group.submissions.length })}
|
||||
</p>
|
||||
|
||||
@@ -48,7 +48,7 @@ function WorkspaceTerminalPanel({
|
||||
aria-pressed={terminalMode === mode}
|
||||
onClick={() => onModeChange(mode)}
|
||||
className={['h-6 cursor-pointer rounded px-2 text-[12px] font-bold transition-colors', terminalMode === mode
|
||||
? dark ? 'bg-white/15 text-white' : 'bg-[#e4d8f5] text-[#38205b]'
|
||||
? dark ? 'bg-white/15 text-white' : 'bg-[#e2e8f0] text-[#38205b]'
|
||||
: `${textMid} ${dark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`].join(' ')}
|
||||
>
|
||||
{mode === 'host' ? t("호스트") : t("컨테이너")}
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function WorkspaceVisualizationPanel({
|
||||
className={[
|
||||
'flex h-9 select-none touch-none items-center justify-between border-b px-3',
|
||||
large ? 'cursor-default' : 'cursor-move',
|
||||
dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-[#f3f5f9]'
|
||||
dark ? 'border-white/10 bg-[#252525]' : 'border-[#bec9d8] bg-[#e2e8f0]'
|
||||
].join(' ')}
|
||||
>
|
||||
<strong className={['flex items-center gap-2 text-[12px] font-bold', textStrong].join(' ')}>
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"로컬 모듈 Git 가져오기": "Import local module from Git",
|
||||
"로컬 사용자 모듈": "Local custom module",
|
||||
"현재 강좌·주차의 로컬 사용자 모듈이 없습니다.": "No local custom modules for this course and week.",
|
||||
"브랜치": "Branch",
|
||||
"가져오기": "Import",
|
||||
"로컬 변경 사항을 커밋하고 푸시한 뒤 다시 시도하세요.": "Commit and push your local changes, then try again.",
|
||||
"브랜치가 변경되었습니다. 저장소 정보를 새로고침하세요.": "The branch has changed. Refresh the repository information.",
|
||||
"원격 워크스페이스에서만 사용할 수 있습니다.": "Only available in a remote workspace.",
|
||||
"가져오기에 실패했고 모듈 등록을 취소하지 못했습니다. 목록에서 해당 모듈을 확인하세요.": "Import failed and registration could not be rolled back. Check the module in the list.",
|
||||
"Git 모듈을 가져왔습니다. 차량에서 빌드한 뒤 실행하세요.": "Git module imported. Build it on the platform before running.",
|
||||
"모듈 자체의 Git 저장소가 필요합니다.": "The module needs its own Git repository.",
|
||||
"Git 저장소를 조회하지 못했습니다. 접근 권한과 네트워크를 확인하세요.": "Could not query the Git repository. Check access and connectivity.",
|
||||
"Git 모듈 복제에 실패했습니다. 원격 저장소 접근, 기존 폴더, 소스 및 빌드 산출물 포함 여부를 확인하세요.": "Git import failed. Check platform repository access, existing folders, source files, and tracked build artifacts.",
|
||||
"조회 중...": "Loading...",
|
||||
"ROS 그래프 조회에 실패했습니다.": "Failed to retrieve the ROS graph.",
|
||||
"조회된 ROS 토픽이 없습니다.": "No ROS topics found.",
|
||||
@@ -6,6 +19,16 @@
|
||||
"완료한 주차": "Completed weeks",
|
||||
"학습 시간": "Learning time",
|
||||
"커밋 수": "Commits",
|
||||
"커밋": "Commit",
|
||||
"커밋 {{v0}}": "Commit {{v0}}",
|
||||
"커밋 내역": "Commit history",
|
||||
"커밋 목록": "Commits",
|
||||
"커밋 내역 불러오는 중": "Loading commits",
|
||||
"커밋 내역 없음": "No commits",
|
||||
"커밋 내역 {{count}}개": "Commit history: {{count}}",
|
||||
"커밋할 변경사항이 없습니다": "No changes to commit",
|
||||
"커밋 또는 푸시에 실패했습니다": "Commit or push failed",
|
||||
"변경사항을 커밋하고 푸시했습니다": "Changes committed and pushed",
|
||||
"평균 점수": "Average score",
|
||||
"[{{name}}] 맵이 선택되었습니다.": "Map selected: [{{name}}].",
|
||||
"언어": "Language",
|
||||
@@ -197,7 +220,7 @@
|
||||
"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.",
|
||||
"모듈 Docker 실행 시 NVIDIA GPU를 사용합니다.": "Use the NVIDIA GPU when running module Docker containers.",
|
||||
"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.",
|
||||
@@ -232,7 +255,7 @@
|
||||
"강좌 선택 후 이동 가능": "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.",
|
||||
"같은 네트워크의 기체와 통신을 허용합니다. 플랫폼 연동 통신은 별도 설정을 따릅니다.": "Allow communication with robots on the same network. Linked platform communication uses separate settings.",
|
||||
"같은 번호의 ROS 모듈끼리 토픽과 서비스를 공유합니다.": "ROS modules with the same domain ID share topics and services.",
|
||||
"개": " items",
|
||||
"개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.": "Ask about concepts, practical steps, or troubleshooting.",
|
||||
@@ -630,5 +653,7 @@
|
||||
"확인": "OK",
|
||||
"확인 중": "Checking",
|
||||
"활성": "Active",
|
||||
"활성 연결": "Active connections"
|
||||
"활성 연결": "Active connections",
|
||||
"활성 연결만 보기": "Show active connections only",
|
||||
"활성 연결이 없습니다.": "No active connections."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user