From c46c8df47540f0d58dabc212f62af7ce75f78d1b Mon Sep 17 00:00:00 2001 From: chyoo Date: Fri, 7 Aug 2026 10:26:57 +0900 Subject: [PATCH] patch curriculum.. --- package.json | 2 +- src/main/index.ts | 20 +- src/renderer/src/App.tsx | 69 ++++- src/renderer/src/components/TitleBar.tsx | 39 ++- .../curriculum/CourseSelectionScreen.tsx | 244 +++++++++++++++--- .../src/features/curriculum/curriculumApi.ts | 52 +++- .../features/workspace/WorkspaceQaPanel.tsx | 22 +- .../workspace/WorkspaceVisualizationPanel.tsx | 3 +- .../workspace/useWorkspaceVisualization.ts | 30 ++- 9 files changed, 408 insertions(+), 73 deletions(-) diff --git a/package.json b/package.json index 28ced90..28b88f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.1.6", + "version": "1.1.7", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/src/main/index.ts b/src/main/index.ts index 1f17123..ee67962 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1148,12 +1148,19 @@ async function ensureArtifactFile(url?: string | null, fileName?: string | null, if (!artifactUrl) return null const targetPath = artifactCachePath(artifactUrl, fileName) + const expected = checksumSha256?.trim().toLowerCase() if (!existsSync(targetPath)) await downloadArtifact(artifactUrl, targetPath) - if (checksumSha256?.trim()) { - const actual = createHash('sha256').update(await readFile(targetPath)).digest('hex') - if (actual.toLowerCase() !== checksumSha256.trim().toLowerCase()) { - await rm(targetPath, { force: true }).catch(() => undefined) - throw new Error(`artifact checksum mismatch: ${safeArtifactFileName(artifactUrl, fileName)}`) + if (expected) { + let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').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) + throw new Error(`artifact checksum mismatch: ${safeArtifactFileName(artifactUrl, fileName)}`) + } } } return targetPath @@ -3434,6 +3441,7 @@ function buildNav2ModuleEnvScript(): string { ' "[lms-nav2-gazebo-perception]nav2-perception_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-gui]nav2-rviz2_v1.0.0_ws"', ')', '', '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-control*) PACKAGE=control_pkg; LAUNCH=control.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-perception*) PACKAGE=perception_pkg; LAUNCH=perception.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-control*) TARGET=control_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-perception*) TARGET=perception_pkg ;;', ' *nav2-simulator*) TARGET=nav2_turtlebot_simulator ;;', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 288ee14..d232cd4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -16,7 +16,7 @@ import CourseSelectionScreen from './features/curriculum/CourseSelectionScreen' 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 { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } 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' @@ -449,8 +449,10 @@ function LocalPcWorkspaceMockup({ courseKey, curriculumWeek, teamGroupId, + publicGuideCourse, activeLearningStep, allowFutureLearningSteps, + teacherQaDisabled, onLearningStepSelect, onSubmissionRecorded, onVisualizationHostChange @@ -495,8 +497,10 @@ function LocalPcWorkspaceMockup({ courseKey: string curriculumWeek: CurriculumWeek teamGroupId?: string | null + publicGuideCourse: boolean activeLearningStep: string | null allowFutureLearningSteps: boolean + teacherQaDisabled?: boolean onLearningStepSelect?: (step: string) => void onSubmissionRecorded: () => void onVisualizationHostChange?: (host: WorkspaceVisualizationHost) => void @@ -3708,7 +3712,7 @@ function LocalPcWorkspaceMockup({ }} selectedMap={selectedWorldMap} showSelectedMapPreview={Boolean(curriculumWeek.maps?.some((map) => map.platformKey === 'NAV2'))} - allowRepositoryDeletion={!remoteWorkspace} + allowRepositoryDeletion={!remoteWorkspace && !publicGuideCourse} allowCustomModuleCreation={!remoteWorkspace} onAddModuleTab={addModuleTabOnly} onOpenSource={openModuleSource} @@ -3801,6 +3805,7 @@ function LocalPcWorkspaceMockup({ iconButtonClass={iconButtonClass} accessToken={accessToken} qaMode={qaMode} + teacherQaDisabled={teacherQaDisabled} teacherChatFlash={teacherChatFlash} qaMessagesLoading={qaMessagesLoading} visibleAiMessages={visibleAiMessages} @@ -3856,8 +3861,9 @@ export default function App(): React.JSX.Element { const [studentGroup, setStudentGroup] = useState<{ id: string; name: string } | null>(null) const [courseKey, setCourseKey] = useState('default-course') const [studentCourses, setStudentCourses] = useState([]) + const [trialCourse, setTrialCourse] = useState(null) 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 [outerView, setOuterView] = useState<'curriculum' | 'guide'>('curriculum') const [selectedWeekState, setSelectedWeekState] = useState(null) @@ -3904,6 +3910,7 @@ export default function App(): React.JSX.Element { const [downloadResources, setDownloadResources] = useState([]) const [authSession, setAuthSession] = useState(null) const authSessionRef = useRef(null) + const selectedCourseUserRef = useRef(null) const [authReady, setAuthReady] = useState(false) const [sessionExpired, setSessionExpired] = useState(false) const [studentCoursesLoading, setStudentCoursesLoading] = useState(false) @@ -3957,6 +3964,9 @@ export default function App(): React.JSX.Element { const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState(null) const [titlebarNotices, setTitlebarNotices] = useState>([]) const instructorAccount = isInstructorAccount(authSession?.user) + const selectedCourseStorageKey = authSession?.user.id + ? `${SELECTED_COURSE_STORAGE_KEY}.${authSession.user.id}` + : null const openContextHelp = (): void => { const steps = getContextHelpSteps({ @@ -3989,7 +3999,8 @@ export default function App(): React.JSX.Element { const selectedWeek = selectedWeekState ?? EMPTY_WEEK const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK 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 studioDisabled = !selectedWeekState const connectedEnvironmentProfile = connectedPlatform @@ -4037,6 +4048,22 @@ export default function App(): React.JSX.Element { useEffect(() => { authSessionRef.current = 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(() => { connectedPlatformRef.current = connectedPlatform }, [connectedPlatform]) @@ -4234,23 +4261,27 @@ export default function App(): React.JSX.Element { setStudentCoursesLoading(true) try { - const courses = await fetchStudentCourses(fetchWithFreshAuth) + const { courses, guideCourse } = await fetchStudentCourses(fetchWithFreshAuth) + const selectableCourses = guideCourse ? [...courses, guideCourse] : courses setStudentCourses(courses) + setTrialCourse(guideCourse) setStudentCoursesLoaded(true) setSelectedCourseId((current) => { - if (current && courses.some((course) => course.id === current)) { + if (current && selectableCourses.some((course) => course.id === current)) { if (options.autoSelectSingle) setCourseSelectionVisible(false) return current } - const stored = window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) - if (stored && courses.some((course) => course.id === stored)) { + const stored = selectedCourseStorageKey + ? window.localStorage.getItem(selectedCourseStorageKey) + : null + if (stored && selectableCourses.some((course) => course.id === stored)) { if (options.autoSelectSingle) setCourseSelectionVisible(false) return stored } - window.localStorage.removeItem(SELECTED_COURSE_STORAGE_KEY) + if (selectedCourseStorageKey) window.localStorage.removeItem(selectedCourseStorageKey) 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) return courses[0].id } @@ -4259,12 +4290,13 @@ export default function App(): React.JSX.Element { }) } catch { setStudentCourses([]) + setTrialCourse(null) setStudentCoursesLoaded(true) setCourseSelectionVisible(true) } finally { setStudentCoursesLoading(false) } - }, [authReady, authSession?.accessToken, fetchWithFreshAuth]) + }, [authReady, authSession?.accessToken, fetchWithFreshAuth, selectedCourseStorageKey]) useEffect(() => { void loadStudentCourses({ autoSelectSingle: true }) @@ -4705,12 +4737,20 @@ export default function App(): React.JSX.Element { setCourseChangeBlockedOpen(true) return } - window.localStorage.setItem(SELECTED_COURSE_STORAGE_KEY, courseId) + if (selectedCourseStorageKey) window.localStorage.setItem(selectedCourseStorageKey, courseId) setSelectedCourseId(courseId) setCourseSelectionVisible(false) setLocalPcLayoutOpen(false) setCurriculumRefreshSignal((current) => current + 1) } + const searchStudentCourseCatalog = useCallback( + (query: string) => searchCourseCatalog(fetchWithFreshAuth, query), + [fetchWithFreshAuth], + ) + const enrollStudentInCourse = useCallback(async (courseId: string): Promise => { + await enrollInCourse(fetchWithFreshAuth, courseId) + await loadStudentCourses({ autoSelectSingle: false }) + }, [fetchWithFreshAuth, loadStudentCourses]) const closeLocalPcWorkspace = (): void => { setLocalPcLayoutOpen(false) if (!userSidebarCollapsed) setSidebarCollapsed(false) @@ -5591,8 +5631,10 @@ export default function App(): React.JSX.Element { courseKey={courseKey} curriculumWeek={workspaceWeek} teamGroupId={teamWorkspaceGroupId} + publicGuideCourse={publicGuideCourseActive} activeLearningStep={activeLearningStep} allowFutureLearningSteps={publicGuideCourseActive} + teacherQaDisabled={publicGuideCourseActive} onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined} onSubmissionRecorded={() => undefined} onVisualizationHostChange={handleWorkspaceVisualizationHostChange} @@ -5628,9 +5670,12 @@ export default function App(): React.JSX.Element { ) : courseSelectionVisible ? ( setCourseSelectionVisible(false)} /> ) : ( diff --git a/src/renderer/src/components/TitleBar.tsx b/src/renderer/src/components/TitleBar.tsx index f70fc21..7e584b5 100644 --- a/src/renderer/src/components/TitleBar.tsx +++ b/src/renderer/src/components/TitleBar.tsx @@ -88,6 +88,38 @@ const LMS_AUTH_SESSION_STORAGE_KEY = 'autonomy-studio.lms.auth-session' const NOTIFICATION_DROPDOWN_LIMIT = 10 const NOTIFICATION_MODAL_PAGE_SIZE = 8 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 => ({ ...notification, @@ -128,6 +160,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo ...notifications ] }, [localNotifications, notifications]) + const appLogDisplayContent = useMemo(() => formatAppLogContentForDisplay(appLogContent), [appLogContent]) const unreadCount = sortedNotifications.filter((notification) => !notification.read).length const hasUnreadNotifications = unreadCount > 0 const dropdownNotifications = sortedNotifications.slice(0, NOTIFICATION_DROPDOWN_LIMIT) @@ -174,13 +207,13 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo }, []) const handleCopyAppLogs = useCallback((): void => { if (!appLogContent) return - void navigator.clipboard.writeText(appLogContent) + void navigator.clipboard.writeText(appLogDisplayContent) .then(() => { setAppLogsCopied(true) window.setTimeout(() => setAppLogsCopied(false), 1600) }) .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 title = notice.title.trim() if (!title) return @@ -970,7 +1003,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
로그를 불러오는 중입니다.
) : appLogContent ? (
-                  {appLogContent}
+                  {appLogDisplayContent}
                 
) : (
diff --git a/src/renderer/src/features/curriculum/CourseSelectionScreen.tsx b/src/renderer/src/features/curriculum/CourseSelectionScreen.tsx index 991029f..b272669 100644 --- a/src/renderer/src/features/curriculum/CourseSelectionScreen.tsx +++ b/src/renderer/src/features/curriculum/CourseSelectionScreen.tsx @@ -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 { CourseCatalogOption } from './curriculumApi' + type CourseOption = { id: string title: string @@ -15,26 +18,95 @@ type CourseOption = { type CourseSelectionScreenProps = { courses: CourseOption[] + trialCourse?: CourseOption | null selectedCourseId: string | null hasSelectedCourse: boolean onSelectCourse: (courseId: string) => void + onSearchCourses: (query: string) => Promise + onEnrollCourse: (courseId: string) => Promise onBack: () => void } +function teacherLabel(course: Pick): string { + return `${course.teacher?.name ?? course.teacher?.email ?? '강사 미지정'} / ${course.teacher?.primaryOrganizationName ?? course.organizationName ?? '소속 미지정'}` +} + export default function CourseSelectionScreen({ courses, + trialCourse, selectedCourseId, hasSelectedCourse, onSelectCourse, - onBack + onSearchCourses, + onEnrollCourse, + onBack, }: CourseSelectionScreenProps): React.JSX.Element { + const [query, setQuery] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [searching, setSearching] = useState(false) + const [searchError, setSearchError] = useState('') + const [selectedCatalogCourseId, setSelectedCatalogCourseId] = useState(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 => { + 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 (

강좌 선택

-

학습을 진행할 수강중인 강좌를 선택해주세요.

+

수강 중인 강좌를 선택하거나 새 강좌를 검색해 수강 신청하세요.

{hasSelectedCourse ? (
-
- {courses.length ? courses.map((course) => { - const selected = course.id === selectedCourseId - return ( - - ) - }) : ( -
수강 중인 강좌가 없습니다.
- )} + ) : searchResults.length ? ( +
+ {searchResults.map((course) => { + const selected = course.id === selectedCatalogCourseId + return ( + + ) + })} +
+ ) : ( +
검색 결과가 없습니다.
+ )} + {searchError ?

{searchError}

: null} + {selectedCatalogCourse ? ( +
+ +
+ ) : null} +
+ ) : null}
+ +
+

수강 중인 강좌

+
+ {courses.length ? courses.map((course) => { + const selected = course.id === selectedCourseId + return ( + + ) + }) : ( +
수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.
+ )} +
+
+ + {trialCourse ? ( +
+

둘러보기

+ +
+ ) : null}
) diff --git a/src/renderer/src/features/curriculum/curriculumApi.ts b/src/renderer/src/features/curriculum/curriculumApi.ts index 796bdea..dbc4324 100644 --- a/src/renderer/src/features/curriculum/curriculumApi.ts +++ b/src/renderer/src/features/curriculum/curriculumApi.ts @@ -3,10 +3,56 @@ import type { CurriculumResponse, DownloadResource, StudentCourseOption } from ' type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise -export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise { +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 { const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`) - const payload = response.ok ? await response.json() as { courses?: StudentCourseOption[] } : null - return payload?.courses ?? [] + const payload = response.ok ? await response.json() as Partial : null + return { + courses: payload?.courses ?? [], + guideCourse: payload?.guideCourse ?? null, + } +} + +async function responseMessage(response: Response, fallback: string): Promise { + 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 { + 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 { + 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 { diff --git a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx index 49928f4..1eb473e 100644 --- a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx @@ -25,6 +25,7 @@ type WorkspaceQaPanelProps = { iconButtonClass: string accessToken: string qaMode: QaMode + teacherQaDisabled?: boolean teacherChatFlash: boolean qaMessagesLoading: boolean visibleAiMessages: WorkspaceQaMessage[] @@ -53,6 +54,7 @@ export default function WorkspaceQaPanel({ iconButtonClass, accessToken, qaMode, + teacherQaDisabled = false, teacherChatFlash, qaMessagesLoading, visibleAiMessages, @@ -73,6 +75,10 @@ export default function WorkspaceQaPanel({ const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages const lastMessage = activeMessages[activeMessages.length - 1] + useEffect(() => { + if (teacherQaDisabled && qaMode === 'teacher') onModeChange('ai') + }, [onModeChange, qaMode, teacherQaDisabled]) + useEffect(() => { if (!contextMenu) return const closeContextMenu = (): void => setContextMenu(null) @@ -161,16 +167,24 @@ export default function WorkspaceQaPanel({ { id: 'ai' as const, label: 'AI', Icon: Bot }, { id: 'teacher' as const, label: '강사', Icon: User } ].map(({ id, label, Icon }) => { + const disabled = id === 'teacher' && teacherQaDisabled const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai' return (