patch curriculum..
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
+13
-3
@@ -1148,14 +1148,21 @@ async function ensureArtifactFile(url?: string | null, fileName?: string | null,
|
|||||||
if (!artifactUrl) return null
|
if (!artifactUrl) return null
|
||||||
|
|
||||||
const targetPath = artifactCachePath(artifactUrl, fileName)
|
const targetPath = artifactCachePath(artifactUrl, fileName)
|
||||||
|
const expected = checksumSha256?.trim().toLowerCase()
|
||||||
if (!existsSync(targetPath)) await downloadArtifact(artifactUrl, targetPath)
|
if (!existsSync(targetPath)) await downloadArtifact(artifactUrl, targetPath)
|
||||||
if (checksumSha256?.trim()) {
|
if (expected) {
|
||||||
const actual = createHash('sha256').update(await readFile(targetPath)).digest('hex')
|
let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase()
|
||||||
if (actual.toLowerCase() !== checksumSha256.trim().toLowerCase()) {
|
if (actual !== expected) {
|
||||||
|
// Artifact URLs are stable, so an existing filename can be an older cached release.
|
||||||
|
await rm(targetPath, { force: true })
|
||||||
|
await downloadArtifact(artifactUrl, targetPath)
|
||||||
|
actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase()
|
||||||
|
if (actual !== expected) {
|
||||||
await rm(targetPath, { force: true }).catch(() => undefined)
|
await rm(targetPath, { force: true }).catch(() => undefined)
|
||||||
throw new Error(`artifact checksum mismatch: ${safeArtifactFileName(artifactUrl, fileName)}`)
|
throw new Error(`artifact checksum mismatch: ${safeArtifactFileName(artifactUrl, fileName)}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return targetPath
|
return targetPath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3434,6 +3441,7 @@ function buildNav2ModuleEnvScript(): string {
|
|||||||
' "[lms-nav2-gazebo-perception]nav2-perception_v1.0.0_ws"',
|
' "[lms-nav2-gazebo-perception]nav2-perception_v1.0.0_ws"',
|
||||||
' "[lms-nav2-gazebo-perception3d]nav2-perception3d_v1.0.0_ws"',
|
' "[lms-nav2-gazebo-perception3d]nav2-perception3d_v1.0.0_ws"',
|
||||||
' "[lms-nav2-gazebo-slam3d]nav2-slam3d_v1.0.0_ws"',
|
' "[lms-nav2-gazebo-slam3d]nav2-slam3d_v1.0.0_ws"',
|
||||||
|
' "[lms-nav2-gazebo-gui]nav2-rviz2_v1.0.0_ws"',
|
||||||
')',
|
')',
|
||||||
'',
|
'',
|
||||||
'for module_dir in "${MODULES[@]}"; do',
|
'for module_dir in "${MODULES[@]}"; do',
|
||||||
@@ -3489,6 +3497,7 @@ function buildNav2DockerRunScript(): string {
|
|||||||
' *lms-nav2-gazebo-planning*) PACKAGE=planning_pkg; LAUNCH=planning.launch.py ;;',
|
' *lms-nav2-gazebo-planning*) PACKAGE=planning_pkg; LAUNCH=planning.launch.py ;;',
|
||||||
' *lms-nav2-gazebo-control*) PACKAGE=control_pkg; LAUNCH=control.launch.py ;;',
|
' *lms-nav2-gazebo-control*) PACKAGE=control_pkg; LAUNCH=control.launch.py ;;',
|
||||||
' *lms-nav2-gazebo-perception3d*) PACKAGE=perception3d_pkg; LAUNCH=perception.launch.py ;;',
|
' *lms-nav2-gazebo-perception3d*) PACKAGE=perception3d_pkg; LAUNCH=perception.launch.py ;;',
|
||||||
|
' *lms-nav2-gazebo-gui*) PACKAGE=gui_pkg; LAUNCH=gui.launch.py ;;',
|
||||||
' *lms-nav2-gazebo-slam*) PACKAGE=slam_pkg; LAUNCH=slam.launch.py ;;',
|
' *lms-nav2-gazebo-slam*) PACKAGE=slam_pkg; LAUNCH=slam.launch.py ;;',
|
||||||
' *lms-nav2-gazebo-perception*) PACKAGE=perception_pkg; LAUNCH=perception.launch.py ;;',
|
' *lms-nav2-gazebo-perception*) PACKAGE=perception_pkg; LAUNCH=perception.launch.py ;;',
|
||||||
' *nav2-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;',
|
' *nav2-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;',
|
||||||
@@ -3580,6 +3589,7 @@ function buildNav2DockerBuildScript(): string {
|
|||||||
' *lms-nav2-gazebo-planning*) TARGET=planning_pkg ;;',
|
' *lms-nav2-gazebo-planning*) TARGET=planning_pkg ;;',
|
||||||
' *lms-nav2-gazebo-control*) TARGET=control_pkg ;;',
|
' *lms-nav2-gazebo-control*) TARGET=control_pkg ;;',
|
||||||
' *lms-nav2-gazebo-perception3d*) TARGET=perception3d_pkg ;;',
|
' *lms-nav2-gazebo-perception3d*) TARGET=perception3d_pkg ;;',
|
||||||
|
' *lms-nav2-gazebo-gui*) TARGET=gui_pkg ;;',
|
||||||
' *lms-nav2-gazebo-slam*) TARGET=slam_pkg ;;',
|
' *lms-nav2-gazebo-slam*) TARGET=slam_pkg ;;',
|
||||||
' *lms-nav2-gazebo-perception*) TARGET=perception_pkg ;;',
|
' *lms-nav2-gazebo-perception*) TARGET=perception_pkg ;;',
|
||||||
' *nav2-simulator*) TARGET=nav2_turtlebot_simulator ;;',
|
' *nav2-simulator*) TARGET=nav2_turtlebot_simulator ;;',
|
||||||
|
|||||||
+57
-12
@@ -16,7 +16,7 @@ import CourseSelectionScreen from './features/curriculum/CourseSelectionScreen'
|
|||||||
import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen'
|
import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen'
|
||||||
import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal'
|
import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal'
|
||||||
import MaterialsModal from './features/curriculum/MaterialsModal'
|
import MaterialsModal from './features/curriculum/MaterialsModal'
|
||||||
import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi'
|
import { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } from './features/curriculum/curriculumApi'
|
||||||
import type { CustomModuleInput } from './features/workspace/ModuleListView'
|
import type { CustomModuleInput } from './features/workspace/ModuleListView'
|
||||||
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
|
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
|
||||||
import { getContextHelpSteps } from './features/help/contextHelp'
|
import { getContextHelpSteps } from './features/help/contextHelp'
|
||||||
@@ -449,8 +449,10 @@ function LocalPcWorkspaceMockup({
|
|||||||
courseKey,
|
courseKey,
|
||||||
curriculumWeek,
|
curriculumWeek,
|
||||||
teamGroupId,
|
teamGroupId,
|
||||||
|
publicGuideCourse,
|
||||||
activeLearningStep,
|
activeLearningStep,
|
||||||
allowFutureLearningSteps,
|
allowFutureLearningSteps,
|
||||||
|
teacherQaDisabled,
|
||||||
onLearningStepSelect,
|
onLearningStepSelect,
|
||||||
onSubmissionRecorded,
|
onSubmissionRecorded,
|
||||||
onVisualizationHostChange
|
onVisualizationHostChange
|
||||||
@@ -495,8 +497,10 @@ function LocalPcWorkspaceMockup({
|
|||||||
courseKey: string
|
courseKey: string
|
||||||
curriculumWeek: CurriculumWeek
|
curriculumWeek: CurriculumWeek
|
||||||
teamGroupId?: string | null
|
teamGroupId?: string | null
|
||||||
|
publicGuideCourse: boolean
|
||||||
activeLearningStep: string | null
|
activeLearningStep: string | null
|
||||||
allowFutureLearningSteps: boolean
|
allowFutureLearningSteps: boolean
|
||||||
|
teacherQaDisabled?: boolean
|
||||||
onLearningStepSelect?: (step: string) => void
|
onLearningStepSelect?: (step: string) => void
|
||||||
onSubmissionRecorded: () => void
|
onSubmissionRecorded: () => void
|
||||||
onVisualizationHostChange?: (host: WorkspaceVisualizationHost) => void
|
onVisualizationHostChange?: (host: WorkspaceVisualizationHost) => void
|
||||||
@@ -3708,7 +3712,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
}}
|
}}
|
||||||
selectedMap={selectedWorldMap}
|
selectedMap={selectedWorldMap}
|
||||||
showSelectedMapPreview={Boolean(curriculumWeek.maps?.some((map) => map.platformKey === 'NAV2'))}
|
showSelectedMapPreview={Boolean(curriculumWeek.maps?.some((map) => map.platformKey === 'NAV2'))}
|
||||||
allowRepositoryDeletion={!remoteWorkspace}
|
allowRepositoryDeletion={!remoteWorkspace && !publicGuideCourse}
|
||||||
allowCustomModuleCreation={!remoteWorkspace}
|
allowCustomModuleCreation={!remoteWorkspace}
|
||||||
onAddModuleTab={addModuleTabOnly}
|
onAddModuleTab={addModuleTabOnly}
|
||||||
onOpenSource={openModuleSource}
|
onOpenSource={openModuleSource}
|
||||||
@@ -3801,6 +3805,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
iconButtonClass={iconButtonClass}
|
iconButtonClass={iconButtonClass}
|
||||||
accessToken={accessToken}
|
accessToken={accessToken}
|
||||||
qaMode={qaMode}
|
qaMode={qaMode}
|
||||||
|
teacherQaDisabled={teacherQaDisabled}
|
||||||
teacherChatFlash={teacherChatFlash}
|
teacherChatFlash={teacherChatFlash}
|
||||||
qaMessagesLoading={qaMessagesLoading}
|
qaMessagesLoading={qaMessagesLoading}
|
||||||
visibleAiMessages={visibleAiMessages}
|
visibleAiMessages={visibleAiMessages}
|
||||||
@@ -3856,8 +3861,9 @@ export default function App(): React.JSX.Element {
|
|||||||
const [studentGroup, setStudentGroup] = useState<{ id: string; name: string } | null>(null)
|
const [studentGroup, setStudentGroup] = useState<{ id: string; name: string } | null>(null)
|
||||||
const [courseKey, setCourseKey] = useState('default-course')
|
const [courseKey, setCourseKey] = useState('default-course')
|
||||||
const [studentCourses, setStudentCourses] = useState<StudentCourseOption[]>([])
|
const [studentCourses, setStudentCourses] = useState<StudentCourseOption[]>([])
|
||||||
|
const [trialCourse, setTrialCourse] = useState<StudentCourseOption | null>(null)
|
||||||
const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false)
|
const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false)
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) ?? '')
|
const [selectedCourseId, setSelectedCourseId] = useState('')
|
||||||
const [courseSelectionVisible, setCourseSelectionVisible] = useState(false)
|
const [courseSelectionVisible, setCourseSelectionVisible] = useState(false)
|
||||||
const [outerView, setOuterView] = useState<'curriculum' | 'guide'>('curriculum')
|
const [outerView, setOuterView] = useState<'curriculum' | 'guide'>('curriculum')
|
||||||
const [selectedWeekState, setSelectedWeekState] = useState<CurriculumWeek | null>(null)
|
const [selectedWeekState, setSelectedWeekState] = useState<CurriculumWeek | null>(null)
|
||||||
@@ -3904,6 +3910,7 @@ export default function App(): React.JSX.Element {
|
|||||||
const [downloadResources, setDownloadResources] = useState<DownloadResource[]>([])
|
const [downloadResources, setDownloadResources] = useState<DownloadResource[]>([])
|
||||||
const [authSession, setAuthSession] = useState<LmsAuthSession | null>(null)
|
const [authSession, setAuthSession] = useState<LmsAuthSession | null>(null)
|
||||||
const authSessionRef = useRef<LmsAuthSession | null>(null)
|
const authSessionRef = useRef<LmsAuthSession | null>(null)
|
||||||
|
const selectedCourseUserRef = useRef<string | null>(null)
|
||||||
const [authReady, setAuthReady] = useState(false)
|
const [authReady, setAuthReady] = useState(false)
|
||||||
const [sessionExpired, setSessionExpired] = useState(false)
|
const [sessionExpired, setSessionExpired] = useState(false)
|
||||||
const [studentCoursesLoading, setStudentCoursesLoading] = useState(false)
|
const [studentCoursesLoading, setStudentCoursesLoading] = useState(false)
|
||||||
@@ -3957,6 +3964,9 @@ export default function App(): React.JSX.Element {
|
|||||||
const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState<PlatformOption | null>(null)
|
const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState<PlatformOption | null>(null)
|
||||||
const [titlebarNotices, setTitlebarNotices] = useState<Array<{ id: number; title: string; tone?: 'info' | 'warning' | 'error'; addToList?: boolean }>>([])
|
const [titlebarNotices, setTitlebarNotices] = useState<Array<{ id: number; title: string; tone?: 'info' | 'warning' | 'error'; addToList?: boolean }>>([])
|
||||||
const instructorAccount = isInstructorAccount(authSession?.user)
|
const instructorAccount = isInstructorAccount(authSession?.user)
|
||||||
|
const selectedCourseStorageKey = authSession?.user.id
|
||||||
|
? `${SELECTED_COURSE_STORAGE_KEY}.${authSession.user.id}`
|
||||||
|
: null
|
||||||
|
|
||||||
const openContextHelp = (): void => {
|
const openContextHelp = (): void => {
|
||||||
const steps = getContextHelpSteps({
|
const steps = getContextHelpSteps({
|
||||||
@@ -3989,7 +3999,8 @@ export default function App(): React.JSX.Element {
|
|||||||
const selectedWeek = selectedWeekState ?? EMPTY_WEEK
|
const selectedWeek = selectedWeekState ?? EMPTY_WEEK
|
||||||
const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK
|
const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK
|
||||||
const teamWorkspaceGroupId = workspaceWeek.isTeamProject ? studentGroup?.id ?? null : null
|
const teamWorkspaceGroupId = workspaceWeek.isTeamProject ? studentGroup?.id ?? null : null
|
||||||
const selectedCourse = studentCourses.find((course) => course.id === selectedCourseId) ?? null
|
const selectedCourse = studentCourses.find((course) => course.id === selectedCourseId)
|
||||||
|
?? (trialCourse?.id === selectedCourseId ? trialCourse : null)
|
||||||
const publicGuideCourseActive = selectedCourse?.slug === PUBLIC_GUIDE_COURSE_SLUG
|
const publicGuideCourseActive = selectedCourse?.slug === PUBLIC_GUIDE_COURSE_SLUG
|
||||||
const studioDisabled = !selectedWeekState
|
const studioDisabled = !selectedWeekState
|
||||||
const connectedEnvironmentProfile = connectedPlatform
|
const connectedEnvironmentProfile = connectedPlatform
|
||||||
@@ -4037,6 +4048,22 @@ export default function App(): React.JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authSessionRef.current = authSession
|
authSessionRef.current = authSession
|
||||||
}, [authSession])
|
}, [authSession])
|
||||||
|
useEffect(() => {
|
||||||
|
const userId = authSession?.user.id ?? null
|
||||||
|
if (!userId) {
|
||||||
|
selectedCourseUserRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedCourseUserRef.current === userId) return
|
||||||
|
|
||||||
|
selectedCourseUserRef.current = userId
|
||||||
|
setSelectedCourseId('')
|
||||||
|
setStudentCourses([])
|
||||||
|
setTrialCourse(null)
|
||||||
|
setStudentCoursesLoaded(false)
|
||||||
|
setCourseSelectionVisible(true)
|
||||||
|
window.localStorage.removeItem(SELECTED_COURSE_STORAGE_KEY)
|
||||||
|
}, [authSession?.user.id])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connectedPlatformRef.current = connectedPlatform
|
connectedPlatformRef.current = connectedPlatform
|
||||||
}, [connectedPlatform])
|
}, [connectedPlatform])
|
||||||
@@ -4234,23 +4261,27 @@ export default function App(): React.JSX.Element {
|
|||||||
|
|
||||||
setStudentCoursesLoading(true)
|
setStudentCoursesLoading(true)
|
||||||
try {
|
try {
|
||||||
const courses = await fetchStudentCourses(fetchWithFreshAuth)
|
const { courses, guideCourse } = await fetchStudentCourses(fetchWithFreshAuth)
|
||||||
|
const selectableCourses = guideCourse ? [...courses, guideCourse] : courses
|
||||||
|
|
||||||
setStudentCourses(courses)
|
setStudentCourses(courses)
|
||||||
|
setTrialCourse(guideCourse)
|
||||||
setStudentCoursesLoaded(true)
|
setStudentCoursesLoaded(true)
|
||||||
setSelectedCourseId((current) => {
|
setSelectedCourseId((current) => {
|
||||||
if (current && courses.some((course) => course.id === current)) {
|
if (current && selectableCourses.some((course) => course.id === current)) {
|
||||||
if (options.autoSelectSingle) setCourseSelectionVisible(false)
|
if (options.autoSelectSingle) setCourseSelectionVisible(false)
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
const stored = window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY)
|
const stored = selectedCourseStorageKey
|
||||||
if (stored && courses.some((course) => course.id === stored)) {
|
? window.localStorage.getItem(selectedCourseStorageKey)
|
||||||
|
: null
|
||||||
|
if (stored && selectableCourses.some((course) => course.id === stored)) {
|
||||||
if (options.autoSelectSingle) setCourseSelectionVisible(false)
|
if (options.autoSelectSingle) setCourseSelectionVisible(false)
|
||||||
return stored
|
return stored
|
||||||
}
|
}
|
||||||
window.localStorage.removeItem(SELECTED_COURSE_STORAGE_KEY)
|
if (selectedCourseStorageKey) window.localStorage.removeItem(selectedCourseStorageKey)
|
||||||
if (options.autoSelectSingle && courses.length === 1) {
|
if (options.autoSelectSingle && courses.length === 1) {
|
||||||
window.localStorage.setItem(SELECTED_COURSE_STORAGE_KEY, courses[0].id)
|
if (selectedCourseStorageKey) window.localStorage.setItem(selectedCourseStorageKey, courses[0].id)
|
||||||
setCourseSelectionVisible(false)
|
setCourseSelectionVisible(false)
|
||||||
return courses[0].id
|
return courses[0].id
|
||||||
}
|
}
|
||||||
@@ -4259,12 +4290,13 @@ export default function App(): React.JSX.Element {
|
|||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setStudentCourses([])
|
setStudentCourses([])
|
||||||
|
setTrialCourse(null)
|
||||||
setStudentCoursesLoaded(true)
|
setStudentCoursesLoaded(true)
|
||||||
setCourseSelectionVisible(true)
|
setCourseSelectionVisible(true)
|
||||||
} finally {
|
} finally {
|
||||||
setStudentCoursesLoading(false)
|
setStudentCoursesLoading(false)
|
||||||
}
|
}
|
||||||
}, [authReady, authSession?.accessToken, fetchWithFreshAuth])
|
}, [authReady, authSession?.accessToken, fetchWithFreshAuth, selectedCourseStorageKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadStudentCourses({ autoSelectSingle: true })
|
void loadStudentCourses({ autoSelectSingle: true })
|
||||||
@@ -4705,12 +4737,20 @@ export default function App(): React.JSX.Element {
|
|||||||
setCourseChangeBlockedOpen(true)
|
setCourseChangeBlockedOpen(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
window.localStorage.setItem(SELECTED_COURSE_STORAGE_KEY, courseId)
|
if (selectedCourseStorageKey) window.localStorage.setItem(selectedCourseStorageKey, courseId)
|
||||||
setSelectedCourseId(courseId)
|
setSelectedCourseId(courseId)
|
||||||
setCourseSelectionVisible(false)
|
setCourseSelectionVisible(false)
|
||||||
setLocalPcLayoutOpen(false)
|
setLocalPcLayoutOpen(false)
|
||||||
setCurriculumRefreshSignal((current) => current + 1)
|
setCurriculumRefreshSignal((current) => current + 1)
|
||||||
}
|
}
|
||||||
|
const searchStudentCourseCatalog = useCallback(
|
||||||
|
(query: string) => searchCourseCatalog(fetchWithFreshAuth, query),
|
||||||
|
[fetchWithFreshAuth],
|
||||||
|
)
|
||||||
|
const enrollStudentInCourse = useCallback(async (courseId: string): Promise<void> => {
|
||||||
|
await enrollInCourse(fetchWithFreshAuth, courseId)
|
||||||
|
await loadStudentCourses({ autoSelectSingle: false })
|
||||||
|
}, [fetchWithFreshAuth, loadStudentCourses])
|
||||||
const closeLocalPcWorkspace = (): void => {
|
const closeLocalPcWorkspace = (): void => {
|
||||||
setLocalPcLayoutOpen(false)
|
setLocalPcLayoutOpen(false)
|
||||||
if (!userSidebarCollapsed) setSidebarCollapsed(false)
|
if (!userSidebarCollapsed) setSidebarCollapsed(false)
|
||||||
@@ -5591,8 +5631,10 @@ export default function App(): React.JSX.Element {
|
|||||||
courseKey={courseKey}
|
courseKey={courseKey}
|
||||||
curriculumWeek={workspaceWeek}
|
curriculumWeek={workspaceWeek}
|
||||||
teamGroupId={teamWorkspaceGroupId}
|
teamGroupId={teamWorkspaceGroupId}
|
||||||
|
publicGuideCourse={publicGuideCourseActive}
|
||||||
activeLearningStep={activeLearningStep}
|
activeLearningStep={activeLearningStep}
|
||||||
allowFutureLearningSteps={publicGuideCourseActive}
|
allowFutureLearningSteps={publicGuideCourseActive}
|
||||||
|
teacherQaDisabled={publicGuideCourseActive}
|
||||||
onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined}
|
onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined}
|
||||||
onSubmissionRecorded={() => undefined}
|
onSubmissionRecorded={() => undefined}
|
||||||
onVisualizationHostChange={handleWorkspaceVisualizationHostChange}
|
onVisualizationHostChange={handleWorkspaceVisualizationHostChange}
|
||||||
@@ -5628,9 +5670,12 @@ export default function App(): React.JSX.Element {
|
|||||||
) : courseSelectionVisible ? (
|
) : courseSelectionVisible ? (
|
||||||
<CourseSelectionScreen
|
<CourseSelectionScreen
|
||||||
courses={studentCourses}
|
courses={studentCourses}
|
||||||
|
trialCourse={trialCourse}
|
||||||
selectedCourseId={selectedCourseId}
|
selectedCourseId={selectedCourseId}
|
||||||
hasSelectedCourse={Boolean(selectedCourse)}
|
hasSelectedCourse={Boolean(selectedCourse)}
|
||||||
onSelectCourse={selectStudentCourse}
|
onSelectCourse={selectStudentCourse}
|
||||||
|
onSearchCourses={searchStudentCourseCatalog}
|
||||||
|
onEnrollCourse={enrollStudentInCourse}
|
||||||
onBack={() => setCourseSelectionVisible(false)}
|
onBack={() => setCourseSelectionVisible(false)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -88,6 +88,38 @@ const LMS_AUTH_SESSION_STORAGE_KEY = 'autonomy-studio.lms.auth-session'
|
|||||||
const NOTIFICATION_DROPDOWN_LIMIT = 10
|
const NOTIFICATION_DROPDOWN_LIMIT = 10
|
||||||
const NOTIFICATION_MODAL_PAGE_SIZE = 8
|
const NOTIFICATION_MODAL_PAGE_SIZE = 8
|
||||||
const NOTIFICATION_REFRESH_INTERVAL_MS = 60_000
|
const NOTIFICATION_REFRESH_INTERVAL_MS = 60_000
|
||||||
|
const APP_LOG_TIMESTAMP_FORMATTER = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Seoul',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23'
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatAppLogTimestamp = (timestamp: string): string | null => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
APP_LOG_TIMESTAMP_FORMATTER
|
||||||
|
.formatToParts(date)
|
||||||
|
.filter(({ type }) => type !== 'literal')
|
||||||
|
.map(({ type, value }) => [type, value])
|
||||||
|
)
|
||||||
|
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second} KST`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatAppLogContentForDisplay = (content: string): string => content.replace(
|
||||||
|
/^\[([^\]]+)]/gm,
|
||||||
|
(matched, timestamp: string) => {
|
||||||
|
const formatted = formatAppLogTimestamp(timestamp)
|
||||||
|
return formatted ? `[${formatted}]` : matched
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const normalizeNotification = (notification: NotificationMessage): NotificationMessage => ({
|
const normalizeNotification = (notification: NotificationMessage): NotificationMessage => ({
|
||||||
...notification,
|
...notification,
|
||||||
@@ -128,6 +160,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
...notifications
|
...notifications
|
||||||
]
|
]
|
||||||
}, [localNotifications, notifications])
|
}, [localNotifications, notifications])
|
||||||
|
const appLogDisplayContent = useMemo(() => formatAppLogContentForDisplay(appLogContent), [appLogContent])
|
||||||
const unreadCount = sortedNotifications.filter((notification) => !notification.read).length
|
const unreadCount = sortedNotifications.filter((notification) => !notification.read).length
|
||||||
const hasUnreadNotifications = unreadCount > 0
|
const hasUnreadNotifications = unreadCount > 0
|
||||||
const dropdownNotifications = sortedNotifications.slice(0, NOTIFICATION_DROPDOWN_LIMIT)
|
const dropdownNotifications = sortedNotifications.slice(0, NOTIFICATION_DROPDOWN_LIMIT)
|
||||||
@@ -174,13 +207,13 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
}, [])
|
}, [])
|
||||||
const handleCopyAppLogs = useCallback((): void => {
|
const handleCopyAppLogs = useCallback((): void => {
|
||||||
if (!appLogContent) return
|
if (!appLogContent) return
|
||||||
void navigator.clipboard.writeText(appLogContent)
|
void navigator.clipboard.writeText(appLogDisplayContent)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setAppLogsCopied(true)
|
setAppLogsCopied(true)
|
||||||
window.setTimeout(() => setAppLogsCopied(false), 1600)
|
window.setTimeout(() => setAppLogsCopied(false), 1600)
|
||||||
})
|
})
|
||||||
.catch((error) => console.error('[app-logs] copy failed', error))
|
.catch((error) => console.error('[app-logs] copy failed', error))
|
||||||
}, [appLogContent])
|
}, [appLogContent, appLogDisplayContent])
|
||||||
const enqueueTransientNotice = useCallback((notice: { id: number; title: string; tone?: 'info' | 'warning' | 'error' }): void => {
|
const enqueueTransientNotice = useCallback((notice: { id: number; title: string; tone?: 'info' | 'warning' | 'error' }): void => {
|
||||||
const title = notice.title.trim()
|
const title = notice.title.trim()
|
||||||
if (!title) return
|
if (!title) return
|
||||||
@@ -970,7 +1003,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
<div className="flex h-full items-center justify-center text-[13px] font-medium text-white/48">로그를 불러오는 중입니다.</div>
|
<div className="flex h-full items-center justify-center text-[13px] font-medium text-white/48">로그를 불러오는 중입니다.</div>
|
||||||
) : appLogContent ? (
|
) : appLogContent ? (
|
||||||
<pre className="h-full overflow-auto whitespace-pre-wrap break-words rounded-md border border-white/[0.07] bg-black/20 p-3 font-mono text-[11px] leading-5 text-white/70">
|
<pre className="h-full overflow-auto whitespace-pre-wrap break-words rounded-md border border-white/[0.07] bg-black/20 p-3 font-mono text-[11px] leading-5 text-white/70">
|
||||||
{appLogContent}
|
{appLogDisplayContent}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center rounded-md border border-dashed border-white/10 text-[13px] font-medium text-white/42">
|
<div className="flex h-full items-center justify-center rounded-md border border-dashed border-white/10 text-[13px] font-medium text-white/42">
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { CheckCircle2 } from 'lucide-react'
|
import { CheckCircle2, LoaderCircle, Search, Sparkles, UserPlus } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
|
import type { CourseCatalogOption } from './curriculumApi'
|
||||||
|
|
||||||
type CourseOption = {
|
type CourseOption = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -15,26 +18,95 @@ type CourseOption = {
|
|||||||
|
|
||||||
type CourseSelectionScreenProps = {
|
type CourseSelectionScreenProps = {
|
||||||
courses: CourseOption[]
|
courses: CourseOption[]
|
||||||
|
trialCourse?: CourseOption | null
|
||||||
selectedCourseId: string | null
|
selectedCourseId: string | null
|
||||||
hasSelectedCourse: boolean
|
hasSelectedCourse: boolean
|
||||||
onSelectCourse: (courseId: string) => void
|
onSelectCourse: (courseId: string) => void
|
||||||
|
onSearchCourses: (query: string) => Promise<CourseCatalogOption[]>
|
||||||
|
onEnrollCourse: (courseId: string) => Promise<void>
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function teacherLabel(course: Pick<CourseOption, 'teacher' | 'organizationName'>): string {
|
||||||
|
return `${course.teacher?.name ?? course.teacher?.email ?? '강사 미지정'} / ${course.teacher?.primaryOrganizationName ?? course.organizationName ?? '소속 미지정'}`
|
||||||
|
}
|
||||||
|
|
||||||
export default function CourseSelectionScreen({
|
export default function CourseSelectionScreen({
|
||||||
courses,
|
courses,
|
||||||
|
trialCourse,
|
||||||
selectedCourseId,
|
selectedCourseId,
|
||||||
hasSelectedCourse,
|
hasSelectedCourse,
|
||||||
onSelectCourse,
|
onSelectCourse,
|
||||||
onBack
|
onSearchCourses,
|
||||||
|
onEnrollCourse,
|
||||||
|
onBack,
|
||||||
}: CourseSelectionScreenProps): React.JSX.Element {
|
}: CourseSelectionScreenProps): React.JSX.Element {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<CourseCatalogOption[]>([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [searchError, setSearchError] = useState('')
|
||||||
|
const [selectedCatalogCourseId, setSelectedCatalogCourseId] = useState<string | null>(null)
|
||||||
|
const [enrolling, setEnrolling] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const keyword = query.trim()
|
||||||
|
if (!keyword) {
|
||||||
|
setSearchResults([])
|
||||||
|
setSelectedCatalogCourseId(null)
|
||||||
|
setSearchError('')
|
||||||
|
setSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setSearching(true)
|
||||||
|
setSearchError('')
|
||||||
|
void onSearchCourses(keyword)
|
||||||
|
.then((nextResults) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setSearchResults(nextResults)
|
||||||
|
setSelectedCatalogCourseId((current) => nextResults.some((course) => course.id === current) ? current : null)
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setSearchResults([])
|
||||||
|
setSelectedCatalogCourseId(null)
|
||||||
|
setSearchError(error instanceof Error ? error.message : '강좌 검색에 실패했습니다.')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setSearching(false)
|
||||||
|
})
|
||||||
|
}, 250)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [onSearchCourses, query])
|
||||||
|
|
||||||
|
const selectedCatalogCourse = searchResults.find((course) => course.id === selectedCatalogCourseId) ?? null
|
||||||
|
const submitEnrollment = async (): Promise<void> => {
|
||||||
|
if (!selectedCatalogCourse || enrolling) return
|
||||||
|
setEnrolling(true)
|
||||||
|
setSearchError('')
|
||||||
|
try {
|
||||||
|
await onEnrollCourse(selectedCatalogCourse.id)
|
||||||
|
onSelectCourse(selectedCatalogCourse.id)
|
||||||
|
} catch (error) {
|
||||||
|
setSearchError(error instanceof Error ? error.message : '수강 신청에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
setEnrolling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto grid min-h-[calc(100vh-120px)] max-w-[900px] place-items-center px-4">
|
<section className="mx-auto grid min-h-[calc(100vh-120px)] max-w-[900px] place-items-center px-4">
|
||||||
<div className="w-full rounded-xl border border-[#d5dbea] bg-white p-6 shadow-[0_18px_42px_rgba(20,27,44,0.12)]">
|
<div className="w-full rounded-xl border border-[#d5dbea] bg-white p-6 shadow-[0_18px_42px_rgba(20,27,44,0.12)]">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-[24px] font-black text-[#111520]">강좌 선택</h1>
|
<h1 className="text-[24px] font-black text-[#111520]">강좌 선택</h1>
|
||||||
<p className="mt-2 text-[13px] font-semibold text-[#64748b]">학습을 진행할 수강중인 강좌를 선택해주세요.</p>
|
<p className="mt-2 text-[13px] font-semibold text-[#64748b]">수강 중인 강좌를 선택하거나 새 강좌를 검색해 수강 신청하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
{hasSelectedCourse ? (
|
{hasSelectedCourse ? (
|
||||||
<button
|
<button
|
||||||
@@ -46,7 +118,80 @@ export default function CourseSelectionScreen({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-3">
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<label className="relative block">
|
||||||
|
<Search size={18} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[#738096]" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="강좌명 또는 강사명 검색"
|
||||||
|
className="h-12 w-full rounded-lg border border-[#cfd8e6] bg-[#fbfcff] pl-11 pr-4 text-[14px] font-semibold text-[#172033] outline-none transition focus:border-[#6f4df6] focus:bg-white focus:ring-2 focus:ring-[#6f4df6]/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{query.trim() ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
{searching ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-[13px] font-bold text-[#64748b]">
|
||||||
|
<LoaderCircle size={17} className="animate-spin" /> 검색 중입니다.
|
||||||
|
</div>
|
||||||
|
) : searchResults.length ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{searchResults.map((course) => {
|
||||||
|
const selected = course.id === selectedCatalogCourseId
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={course.id}
|
||||||
|
type="button"
|
||||||
|
disabled={course.enrolled || enrolling}
|
||||||
|
onClick={() => setSelectedCatalogCourseId(course.id)}
|
||||||
|
className={[
|
||||||
|
'rounded-lg border p-3.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35',
|
||||||
|
course.enrolled
|
||||||
|
? 'cursor-default border-[#dce3ee] bg-[#f8fafc] opacity-70'
|
||||||
|
: selected
|
||||||
|
? 'cursor-pointer border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)]'
|
||||||
|
: 'cursor-pointer border-[#dce3ee] bg-white hover:border-[#6f4df6] hover:bg-[#fbfaff]',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-[15px] font-black text-[#111520]">{course.title}</h2>
|
||||||
|
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{teacherLabel(course)}</p>
|
||||||
|
</div>
|
||||||
|
{course.enrolled ? (
|
||||||
|
<span className="shrink-0 rounded-md bg-[#edf7f2] px-2 py-1 text-[11px] font-black text-[#16845b]">수강 중</span>
|
||||||
|
) : selected ? <CheckCircle2 size={18} className="shrink-0 text-[#6f4df6]" /> : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-center text-[13px] font-bold text-[#64748b]">검색 결과가 없습니다.</div>
|
||||||
|
)}
|
||||||
|
{searchError ? <p className="mt-2 text-[12px] font-bold text-[#d74d54]">{searchError}</p> : null}
|
||||||
|
{selectedCatalogCourse ? (
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void submitEnrollment()}
|
||||||
|
disabled={enrolling}
|
||||||
|
className="inline-flex h-10 items-center gap-2 rounded-md bg-[#6f4df6] px-4 text-[13px] font-black text-white transition hover:bg-[#5f42d7] disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{enrolling ? <LoaderCircle size={16} className="animate-spin" /> : <UserPlus size={16} />}
|
||||||
|
수강 신청
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 border-t border-[#edf0f5] pt-5">
|
||||||
|
<p className="text-[12px] font-black text-[#64748b]">수강 중인 강좌</p>
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
{courses.length ? courses.map((course) => {
|
{courses.length ? courses.map((course) => {
|
||||||
const selected = course.id === selectedCourseId
|
const selected = course.id === selectedCourseId
|
||||||
return (
|
return (
|
||||||
@@ -57,7 +202,7 @@ export default function CourseSelectionScreen({
|
|||||||
'cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35',
|
'cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35',
|
||||||
selected
|
selected
|
||||||
? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)] hover:border-[#5f42d7] hover:bg-[#f2efff] hover:shadow-[0_12px_28px_rgba(111,77,246,0.18)]'
|
? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)] hover:border-[#5f42d7] hover:bg-[#f2efff] hover:shadow-[0_12px_28px_rgba(111,77,246,0.18)]'
|
||||||
: 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]'
|
: 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={() => onSelectCourse(course.id)}
|
onClick={() => onSelectCourse(course.id)}
|
||||||
>
|
>
|
||||||
@@ -67,21 +212,48 @@ export default function CourseSelectionScreen({
|
|||||||
<span className="min-w-0 truncate">{course.title}</span>
|
<span className="min-w-0 truncate">{course.title}</span>
|
||||||
{selected ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}
|
{selected ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
|
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{teacherLabel(course)}</p>
|
||||||
{course.teacher?.name ?? course.teacher?.email ?? '강사 미지정'} / {course.teacher?.primaryOrganizationName ?? course.organizationName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<span className="rounded-md bg-[#f0efff] px-2.5 py-1 text-[11px] font-black text-[#6f4df6]">{course.progress ?? 0}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="shrink-0 rounded-md bg-[#f0efff] px-2.5 py-1 text-[11px] font-black text-[#6f4df6]">{course.progress ?? 0}%</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}) : (
|
}) : (
|
||||||
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-12 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다.</div>
|
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-10 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{trialCourse ? (
|
||||||
|
<div className="mt-6 border-t border-[#edf0f5] pt-5">
|
||||||
|
<p className="text-[12px] font-black text-[#64748b]">둘러보기</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectCourse(trialCourse.id)}
|
||||||
|
className={[
|
||||||
|
'mt-3 w-full cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35',
|
||||||
|
selectedCourseId === trialCourse.id
|
||||||
|
? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)]'
|
||||||
|
: 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="flex min-w-0 items-center gap-1.5 text-[16px] font-black text-[#111520]">
|
||||||
|
<span className="min-w-0 truncate">{trialCourse.title}</span>
|
||||||
|
{selectedCourseId === trialCourse.id ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{trialCourse.teacher?.primaryOrganizationName ?? trialCourse.organizationName ?? 'Autonomy Studio'}</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-[#ded4ff] bg-[#f4f0ff] px-2.5 py-1 text-[11px] font-black text-[#6944d8]">
|
||||||
|
<Sparkles size={12} strokeWidth={2.5} />
|
||||||
|
체험
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,56 @@ import type { CurriculumResponse, DownloadResource, StudentCourseOption } from '
|
|||||||
|
|
||||||
type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise<Response>
|
type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise<Response>
|
||||||
|
|
||||||
export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise<StudentCourseOption[]> {
|
export type CourseCatalogOption = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string | null
|
||||||
|
organizationName?: string | null
|
||||||
|
enrolled: boolean
|
||||||
|
teacher?: {
|
||||||
|
email?: string | null
|
||||||
|
name?: string | null
|
||||||
|
primaryOrganizationName?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StudentCourseList = {
|
||||||
|
courses: StudentCourseOption[]
|
||||||
|
guideCourse: StudentCourseOption | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise<StudentCourseList> {
|
||||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`)
|
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`)
|
||||||
const payload = response.ok ? await response.json() as { courses?: StudentCourseOption[] } : null
|
const payload = response.ok ? await response.json() as Partial<StudentCourseList> : null
|
||||||
return payload?.courses ?? []
|
return {
|
||||||
|
courses: payload?.courses ?? [],
|
||||||
|
guideCourse: payload?.guideCourse ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseMessage(response: Response, fallback: string): Promise<string> {
|
||||||
|
const payload = await response.json().catch(() => null) as { message?: string | string[] } | null
|
||||||
|
if (Array.isArray(payload?.message)) return payload.message.join(' ')
|
||||||
|
return payload?.message || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCourseCatalog(
|
||||||
|
fetchWithFreshAuth: AuthFetch,
|
||||||
|
query: string,
|
||||||
|
): Promise<CourseCatalogOption[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (query.trim()) params.set('query', query.trim())
|
||||||
|
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/course-catalog?${params.toString()}`)
|
||||||
|
if (!response.ok) throw new Error(await responseMessage(response, '강좌 검색에 실패했습니다.'))
|
||||||
|
const payload = await response.json() as { courses?: CourseCatalogOption[] }
|
||||||
|
return payload.courses ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enrollInCourse(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<void> {
|
||||||
|
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/course-catalog/${encodeURIComponent(courseId)}/enroll`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(await responseMessage(response, '수강 신청에 실패했습니다.'))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCurriculum(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<CurriculumResponse | null> {
|
export async function fetchCurriculum(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<CurriculumResponse | null> {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type WorkspaceQaPanelProps = {
|
|||||||
iconButtonClass: string
|
iconButtonClass: string
|
||||||
accessToken: string
|
accessToken: string
|
||||||
qaMode: QaMode
|
qaMode: QaMode
|
||||||
|
teacherQaDisabled?: boolean
|
||||||
teacherChatFlash: boolean
|
teacherChatFlash: boolean
|
||||||
qaMessagesLoading: boolean
|
qaMessagesLoading: boolean
|
||||||
visibleAiMessages: WorkspaceQaMessage[]
|
visibleAiMessages: WorkspaceQaMessage[]
|
||||||
@@ -53,6 +54,7 @@ export default function WorkspaceQaPanel({
|
|||||||
iconButtonClass,
|
iconButtonClass,
|
||||||
accessToken,
|
accessToken,
|
||||||
qaMode,
|
qaMode,
|
||||||
|
teacherQaDisabled = false,
|
||||||
teacherChatFlash,
|
teacherChatFlash,
|
||||||
qaMessagesLoading,
|
qaMessagesLoading,
|
||||||
visibleAiMessages,
|
visibleAiMessages,
|
||||||
@@ -73,6 +75,10 @@ export default function WorkspaceQaPanel({
|
|||||||
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages
|
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages
|
||||||
const lastMessage = activeMessages[activeMessages.length - 1]
|
const lastMessage = activeMessages[activeMessages.length - 1]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (teacherQaDisabled && qaMode === 'teacher') onModeChange('ai')
|
||||||
|
}, [onModeChange, qaMode, teacherQaDisabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!contextMenu) return
|
if (!contextMenu) return
|
||||||
const closeContextMenu = (): void => setContextMenu(null)
|
const closeContextMenu = (): void => setContextMenu(null)
|
||||||
@@ -161,16 +167,24 @@ export default function WorkspaceQaPanel({
|
|||||||
{ id: 'ai' as const, label: 'AI', Icon: Bot },
|
{ id: 'ai' as const, label: 'AI', Icon: Bot },
|
||||||
{ id: 'teacher' as const, label: '강사', Icon: User }
|
{ id: 'teacher' as const, label: '강사', Icon: User }
|
||||||
].map(({ id, label, Icon }) => {
|
].map(({ id, label, Icon }) => {
|
||||||
|
const disabled = id === 'teacher' && teacherQaDisabled
|
||||||
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
|
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onModeChange(id)}
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) onModeChange(id)
|
||||||
|
}}
|
||||||
className={[
|
className={[
|
||||||
'relative flex h-8 cursor-pointer items-center justify-center gap-1.5 text-[12px] font-bold transition',
|
'relative flex h-8 items-center justify-center gap-1.5 text-[12px] font-bold transition',
|
||||||
teacherTabFlash
|
disabled
|
||||||
|
? dark
|
||||||
|
? 'cursor-not-allowed bg-[#202020] text-white/25'
|
||||||
|
: 'cursor-not-allowed bg-[#f3f5f8] text-[#aab3c1]'
|
||||||
|
: teacherTabFlash
|
||||||
? dark
|
? dark
|
||||||
? 'animate-pulse bg-[#2563eb]/28 text-[#9ec2ff] shadow-[inset_0_0_0_1px_rgba(96,165,250,0.28)]'
|
? 'animate-pulse bg-[#2563eb]/28 text-[#9ec2ff] shadow-[inset_0_0_0_1px_rgba(96,165,250,0.28)]'
|
||||||
: 'animate-pulse bg-[#dbeafe] text-[#1d4ed8] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.18)]'
|
: 'animate-pulse bg-[#dbeafe] text-[#1d4ed8] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.18)]'
|
||||||
@@ -234,7 +248,7 @@ export default function WorkspaceQaPanel({
|
|||||||
<QaInputBox
|
<QaInputBox
|
||||||
dark={dark}
|
dark={dark}
|
||||||
busy={inputBusy}
|
busy={inputBusy}
|
||||||
disabled={inputDisabled}
|
disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -163,8 +163,7 @@ export default function WorkspaceVisualizationPanel({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid h-full place-items-center px-5 text-center">
|
<div className="grid h-full place-items-center px-5 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[13px] font-bold text-white/72">해당 주차는 시각화를 지원하지 않습니다</p>
|
<p className="text-[13px] font-bold text-white/72">시각화 준비 중입니다.</p>
|
||||||
<p className="mt-2 text-[11px] font-medium text-white/52">강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ import type { VisualizationStack } from './WorkspaceVisualizationPanel'
|
|||||||
|
|
||||||
export type Nav2VisualizationMode = 'slam' | 'navigation'
|
export type Nav2VisualizationMode = 'slam' | 'navigation'
|
||||||
|
|
||||||
|
const DEFAULT_NAV2_VISUALIZATION_PROFILE: EnvironmentProfile = {
|
||||||
|
id: 'default-nav2-visualization',
|
||||||
|
week: 0,
|
||||||
|
platformKey: 'futuredrive-local-pc',
|
||||||
|
profileType: 'NAV2',
|
||||||
|
name: 'NAV2',
|
||||||
|
config: {
|
||||||
|
type: 'NAV2',
|
||||||
|
target: 'local',
|
||||||
|
viewer: { stack: 'nav2' },
|
||||||
|
bridge: {
|
||||||
|
name: 'nav2-ros-bridge',
|
||||||
|
port: 8765,
|
||||||
|
image: 'slam-ros-bridge:latest',
|
||||||
|
buildContext: 'resources/slam-ros-bridge',
|
||||||
|
env: { ROBOT_STACK: 'nav2' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
|
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
|
||||||
const type = profileType.toLowerCase()
|
const type = profileType.toLowerCase()
|
||||||
if (type === 'local' || type === 'platform') return 'nav2'
|
if (type === 'local' || type === 'platform') return 'nav2'
|
||||||
@@ -106,11 +126,12 @@ export function useWorkspaceVisualization({
|
|||||||
|
|
||||||
const runtimeProfiles = useMemo(() => {
|
const runtimeProfiles = useMemo(() => {
|
||||||
const candidates = selectRuntimeProfiles(profiles, remoteWorkspace)
|
const candidates = selectRuntimeProfiles(profiles, remoteWorkspace)
|
||||||
if (fallbackStack === 'autoware') return candidates
|
if (fallbackStack === 'autoware') return candidates.length ? candidates : [DEFAULT_NAV2_VISUALIZATION_PROFILE]
|
||||||
|
|
||||||
// A NAV2 curriculum can retain legacy Autoware profiles. Do not let one
|
// A NAV2 curriculum can retain legacy Autoware profiles. Do not let one
|
||||||
// become the active viewer merely because it appears first in the list.
|
// become the active viewer merely because it appears first in the list.
|
||||||
return candidates.filter((profile) => resolveVisualizationStack(profile.profileType) !== 'autoware')
|
const nav2Profiles = candidates.filter((profile) => resolveVisualizationStack(profile.profileType) !== 'autoware')
|
||||||
|
return nav2Profiles.length ? nav2Profiles : [DEFAULT_NAV2_VISUALIZATION_PROFILE]
|
||||||
}, [fallbackStack, profiles, remoteWorkspace])
|
}, [fallbackStack, profiles, remoteWorkspace])
|
||||||
const visualizationStacks = useMemo(() => (
|
const visualizationStacks = useMemo(() => (
|
||||||
runtimeProfiles
|
runtimeProfiles
|
||||||
@@ -219,11 +240,6 @@ export function useWorkspaceVisualization({
|
|||||||
|
|
||||||
const startVisualizationBridge = async (mode = nav2VisualizationMode): Promise<void> => {
|
const startVisualizationBridge = async (mode = nav2VisualizationMode): Promise<void> => {
|
||||||
if (visualizationBusy) return
|
if (visualizationBusy) return
|
||||||
if (!activeEnvironmentProfile) {
|
|
||||||
setVisualizationStatusMessage('지원 안함')
|
|
||||||
onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (mode !== nav2VisualizationMode) setNav2VisualizationMode(mode)
|
if (mode !== nav2VisualizationMode) setNav2VisualizationMode(mode)
|
||||||
setVisualizationBusy(true)
|
setVisualizationBusy(true)
|
||||||
setVisualizationStatusMessage('시작 중')
|
setVisualizationStatusMessage('시작 중')
|
||||||
|
|||||||
Reference in New Issue
Block a user