version up!
This commit is contained in:
@@ -420,7 +420,8 @@ let remoteNavigationSplatScene = null;
|
||||
let remoteNavigationSplatTransform = null;
|
||||
let remoteNavigationSplatEnabled = false;
|
||||
let remoteNavigationSplatLoading = null;
|
||||
const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.4822845855746674, 1.3107954323633903, -0.8021416967574936);
|
||||
// 화면의 빨간 X축과 평행하게 오른쪽으로 미세 보정합니다.
|
||||
const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.7322845855746674, 1.3107954323633903, -0.8021416967574936);
|
||||
const remoteNavigationSplatBaseQuaternion = new THREE.Quaternion(-0.10999094822602469, 0.6984998148234114, 0.6984998148234114, 0.10999094822602469);
|
||||
const remoteNavigationSplatBaseScale = new THREE.Vector3(3.672624762730334, 3.672624762730334, 3.5309628207501587);
|
||||
const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null;
|
||||
|
||||
+163
-19
@@ -7,7 +7,7 @@ import { io } from 'socket.io-client'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
|
||||
import { ControlExtensionConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
||||
import { ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
||||
import TitleBar from './components/TitleBar'
|
||||
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig'
|
||||
import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession'
|
||||
@@ -17,6 +17,7 @@ import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen'
|
||||
import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal'
|
||||
import MaterialsModal from './features/curriculum/MaterialsModal'
|
||||
import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi'
|
||||
import type { 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'
|
||||
@@ -391,6 +392,7 @@ function LocalPcWorkspaceMockup({
|
||||
onRequestControlExtension,
|
||||
onTitlebarNotice,
|
||||
onRegisterStopRunningModules,
|
||||
onRegisterRunningModuleNames,
|
||||
canReleaseControl,
|
||||
canExtendControl,
|
||||
controlEndsAt,
|
||||
@@ -421,6 +423,7 @@ function LocalPcWorkspaceMockup({
|
||||
onRequestControlExtension: () => void
|
||||
onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void
|
||||
onRegisterStopRunningModules?: (handler: (() => Promise<void>) | null) => void
|
||||
onRegisterRunningModuleNames?: (handler: (() => string[]) | null) => void
|
||||
canReleaseControl: boolean
|
||||
canExtendControl: boolean
|
||||
controlEndsAt?: string | null
|
||||
@@ -818,13 +821,41 @@ function LocalPcWorkspaceMockup({
|
||||
if (visualizationStack !== 'nav2') setVisualizationStack('nav2')
|
||||
if (nav2VisualizationMode !== 'navigation') setNav2VisualizationMode('navigation')
|
||||
}, [nav2VisualizationMode, selectedWorldMap?.databasePath, selectedWorldMap?.key, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack])
|
||||
const runtimeModules = useMemo(() => resolveRuntimeModules(
|
||||
curriculumWeek.modules,
|
||||
remoteWorkspace
|
||||
? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
|
||||
: activeEnvironmentProfile,
|
||||
remoteWorkspace ? 'platform' : 'local'
|
||||
), [activeEnvironmentProfile, curriculumWeek.modules, remoteWorkspace, visualizationProfiles])
|
||||
const [customModules, setCustomModules] = useState<NonNullable<CurriculumWeek['modules']>>(() => (
|
||||
(curriculumWeek.modules ?? []).filter((module) => module.sourceType === 'CUSTOM')
|
||||
))
|
||||
const [deletedCustomModuleIds, setDeletedCustomModuleIds] = useState<Set<string>>(() => new Set())
|
||||
useEffect(() => {
|
||||
setCustomModules((current) => {
|
||||
const fromCurriculum = (curriculumWeek.modules ?? []).filter((module) => (
|
||||
module.sourceType === 'CUSTOM' && !deletedCustomModuleIds.has(module.id)
|
||||
))
|
||||
const byId = new Map(current.map((module) => [module.id, module]))
|
||||
fromCurriculum.forEach((module) => byId.set(module.id, module))
|
||||
return Array.from(byId.values())
|
||||
})
|
||||
}, [curriculumWeek.modules, deletedCustomModuleIds])
|
||||
const curriculumModulesWithCustom = useMemo(() => {
|
||||
const baseModules = (curriculumWeek.modules ?? []).filter((module) => (
|
||||
module.sourceType !== 'CUSTOM' || !deletedCustomModuleIds.has(module.id)
|
||||
))
|
||||
const knownIds = new Set(baseModules.map((module) => module.id))
|
||||
return [...baseModules, ...customModules.filter((module) => !knownIds.has(module.id))]
|
||||
}, [curriculumWeek.modules, customModules, deletedCustomModuleIds])
|
||||
const runtimeModules = useMemo(() => {
|
||||
const resolvedModules = resolveRuntimeModules(
|
||||
curriculumModulesWithCustom,
|
||||
remoteWorkspace
|
||||
? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
|
||||
: activeEnvironmentProfile,
|
||||
remoteWorkspace ? 'platform' : 'local'
|
||||
) ?? []
|
||||
const knownIds = new Set(resolvedModules.map((module) => module.id))
|
||||
return [
|
||||
...resolvedModules,
|
||||
...customModules.filter((module) => !knownIds.has(module.id)),
|
||||
].sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
}, [activeEnvironmentProfile, curriculumModulesWithCustom, customModules, remoteWorkspace, visualizationProfiles])
|
||||
const moduleRuntimeImages = useMemo(() => (
|
||||
resolveModuleRuntimeImages(visualizationProfiles, remoteWorkspace, activeRuntimeImages)
|
||||
), [activeRuntimeImages, remoteWorkspace, visualizationProfiles])
|
||||
@@ -1875,6 +1906,12 @@ function LocalPcWorkspaceMockup({
|
||||
},
|
||||
onTitlebarNotice
|
||||
})
|
||||
useEffect(() => {
|
||||
onRegisterRunningModuleNames?.(() => availableModules
|
||||
.filter((module) => module.state === 'running')
|
||||
.map((module) => module.name))
|
||||
return () => onRegisterRunningModuleNames?.(null)
|
||||
}, [availableModules, onRegisterRunningModuleNames])
|
||||
const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise<void> => {
|
||||
if (map.custom && map.layout && !remoteWorkspace) {
|
||||
await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout)
|
||||
@@ -1921,6 +1958,16 @@ function LocalPcWorkspaceMockup({
|
||||
} else {
|
||||
await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName)
|
||||
}
|
||||
if (module.sourceType === 'CUSTOM') {
|
||||
if (!module.id) throw new Error('커스텀 모듈 식별자가 없습니다.')
|
||||
const response = await fetchWithFreshAuth(
|
||||
`${LMS_API_BASE_URL}/curriculum/custom-modules/${encodeURIComponent(module.id)}?courseId=${encodeURIComponent(courseKey)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
if (!response.ok) throw new Error(await response.text())
|
||||
setCustomModules((modules) => modules.filter((item) => item.id !== module.id))
|
||||
setDeletedCustomModuleIds((ids) => new Set([...ids, module.id!]))
|
||||
}
|
||||
if (options.deleteRepository) {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/repositories`, {
|
||||
method: 'DELETE',
|
||||
@@ -2519,6 +2566,51 @@ function LocalPcWorkspaceMockup({
|
||||
openEditorFile(editorPath)
|
||||
setActiveIdeActivity('workspace')
|
||||
}
|
||||
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
|
||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/custom-modules`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ courseId: courseKey, week: weekNumber, ...input }),
|
||||
})
|
||||
const payload = await response.json().catch(() => null) as {
|
||||
message?: string
|
||||
id?: string
|
||||
sortOrder?: number
|
||||
category?: string
|
||||
name?: string
|
||||
folderName?: string
|
||||
dockerImage?: string | null
|
||||
packageName?: string
|
||||
launchFile?: string
|
||||
} | null
|
||||
if (!response.ok || !payload?.id || !payload.folderName || !payload.name || !payload.category) {
|
||||
throw new Error(payload?.message ?? '모듈을 추가하지 못했습니다.')
|
||||
}
|
||||
const customModule = {
|
||||
id: payload.id,
|
||||
sortOrder: payload.sortOrder ?? 10_000 + customModules.length,
|
||||
category: payload.category,
|
||||
name: payload.name,
|
||||
folderName: payload.folderName,
|
||||
templateRepositoryUrl: null,
|
||||
sourceType: 'CUSTOM',
|
||||
prebuiltArtifacts: [],
|
||||
defaultParameters: [],
|
||||
dockerImage: payload.dockerImage ?? null,
|
||||
packageName: payload.packageName ?? input.packageName,
|
||||
launchFile: payload.launchFile ?? input.launchFile,
|
||||
} as const
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), customModule)
|
||||
} else {
|
||||
await window.localWorkspace.modules.createCustom(clonePathPreview, customModule)
|
||||
}
|
||||
setCustomModules((current) => [
|
||||
...current,
|
||||
{ ...customModule, sortOrder: payload.sortOrder ?? 10_000 + current.length },
|
||||
])
|
||||
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)
|
||||
@@ -3067,6 +3159,7 @@ function LocalPcWorkspaceMockup({
|
||||
onOpenWorldEditor={() => {
|
||||
setWorldMapSelectorOpen(true)
|
||||
}}
|
||||
onCreateCustomModule={createCustomModule}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -3182,6 +3275,7 @@ export default function App(): React.JSX.Element {
|
||||
const previousWeekStateRef = useRef<Record<number, CurriculumWeek['state']>>({})
|
||||
const controlExpiryHandledRef = useRef<Set<string>>(new Set())
|
||||
const stopRunningWorkspaceModulesRef = useRef<(() => Promise<void>) | null>(null)
|
||||
const getRunningWorkspaceModuleNamesRef = useRef<(() => string[]) | null>(null)
|
||||
const restoredStudioSession = restoredStudioSessionRef.current
|
||||
const [curriculumWeeks, setCurriculumWeeks] = useState<CurriculumWeek[]>([])
|
||||
const [courseKey, setCourseKey] = useState('default-course')
|
||||
@@ -3238,6 +3332,10 @@ export default function App(): React.JSX.Element {
|
||||
const forcedPlatformTerminationHandlerRef = useRef<(payload: PlatformControlTerminatedPayload) => void>(() => undefined)
|
||||
const [workspaceWeekState, setWorkspaceWeekState] = useState<CurriculumWeek | null>(null)
|
||||
const [sessionConfirmAction, setSessionConfirmAction] = useState<'disconnect' | 'release' | null>(null)
|
||||
const [moduleShutdownConfirm, setModuleShutdownConfirm] = useState<{
|
||||
action: 'disconnect' | 'release'
|
||||
moduleNames: string[]
|
||||
} | null>(null)
|
||||
const [controlExtensionConfirmOpen, setControlExtensionConfirmOpen] = useState(false)
|
||||
const [controlExtensionSubmitting, setControlExtensionSubmitting] = useState(false)
|
||||
const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState<PlatformOption | null>(null)
|
||||
@@ -3999,6 +4097,11 @@ export default function App(): React.JSX.Element {
|
||||
setSessionConfirmAction(null)
|
||||
return
|
||||
}
|
||||
if (moduleShutdownConfirm) {
|
||||
event.preventDefault()
|
||||
setModuleShutdownConfirm(null)
|
||||
return
|
||||
}
|
||||
if (runningPlatformCloseTarget) {
|
||||
event.preventDefault()
|
||||
setRunningPlatformCloseTarget(null)
|
||||
@@ -4053,7 +4156,7 @@ export default function App(): React.JSX.Element {
|
||||
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [completedCurriculumNotice, controlWarningNotice, courseChangeBlockedOpen, instructorStudioModalOpen, materialModalOpen, platformModalMode, platformModalOpen, recentSessionModalOpen, runningPlatformCloseTarget, sessionConfirmAction, weekSwitchBlockedOpen])
|
||||
}, [completedCurriculumNotice, controlWarningNotice, courseChangeBlockedOpen, instructorStudioModalOpen, materialModalOpen, moduleShutdownConfirm, platformModalMode, platformModalOpen, recentSessionModalOpen, runningPlatformCloseTarget, sessionConfirmAction, weekSwitchBlockedOpen])
|
||||
const removePlatformSession = (platformToRemove: PlatformOption, options: { clearWorkspace: boolean }): void => {
|
||||
if (options.clearWorkspace) {
|
||||
const studentSlug = makeStudentSlug(authSession?.user.email)
|
||||
@@ -4165,7 +4268,7 @@ export default function App(): React.JSX.Element {
|
||||
}
|
||||
await window.localWorkspace.modules.stopRunning().catch(() => ({ ok: false, message: '모듈 중지 실패' }))
|
||||
}
|
||||
const handleDisconnectSession = async (): Promise<void> => {
|
||||
const handleDisconnectSession = async (options?: { stopModules?: boolean }): Promise<void> => {
|
||||
const platformToDisconnect = connectedPlatform
|
||||
if (!platformToDisconnect) {
|
||||
closeLocalPcWorkspace()
|
||||
@@ -4180,7 +4283,9 @@ export default function App(): React.JSX.Element {
|
||||
})
|
||||
setPlatformLoading(true)
|
||||
const loadingStartedAt = Date.now()
|
||||
await stopConnectedWorkspaceModules()
|
||||
if (options?.stopModules !== false) {
|
||||
await stopConnectedWorkspaceModules()
|
||||
}
|
||||
await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' }))
|
||||
const remainingMs = Math.max(0, 2000 - (Date.now() - loadingStartedAt))
|
||||
window.setTimeout(() => {
|
||||
@@ -4189,7 +4294,7 @@ export default function App(): React.JSX.Element {
|
||||
showTitlebarNotice(`${platformToDisconnect.title} 개발 스튜디오가 종료되었습니다`, 'info', true)
|
||||
}, remainingMs)
|
||||
}
|
||||
const handleReleaseControl = async (options?: { forceClose?: boolean; expired?: boolean }): Promise<void> => {
|
||||
const handleReleaseControl = async (options?: { forceClose?: boolean; expired?: boolean; stopModules?: boolean }): Promise<void> => {
|
||||
if (!connectedPlatform) {
|
||||
setSessionConfirmAction(null)
|
||||
return
|
||||
@@ -4207,7 +4312,9 @@ export default function App(): React.JSX.Element {
|
||||
setPlatformLoading(true)
|
||||
let released = false
|
||||
try {
|
||||
await stopConnectedWorkspaceModules()
|
||||
if (options?.stopModules !== false) {
|
||||
await stopConnectedWorkspaceModules()
|
||||
}
|
||||
await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' }))
|
||||
if (authSession?.accessToken) {
|
||||
await releasePlatformControl(fetchWithFreshAuth, platformToRelease.id)
|
||||
@@ -4234,6 +4341,28 @@ export default function App(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualSessionEnd = (action: 'disconnect' | 'release', stopModules: boolean): void => {
|
||||
if (action === 'release') {
|
||||
void handleReleaseControl({ stopModules })
|
||||
return
|
||||
}
|
||||
void handleDisconnectSession({ stopModules })
|
||||
}
|
||||
|
||||
const confirmManualSessionEnd = (): void => {
|
||||
const action = sessionConfirmAction
|
||||
if (!action) return
|
||||
|
||||
const moduleNames = getRunningWorkspaceModuleNamesRef.current?.() ?? []
|
||||
setSessionConfirmAction(null)
|
||||
if (moduleNames.length > 0) {
|
||||
setModuleShutdownConfirm({ action, moduleNames })
|
||||
return
|
||||
}
|
||||
|
||||
handleManualSessionEnd(action, false)
|
||||
}
|
||||
|
||||
const handleExtendControl = async (): Promise<void> => {
|
||||
if (!connectedPlatform || !canExtendConnectedPlatform || controlExtensionSubmitting) return
|
||||
|
||||
@@ -4714,6 +4843,9 @@ export default function App(): React.JSX.Element {
|
||||
onRegisterStopRunningModules={(handler) => {
|
||||
stopRunningWorkspaceModulesRef.current = handler
|
||||
}}
|
||||
onRegisterRunningModuleNames={(handler) => {
|
||||
getRunningWorkspaceModuleNamesRef.current = handler
|
||||
}}
|
||||
canReleaseControl={canReleaseConnectedPlatform}
|
||||
canExtendControl={canExtendConnectedPlatform}
|
||||
controlEndsAt={connectedPlatformControlEndsAt}
|
||||
@@ -4960,12 +5092,24 @@ export default function App(): React.JSX.Element {
|
||||
action={sessionConfirmAction}
|
||||
platformName={activePlatformName}
|
||||
onCancel={() => setSessionConfirmAction(null)}
|
||||
onConfirm={() => {
|
||||
if (sessionConfirmAction === 'release') {
|
||||
void handleReleaseControl()
|
||||
} else {
|
||||
void handleDisconnectSession()
|
||||
}
|
||||
onConfirm={confirmManualSessionEnd}
|
||||
/>
|
||||
)}
|
||||
{moduleShutdownConfirm && (
|
||||
<ModuleShutdownConfirmModal
|
||||
overlayClassName={fullAppModalOverlayClass}
|
||||
dark={appModalDark}
|
||||
action={moduleShutdownConfirm.action}
|
||||
moduleNames={moduleShutdownConfirm.moduleNames}
|
||||
onKeepRunning={() => {
|
||||
const { action } = moduleShutdownConfirm
|
||||
setModuleShutdownConfirm(null)
|
||||
handleManualSessionEnd(action, false)
|
||||
}}
|
||||
onStopModules={() => {
|
||||
const { action } = moduleShutdownConfirm
|
||||
setModuleShutdownConfirm(null)
|
||||
handleManualSessionEnd(action, true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -204,6 +204,40 @@ export function SessionConfirmModal({
|
||||
)
|
||||
}
|
||||
|
||||
export function ModuleShutdownConfirmModal({
|
||||
overlayClassName,
|
||||
dark,
|
||||
action,
|
||||
moduleNames,
|
||||
onKeepRunning,
|
||||
onStopModules,
|
||||
}: AppModalTheme & {
|
||||
action: 'disconnect' | 'release'
|
||||
moduleNames: string[]
|
||||
onKeepRunning: () => void
|
||||
onStopModules: () => void
|
||||
}): React.JSX.Element {
|
||||
const release = action === 'release'
|
||||
return (
|
||||
<ConfirmStatusModal
|
||||
overlayClassName={overlayClassName}
|
||||
dark={dark}
|
||||
icon={<AlertCircle size={15} className="text-[#f59e0b]" />}
|
||||
title="실행 중인 모듈"
|
||||
message={`${moduleNames.length}개의 모듈이 실행 중입니다. 모듈을 종료할까요?`}
|
||||
detail={moduleNames.join(', ')}
|
||||
cancelLabel="아니오"
|
||||
confirmLabel="예"
|
||||
onCancel={onKeepRunning}
|
||||
onConfirm={onStopModules}
|
||||
>
|
||||
<p className={['mt-3 text-[11px] font-medium', dimClass(dark)].join(' ')}>
|
||||
{release ? '아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.' : '아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.'}
|
||||
</p>
|
||||
</ConfirmStatusModal>
|
||||
)
|
||||
}
|
||||
|
||||
export function ControlExtensionConfirmModal({
|
||||
overlayClassName,
|
||||
dark,
|
||||
@@ -248,17 +282,21 @@ function ConfirmStatusModal({
|
||||
title,
|
||||
message,
|
||||
detail,
|
||||
cancelLabel = '취소',
|
||||
confirmLabel,
|
||||
onCancel,
|
||||
onConfirm
|
||||
onConfirm,
|
||||
children
|
||||
}: AppModalTheme & {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
message: string
|
||||
detail: string
|
||||
cancelLabel?: string
|
||||
confirmLabel: string
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
children?: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className={overlayClassName}>
|
||||
@@ -272,8 +310,9 @@ function ConfirmStatusModal({
|
||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
||||
{detail}
|
||||
</p>
|
||||
{children}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>취소</button>
|
||||
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{cancelLabel}</button>
|
||||
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{confirmLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
+2
@@ -103,6 +103,7 @@ interface Window {
|
||||
runtime: string
|
||||
}
|
||||
}>
|
||||
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: 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) => Promise<{ ok: boolean; message: string }>
|
||||
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
@@ -255,6 +256,7 @@ interface Window {
|
||||
runtime: string
|
||||
}
|
||||
}>
|
||||
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }>
|
||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookOpen, Clock3, FileText, Play, Upload } from 'lucide-react'
|
||||
import { BookOpen, Car, Clock3, FileText, Play, Upload } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
|
||||
type LearningInfoWeek = {
|
||||
@@ -59,8 +59,9 @@ export default function LearningInfoCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff]">
|
||||
<div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff] pb-2 pt-2">
|
||||
{[
|
||||
{ label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car },
|
||||
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 },
|
||||
...(week.detail.requiresSourceSubmission
|
||||
? [{
|
||||
@@ -82,7 +83,7 @@ export default function LearningInfoCard({
|
||||
<Icon size={14} className="shrink-0 text-[#6d7788]" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<strong className={['shrink-0 text-right text-[13px] font-bold', valueClassName ?? 'text-[#151827]'].join(' ')}>
|
||||
<strong className={['shrink-0 text-right text-[12px] font-bold', valueClassName ?? 'text-[#151827]'].join(' ')}>
|
||||
{value}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,8 @@ export type CurriculumWeek = {
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
defaultParameters?: string[] | null
|
||||
dockerImage?: string | null
|
||||
packageName?: string | null
|
||||
launchFile?: string | null
|
||||
}>
|
||||
environmentProfiles?: EnvironmentProfile[]
|
||||
maps?: Array<{
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ModuleListRowItem = {
|
||||
state: 'running' | 'stopped' | 'uninstalled'
|
||||
category: string
|
||||
name: string
|
||||
sourceType?: string | null
|
||||
parameters: string[]
|
||||
cpu: number | null
|
||||
memory: number | null
|
||||
@@ -135,7 +136,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
]
|
||||
: []
|
||||
const installAction = module.state === 'uninstalled'
|
||||
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
|
||||
? { label: module.sourceType === 'CUSTOM' ? '소스 생성' : '설치', Icon: Download, tone: 'install' as ModuleActionTone }
|
||||
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
|
||||
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
|
||||
|
||||
@@ -180,8 +181,13 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
||||
</span>
|
||||
<span className={['text-center', dark ? 'text-white/52' : 'text-[#687284]'].join(' ')}>{module.category}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className={['truncate text-[12px] font-semibold', dark ? 'text-white/86' : 'text-[#111520]'].join(' ')}>{module.name}</span>
|
||||
{module.sourceType === 'CUSTOM' && (
|
||||
<span className={['shrink-0 rounded border px-1.5 py-[1px] text-[9px] font-bold', dark ? 'border-[#a78bfa]/35 bg-[#a78bfa]/12 text-[#c4b5fd]' : 'border-[#8b5cf6]/22 bg-[#f3efff] text-[#7350c8]'].join(' ')}>
|
||||
커스텀
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CirclePlus, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type React from 'react'
|
||||
import ModuleDeleteConfirm from './ModuleDeleteConfirm'
|
||||
@@ -32,6 +33,7 @@ type ModuleListViewProps = {
|
||||
onStopAll: () => void
|
||||
onReorderModules: (source: ModuleRow, target: ModuleRow) => void
|
||||
onOpenWorldEditor: () => void
|
||||
onCreateCustomModule: (input: CustomModuleInput) => Promise<void>
|
||||
}
|
||||
|
||||
export default function ModuleListView({
|
||||
@@ -56,11 +58,13 @@ export default function ModuleListView({
|
||||
onStartAll,
|
||||
onStopAll,
|
||||
onReorderModules,
|
||||
onOpenWorldEditor
|
||||
onOpenWorldEditor,
|
||||
onCreateCustomModule
|
||||
}: 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 [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null)
|
||||
const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null)
|
||||
const filteredModuleRows = moduleFilter === 'all' ? modules : modules.filter((row) => row.state === moduleFilter)
|
||||
@@ -82,12 +86,17 @@ export default function ModuleListView({
|
||||
if (deleteConfirmModule) {
|
||||
event.preventDefault()
|
||||
setDeleteConfirmModule(null)
|
||||
return
|
||||
}
|
||||
if (moduleAddDialogOpen) {
|
||||
event.preventDefault()
|
||||
setModuleAddDialogOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [deleteConfirmModule, parameterDialog])
|
||||
}, [deleteConfirmModule, moduleAddDialogOpen, parameterDialog])
|
||||
|
||||
return (
|
||||
<section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
|
||||
@@ -169,6 +178,18 @@ export default function ModuleListView({
|
||||
onRestartModule={onRestartModule}
|
||||
/>
|
||||
))}
|
||||
{!loading && (
|
||||
<div className={['flex justify-center pb-4 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModuleAddDialogOpen(true)}
|
||||
className={['inline-flex cursor-pointer items-center gap-1.5 text-[12px] font-bold underline-offset-4 transition hover:underline', dark ? 'text-[#8ab8ff] hover:text-white' : 'text-[#2563eb] hover:text-[#1d4ed8]'].join(' ')}
|
||||
>
|
||||
<CirclePlus size={14} aria-hidden="true" />
|
||||
모듈 추가
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,6 +218,112 @@ export default function ModuleListView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{moduleAddDialogOpen && (
|
||||
<ModuleAddDialog
|
||||
dark={dark}
|
||||
onClose={() => setModuleAddDialogOpen(false)}
|
||||
onCreate={async (input) => {
|
||||
await onCreateCustomModule(input)
|
||||
setModuleAddDialogOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export type CustomModuleInput = {
|
||||
name: string
|
||||
category: string
|
||||
dockerImage: string
|
||||
packageName: string
|
||||
launchFile: string
|
||||
}
|
||||
|
||||
function ModuleAddDialog({
|
||||
dark,
|
||||
onClose,
|
||||
onCreate
|
||||
}: {
|
||||
dark: boolean
|
||||
onClose: () => void
|
||||
onCreate: (input: CustomModuleInput) => Promise<void>
|
||||
}): React.JSX.Element {
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [dockerImage, setDockerImage] = useState('')
|
||||
const [packageName, setPackageName] = useState('')
|
||||
const [launchFile, setLaunchFile] = useState('')
|
||||
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(' ')
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 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>
|
||||
<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 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 (submitting) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
void onCreate({ name: name.trim(), category, dockerImage: dockerImage.trim(), packageName: packageName.trim(), launchFile: launchFile.trim() })
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : '모듈을 추가하지 못했습니다.'))
|
||||
.finally(() => setSubmitting(false))
|
||||
}}>
|
||||
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
|
||||
모듈 이름
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} className={inputClassName} placeholder="예: NAV2-MyPlanner" autoFocus />
|
||||
</label>
|
||||
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
|
||||
카테고리
|
||||
<select value={category} onChange={(event) => setCategory(event.target.value)} className={inputClassName}>
|
||||
<option value="">선택</option>
|
||||
<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(' ')}>
|
||||
실행 이미지
|
||||
<input value={dockerImage} onChange={(event) => setDockerImage(event.target.value)} className={inputClassName} placeholder="예: future-nav2:humble" />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
|
||||
패키지명
|
||||
<input value={packageName} onChange={(event) => setPackageName(event.target.value)} className={inputClassName} placeholder="my_planner_pkg" />
|
||||
</label>
|
||||
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
|
||||
Launch 파일
|
||||
<input value={launchFile} onChange={(event) => setLaunchFile(event.target.value)} className={inputClassName} placeholder="planner.launch.py" />
|
||||
</label>
|
||||
</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 rounded-md border px-3 text-[12px] font-bold transition', dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}>취소</button>
|
||||
<button type="submit" disabled={submitting || !name.trim() || !category || !packageName.trim() || !launchFile.trim()} className="h-8 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto overscroll-contain p-3">
|
||||
<div className="thin-y-scrollbar min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain p-3">
|
||||
{resourceMode === 'material' ? (
|
||||
<div className="grid gap-3">
|
||||
{materialItems.length ? materialItems.map((item, index) => {
|
||||
@@ -178,7 +178,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
].join(' ')}>{index + 1}</span>
|
||||
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<strong className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
|
||||
<strong title={item.title} className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{active && (
|
||||
<span className="rounded border border-[#60a5fa]/24 bg-[#2563eb]/16 px-2 py-0.5 text-[10px] font-bold text-[#bfdbfe]">
|
||||
|
||||
@@ -5,6 +5,7 @@ export type ModuleParameterPair = {
|
||||
}
|
||||
|
||||
export type ModuleRow = {
|
||||
id?: string
|
||||
state: 'running' | 'stopped' | 'uninstalled'
|
||||
sortOrder: number
|
||||
category: string
|
||||
@@ -15,6 +16,9 @@ export type ModuleRow = {
|
||||
templateRepositoryUrl?: string | null
|
||||
sourceType?: string | null
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
dockerImage?: string | null
|
||||
packageName?: string | null
|
||||
launchFile?: string | null
|
||||
parameters: string[]
|
||||
cpu: number | null
|
||||
memory: number | null
|
||||
|
||||
@@ -113,7 +113,30 @@ export function useWorkspaceModuleControl({
|
||||
return parameters.join(' ')
|
||||
}, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile])
|
||||
|
||||
const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => {
|
||||
if (!module.folderName || !module.packageName || !module.launchFile) {
|
||||
throw new Error('커스텀 모듈의 패키지명 또는 Launch 파일 정보가 없습니다.')
|
||||
}
|
||||
const input = {
|
||||
name: module.name,
|
||||
category: module.category,
|
||||
folderName: module.folderName,
|
||||
dockerImage: module.dockerImage ?? null,
|
||||
packageName: module.packageName,
|
||||
launchFile: module.launchFile,
|
||||
}
|
||||
if (remoteWorkspace) {
|
||||
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), input)
|
||||
return
|
||||
}
|
||||
await window.localWorkspace.modules.createCustom(clonePathPreview, input)
|
||||
}, [clonePathPreview, getRemoteWorkspaceTarget, remoteWorkspace])
|
||||
|
||||
const installModuleOnce = useCallback(async (module: ModuleRow, requestAccessToken: string): Promise<void> => {
|
||||
if (module.sourceType === 'CUSTOM') {
|
||||
await createCustomModuleSource(module)
|
||||
return
|
||||
}
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -160,7 +183,7 @@ export function useWorkspaceModuleControl({
|
||||
prepared.repositoryUrlWithToken,
|
||||
installOptions,
|
||||
)
|
||||
}, [activeRuntimeImages, clonePathPreview, courseKey, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
|
||||
}, [activeRuntimeImages, clonePathPreview, courseKey, createCustomModuleSource, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
|
||||
|
||||
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
|
||||
await prepareRemoteRuntimeModule(module)
|
||||
|
||||
@@ -8,6 +8,7 @@ export const compareModuleShutdownPriority = (a: ModuleRow, b: ModuleRow): numbe
|
||||
export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['modules']): ModuleRow[] {
|
||||
return (curriculumModules ?? [])
|
||||
.map((module) => ({
|
||||
id: module.id,
|
||||
state: 'uninstalled' as const,
|
||||
sortOrder: module.sortOrder,
|
||||
category: module.category,
|
||||
@@ -18,6 +19,9 @@ export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['mod
|
||||
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
||||
sourceType: module.sourceType ?? null,
|
||||
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
|
||||
dockerImage: module.dockerImage ?? null,
|
||||
packageName: module.packageName ?? null,
|
||||
launchFile: module.launchFile ?? null,
|
||||
parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [],
|
||||
cpu: null,
|
||||
memory: null,
|
||||
|
||||
Reference in New Issue
Block a user