feat: add student lecture slides and server-graded quizzes

This commit is contained in:
2026-11-02 13:08:38 +09:00
parent 8c5ccc99e9
commit eab95c51fb
8 changed files with 487 additions and 5 deletions
+33
View File
@@ -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);
});
+8
View File
@@ -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}
+13 -1
View File
@@ -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"
} }