fix: display module dialog overlays across the full viewport
This commit is contained in:
@@ -17,7 +17,7 @@ import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen'
|
||||
import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal'
|
||||
import MaterialsModal from './features/curriculum/MaterialsModal'
|
||||
import { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } from './features/curriculum/curriculumApi'
|
||||
import type { CustomModuleInput } from './features/workspace/ModuleListView'
|
||||
import type { CustomModuleImportInput, CustomModuleInput } from './features/workspace/ModuleListView'
|
||||
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
|
||||
import { getContextHelpSteps } from './features/help/contextHelp'
|
||||
import AiGuideView from './features/AiGuideView'
|
||||
@@ -3292,7 +3292,7 @@ function LocalPcWorkspaceMockup({
|
||||
openEditorFile(editorPath)
|
||||
setActiveIdeActivity('workspace')
|
||||
}
|
||||
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
|
||||
const registerCustomModule = async (input: CustomModuleInput): Promise<NonNullable<CurriculumWeek['modules']>[number]> => {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/custom-modules`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -3333,17 +3333,47 @@ function LocalPcWorkspaceMockup({
|
||||
launchFile: payload.launchFile ?? input.launchFile,
|
||||
runtimeTarget: payload.runtimeTarget ?? customModuleRuntimeTarget,
|
||||
} satisfies NonNullable<CurriculumWeek['modules']>[number]
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), customModule)
|
||||
} else {
|
||||
await window.localWorkspace.modules.createCustom(clonePathPreview, customModule)
|
||||
}
|
||||
return customModule
|
||||
}
|
||||
const addCustomModuleToList = (customModule: NonNullable<CurriculumWeek['modules']>[number]): void => {
|
||||
setCustomModules((current) => [
|
||||
...current,
|
||||
{ ...customModule, sortOrder: payload.sortOrder ?? 10_000 + current.length },
|
||||
{ ...customModule, sortOrder: customModule.sortOrder ?? 10_000 + current.length },
|
||||
])
|
||||
}
|
||||
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
|
||||
const customModule = await registerCustomModule(input)
|
||||
const workspaceModule = {
|
||||
name: customModule.name,
|
||||
category: customModule.category,
|
||||
folderName: customModule.folderName,
|
||||
dockerImage: customModule.dockerImage ?? null,
|
||||
packageName: customModule.packageName ?? input.packageName,
|
||||
launchFile: customModule.launchFile ?? input.launchFile,
|
||||
}
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), workspaceModule)
|
||||
} else {
|
||||
await window.localWorkspace.modules.createCustom(clonePathPreview, workspaceModule)
|
||||
}
|
||||
addCustomModuleToList(customModule)
|
||||
onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info')
|
||||
}
|
||||
const importCustomModule = async ({ sourcePath, ...input }: CustomModuleImportInput): Promise<void> => {
|
||||
if (remoteWorkspace) throw new Error('모듈 불러오기는 로컬 워크스페이스에서만 사용할 수 있습니다.')
|
||||
const customModule = await registerCustomModule(input)
|
||||
await window.localWorkspace.modules.importCustom(clonePathPreview, {
|
||||
name: customModule.name,
|
||||
category: customModule.category,
|
||||
folderName: customModule.folderName,
|
||||
dockerImage: customModule.dockerImage ?? null,
|
||||
packageName: customModule.packageName ?? input.packageName,
|
||||
launchFile: customModule.launchFile ?? input.launchFile,
|
||||
sourcePath,
|
||||
})
|
||||
addCustomModuleToList(customModule)
|
||||
onTitlebarNotice('모듈을 현재 워크스페이스로 불러왔습니다.', 'info')
|
||||
}
|
||||
const controlEndTime = controlEndsAt ? new Date(controlEndsAt).getTime() : Number.NaN
|
||||
const controlRemainingMs = Number.isNaN(controlEndTime) ? null : Math.max(0, controlEndTime - controlCountdownNow)
|
||||
const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000)
|
||||
@@ -3911,6 +3941,7 @@ function LocalPcWorkspaceMockup({
|
||||
setWorldMapSelectorOpen(true)
|
||||
}}
|
||||
onCreateCustomModule={createCustomModule}
|
||||
onImportCustomModule={importCustomModule}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Vendored
+7
@@ -127,6 +127,13 @@ interface Window {
|
||||
}
|
||||
}>
|
||||
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }>
|
||||
selectImportSource: () => Promise<{
|
||||
sourcePath: string
|
||||
packageName: string
|
||||
launchFiles: string[]
|
||||
buildType: 'ament_python' | 'ament_cmake'
|
||||
} | null>
|
||||
importCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string; sourcePath: string }) => Promise<{ ok: boolean; workspacePath: string }>
|
||||
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
|
||||
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => Promise<{ ok: boolean; message: string }>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookOpen, ChevronLeft, ChevronRight, PackagePlus, X } from 'lucide-react'
|
||||
import { BookOpen, ChevronLeft, ChevronRight, FolderOpen, PackagePlus, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import ModuleDeleteConfirm from './ModuleDeleteConfirm'
|
||||
@@ -46,6 +46,7 @@ type ModuleListViewProps = {
|
||||
parkingHeightStorageKey?: string
|
||||
onOpenWorldEditor: () => void
|
||||
onCreateCustomModule: (input: CustomModuleInput) => Promise<void>
|
||||
onImportCustomModule: (input: CustomModuleImportInput) => Promise<void>
|
||||
}
|
||||
|
||||
export default function ModuleListView({
|
||||
@@ -80,12 +81,14 @@ export default function ModuleListView({
|
||||
onRestoreParkedModule,
|
||||
parkingHeightStorageKey,
|
||||
onOpenWorldEditor,
|
||||
onCreateCustomModule
|
||||
onCreateCustomModule,
|
||||
onImportCustomModule
|
||||
}: ModuleListViewProps): React.JSX.Element {
|
||||
const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all')
|
||||
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
|
||||
const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null)
|
||||
const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false)
|
||||
const [moduleImportDialogOpen, setModuleImportDialogOpen] = useState(false)
|
||||
const [moduleGuideOpen, setModuleGuideOpen] = useState(false)
|
||||
const [floatingActionMenuOpen, setFloatingActionMenuOpen] = useState(false)
|
||||
const floatingActionMenuRef = useRef<HTMLDivElement>(null)
|
||||
@@ -101,6 +104,7 @@ 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 {
|
||||
@@ -234,11 +238,15 @@ export default function ModuleListView({
|
||||
setModuleAddDialogOpen(false)
|
||||
return
|
||||
}
|
||||
if (moduleImportDialogOpen) {
|
||||
event.preventDefault()
|
||||
setModuleImportDialogOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [deleteConfirmModule, moduleAddDialogOpen, parameterDialog])
|
||||
}, [deleteConfirmModule, moduleAddDialogOpen, moduleImportDialogOpen, parameterDialog])
|
||||
|
||||
return (
|
||||
<section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
@@ -306,7 +314,7 @@ export default function ModuleListView({
|
||||
}}
|
||||
>
|
||||
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'flex min-h-full flex-col'].join(' ')}>
|
||||
{loading ? (
|
||||
{loading && !hasModuleRows ? (
|
||||
<ModuleListEmptyState dark={dark} loading />
|
||||
) : filteredModuleRows.length === 0 ? (
|
||||
<ModuleListEmptyState dark={dark} />
|
||||
@@ -381,12 +389,36 @@ export default function ModuleListView({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="모듈 추가"
|
||||
aria-label="새 모듈"
|
||||
disabled={!allowCustomModuleCreation}
|
||||
onClick={() => {
|
||||
setModuleAddDialogOpen(true)
|
||||
setFloatingActionMenuOpen(false)
|
||||
}}
|
||||
className={[
|
||||
'absolute bottom-[180px] left-1/2 grid h-12 w-12 -translate-x-1/2 place-items-center rounded-full transition-all duration-200 ease-out',
|
||||
allowCustomModuleCreation
|
||||
? '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-100' : 'pointer-events-none translate-y-4 scale-50 opacity-0'
|
||||
].join(' ')}
|
||||
>
|
||||
<PackagePlus 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'
|
||||
].join(' ')}>
|
||||
새 모듈
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="모듈 불러오기"
|
||||
disabled={!allowCustomModuleCreation}
|
||||
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
|
||||
@@ -395,12 +427,12 @@ export default function ModuleListView({
|
||||
floatingActionMenuOpen ? 'pointer-events-auto translate-y-0 scale-100 opacity-100 delay-75' : 'pointer-events-none translate-y-4 scale-50 opacity-0'
|
||||
].join(' ')}
|
||||
>
|
||||
<PackagePlus size={18} />
|
||||
<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'
|
||||
].join(' ')}>
|
||||
모듈 추가
|
||||
모듈 불러오기
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -439,6 +471,16 @@ export default function ModuleListView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{allowCustomModuleCreation && moduleImportDialogOpen && (
|
||||
<ModuleImportDialog
|
||||
dark={dark}
|
||||
onClose={() => setModuleImportDialogOpen(false)}
|
||||
onImport={async (input) => {
|
||||
await onImportCustomModule(input)
|
||||
setModuleImportDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{moduleGuideOpen && (
|
||||
<ModuleGuideDialog
|
||||
dark={dark}
|
||||
@@ -616,6 +658,10 @@ export type CustomModuleInput = {
|
||||
launchFile: string
|
||||
}
|
||||
|
||||
export type CustomModuleImportInput = CustomModuleInput & {
|
||||
sourcePath: string
|
||||
}
|
||||
|
||||
function ModuleAddDialog({
|
||||
dark,
|
||||
onClose,
|
||||
@@ -665,7 +711,7 @@ function ModuleAddDialog({
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 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="모듈 정보 입력">
|
||||
<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>
|
||||
@@ -749,3 +795,188 @@ function ModuleAddDialog({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModuleImportDialog({
|
||||
dark,
|
||||
onClose,
|
||||
onImport
|
||||
}: {
|
||||
dark: boolean
|
||||
onClose: () => void
|
||||
onImport: (input: CustomModuleImportInput) => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const directDockerImageOption = '__direct_input__'
|
||||
const [source, setSource] = useState<{
|
||||
sourcePath: string
|
||||
packageName: string
|
||||
launchFiles: string[]
|
||||
buildType: 'ament_python' | 'ament_cmake'
|
||||
} | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [dockerImage, setDockerImage] = useState('')
|
||||
const [dockerImageSelection, setDockerImageSelection] = useState('')
|
||||
const [dockerImages, setDockerImages] = useState<string[]>([])
|
||||
const [dockerImagesLoading, setDockerImagesLoading] = useState(true)
|
||||
const [dockerImagesMessage, setDockerImagesMessage] = useState<string | null>(null)
|
||||
const [launchFile, setLaunchFile] = useState('')
|
||||
const [selectingSource, setSelectingSource] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const inputClassName = [
|
||||
'h-9 w-full rounded-md border px-2.5 text-[12px] font-medium outline-none transition',
|
||||
dark ? 'border-white/12 bg-[#1f1f1f] text-white/82 placeholder:text-white/28 focus:border-[#2f8dff]/55' : 'border-[#cfd6e4] bg-white text-[#111520] placeholder:text-[#9aa3b2] focus:border-[#2f8dff]/55'
|
||||
].join(' ')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void window.localWorkspace.modules.listDockerImages()
|
||||
.then(({ images, message }) => {
|
||||
if (cancelled) return
|
||||
setDockerImages(images)
|
||||
setDockerImagesMessage(message ?? null)
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (cancelled) return
|
||||
setDockerImagesMessage(reason instanceof Error ? reason.message : '로컬 Docker 이미지를 불러오지 못했습니다.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setDockerImagesLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectSource = (): void => {
|
||||
if (selectingSource) return
|
||||
setSelectingSource(true)
|
||||
setError(null)
|
||||
void window.localWorkspace.modules.selectImportSource()
|
||||
.then((selectedSource) => {
|
||||
if (!selectedSource) return
|
||||
setSource(selectedSource)
|
||||
setName(selectedSource.packageName)
|
||||
setLaunchFile(selectedSource.launchFiles[0] ?? '')
|
||||
})
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : '모듈 구조를 확인하지 못했습니다.'))
|
||||
.finally(() => setSelectingSource(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label="모듈 불러오기">
|
||||
<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>
|
||||
</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="닫기">
|
||||
<X size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<form className="grid gap-3 p-4" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!source || submitting) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
void onImport({
|
||||
name: name.trim(),
|
||||
category,
|
||||
dockerImage: dockerImage.trim(),
|
||||
packageName: source.packageName,
|
||||
launchFile,
|
||||
sourcePath: source.sourcePath,
|
||||
})
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : '모듈을 불러오지 못했습니다.'))
|
||||
.finally(() => setSubmitting(false))
|
||||
}}>
|
||||
<div className="grid gap-1.5">
|
||||
<span className={['text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>모듈 경로</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 ?? '선택한 폴더 없음'}
|
||||
</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 ? '확인 중' : '경로 선택'}
|
||||
</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 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>
|
||||
<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>
|
||||
<option value="Vehicle">Vehicle</option>
|
||||
<option value="Sensing">Sensing</option>
|
||||
<option value="Map">Map</option>
|
||||
<option value="Perception">Perception</option>
|
||||
<option value="Localization">Localization</option>
|
||||
<option value="Planning">Planning</option>
|
||||
<option value="Control">Control</option>
|
||||
<option value="System">System</option>
|
||||
<option value="Simulator">Simulator</option>
|
||||
<option value="Gui">Gui</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
|
||||
실행 이미지
|
||||
<select
|
||||
value={dockerImageSelection}
|
||||
disabled={!source}
|
||||
onChange={(event) => {
|
||||
const selection = event.target.value
|
||||
setDockerImageSelection(selection)
|
||||
setDockerImage(selection === directDockerImageOption ? '' : selection)
|
||||
}}
|
||||
className={inputClassName}
|
||||
>
|
||||
<option value="" disabled>선택</option>
|
||||
<option value={directDockerImageOption}>직접 입력</option>
|
||||
{dockerImagesLoading && <option value="__loading__" disabled>로컬 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>
|
||||
)}
|
||||
{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>
|
||||
<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>
|
||||
{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>}
|
||||
<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>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type ModuleActionResult = {
|
||||
type SavedMapPathOverrides = {
|
||||
savedMapYamlPath?: string | null
|
||||
savedMapDatabasePath?: string | null
|
||||
savedMapWorldLayoutPath?: string | null
|
||||
}
|
||||
|
||||
const startupCategoryOrder = ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control', 'system']
|
||||
@@ -142,14 +143,21 @@ export function useWorkspaceModuleControl({
|
||||
bulkExcludedModuleKeys.includes(module.folderName ?? module.name)
|
||||
), [bulkExcludedModuleKeys])
|
||||
|
||||
const getModuleRosParams = useCallback((module: ModuleRow, overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null; savedMapDatabasePath?: string | null }): string => {
|
||||
const getModuleRosParams = useCallback((module: ModuleRow, overrides?: {
|
||||
simulatorWorldFile?: string | null
|
||||
savedMapYamlPath?: string | null
|
||||
savedMapDatabasePath?: string | null
|
||||
savedMapWorldLayoutPath?: string | null
|
||||
}): string => {
|
||||
const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
|
||||
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
|
||||
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath
|
||||
const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath
|
||||
const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '')
|
||||
const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini')
|
||||
const parameters = module.parameters.filter((parameter) => (
|
||||
!parameter.startsWith('world:=')
|
||||
&& !parameter.startsWith('world_layout_path:=')
|
||||
&& !parameter.startsWith('world_layout_enabled:=')
|
||||
&& !parameter.startsWith('map:=')
|
||||
&& !parameter.startsWith('database_path:=')
|
||||
@@ -176,6 +184,9 @@ export function useWorkspaceModuleControl({
|
||||
}
|
||||
if (module.category.toLowerCase() === 'simulator' && worldFile) {
|
||||
parameters.push(`world:=${worldFile}`)
|
||||
if (mapWorldLayoutPath) {
|
||||
parameters.push(`world_layout_path:=${mapWorldLayoutPath.replace(/^\/workspace\//, '/modules/')}`)
|
||||
}
|
||||
}
|
||||
if (module.category.toLowerCase() === 'map') {
|
||||
parameters.push(`world_layout_enabled:=${worldFile === 'empty_world.world'}`)
|
||||
|
||||
Reference in New Issue
Block a user