feat: improve lecture slide viewer and bump version to 1.2.28
This commit is contained in:
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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, /<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"
|
||||
|
||||
@@ -775,5 +775,8 @@
|
||||
"일반 텍스트": "Plain text",
|
||||
"코드": "Code",
|
||||
"표": "Table",
|
||||
"강의": "Lecture"
|
||||
"강의": "Lecture",
|
||||
"전체 화면": "Expand",
|
||||
"슬라이드 목록": "Slide list",
|
||||
"예시·실습": "Example / Practice"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user