patch curriculum..
This commit is contained in:
@@ -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<CurriculumWeek[]>([])
|
||||
const [courseKey, setCourseKey] = useState('default-course')
|
||||
const [studentCourses, setStudentCourses] = useState<StudentCourseOption[]>([])
|
||||
const [trialCourse, setTrialCourse] = useState<StudentCourseOption | null>(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<void> => {
|
||||
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 ? (
|
||||
<CourseSelectionScreen
|
||||
courses={studentCourses}
|
||||
trialCourse={trialCourse}
|
||||
selectedCourseId={selectedCourseId}
|
||||
hasSelectedCourse={Boolean(selectedCourse)}
|
||||
onSelectCourse={selectStudentCourse}
|
||||
onSearchCourses={searchStudentCourseCatalog}
|
||||
onEnrollCourse={enrollStudentInCourse}
|
||||
onBack={() => setCourseSelectionVisible(false)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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<CourseCatalogOption[]>
|
||||
onEnrollCourse: (courseId: string) => Promise<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({
|
||||
courses,
|
||||
trialCourse,
|
||||
selectedCourseId,
|
||||
hasSelectedCourse,
|
||||
onSelectCourse,
|
||||
onBack
|
||||
onSearchCourses,
|
||||
onEnrollCourse,
|
||||
onBack,
|
||||
}: 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 (
|
||||
<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="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
{hasSelectedCourse ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="shrink-0 cursor-pointer border-0 bg-transparent px-0 py-1 text-[13px] font-black text-[#6f4df6] transition hover:text-[#4f35c8]"
|
||||
>
|
||||
뒤로가기
|
||||
</button>
|
||||
<button type="button" onClick={onBack} className="shrink-0 cursor-pointer border-0 bg-transparent px-0 py-1 text-[13px] font-black text-[#6f4df6] transition hover:text-[#4f35c8]">뒤로가기</button>
|
||||
) : null}
|
||||
</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) => {
|
||||
const selected = course.id === selectedCourseId
|
||||
return (
|
||||
<button
|
||||
key={course.id}
|
||||
type="button"
|
||||
className={[
|
||||
'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
|
||||
? '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)]'
|
||||
].join(' ')}
|
||||
onClick={() => onSelectCourse(course.id)}
|
||||
>
|
||||
<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">{course.title}</span>
|
||||
{selected ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}
|
||||
</h2>
|
||||
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
|
||||
{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>
|
||||
</button>
|
||||
)
|
||||
}) : (
|
||||
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-12 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
return <button key={course.id} type="button" className={['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 ? '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)]'].join(' ')} onClick={() => onSelectCourse(course.id)}>
|
||||
<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">{course.title}</span>{selected ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}</h2><p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{teacherLabel(course)}</p></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>
|
||||
</button>
|
||||
}) : <div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-10 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.</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>
|
||||
)
|
||||
|
||||
@@ -3,10 +3,56 @@ import type { CurriculumResponse, DownloadResource, StudentCourseOption } from '
|
||||
|
||||
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 payload = response.ok ? await response.json() as { courses?: StudentCourseOption[] } : null
|
||||
return payload?.courses ?? []
|
||||
const payload = response.ok ? await response.json() as Partial<StudentCourseList> : null
|
||||
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> {
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onModeChange(id)}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled) onModeChange(id)
|
||||
}}
|
||||
className={[
|
||||
'relative flex h-8 cursor-pointer items-center justify-center gap-1.5 text-[12px] font-bold transition',
|
||||
teacherTabFlash
|
||||
'relative flex h-8 items-center justify-center gap-1.5 text-[12px] font-bold transition',
|
||||
disabled
|
||||
? dark
|
||||
? 'cursor-not-allowed bg-[#202020] text-white/25'
|
||||
: 'cursor-not-allowed bg-[#f3f5f8] text-[#aab3c1]'
|
||||
: teacherTabFlash
|
||||
? dark
|
||||
? '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)]'
|
||||
@@ -234,7 +248,7 @@ export default function WorkspaceQaPanel({
|
||||
<QaInputBox
|
||||
dark={dark}
|
||||
busy={inputBusy}
|
||||
disabled={inputDisabled}
|
||||
disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')}
|
||||
onStop={onStop}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function WorkspaceVisualizationPanel({
|
||||
{!running && (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-black/34">
|
||||
<div className="rounded-lg border border-white/10 bg-[#101624]/88 px-5 py-4 text-center shadow-[0_18px_48px_rgba(0,0,0,0.35)]">
|
||||
<p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '해당 주차는 시각화를 지원하지 않습니다'}</p>
|
||||
<p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '시각화 준비 중입니다.'}</p>
|
||||
<p className="mt-2 text-[11px] font-semibold text-white/50">{stacks.length ? '지도와 차량 상태가 표시됩니다.' : '강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,26 @@ import type { VisualizationStack } from './WorkspaceVisualizationPanel'
|
||||
|
||||
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 {
|
||||
const type = profileType.toLowerCase()
|
||||
if (type === 'real_slam') return 'slam'
|
||||
@@ -77,7 +97,11 @@ export function useWorkspaceVisualization({
|
||||
const [, setVisualizationStatusMessage] = useState('대기')
|
||||
const [visualizationBusy, setVisualizationBusy] = useState(false)
|
||||
|
||||
const runtimeProfiles = useMemo(() => selectRuntimeProfiles(profiles, remoteWorkspace), [profiles, remoteWorkspace])
|
||||
const runtimeProfiles = useMemo(() => {
|
||||
const candidates = selectRuntimeProfiles(profiles, remoteWorkspace)
|
||||
const nav2Profiles = candidates.filter((profile) => resolveVisualizationStack(profile.profileType) !== 'autoware')
|
||||
return nav2Profiles.length ? nav2Profiles : [DEFAULT_NAV2_VISUALIZATION_PROFILE]
|
||||
}, [profiles, remoteWorkspace])
|
||||
const visualizationStacks = useMemo(() => (
|
||||
runtimeProfiles
|
||||
.map((profile) => resolveVisualizationStack(profile.profileType))
|
||||
@@ -141,11 +165,6 @@ export function useWorkspaceVisualization({
|
||||
|
||||
const startVisualizationBridge = async (): Promise<void> => {
|
||||
if (visualizationBusy) return
|
||||
if (!activeEnvironmentProfile) {
|
||||
setVisualizationStatusMessage('지원 안함')
|
||||
onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning')
|
||||
return
|
||||
}
|
||||
setVisualizationBusy(true)
|
||||
setVisualizationStatusMessage('시작 중')
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user