From 7c0bdf02a2ff004148db84ba175b6776196e3c8a Mon Sep 17 00:00:00 2001 From: web-admin Date: Fri, 7 Aug 2026 10:26:26 +0900 Subject: [PATCH] patch curriculum.. --- src/lms-renderer/App.tsx | 35 ++++- .../curriculum/CourseSelectionScreen.tsx | 148 +++++++++++++----- .../features/curriculum/curriculumApi.ts | 52 +++++- .../features/workspace/WorkspaceQaPanel.tsx | 22 ++- .../workspace/WorkspaceVisualizationPanel.tsx | 2 +- .../workspace/useWorkspaceVisualization.ts | 31 +++- 6 files changed, 227 insertions(+), 63 deletions(-) diff --git a/src/lms-renderer/App.tsx b/src/lms-renderer/App.tsx index 0a3729d..5f6f958 100644 --- a/src/lms-renderer/App.tsx +++ b/src/lms-renderer/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 { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types' import { getContextHelpSteps } from './features/help/contextHelp' import AiGuideView from '../features/AiGuideView' @@ -408,8 +408,10 @@ function LocalPcWorkspaceMockup({ weekNumber, courseKey, curriculumWeek, + publicGuideCourse, activeLearningStep, allowFutureLearningSteps, + teacherQaDisabled, onLearningStepSelect, onSubmissionRecorded }: { @@ -436,8 +438,10 @@ function LocalPcWorkspaceMockup({ weekNumber: number courseKey: string curriculumWeek: CurriculumWeek + publicGuideCourse: boolean activeLearningStep: string | null allowFutureLearningSteps: boolean + teacherQaDisabled?: boolean onLearningStepSelect?: (step: string) => void onSubmissionRecorded: () => void }): React.JSX.Element { @@ -2784,7 +2788,7 @@ function LocalPcWorkspaceMockup({ controllingModuleNames={controllingModuleNames} listError={moduleListError} installError={moduleInstallError} - allowRepositoryDeletion={!remoteWorkspace} + allowRepositoryDeletion={!remoteWorkspace && !publicGuideCourse} onAddModuleTab={addModuleTabOnly} onOpenSource={openModuleSource} onInstallModule={(module) => void installModule(module)} @@ -2831,6 +2835,7 @@ function LocalPcWorkspaceMockup({ iconButtonClass={iconButtonClass} accessToken={accessToken} qaMode={qaMode} + teacherQaDisabled={teacherQaDisabled} teacherChatFlash={teacherChatFlash} qaMessagesLoading={qaMessagesLoading} visibleAiMessages={visibleAiMessages} @@ -2884,6 +2889,7 @@ export default function App(): React.JSX.Element { const [curriculumWeeks, setCurriculumWeeks] = useState([]) 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 [courseSelectionVisible, setCourseSelectionVisible] = useState(false) @@ -2968,7 +2974,8 @@ export default function App(): React.JSX.Element { } const selectedWeek = selectedWeekState ?? EMPTY_WEEK const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK - 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 @@ -3205,17 +3212,19 @@ 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)) { + if (stored && selectableCourses.some((course) => course.id === stored)) { if (options.autoSelectSingle) setCourseSelectionVisible(false) return stored } @@ -3230,6 +3239,7 @@ export default function App(): React.JSX.Element { }) } catch { setStudentCourses([]) + setTrialCourse(null) setStudentCoursesLoaded(true) setCourseSelectionVisible(true) } finally { @@ -3676,6 +3686,14 @@ export default function App(): React.JSX.Element { 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) @@ -4420,8 +4438,10 @@ export default function App(): React.JSX.Element { weekNumber={workspaceWeek.week} courseKey={courseKey} curriculumWeek={workspaceWeek} + publicGuideCourse={publicGuideCourseActive} activeLearningStep={activeLearningStep} allowFutureLearningSteps={publicGuideCourseActive} + teacherQaDisabled={publicGuideCourseActive} onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined} onSubmissionRecorded={() => undefined} /> @@ -4456,9 +4476,12 @@ export default function App(): React.JSX.Element { ) : courseSelectionVisible ? ( setCourseSelectionVisible(false)} /> ) : ( diff --git a/src/lms-renderer/features/curriculum/CourseSelectionScreen.tsx b/src/lms-renderer/features/curriculum/CourseSelectionScreen.tsx index 991029f..d3de8ea 100644 --- a/src/lms-renderer/features/curriculum/CourseSelectionScreen.tsx +++ b/src/lms-renderer/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,72 +18,131 @@ 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 ? ( - + ) : null}
-
+ +
+ + {query.trim() ? ( +
+ {searching ?
검색 중입니다.
+ : 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 ( - - ) - }) : ( -
수강 중인 강좌가 없습니다.
- )} -
+ return + }) :
수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.
} +
+ {trialCourse ?

둘러보기

: null}
) diff --git a/src/lms-renderer/features/curriculum/curriculumApi.ts b/src/lms-renderer/features/curriculum/curriculumApi.ts index 796bdea..dbc4324 100644 --- a/src/lms-renderer/features/curriculum/curriculumApi.ts +++ b/src/lms-renderer/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/lms-renderer/features/workspace/WorkspaceQaPanel.tsx b/src/lms-renderer/features/workspace/WorkspaceQaPanel.tsx index ae023df..745718e 100644 --- a/src/lms-renderer/features/workspace/WorkspaceQaPanel.tsx +++ b/src/lms-renderer/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 (