diff --git a/scripts/test-lecture-viewer.cjs b/scripts/test-lecture-viewer.cjs new file mode 100644 index 0000000..81d8ba9 --- /dev/null +++ b/scripts/test-lecture-viewer.cjs @@ -0,0 +1,33 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const ts = require('typescript'); +const React = require('react'); +const { renderToStaticMarkup } = require('react-dom/server'); + +test('course detail opens a pinned lecture without a platform connection', () => { + const source = fs.readFileSync(require.resolve('../src/renderer/src/features/curriculum/CurriculumDetailPanel.tsx'), 'utf8'); + const exports = {}; + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText; + vm.runInNewContext(output, { exports, require: name => name === '../../i18n' ? { t: value => value, useLocaleRender() {} } : require(name) }); + const props = { week: { week: 1, title: '제어', topics: ['속도'], detail: { overview: '개요', topicDetails: ['설명'], deliverable: '확인', learningSteps: [{ lectureVersionId: 'pinned-v1' }] } }, selected: true, activeLearningStep: '속도' }; + const html = renderToStaticMarkup(React.createElement(exports.default, { ...props, onOpenLecture() {} })); + assert.match(html, /슬라이드·퀴즈 열기/); + assert.doesNotMatch(renderToStaticMarkup(React.createElement(exports.default, props)), /슬라이드·퀴즈 열기/); +}); + +test('quiz sends answers only and maintains a stable request ID for retry', () => { + const source = fs.readFileSync(require.resolve('../src/renderer/src/features/curriculum/LectureViewerModal.tsx'), 'utf8'); + assert.match(source, /\/quiz/); + assert.match(source, /questionId: q.id/); + assert.match(source, /optionIndex: answers\[q.id\]/); + assert.match(source, /requestId.current \?\?= crypto.randomUUID/); + assert.match(source, /signal: controller.current.signal/); + assert.doesNotMatch(source, /score:.*answers/); +}); + +test('lecture controls have English translations', () => { + const en = require('../src/renderer/src/i18n/en.json'); + for (const key of ['슬라이드·퀴즈 열기', '강의 닫기', '슬라이드', '퀴즈', '답안 제출', '다시 풀기']) assert.ok(en[key], key); +}); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9d5058d..6cc607a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -22,6 +22,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 LectureViewerModal from './features/curriculum/LectureViewerModal' import { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } from './features/curriculum/curriculumApi' import type { CustomModuleImportInput, CustomModuleInput } from './features/workspace/ModuleListView' import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types' @@ -582,6 +583,7 @@ function LocalPcWorkspaceMockup({ }) const [resourceMode, setResourceMode] = useState<'material' | 'reference'>('material') const [expandedMaterialIndexes, setExpandedMaterialIndexes] = useState([]) + const [openLectureVersionId, setOpenLectureVersionId] = useState(null) const [downloadingResourceIds, setDownloadingResourceIds] = useState([]) const [activeIdeActivity, setActiveIdeActivity] = useState('modules') const exampleActive = activeIdeActivity === 'examples' @@ -837,6 +839,7 @@ function LocalPcWorkspaceMockup({ description: step.description, videoUrl: step.videoEnabled === false ? '' : (step.videoUrl ?? ''), concepts: step.concepts?.length ? step.concepts : [curriculumWeek.topics[index]].filter(Boolean), + lectureVersionId: 'lectureVersionId' in step ? step.lectureVersionId : null, })) const activeTopicIndex = curriculumWeek.topics.findIndex((topic) => topic === activeLearningStep) const activeMaterialIndex = Math.min( @@ -4242,9 +4245,11 @@ function LocalPcWorkspaceMockup({ onModeChange={setResourceMode} onMinimize={() => setLeftPanelMinimized(true)} onToggleMaterial={toggleMaterialItem} + onOpenLecture={setOpenLectureVersionId} onDownloadResource={(resource) => void handleDownloadResource(resource)} /> + {openLectureVersionId && setOpenLectureVersionId(null)} />}
(null) const [courseChangeBlockedOpen, setCourseChangeBlockedOpen] = useState(false) const [weekSwitchBlockedOpen, setWeekSwitchBlockedOpen] = useState(false) const [completedCurriculumNotice, setCompletedCurriculumNotice] = useState(null) @@ -6784,6 +6790,7 @@ export default function App(): React.JSX.Element { onOpenMaterials={() => { if (hasSelectedWeekMaterials) setMaterialModalOpen(true) }} + onOpenLecture={authSession?.accessToken && !publicGuideCourseActive ? versionId => setCourseLecture({ versionId, courseId: courseKey, week: selectedWeek.week }) : undefined} /> )} @@ -6794,6 +6801,7 @@ export default function App(): React.JSX.Element { /> )} + {courseLecture && courseLecture.courseId === courseKey && authSession?.accessToken && setCourseLecture(null)} />} {materialModalOpen && ( practiceContents?: Array<{ title: string description: string @@ -25,6 +26,7 @@ type CurriculumDetailPanelProps = { activeLearningStep: string | null allowLearningStepSelection?: boolean onSelectLearningStep?: (step: string) => void + onOpenLecture?: (versionId: string) => void } export default function CurriculumDetailPanel({ @@ -32,7 +34,8 @@ export default function CurriculumDetailPanel({ selected, activeLearningStep, allowLearningStepSelection = false, - onSelectLearningStep + onSelectLearningStep, + onOpenLecture }: CurriculumDetailPanelProps): React.JSX.Element { useLocaleRender() const [failedImageUrl, setFailedImageUrl] = useState(null) @@ -94,6 +97,7 @@ export default function CurriculumDetailPanel({

{week.detail.topicDetails[index]}

+ {week.detail.learningSteps?.[index]?.lectureVersionId && onOpenLecture && } ) })} diff --git a/src/renderer/src/features/curriculum/CurriculumHomeScreen.tsx b/src/renderer/src/features/curriculum/CurriculumHomeScreen.tsx index b749862..8baa015 100644 --- a/src/renderer/src/features/curriculum/CurriculumHomeScreen.tsx +++ b/src/renderer/src/features/curriculum/CurriculumHomeScreen.tsx @@ -16,6 +16,7 @@ type CurriculumHomeWeek = { detail: { overview: string topicDetails: string[] + learningSteps?: Array<{ lectureVersionId?: string | null }> practiceContents?: Array<{ title: string description: string @@ -40,6 +41,7 @@ type CurriculumHomeScreenProps = { onSelectLearningStep?: (step: string) => void onStartStudio: () => void onOpenMaterials: () => void + onOpenLecture?: (versionId: string) => void } export default function CurriculumHomeScreen({ @@ -55,7 +57,8 @@ export default function CurriculumHomeScreen({ onSelectWeek, onSelectLearningStep, onStartStudio, - onOpenMaterials + onOpenMaterials, + onOpenLecture }: CurriculumHomeScreenProps): React.JSX.Element { useLocaleRender() return ( @@ -97,6 +100,7 @@ export default function CurriculumHomeScreen({ activeLearningStep={activeLearningStep} allowLearningStepSelection={publicGuideCourse} onSelectLearningStep={onSelectLearningStep} + onOpenLecture={selectedWeek.state === 'locked' ? undefined : onOpenLecture} />
diff --git a/src/renderer/src/features/curriculum/LectureViewerModal.tsx b/src/renderer/src/features/curriculum/LectureViewerModal.tsx new file mode 100644 index 0000000..edffda7 --- /dev/null +++ b/src/renderer/src/features/curriculum/LectureViewerModal.tsx @@ -0,0 +1,416 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + BookOpen, + ChevronLeft, + ChevronRight, + ClipboardCheck, + Loader2, + RotateCcw, + Send, + X, +} from "lucide-react"; +import { t, useLocaleRender } from "../../i18n"; +import { LMS_API_BASE_URL } from "../../config/appConfig"; + +type Slide = { + id: string; + type: string; + title: string; + body: string; + bullets: string[]; + code?: string; + language?: string; +}; +type Question = { id: string; question: string; options: string[] }; +type Lecture = { + id: string; + version: number; + title: string; + objectives: string[]; + slides: Slide[]; + quiz: Question[]; + progress: { lastSlide: number; completedAt: string | null } | null; +}; +type Grade = { + attemptId: string; + score: number; + total: number; + results: Array<{ + questionId: string; + selectedIndex: number; + correctIndex: number; + correct: boolean; + explanation: string; + }>; +}; + +export default function LectureViewerModal({ + versionId, + courseId, + week, + dark, + request, + onClose, +}: { + versionId: string; + courseId: string; + week: number; + dark: boolean; + request: (input: RequestInfo | URL, init?: RequestInit) => Promise; + onClose: () => void; +}) { + useLocaleRender(); + const [lecture, setLecture] = useState(null); + const [slideIndex, setSlideIndex] = useState(0); + const [tab, setTab] = useState<"slides" | "quiz">("slides"); + const [answers, setAnswers] = useState>({}); + const [grade, setGrade] = useState(null); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const requestId = useRef(null); + const controller = useRef(new AbortController()); + const closeRef = useRef(onClose); + closeRef.current = onClose; + const api = useCallback( + async (suffix: string, method = "GET", body?: unknown): Promise => { + const query = + method === "GET" + ? `?${new URLSearchParams({ courseId, week: String(week) })}` + : ""; + const response = await request( + `${LMS_API_BASE_URL}/lectures/student/${encodeURIComponent(versionId)}${suffix}${query}`, + { + method, + signal: controller.current.signal, + ...(body + ? { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ courseId, week, ...(body as object) }), + } + : {}), + }, + ); + if (!response.ok) { + const data = (await response.json().catch(() => null)) as { + message?: string; + } | null; + throw new Error(data?.message ?? "강의를 불러오지 못했습니다."); + } + return response.json() as Promise; + }, + [request, courseId, week, versionId], + ); + useEffect(() => { + const abort = new AbortController(); + controller.current = abort; + const previousFocus = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + api("") + .then((data) => { + if (!abort.signal.aborted) { + setLecture(data); + setSlideIndex( + Math.min( + Math.max(0, data.progress?.lastSlide ?? 0), + data.slides.length - 1, + ), + ); + } + }) + .catch((e) => { + if (!abort.signal.aborted) + setError( + e instanceof Error ? e.message : "강의 조회에 실패했습니다.", + ); + }); + const keydown = (event: KeyboardEvent) => { + if (event.key === 'Tab') { + const dialog = document.getElementById('lecture-viewer-title')?.closest('[role="dialog"]'); + const nodes = Array.from(dialog?.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]') ?? []).filter(e => e.getClientRects().length); + const first = nodes[0], last = nodes.at(-1); + if (first && last && ((event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last))) { event.preventDefault(); (event.shiftKey ? last : first).focus(); } + } + if (event.key === "Escape") { + event.stopPropagation(); + closeRef.current(); + } + }; + window.addEventListener("keydown", keydown, true); + document.getElementById("lecture-viewer-close")?.focus(); + return () => { + abort.abort(); + window.removeEventListener("keydown", keydown, true); + previousFocus?.focus(); + }; + }, [api]); + useEffect(() => { + if (!lecture) return; + const timer = window.setTimeout(() => { + void api("/progress", "PUT", { slideIndex }).catch((e) => { + if (!controller.current.signal.aborted) + setError( + e instanceof Error ? e.message : "진도를 저장하지 못했습니다.", + ); + }); + }, 700); + return () => window.clearTimeout(timer); + }, [slideIndex, lecture?.id, api]); + const submit = async () => { + if (!lecture || submitting) return; + setSubmitting(true); + setError(null); + requestId.current ??= crypto.randomUUID(); + try { + const result = await api("/quiz", "POST", { + requestId: requestId.current, + answers: lecture.quiz.map((q) => ({ + questionId: q.id, + optionIndex: answers[q.id], + })), + }); + if (!controller.current.signal.aborted) setGrade(result); + } catch (e) { + if (!controller.current.signal.aborted) + setError( + e instanceof Error ? e.message : "퀴즈를 제출하지 못했습니다.", + ); + } finally { + if (!controller.current.signal.aborted) setSubmitting(false); + } + }; + const slide = lecture?.slides[slideIndex]; + const muted = dark ? "text-white/65" : "text-[#475569]"; + const border = dark ? "border-white/15" : "border-[#cbd5e1]"; + const button = `inline-flex min-h-9 items-center justify-center gap-2 rounded-md border px-3 py-2 text-xs font-bold disabled:opacity-40 ${border}`; + return createPortal( +
+
+
+
+

+ {lecture?.title ?? t("강의")} +

+ {lecture && ( +

+ v{lecture.version} + {lecture.progress?.completedAt + ? ` · ${t("퀴즈 제출 완료")}` + : ""} +

+ )} +
+ +
+ {lecture && ( + + )} +
+ {error && ( +

+ {error} +

+ )} + {!lecture && !error && ( +

+ + {t("불러오는 중")} +

+ )} + {lecture && tab === "slides" && slide && ( +
+
+ {slideIndex + 1} / {lecture.slides.length} +
+

+ {slide.title} +

+

+ {slide.body} +

+
    + {slide.bullets.map((b, i) => ( +
  • + {b} +
  • + ))} +
+ {slide.code && ( +
+                  {slide.code}
+                
+ )} +
+ )} + {lecture && tab === "quiz" && ( +
+ {grade && ( +

+ {grade.score} / {grade.total} +

+ )} + {lecture.quiz.map((q, index) => { + const result = grade?.results.find( + (r) => r.questionId === q.id, + ); + return ( +
+ + {index + 1}. {q.question} + +
+ {q.options.map((option, i) => ( + + ))} +
+ {result && ( +

+ {result.correct ? t("정답") : t("오답")} ·{" "} + {result.explanation} +

+ )} +
+ ); + })} +
+ {grade ? ( + + ) : ( + + )} +
+
+ )} +
+ {lecture && tab === "slides" && ( +
+ + + {slideIndex + 1} / {lecture.slides.length} + + +
+ )} +
+
, + document.body, + ); +} diff --git a/src/renderer/src/features/curriculum/types.ts b/src/renderer/src/features/curriculum/types.ts index 9d31dba..206b7d8 100644 --- a/src/renderer/src/features/curriculum/types.ts +++ b/src/renderer/src/features/curriculum/types.ts @@ -24,6 +24,7 @@ export type DownloadResource = { export type LearningStat = LearningStatItem export type LearningStep = { + lectureVersionId?: string | null title: string description: string videoEnabled?: boolean diff --git a/src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx b/src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx index 931f75c..95d70ab 100644 --- a/src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx @@ -6,6 +6,7 @@ import { useId } from 'react' export type WorkspaceGuideResourceMode = 'material' | 'reference' export type WorkspaceGuideMaterialItem = { + lectureVersionId?: string | null title: string description: string videoUrl: string @@ -43,6 +44,7 @@ type WorkspaceGuidePanelProps = { onMinimize: () => void onToggleMaterial: (index: number) => void onDownloadResource: (resource: Resource) => void + onOpenLecture?: (versionId: string) => void } function getYoutubeEmbedUrl(url: string | undefined): string | null { @@ -95,7 +97,8 @@ export default function WorkspaceGuidePanel): React.JSX.Element { useLocaleRender() const panelId = useId() @@ -225,6 +228,7 @@ export default function WorkspaceGuidePanel )}
+ {item.lectureVersionId && onOpenLecture && } {t("학습 내용")}

{item.description || topicDetails[index] || overview} diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 8dc5a0e..52c630c 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -1,4 +1,15 @@ { + "슬라이드·퀴즈 열기": "Open Slides and Quiz", + "퀴즈 제출 완료": "Quiz Submitted", + "강의 닫기": "Close Lecture", + "강의 구성": "Lecture Content", + "슬라이드": "Slides", + "퀴즈": "Quiz", + "정답": "Correct", + "오답": "Incorrect", + "다시 풀기": "Try Again", + "답안 제출": "Submit Answers", + "퀴즈 풀기": "Take Quiz", "지연시간 측정 중": "Measuring latency", "Ping 응답 없음": "No ping response", "원격 플랫폼 왕복 지연시간": "Remote platform round-trip latency", @@ -763,5 +774,6 @@ "코드를 복사하지 못했습니다.": "Could not copy the code.", "일반 텍스트": "Plain text", "코드": "Code", - "표": "Table" + "표": "Table", + "강의": "Lecture" }