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 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<number[]>([])
|
||||
const [openLectureVersionId, setOpenLectureVersionId] = useState<string | null>(null)
|
||||
const [downloadingResourceIds, setDownloadingResourceIds] = useState<string[]>([])
|
||||
const [activeIdeActivity, setActiveIdeActivity] = useState<IdeActivity>('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 && <LectureViewerModal key={`${studentEmail}:${courseKey}:${weekNumber}:${openLectureVersionId}`} versionId={openLectureVersionId} courseId={courseKey} week={weekNumber} dark={dark} request={fetchWithFreshAuth} onClose={() => setOpenLectureVersionId(null)} />}
|
||||
<div
|
||||
role="separator"
|
||||
aria-label={t("학습가이드 패널 크기 조절")}
|
||||
@@ -4934,6 +4939,7 @@ export default function App(): React.JSX.Element {
|
||||
const [recentSessionModalOpen, setRecentSessionModalOpen] = useState(false)
|
||||
const [instructorStudioModalOpen, setInstructorStudioModalOpen] = 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 [weekSwitchBlockedOpen, setWeekSwitchBlockedOpen] = useState(false)
|
||||
const [completedCurriculumNotice, setCompletedCurriculumNotice] = useState<CurriculumWeek | null>(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 && <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 && (
|
||||
<MaterialsModal
|
||||
week={selectedWeek}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import { CheckCircle2, FileText } from 'lucide-react'
|
||||
import { BookOpen, CheckCircle2, FileText } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
@@ -11,6 +11,7 @@ type CurriculumDetailWeek = {
|
||||
coverImage?: { downloadUrl: string } | null
|
||||
overview: string
|
||||
topicDetails: string[]
|
||||
learningSteps?: Array<{ lectureVersionId?: string | null }>
|
||||
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<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]">
|
||||
{week.detail.topicDetails[index]}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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<Week extends CurriculumHomeWeek> = {
|
||||
onSelectLearningStep?: (step: string) => void
|
||||
onStartStudio: () => void
|
||||
onOpenMaterials: () => void
|
||||
onOpenLecture?: (versionId: string) => void
|
||||
}
|
||||
|
||||
export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
|
||||
@@ -55,7 +57,8 @@ export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
|
||||
onSelectWeek,
|
||||
onSelectLearningStep,
|
||||
onStartStudio,
|
||||
onOpenMaterials
|
||||
onOpenMaterials,
|
||||
onOpenLecture
|
||||
}: CurriculumHomeScreenProps<Week>): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
return (
|
||||
@@ -97,6 +100,7 @@ export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
|
||||
activeLearningStep={activeLearningStep}
|
||||
allowLearningStepSelection={publicGuideCourse}
|
||||
onSelectLearningStep={onSelectLearningStep}
|
||||
onOpenLecture={selectedWeek.state === 'locked' ? undefined : onOpenLecture}
|
||||
/>
|
||||
</section>
|
||||
</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 LearningStep = {
|
||||
lectureVersionId?: string | null
|
||||
title: string
|
||||
description: string
|
||||
videoEnabled?: boolean
|
||||
|
||||
@@ -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<Resource extends WorkspaceGuideResource> = {
|
||||
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<Resource extends WorkspaceGuideResou
|
||||
onModeChange,
|
||||
onMinimize,
|
||||
onToggleMaterial,
|
||||
onDownloadResource
|
||||
onDownloadResource,
|
||||
onOpenLecture
|
||||
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const panelId = useId()
|
||||
@@ -225,6 +228,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
||||
{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",
|
||||
"Ping 응답 없음": "No ping response",
|
||||
"원격 플랫폼 왕복 지연시간": "Remote platform round-trip latency",
|
||||
@@ -763,5 +774,6 @@
|
||||
"코드를 복사하지 못했습니다.": "Could not copy the code.",
|
||||
"일반 텍스트": "Plain text",
|
||||
"코드": "Code",
|
||||
"표": "Table"
|
||||
"표": "Table",
|
||||
"강의": "Lecture"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user