feat: improve lecture slide viewer and bump version to 1.2.28

This commit is contained in:
2026-11-02 17:54:33 +09:00
parent eab95c51fb
commit f492f3a31d
6 changed files with 220 additions and 60 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lms",
"version": "1.2.26",
"version": "1.2.28",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lms",
"version": "1.2.26",
"version": "1.2.28",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lms",
"version": "1.2.26",
"version": "1.2.28",
"description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js",
"author": {
+10
View File
@@ -31,3 +31,13 @@ test('lecture controls have English translations', () => {
const en = require('../src/renderer/src/i18n/en.json');
for (const key of ['슬라이드·퀴즈 열기', '강의 닫기', '슬라이드', '퀴즈', '답안 제출', '다시 풀기']) assert.ok(en[key], key);
});
test('presentation slides render key messages, examples and legacy body as safe text', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/features/curriculum/LectureSlideCanvas.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 } : require(name) });
const html = renderToStaticMarkup(React.createElement(exports.default, { slide: { id: 's1', type: 'content', title: '속도', subtitle: '핵심 메시지', body: '기존 본문', bullets: ['요점'], example: { label: '실습 예시', content: '<img src=x onerror=alert(1)>' } }, title: '제어 강의', index: 0, total: 4 }));
assert.match(html, /핵심 메시지/); assert.match(html, /기존 본문/); assert.match(html, /실습 예시/);
assert.doesNotMatch(html, /<img/); assert.match(html, /&lt;img/);
});
@@ -0,0 +1,97 @@
import { Code2, FlaskConical } from "lucide-react";
import { t } from "../../i18n";
export type LectureSlideData = {
id: string;
type: string;
title: string;
body: string;
bullets: string[];
subtitle?: string;
example?: { label: string; content: string };
code?: string;
language?: string;
};
export default function LectureSlideCanvas({
slide,
title,
index,
total,
}: {
slide: LectureSlideData;
title: string;
index: number;
total: number;
}) {
return (
<article
aria-label={slide.title}
className="relative flex min-h-[420px] min-w-0 flex-col overflow-hidden rounded-lg border border-[#d4dfdc] bg-[#fcfdfc] text-[#192b29] shadow-lg md:aspect-video md:min-h-0"
>
<div
className={`h-1.5 shrink-0 ${slide.type === "summary" ? "bg-[#b6504b]" : "bg-[#167866]"}`}
/>
<header className="flex shrink-0 items-center justify-between gap-4 px-5 pt-5 md:px-10 md:pt-7">
<p
className="min-w-0 truncate text-xs font-bold text-[#526a64]"
title={title}
>
{title}
</p>
<span className="shrink-0 text-xs tabular-nums text-[#526a64]">
{String(index + 1).padStart(2, "0")} /{" "}
{String(total).padStart(2, "0")}
</span>
</header>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-6 pt-5 md:px-10 md:pb-9 md:pt-6">
<h3 className="break-words text-[28px] font-bold leading-tight [overflow-wrap:anywhere] md:text-[40px]">
{slide.title}
</h3>
{slide.subtitle && (
<p className="mt-4 border-l-[3px] border-[#167866] pl-4 text-base font-semibold leading-relaxed text-[#226354] md:text-[22px]">
{slide.subtitle}
</p>
)}
{slide.body && (
<p className="mt-5 whitespace-pre-wrap break-words text-sm leading-7 text-[#425a54] md:text-lg">
{slide.body}
</p>
)}
{slide.bullets.length > 0 && (
<ul className="mt-6 space-y-3 text-sm leading-8 md:text-[20px]">
{slide.bullets.map((bullet, i) => (
<li key={i} className="flex min-w-0 gap-3">
<span className="mt-2.5 h-1.5 w-1.5 shrink-0 rounded-full bg-[#167866]" />
<span className="min-w-0 break-words [overflow-wrap:anywhere]">
{bullet}
</span>
</li>
))}
</ul>
)}
{slide.example?.content && (
<aside className="mt-6 border-l-[3px] border-[#b6504b] bg-[#f5efed] px-4 py-3">
<h4 className="flex items-center gap-2 text-xs font-bold text-[#8d3f3a]">
<FlaskConical size={15} />
{slide.example.label || t("예시·실습")}
</h4>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-7 text-[#4e3936] md:text-base">
{slide.example.content}
</p>
</aside>
)}
{slide.code && (
<section className="mt-5 overflow-hidden rounded-md border border-[#c8d4d0] bg-[#202b29] text-[#eef7f3]">
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2 text-xs text-[#a8c6b9]">
<Code2 size={14} />
{slide.language || t("코드")}
</div>
<pre className="overflow-x-auto p-4 text-xs leading-6">
<code>{slide.code}</code>
</pre>
</section>
)}
</div>
</article>
);
}
@@ -9,26 +9,22 @@ import {
RotateCcw,
Send,
X,
Maximize2,
Minimize2,
} from "lucide-react";
import LectureSlideCanvas, {
type LectureSlideData,
} from "./LectureSlideCanvas";
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[];
slides: LectureSlideData[];
quiz: Question[];
progress: { lastSlide: number; completedAt: string | null } | null;
};
@@ -68,6 +64,9 @@ export default function LectureViewerModal({
const [grade, setGrade] = useState<Grade | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [expanded, setExpanded] = useState(false);
const navigation = useRef({ count: 0, tab: "slides" });
navigation.current = { count: lecture?.slides.length ?? 0, tab };
const requestId = useRef<string | null>(null);
const controller = useRef(new AbortController());
const closeRef = useRef(onClose);
@@ -127,11 +126,48 @@ export default function LectureViewerModal({
);
});
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(); }
const editable =
event.target instanceof HTMLElement &&
Boolean(
event.target.closest(
'input, textarea, select, [contenteditable="true"]',
),
);
if (
!editable &&
navigation.current.tab === "slides" &&
["ArrowRight", "ArrowLeft", "PageDown", "PageUp"].includes(event.key)
) {
event.preventDefault();
const direction =
event.key === "ArrowRight" || event.key === "PageDown" ? 1 : -1;
setSlideIndex((current) =>
Math.min(
Math.max(0, current + direction),
Math.max(0, navigation.current.count - 1),
),
);
}
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();
@@ -193,7 +229,7 @@ export default function LectureViewerModal({
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]"}`}
className={`flex w-full min-w-0 flex-col overflow-hidden rounded-lg border shadow-2xl ${expanded ? "h-[calc(100dvh-24px)] max-w-none" : "h-[94dvh] max-h-[1000px] max-w-[1440px]"} ${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}`}
@@ -214,6 +250,16 @@ export default function LectureViewerModal({
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded-md"
title={expanded ? t("원래 크기") : t("전체 화면")}
aria-label={expanded ? t("원래 크기") : t("전체 화면")}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
<button
id="lecture-viewer-close"
className="grid h-8 w-8 shrink-0 place-items-center rounded-md"
@@ -223,6 +269,7 @@ export default function LectureViewerModal({
>
<X size={18} />
</button>
</div>
</header>
{lecture && (
<nav
@@ -233,7 +280,7 @@ export default function LectureViewerModal({
<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"}`}
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} />
@@ -242,7 +289,7 @@ export default function LectureViewerModal({
<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"}`}
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} />
@@ -250,11 +297,11 @@ export default function LectureViewerModal({
</button>
</nav>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-4 md:px-5">
{error && (
<p
role="alert"
className={`mb-4 whitespace-pre-wrap text-sm ${dark ? 'text-[#f87171]' : 'text-[#b91c1c]'}`}
className={`mb-4 whitespace-pre-wrap text-sm ${dark ? "text-[#f87171]" : "text-[#b91c1c]"}`}
>
{error}
</p>
@@ -266,38 +313,41 @@ export default function LectureViewerModal({
</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}`}
<div className="grid min-w-0 gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
<nav
aria-label={t("슬라이드 목록")}
className={`flex gap-2 overflow-x-auto border-b pb-3 lg:max-h-[620px] lg:flex-col lg:overflow-x-hidden lg:overflow-y-auto lg:border-b-0 lg:border-r lg:pb-0 lg:pr-3 ${border}`}
>
{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>
{lecture.slides.map((item, index) => (
<button
type="button"
key={item.id}
aria-current={index === slideIndex ? "step" : undefined}
title={item.title}
onClick={() => setSlideIndex(index)}
className={`flex w-[145px] shrink-0 items-start gap-2 rounded-md border p-3 text-left text-xs leading-5 lg:w-full ${index === slideIndex ? (dark ? "border-[#61c3ab] bg-[#213a33]" : "border-[#167866] bg-[#e9f4ef] text-[#174f40]") : border}`}
>
<span className="shrink-0 font-bold tabular-nums">
{String(index + 1).padStart(2, "0")}
</span>
<span className="min-w-0 break-words">{item.title}</span>
</button>
))}
</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>
</nav>
<LectureSlideCanvas
slide={slide}
title={lecture.title}
index={slideIndex}
total={lecture.slides.length}
/>
</div>
)}
{lecture && tab === "quiz" && (
<div className="grid gap-6">
{grade && (
<p className={`text-lg font-bold ${dark ? 'text-[#34d399]' : 'text-[#15803d]'}`}>
<p
className={`text-lg font-bold ${dark ? "text-[#34d399]" : "text-[#15803d]"}`}
>
{grade.score} / {grade.total}
</p>
)}
@@ -318,7 +368,7 @@ export default function LectureViewerModal({
{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}`}
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"
+4 -1
View File
@@ -775,5 +775,8 @@
"일반 텍스트": "Plain text",
"코드": "Code",
"표": "Table",
"강의": "Lecture"
"강의": "Lecture",
"전체 화면": "Expand",
"슬라이드 목록": "Slide list",
"예시·실습": "Example / Practice"
}