feat: add student lecture slides and server-graded quizzes
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -22,6 +22,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 LectureViewerModal from './features/curriculum/LectureViewerModal'
|
||||||
import { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } from './features/curriculum/curriculumApi'
|
import { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } from './features/curriculum/curriculumApi'
|
||||||
import type { CustomModuleImportInput, CustomModuleInput } from './features/workspace/ModuleListView'
|
import type { CustomModuleImportInput, 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'
|
||||||
@@ -582,6 +583,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
})
|
})
|
||||||
const [resourceMode, setResourceMode] = useState<'material' | 'reference'>('material')
|
const [resourceMode, setResourceMode] = useState<'material' | 'reference'>('material')
|
||||||
const [expandedMaterialIndexes, setExpandedMaterialIndexes] = useState<number[]>([])
|
const [expandedMaterialIndexes, setExpandedMaterialIndexes] = useState<number[]>([])
|
||||||
|
const [openLectureVersionId, setOpenLectureVersionId] = useState<string | null>(null)
|
||||||
const [downloadingResourceIds, setDownloadingResourceIds] = useState<string[]>([])
|
const [downloadingResourceIds, setDownloadingResourceIds] = useState<string[]>([])
|
||||||
const [activeIdeActivity, setActiveIdeActivity] = useState<IdeActivity>('modules')
|
const [activeIdeActivity, setActiveIdeActivity] = useState<IdeActivity>('modules')
|
||||||
const exampleActive = activeIdeActivity === 'examples'
|
const exampleActive = activeIdeActivity === 'examples'
|
||||||
@@ -837,6 +839,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
description: step.description,
|
description: step.description,
|
||||||
videoUrl: step.videoEnabled === false ? '' : (step.videoUrl ?? ''),
|
videoUrl: step.videoEnabled === false ? '' : (step.videoUrl ?? ''),
|
||||||
concepts: step.concepts?.length ? step.concepts : [curriculumWeek.topics[index]].filter(Boolean),
|
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 activeTopicIndex = curriculumWeek.topics.findIndex((topic) => topic === activeLearningStep)
|
||||||
const activeMaterialIndex = Math.min(
|
const activeMaterialIndex = Math.min(
|
||||||
@@ -4242,9 +4245,11 @@ function LocalPcWorkspaceMockup({
|
|||||||
onModeChange={setResourceMode}
|
onModeChange={setResourceMode}
|
||||||
onMinimize={() => setLeftPanelMinimized(true)}
|
onMinimize={() => setLeftPanelMinimized(true)}
|
||||||
onToggleMaterial={toggleMaterialItem}
|
onToggleMaterial={toggleMaterialItem}
|
||||||
|
onOpenLecture={setOpenLectureVersionId}
|
||||||
onDownloadResource={(resource) => void handleDownloadResource(resource)}
|
onDownloadResource={(resource) => void handleDownloadResource(resource)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{openLectureVersionId && <LectureViewerModal key={`${studentEmail}:${courseKey}:${weekNumber}:${openLectureVersionId}`} versionId={openLectureVersionId} courseId={courseKey} week={weekNumber} dark={dark} request={fetchWithFreshAuth} onClose={() => setOpenLectureVersionId(null)} />}
|
||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-label={t("학습가이드 패널 크기 조절")}
|
aria-label={t("학습가이드 패널 크기 조절")}
|
||||||
@@ -4934,6 +4939,7 @@ export default function App(): React.JSX.Element {
|
|||||||
const [recentSessionModalOpen, setRecentSessionModalOpen] = useState(false)
|
const [recentSessionModalOpen, setRecentSessionModalOpen] = useState(false)
|
||||||
const [instructorStudioModalOpen, setInstructorStudioModalOpen] = useState(false)
|
const [instructorStudioModalOpen, setInstructorStudioModalOpen] = useState(false)
|
||||||
const [materialModalOpen, setMaterialModalOpen] = useState(false)
|
const [materialModalOpen, setMaterialModalOpen] = useState(false)
|
||||||
|
const [courseLecture, setCourseLecture] = useState<{ versionId: string; courseId: string; week: number } | null>(null)
|
||||||
const [courseChangeBlockedOpen, setCourseChangeBlockedOpen] = useState(false)
|
const [courseChangeBlockedOpen, setCourseChangeBlockedOpen] = useState(false)
|
||||||
const [weekSwitchBlockedOpen, setWeekSwitchBlockedOpen] = useState(false)
|
const [weekSwitchBlockedOpen, setWeekSwitchBlockedOpen] = useState(false)
|
||||||
const [completedCurriculumNotice, setCompletedCurriculumNotice] = useState<CurriculumWeek | null>(null)
|
const [completedCurriculumNotice, setCompletedCurriculumNotice] = useState<CurriculumWeek | null>(null)
|
||||||
@@ -6784,6 +6790,7 @@ export default function App(): React.JSX.Element {
|
|||||||
onOpenMaterials={() => {
|
onOpenMaterials={() => {
|
||||||
if (hasSelectedWeekMaterials) setMaterialModalOpen(true)
|
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 && <LectureViewerModal key={`${authSession.user.id}:${courseLecture.courseId}:${courseLecture.week}:${courseLecture.versionId}`} versionId={courseLecture.versionId} courseId={courseLecture.courseId} week={courseLecture.week} dark={false} request={fetchWithFreshAuth} onClose={() => setCourseLecture(null)} />}
|
||||||
{materialModalOpen && (
|
{materialModalOpen && (
|
||||||
<MaterialsModal
|
<MaterialsModal
|
||||||
week={selectedWeek}
|
week={selectedWeek}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { t, useLocaleRender } from '../../i18n'
|
import { t, useLocaleRender } from '../../i18n'
|
||||||
import { CheckCircle2, FileText } from 'lucide-react'
|
import { BookOpen, CheckCircle2, FileText } from 'lucide-react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ type CurriculumDetailWeek = {
|
|||||||
coverImage?: { downloadUrl: string } | null
|
coverImage?: { downloadUrl: string } | null
|
||||||
overview: string
|
overview: string
|
||||||
topicDetails: string[]
|
topicDetails: string[]
|
||||||
|
learningSteps?: Array<{ lectureVersionId?: string | null }>
|
||||||
practiceContents?: Array<{
|
practiceContents?: Array<{
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -25,6 +26,7 @@ type CurriculumDetailPanelProps = {
|
|||||||
activeLearningStep: string | null
|
activeLearningStep: string | null
|
||||||
allowLearningStepSelection?: boolean
|
allowLearningStepSelection?: boolean
|
||||||
onSelectLearningStep?: (step: string) => void
|
onSelectLearningStep?: (step: string) => void
|
||||||
|
onOpenLecture?: (versionId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CurriculumDetailPanel({
|
export default function CurriculumDetailPanel({
|
||||||
@@ -32,7 +34,8 @@ export default function CurriculumDetailPanel({
|
|||||||
selected,
|
selected,
|
||||||
activeLearningStep,
|
activeLearningStep,
|
||||||
allowLearningStepSelection = false,
|
allowLearningStepSelection = false,
|
||||||
onSelectLearningStep
|
onSelectLearningStep,
|
||||||
|
onOpenLecture
|
||||||
}: CurriculumDetailPanelProps): React.JSX.Element {
|
}: CurriculumDetailPanelProps): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
const [failedImageUrl, setFailedImageUrl] = useState<string | null>(null)
|
const [failedImageUrl, setFailedImageUrl] = useState<string | null>(null)
|
||||||
@@ -94,6 +97,7 @@ export default function CurriculumDetailPanel({
|
|||||||
<p className="mt-1 pl-[38px] text-[14px] font-medium leading-[1.5] text-[#4f596b]">
|
<p className="mt-1 pl-[38px] text-[14px] font-medium leading-[1.5] text-[#4f596b]">
|
||||||
{week.detail.topicDetails[index]}
|
{week.detail.topicDetails[index]}
|
||||||
</p>
|
</p>
|
||||||
|
{week.detail.learningSteps?.[index]?.lectureVersionId && onOpenLecture && <button type="button" onClick={event => { event.stopPropagation(); onOpenLecture(week.detail.learningSteps![index].lectureVersionId!) }} className="ml-[38px] mt-3 inline-flex items-center gap-2 rounded-md border border-[#2563eb] px-3 py-2 text-xs font-bold text-[#1d4ed8]"><BookOpen size={14} />{t('슬라이드·퀴즈 열기')}</button>}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type CurriculumHomeWeek = {
|
|||||||
detail: {
|
detail: {
|
||||||
overview: string
|
overview: string
|
||||||
topicDetails: string[]
|
topicDetails: string[]
|
||||||
|
learningSteps?: Array<{ lectureVersionId?: string | null }>
|
||||||
practiceContents?: Array<{
|
practiceContents?: Array<{
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -40,6 +41,7 @@ type CurriculumHomeScreenProps<Week extends CurriculumHomeWeek> = {
|
|||||||
onSelectLearningStep?: (step: string) => void
|
onSelectLearningStep?: (step: string) => void
|
||||||
onStartStudio: () => void
|
onStartStudio: () => void
|
||||||
onOpenMaterials: () => void
|
onOpenMaterials: () => void
|
||||||
|
onOpenLecture?: (versionId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
|
export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
|
||||||
@@ -55,7 +57,8 @@ export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
|
|||||||
onSelectWeek,
|
onSelectWeek,
|
||||||
onSelectLearningStep,
|
onSelectLearningStep,
|
||||||
onStartStudio,
|
onStartStudio,
|
||||||
onOpenMaterials
|
onOpenMaterials,
|
||||||
|
onOpenLecture
|
||||||
}: CurriculumHomeScreenProps<Week>): React.JSX.Element {
|
}: CurriculumHomeScreenProps<Week>): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
return (
|
return (
|
||||||
@@ -97,6 +100,7 @@ export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
|
|||||||
activeLearningStep={activeLearningStep}
|
activeLearningStep={activeLearningStep}
|
||||||
allowLearningStepSelection={publicGuideCourse}
|
allowLearningStepSelection={publicGuideCourse}
|
||||||
onSelectLearningStep={onSelectLearningStep}
|
onSelectLearningStep={onSelectLearningStep}
|
||||||
|
onOpenLecture={selectedWeek.state === 'locked' ? undefined : onOpenLecture}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<Response>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
useLocaleRender();
|
||||||
|
const [lecture, setLecture] = useState<Lecture | null>(null);
|
||||||
|
const [slideIndex, setSlideIndex] = useState(0);
|
||||||
|
const [tab, setTab] = useState<"slides" | "quiz">("slides");
|
||||||
|
const [answers, setAnswers] = useState<Record<string, number>>({});
|
||||||
|
const [grade, setGrade] = useState<Grade | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const requestId = useRef<string | null>(null);
|
||||||
|
const controller = useRef(new AbortController());
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
closeRef.current = onClose;
|
||||||
|
const api = useCallback(
|
||||||
|
async <T,>(suffix: string, method = "GET", body?: unknown): Promise<T> => {
|
||||||
|
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<T>;
|
||||||
|
},
|
||||||
|
[request, courseId, week, versionId],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
controller.current = abort;
|
||||||
|
const previousFocus =
|
||||||
|
document.activeElement instanceof HTMLElement
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
api<Lecture>("")
|
||||||
|
.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<HTMLElement>('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<Grade>("/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(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[2400] flex items-center justify-center bg-black/55 p-3"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="lecture-viewer-title"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
className={`flex max-h-[94vh] w-full max-w-[1000px] min-w-0 flex-col overflow-hidden rounded-lg border shadow-2xl ${dark ? "border-white/15 bg-[#252525] text-white" : "border-[#cbd5e1] bg-white text-[#111827]"}`}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className={`flex items-start justify-between gap-3 border-b px-5 py-4 ${border}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2
|
||||||
|
id="lecture-viewer-title"
|
||||||
|
className="break-words text-lg font-bold"
|
||||||
|
>
|
||||||
|
{lecture?.title ?? t("강의")}
|
||||||
|
</h2>
|
||||||
|
{lecture && (
|
||||||
|
<p className={`mt-1 text-xs ${muted}`}>
|
||||||
|
v{lecture.version}
|
||||||
|
{lecture.progress?.completedAt
|
||||||
|
? ` · ${t("퀴즈 제출 완료")}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="lecture-viewer-close"
|
||||||
|
className="grid h-8 w-8 shrink-0 place-items-center rounded-md"
|
||||||
|
aria-label={t("강의 닫기")}
|
||||||
|
title={t("닫기")}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{lecture && (
|
||||||
|
<nav
|
||||||
|
className={`flex gap-4 border-b px-5 ${border}`}
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t("강의 구성")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "slides"}
|
||||||
|
className={`inline-flex items-center gap-2 border-b-2 py-3 text-sm font-bold ${tab === "slides" ? dark ? "border-[#60a5fa] text-[#60a5fa]" : "border-[#2563eb] text-[#1d4ed8]" : "border-transparent"}`}
|
||||||
|
onClick={() => setTab("slides")}
|
||||||
|
>
|
||||||
|
<BookOpen size={15} />
|
||||||
|
{t("슬라이드")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === "quiz"}
|
||||||
|
className={`inline-flex items-center gap-2 border-b-2 py-3 text-sm font-bold ${tab === "quiz" ? dark ? "border-[#60a5fa] text-[#60a5fa]" : "border-[#2563eb] text-[#1d4ed8]" : "border-transparent"}`}
|
||||||
|
onClick={() => setTab("quiz")}
|
||||||
|
>
|
||||||
|
<ClipboardCheck size={15} />
|
||||||
|
{t("퀴즈")}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className={`mb-4 whitespace-pre-wrap text-sm ${dark ? 'text-[#f87171]' : 'text-[#b91c1c]'}`}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!lecture && !error && (
|
||||||
|
<p className={`flex items-center gap-2 py-12 text-sm ${muted}`}>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
{t("불러오는 중")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lecture && tab === "slides" && slide && (
|
||||||
|
<article className="min-h-[340px] min-w-0">
|
||||||
|
<div className={`mb-4 text-xs ${muted}`}>
|
||||||
|
{slideIndex + 1} / {lecture.slides.length}
|
||||||
|
</div>
|
||||||
|
<h3 className="break-words text-2xl font-bold leading-snug">
|
||||||
|
{slide.title}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className={`mt-5 whitespace-pre-wrap break-words text-sm leading-7 ${muted}`}
|
||||||
|
>
|
||||||
|
{slide.body}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-4 list-disc space-y-3 pl-5 text-sm leading-6">
|
||||||
|
{slide.bullets.map((b, i) => (
|
||||||
|
<li key={i} className="break-words">
|
||||||
|
{b}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{slide.code && (
|
||||||
|
<pre
|
||||||
|
className={`mt-5 overflow-x-auto rounded-md border p-4 text-xs leading-6 ${border} ${dark ? "bg-[#191919]" : "bg-[#f8fafc]"}`}
|
||||||
|
>
|
||||||
|
<code>{slide.code}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
{lecture && tab === "quiz" && (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{grade && (
|
||||||
|
<p className={`text-lg font-bold ${dark ? 'text-[#34d399]' : 'text-[#15803d]'}`}>
|
||||||
|
{grade.score} / {grade.total}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lecture.quiz.map((q, index) => {
|
||||||
|
const result = grade?.results.find(
|
||||||
|
(r) => r.questionId === q.id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
key={q.id}
|
||||||
|
disabled={submitting || Boolean(grade)}
|
||||||
|
className={`min-w-0 border-b pb-5 ${border}`}
|
||||||
|
>
|
||||||
|
<legend className="break-words text-sm font-bold leading-6">
|
||||||
|
{index + 1}. {q.question}
|
||||||
|
</legend>
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
{q.options.map((option, i) => (
|
||||||
|
<label
|
||||||
|
key={i}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-md border p-3 text-sm ${result?.correctIndex === i ? dark ? "border-[#34d399]" : "border-[#15803d]" : border}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`lecture-answer-${q.id}`}
|
||||||
|
className="mt-1 shrink-0"
|
||||||
|
checked={answers[q.id] === i}
|
||||||
|
onChange={() => {
|
||||||
|
setAnswers((a) => ({ ...a, [q.id]: i }));
|
||||||
|
requestId.current = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 break-words">{option}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{result && (
|
||||||
|
<p
|
||||||
|
className={`mt-3 whitespace-pre-wrap text-sm leading-6 ${muted}`}
|
||||||
|
>
|
||||||
|
{result.correct ? t("정답") : t("오답")} ·{" "}
|
||||||
|
{result.explanation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{grade ? (
|
||||||
|
<button
|
||||||
|
className={button}
|
||||||
|
onClick={() => {
|
||||||
|
setGrade(null);
|
||||||
|
setAnswers({});
|
||||||
|
requestId.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={15} />
|
||||||
|
{t("다시 풀기")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={button}
|
||||||
|
disabled={
|
||||||
|
submitting ||
|
||||||
|
lecture.quiz.some((q) => answers[q.id] === undefined)
|
||||||
|
}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send size={15} />
|
||||||
|
)}
|
||||||
|
{t("답안 제출")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{lecture && tab === "slides" && (
|
||||||
|
<footer
|
||||||
|
className={`flex flex-wrap items-center justify-between gap-3 border-t px-5 py-3 ${border}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={button}
|
||||||
|
disabled={slideIndex === 0}
|
||||||
|
onClick={() => setSlideIndex((i) => i - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={15} />
|
||||||
|
{t("이전")}
|
||||||
|
</button>
|
||||||
|
<span className={`text-xs ${muted}`}>
|
||||||
|
{slideIndex + 1} / {lecture.slides.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={button}
|
||||||
|
onClick={() =>
|
||||||
|
slideIndex < lecture.slides.length - 1
|
||||||
|
? setSlideIndex((i) => i + 1)
|
||||||
|
: setTab("quiz")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{slideIndex < lecture.slides.length - 1
|
||||||
|
? t("다음")
|
||||||
|
: t("퀴즈 풀기")}
|
||||||
|
<ChevronRight size={15} />
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export type DownloadResource = {
|
|||||||
export type LearningStat = LearningStatItem
|
export type LearningStat = LearningStatItem
|
||||||
|
|
||||||
export type LearningStep = {
|
export type LearningStep = {
|
||||||
|
lectureVersionId?: string | null
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
videoEnabled?: boolean
|
videoEnabled?: boolean
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useId } from 'react'
|
|||||||
export type WorkspaceGuideResourceMode = 'material' | 'reference'
|
export type WorkspaceGuideResourceMode = 'material' | 'reference'
|
||||||
|
|
||||||
export type WorkspaceGuideMaterialItem = {
|
export type WorkspaceGuideMaterialItem = {
|
||||||
|
lectureVersionId?: string | null
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
videoUrl: string
|
videoUrl: string
|
||||||
@@ -43,6 +44,7 @@ type WorkspaceGuidePanelProps<Resource extends WorkspaceGuideResource> = {
|
|||||||
onMinimize: () => void
|
onMinimize: () => void
|
||||||
onToggleMaterial: (index: number) => void
|
onToggleMaterial: (index: number) => void
|
||||||
onDownloadResource: (resource: Resource) => void
|
onDownloadResource: (resource: Resource) => void
|
||||||
|
onOpenLecture?: (versionId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function getYoutubeEmbedUrl(url: string | undefined): string | null {
|
function getYoutubeEmbedUrl(url: string | undefined): string | null {
|
||||||
@@ -95,7 +97,8 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
|||||||
onModeChange,
|
onModeChange,
|
||||||
onMinimize,
|
onMinimize,
|
||||||
onToggleMaterial,
|
onToggleMaterial,
|
||||||
onDownloadResource
|
onDownloadResource,
|
||||||
|
onOpenLecture
|
||||||
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
|
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
const panelId = useId()
|
const panelId = useId()
|
||||||
@@ -225,6 +228,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<section>
|
<section>
|
||||||
|
{item.lectureVersionId && onOpenLecture && <button type="button" onClick={() => onOpenLecture(item.lectureVersionId!)} className={`mb-3 inline-flex min-h-9 items-center gap-2 rounded-md border px-3 py-2 text-xs font-bold ${dark ? 'border-[#60a5fa]/50 text-[#60a5fa]' : 'border-[#2563eb] text-[#1d4ed8]'}`}><BookOpen size={15} />{t('슬라이드·퀴즈 열기')}</button>}
|
||||||
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("학습 내용")}</span>
|
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("학습 내용")}</span>
|
||||||
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
||||||
{item.description || topicDetails[index] || overview}
|
{item.description || topicDetails[index] || overview}
|
||||||
|
|||||||
@@ -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",
|
"지연시간 측정 중": "Measuring latency",
|
||||||
"Ping 응답 없음": "No ping response",
|
"Ping 응답 없음": "No ping response",
|
||||||
"원격 플랫폼 왕복 지연시간": "Remote platform round-trip latency",
|
"원격 플랫폼 왕복 지연시간": "Remote platform round-trip latency",
|
||||||
@@ -763,5 +774,6 @@
|
|||||||
"코드를 복사하지 못했습니다.": "Could not copy the code.",
|
"코드를 복사하지 못했습니다.": "Could not copy the code.",
|
||||||
"일반 텍스트": "Plain text",
|
"일반 텍스트": "Plain text",
|
||||||
"코드": "Code",
|
"코드": "Code",
|
||||||
"표": "Table"
|
"표": "Table",
|
||||||
|
"강의": "Lecture"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user