Files
Maxim Dolgolyov 8091b48e1c fix(ct-math): практика возвращала меньше count + перенос заголовков в навигации урока
1) exam-prep practice (strategy=random) возвращал около 0.6 от count: функция
   distributeByDifficulty раскладывает count по 5 уровням сложности, а у трека
   ctmath задания только уровней 1-3 (уровни 4-5 пустые) -> часть выборки терялась
   (20 -> 12, 15 -> 10, 10 -> 6). В pickRandomByDifficulty добавлен добор до count
   из доступных уровней. Трек math9 не затронут (там добор не требуется).
2) lesson.html: .lesson-nav-btn-title был inline-span, поэтому max-width и ellipsis
   игнорировались и длинные заголовки вылезали за кнопку. Добавлен display:block.

Бэкенд-правка требует перезапуска сервера; фронт-правка видна после Ctrl+F5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:09:50 +03:00

1817 lines
82 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Урок — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<!-- KaTeX -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="if(window._katexReady) window._katexReady()"></script>
<!-- Highlight.js -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/atom-one-dark.min.css" />
<script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"
onload="window._hljsLoaded=true;document.querySelectorAll('.block-code pre code').forEach(el=>hljs.highlightElement(el))"></script>
<!-- Mermaid diagrams -->
<script defer src="https://cdn.jsdelivr.net/npm/mermaid@10.9.0/dist/mermaid.min.js"
onload="mermaid.initialize({startOnLoad:false,theme:'neutral'});window._mermaidLoaded=true;try{mermaid.run({nodes:document.querySelectorAll('.mermaid')})}catch{}"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #f4f5f8; }
/* ── lesson reader layout ── */
.lesson-layout {
display: flex; gap: 0;
min-height: calc(100vh - 0px);
}
.lesson-main {
flex: 1; min-width: 0;
padding: 0 0 80px;
}
.lesson-sidebar-toc {
width: 220px; flex-shrink: 0;
position: sticky; top: 0; height: 100vh; overflow-y: auto;
border-left: 1px solid rgba(15,23,42,0.07);
background: #fff; padding: 24px 16px;
display: none; /* shown on wide screens */
}
@media (min-width: 1100px) {
.lesson-sidebar-toc { display: block; }
}
/* ── lesson top bar ── */
.lesson-topbar {
background: #fff; border-bottom: 1px solid rgba(15,23,42,0.08);
padding: 14px 28px;
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
position: sticky; top: 0; z-index: 50;
}
.lesson-topbar-back {
display: flex; align-items: center; gap: 6px;
text-decoration: none; color: var(--text-3); font-size: 0.8rem; font-weight: 700;
transition: color 0.15s; flex-shrink: 0;
}
.lesson-topbar-back:hover { color: var(--violet); }
.lesson-topbar-title {
flex: 1; font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
color: #0F172A; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.lesson-topbar-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.lesson-edit-btn {
padding: 6px 14px; border: 1.5px solid rgba(155,93,229,0.25); border-radius: 999px;
background: rgba(155,93,229,0.06); color: var(--violet);
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
cursor: pointer; display: flex; align-items: center; gap: 5px;
transition: all 0.15s;
}
.lesson-edit-btn:hover { background: rgba(155,93,229,0.12); border-color: var(--violet); }
.lesson-bm-btn {
padding: 6px 10px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 999px;
background: transparent; color: var(--text-3); cursor: pointer; display: flex; align-items: center; gap: 4px;
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700; transition: all 0.15s;
}
.lesson-bm-btn:hover { border-color: #FFD166; color: #FFD166; }
.lesson-bm-btn.active { border-color: #FFD166; color: #FFD166; background: rgba(255,209,102,0.08); }
.lesson-progress-bar-wrap {
position: absolute; bottom: -1px; left: 0; right: 0; height: 2px;
background: rgba(15,23,42,0.05);
}
.lesson-progress-bar-fill { height: 100%; background: var(--violet); transition: width 0.3s; width: 0%; }
/* ── lesson content ── */
.lesson-body {
max-width: 720px; margin: 0 auto; padding: 40px 28px 0;
}
.lesson-heading-block { margin-bottom: 36px; }
.lesson-heading-block h1 {
font-family: 'Unbounded', sans-serif; font-size: 1.6rem; font-weight: 800;
color: #0F172A; letter-spacing: -0.03em; line-height: 1.25; margin-bottom: 10px;
}
.lesson-course-crumb {
font-size: 0.78rem; color: var(--text-3); display: flex; align-items: center; gap: 6px;
}
.lesson-course-crumb a { color: var(--violet); text-decoration: none; }
.lesson-course-crumb a:hover { text-decoration: underline; }
/* ── blocks ── */
.lesson-block { margin-bottom: 22px; }
.block-heading h2 {
font-family: 'Unbounded', sans-serif; font-size: 1.15rem; font-weight: 800;
color: #0F172A; margin: 32px 0 12px; padding-bottom: 10px;
border-bottom: 2px solid rgba(155,93,229,0.15);
}
.block-heading h3 {
font-family: 'Unbounded', sans-serif; font-size: 0.95rem; font-weight: 800;
color: #1E293B; margin: 24px 0 10px;
}
.block-text p {
font-size: 1rem; line-height: 1.8; color: #1E293B; margin: 0;
}
.block-text p + p { margin-top: 14px; }
.block-formula {
background: rgba(155,93,229,0.04); border: 1px solid rgba(155,93,229,0.12);
border-left: 3px solid var(--violet);
border-radius: 0 12px 12px 0; padding: 16px 20px;
overflow-x: auto;
font-size: 1.1rem;
}
.block-formula-label {
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--violet); margin-bottom: 8px;
}
.block-image { border-radius: 16px; overflow: hidden; }
.block-image img {
width: 100%; display: block; border-radius: 16px;
border: 1px solid rgba(15,23,42,0.08);
}
.block-image-caption {
text-align: center; font-size: 0.76rem; color: var(--text-3);
margin-top: 8px; font-style: italic;
}
.block-divider hr {
border: none; border-top: 1.5px solid rgba(15,23,42,0.08); margin: 10px 0;
}
.block-code pre {
background: #282c34; color: #abb2bf; border-radius: 14px; padding: 18px 20px;
font-family: 'Fira Code', 'JetBrains Mono', 'Courier New', monospace; font-size: 0.87rem;
line-height: 1.65; overflow-x: auto; margin: 0;
}
.block-code pre code { font-family: inherit; }
/* ── accordion block ── */
.block-accordion {
border: 1.5px solid rgba(99,102,241,0.15); border-radius: 14px; overflow: hidden;
}
.accordion-header {
padding: 14px 18px; background: rgba(99,102,241,0.04);
display: flex; align-items: center; justify-content: space-between;
cursor: pointer; transition: background 0.15s; user-select: none;
}
.accordion-header:hover { background: rgba(99,102,241,0.08); }
.accordion-title { font-weight: 700; font-size: 0.92rem; color: #4338CA; }
.accordion-chevron { color: #6366F1; transition: transform 0.25s; flex-shrink: 0; }
.block-accordion.open .accordion-chevron { transform: rotate(180deg); }
.accordion-body {
max-height: 0; overflow: hidden; transition: max-height 0.3s ease, padding 0.3s;
padding: 0 18px; font-size: 0.9rem; line-height: 1.7; color: #1E293B;
}
.block-accordion.open .accordion-body {
max-height: 600px; padding: 14px 18px; border-top: 1px solid rgba(99,102,241,0.1);
}
/* ── timeline block ── */
.block-timeline { padding: 8px 0; }
.timeline-track { display: flex; flex-direction: column; }
.timeline-entry { display: flex; gap: 16px; }
.timeline-node { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: 20px; }
.timeline-dot-v { width: 12px; height: 12px; border-radius: 50%; background: #0EA5E9; flex-shrink: 0; }
.timeline-line-v { width: 2px; flex: 1; background: rgba(14,165,233,0.2); min-height: 20px; }
.timeline-content { padding-bottom: 20px; }
.timeline-date { font-size: 0.78rem; font-weight: 700; color: #0EA5E9; }
.timeline-event-title { font-size: 0.95rem; font-weight: 700; color: #0F172A; margin-top: 2px; }
.timeline-event-text { font-size: 0.85rem; color: #6B7A8E; margin-top: 3px; line-height: 1.5; }
/* ── diagram block ── */
.block-diagram { text-align: center; }
.diagram-render {
padding: 16px; background: #fff; border-radius: 14px;
border: 1.5px solid rgba(168,85,247,0.12); overflow-x: auto;
}
.diagram-caption { font-size: 0.82rem; color: var(--text-3); margin-top: 8px; }
/* ── geogebra block ── */
.geogebra-embed {
aspect-ratio: 16/10; border-radius: 14px; overflow: hidden;
border: 1.5px solid rgba(34,197,94,0.15);
}
.geogebra-embed iframe { width: 100%; height: 100%; border: none; }
.geogebra-caption { font-size: 0.82rem; color: var(--text-3); margin-top: 8px; text-align: center; }
/* ── audio block ── */
.block-audio audio {
width: 100%; border-radius: 10px;
}
.audio-caption { font-size: 0.82rem; color: var(--text-3); margin-top: 6px; }
/* ── columns block ── */
.block-columns {
display: grid; gap: 20px;
}
.block-columns.cols-2 { grid-template-columns: 1fr 1fr; }
.block-columns.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
@media (max-width: 600px) {
.block-columns.cols-2,
.block-columns.cols-3 { grid-template-columns: 1fr; }
}
.block-col {
font-size: 1rem; line-height: 1.8; color: #1E293B;
}
.block-col img { max-width: 100%; border-radius: 10px; }
/* ── alert/banner block ── */
.block-alert {
border-radius: 16px; padding: 18px 22px; color: #fff;
font-weight: 600; font-size: 0.92rem; line-height: 1.5;
display: flex; align-items: flex-start; gap: 12px;
}
.block-alert-exam { background: linear-gradient(135deg, #9B5DE5 0%, #6366F1 100%); }
.block-alert-homework { background: linear-gradient(135deg, #06B6D4 0%, #0EA5E9 100%); }
.block-alert-important { background: linear-gradient(135deg, #EF476F 0%, #F97316 100%); }
.block-alert-tip { background: linear-gradient(135deg, #06D6A0 0%, #22C55E 100%); }
.block-alert-celebrate { background: linear-gradient(135deg, #FFD166 0%, #F59E0B 100%); color: #422006; }
.block-alert-icon { font-size: 1.4rem; flex-shrink: 0; line-height: 1; }
.block-alert-label {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; opacity: 0.8; margin-bottom: 3px;
}
.block-alert-text { font-weight: 600; }
/* ── inline quiz block ── */
.block-quiz {
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 16px; padding: 20px 22px;
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
}
.quiz-question {
font-weight: 700; font-size: 0.95rem; color: #0F172A; margin-bottom: 14px;
}
.quiz-options { display: flex; flex-direction: column; gap: 8px; }
.quiz-opt {
padding: 10px 16px; border: 1.5px solid rgba(15,23,42,0.1); border-radius: 12px;
cursor: pointer; font-size: 0.88rem; color: #3D4F6B; font-weight: 600;
transition: all 0.15s; background: #f8f9fc; text-align: left;
}
.quiz-opt:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.04); }
.quiz-opt.correct { border-color: #06D6A0 !important; background: rgba(6,214,160,0.07) !important; color: #047857 !important; }
.quiz-opt.wrong { border-color: #EF476F !important; background: rgba(239,71,111,0.07) !important; color: #be123c !important; }
.quiz-feedback { margin-top: 12px; font-size: 0.84rem; font-weight: 600; display: none; }
.quiz-feedback.show { display: block; }
.quiz-feedback.ok { color: #047857; }
.quiz-feedback.bad { color: #be123c; }
/* ── navigation ── */
.lesson-nav {
max-width: 720px; margin: 0 auto; padding: 32px 28px 0;
display: flex; gap: 12px; justify-content: space-between; align-items: stretch;
}
.lesson-nav-btn {
flex: 1; max-width: 240px; padding: 14px 18px; border-radius: 16px;
border: 1.5px solid rgba(15,23,42,0.09); background: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
cursor: pointer; text-decoration: none; color: #3D4F6B;
display: flex; align-items: center; gap: 8px; transition: all 0.15s;
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
}
.lesson-nav-btn:hover { border-color: var(--violet); color: var(--violet); box-shadow: 0 4px 14px rgba(15,23,42,0.09); }
.lesson-nav-btn-prev { justify-content: flex-start; }
.lesson-nav-btn-next { justify-content: flex-end; margin-left: auto; }
.lesson-nav-btn-label { font-size: 0.7rem; font-weight: 600; color: var(--text-3); display: block; }
.lesson-nav-btn-title { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; }
/* ── complete button ── */
.lesson-complete-wrap {
max-width: 720px; margin: 28px auto 0; padding: 0 28px;
display: flex; justify-content: center;
}
.btn-complete {
padding: 13px 36px; border: none; border-radius: 999px;
background: linear-gradient(135deg, var(--violet), #06D6A0);
color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.95rem; font-weight: 800;
cursor: pointer; box-shadow: 0 4px 20px rgba(155,93,229,0.35);
display: flex; align-items: center; gap: 9px; transition: all 0.2s;
}
.btn-complete:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(155,93,229,0.45); }
.btn-complete:disabled { opacity: 0.6; cursor: default; transform: none; }
.btn-complete.done {
background: linear-gradient(135deg, #06D6A0, #059652);
box-shadow: 0 4px 20px rgba(6,214,160,0.3);
}
/* ── callout block ── */
.block-callout {
border-radius: 14px; padding: 14px 18px;
display: flex; gap: 12px; align-items: flex-start;
font-size: 0.92rem; line-height: 1.65;
}
.block-callout-info { background: rgba(6,182,212,0.08); border: 1.5px solid rgba(6,182,212,0.2); color: #0e7490; }
.block-callout-warning { background: rgba(245,158,11,0.08); border: 1.5px solid rgba(245,158,11,0.2); color: #92400e; }
.block-callout-success { background: rgba(6,214,160,0.08); border: 1.5px solid rgba(6,214,160,0.2); color: #065f46; }
.block-callout-error { background: rgba(239,71,111,0.08); border: 1.5px solid rgba(239,71,111,0.2); color: #9f1239; }
.callout-icon { font-size: 1.15rem; flex-shrink: 0; line-height: 1.6; }
.callout-text { flex: 1; }
/* ── video block ── */
.block-video { border-radius: 16px; overflow: hidden; background: #0F172A; }
.block-video iframe { width: 100%; aspect-ratio: 16/9; border: none; display: block; }
.block-video-caption { text-align: center; font-size: 0.76rem; color: var(--text-3); margin-top: 8px; font-style: italic; }
/* ── table block ── */
.block-table { overflow-x: auto; border-radius: 14px; border: 1.5px solid rgba(15,23,42,0.09); }
.block-table table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.block-table th {
background: rgba(15,23,42,0.04); font-weight: 700; color: #0F172A;
padding: 10px 14px; border-bottom: 1.5px solid rgba(15,23,42,0.09);
text-align: left;
}
.block-table td { padding: 9px 14px; border-bottom: 1px solid rgba(15,23,42,0.06); color: #1E293B; }
.block-table tr:last-child td { border-bottom: none; }
/* ── flashcard block ── */
.block-flashcard { perspective: 800px; }
.flashcard-inner {
position: relative; width: 100%; transition: transform 0.5s;
transform-style: preserve-3d; cursor: pointer; min-height: 120px;
}
.flashcard-inner.flipped { transform: rotateY(180deg); }
.flashcard-face {
border-radius: 16px; padding: 28px 24px; min-height: 120px;
display: flex; align-items: center; justify-content: center;
text-align: center; font-size: 0.96rem; font-weight: 600;
backface-visibility: hidden; -webkit-backface-visibility: hidden;
}
.flashcard-front {
background: linear-gradient(135deg, #f8f9fc, #eef0f5);
border: 1.5px solid rgba(15,23,42,0.1);
color: #0F172A;
}
.flashcard-back {
background: linear-gradient(135deg, rgba(155,93,229,0.08), rgba(6,182,212,0.06));
border: 1.5px solid rgba(155,93,229,0.2);
color: #1E293B;
position: absolute; inset: 0;
transform: rotateY(180deg);
}
.flashcard-hint {
text-align: center; font-size: 0.7rem; color: var(--text-3); margin-top: 8px;
display: flex; align-items: center; justify-content: center; gap: 4px;
}
/* ── sim block (inline embed) ── */
.block-sim {
background: linear-gradient(135deg, rgba(15,23,42,0.03), rgba(155,93,229,0.04));
border: 1.5px solid rgba(155,93,229,0.18); border-radius: 16px;
overflow: hidden;
}
.sim-embed-frame {
width: 100%; height: 480px; border: none; display: block;
border-radius: 16px 16px 0 0; background: #0D0D1A;
}
.sim-embed-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; background: rgba(15,23,42,0.03);
border-top: 1px solid rgba(155,93,229,0.1);
}
.sim-caption { font-size: 0.78rem; color: var(--text-3); font-weight: 600; }
.sim-fullscreen-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 999px; border: 1.5px solid rgba(155,93,229,0.25);
background: transparent; color: var(--violet);
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
cursor: pointer; transition: all 0.18s; text-decoration: none;
}
.sim-fullscreen-btn:hover { background: rgba(155,93,229,0.08); border-color: var(--violet); }
/* ── notes panel ── */
.notes-panel {
max-width: 720px; margin: 28px auto 0; padding: 0 28px;
}
.notes-toggle {
display: flex; align-items: center; gap: 8px;
font-size: 0.8rem; font-weight: 700; color: var(--text-3);
cursor: pointer; background: none; border: none;
padding: 8px 0; transition: color 0.15s; font-family: 'Manrope', sans-serif;
}
.notes-toggle:hover { color: var(--violet); }
.notes-area-wrap { display: none; margin-top: 10px; }
.notes-area-wrap.open { display: block; }
.notes-textarea {
width: 100%; min-height: 120px; padding: 14px 16px;
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 14px;
font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: #1E293B;
background: #fff; resize: vertical; box-sizing: border-box;
transition: border-color 0.2s;
}
.notes-textarea:focus { outline: none; border-color: var(--violet); }
.notes-save-row { display: flex; justify-content: flex-end; margin-top: 8px; }
.notes-save-btn {
padding: 7px 18px; border: none; border-radius: 999px;
background: var(--violet); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
cursor: pointer; transition: all 0.15s;
}
.notes-save-btn:hover { background: #8a47d8; }
.notes-save-btn:disabled { opacity: 0.5; cursor: default; }
.notes-saved-lbl { font-size: 0.75rem; color: #06D6A0; align-self: center; margin-right: 10px; display: none; }
/* ── read time in topbar ── */
.topbar-read-time {
font-size: 0.74rem; color: var(--text-3); display: flex; align-items: center; gap: 4px;
}
/* ── toc ── */
.toc-title {
font-family: 'Unbounded', sans-serif; font-size: 0.67rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.08em;
padding: 0 16px; margin-bottom: 14px;
display: flex; align-items: center; gap: 8px;
}
.toc-title-bar {
display: block; width: 3px; height: 14px;
border-radius: 99px; background: var(--violet); flex-shrink: 0;
}
.toc-item {
display: block; padding: 6px 16px 6px 13px;
border-left: 3px solid transparent;
text-decoration: none; font-size: 0.79rem; color: #6B7A8E; font-weight: 600;
line-height: 1.45; transition: all 0.12s; margin-bottom: 1px;
}
.toc-item.h3 { padding-left: 28px; font-size: 0.75rem; color: var(--text-3); font-weight: 500; }
.toc-item:hover { color: var(--violet); border-left-color: rgba(155,93,229,0.3); background: rgba(155,93,229,0.04); }
.toc-item.active { color: var(--violet); border-left-color: var(--violet); background: rgba(155,93,229,0.06); font-weight: 700; }
/* ── nav active ── */
.nav-active { background: rgba(155,93,229,0.08) !important; border-color: var(--violet) !important; color: var(--violet) !important; cursor: default; pointer-events: none; }
/* ── notif ── */
.notif-badge { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 4px; background: var(--pink); color: #fff; border-radius: 99px; font-size: 0.65rem; font-weight: 700; display: flex; align-items: center; justify-content: center; }
.notif-drop-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 10px; border-bottom: 1px solid var(--border); }
.notif-drop-title { font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; }
.notif-read-all { background: none; border: none; font-size: 0.74rem; color: var(--violet); cursor: pointer; font-family: 'Manrope', sans-serif; font-weight: 600; }
.notif-item { display: flex; gap: 10px; padding: 11px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background var(--tr); text-decoration: none; color: inherit; }
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: rgba(155,93,229,0.04); }
.notif-item.unread { background: rgba(155,93,229,0.05); }
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--violet); flex-shrink: 0; margin-top: 5px; }
.notif-dot.read { background: transparent; border: 1.5px solid var(--border-h); }
.notif-msg { font-size: 0.80rem; line-height: 1.45; flex: 1; }
.notif-time { font-size: 0.70rem; color: var(--text-3); margin-top: 2px; }
.notif-empty { padding: 28px 16px; text-align: center; color: var(--text-3); font-size: 0.84rem; }
/* ── matching block ── */
.block-matching {
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 16px; padding: 20px 22px;
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
}
.matching-question {
font-weight: 700; font-size: 0.95rem; color: #0F172A; margin-bottom: 16px;
}
.matching-game {
display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px;
}
.matching-left, .matching-right {
display: flex; flex-direction: column; gap: 8px;
}
.match-item {
padding: 10px 16px; border: 1.5px solid rgba(15,23,42,0.1); border-radius: 12px;
font-size: 0.88rem; font-weight: 600; color: #3D4F6B;
background: #f8f9fc; transition: all 0.15s; text-align: center;
user-select: none;
}
.match-item.match-left {
background: rgba(155,93,229,0.05); border-color: rgba(155,93,229,0.15);
color: #4c2889; cursor: default;
}
.match-item.match-right {
cursor: grab; background: #fff;
}
.match-item.match-right:active { cursor: grabbing; }
.match-item.match-right.drag-over {
border-color: var(--violet); background: rgba(155,93,229,0.08);
}
.match-item.match-right.dragging { opacity: 0.4; }
.match-item.correct {
border-color: #06D6A0 !important; background: rgba(6,214,160,0.07) !important; color: #047857 !important;
}
.match-item.wrong {
border-color: #EF476F !important; background: rgba(239,71,111,0.07) !important; color: #be123c !important;
}
.match-check-btn {
padding: 9px 22px; border: none; border-radius: 999px;
background: var(--violet); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 700;
cursor: pointer; transition: all 0.15s;
}
.match-check-btn:hover { background: #8a47d8; }
.match-check-btn:disabled { opacity: 0.5; cursor: default; }
.match-feedback { margin-top: 12px; font-size: 0.84rem; font-weight: 600; }
.match-feedback.ok { color: #047857; }
.match-feedback.bad { color: #be123c; }
/* ── fill-blank block ── */
.block-fill-blank {
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 16px; padding: 20px 22px;
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
}
.fill-blank-text {
font-size: 0.95rem; line-height: 2; color: #1E293B;
}
.blank-input {
border: none; border-bottom: 2px solid rgba(155,93,229,0.35);
background: transparent; color: #0F172A;
font-family: 'Manrope', sans-serif; font-size: 0.92rem; font-weight: 600;
padding: 2px 6px; min-width: 80px; width: 120px;
outline: none; transition: border-color 0.2s;
text-align: center;
}
.blank-input:focus { border-bottom-color: var(--violet); }
.blank-input.correct {
border-bottom-color: #06D6A0 !important; color: #047857 !important;
background: rgba(6,214,160,0.06);
}
.blank-input.wrong {
border-bottom-color: #EF476F !important; color: #be123c !important;
background: rgba(239,71,111,0.06);
}
.fill-blank-actions { margin-top: 16px; }
.fill-blank-feedback { margin-top: 12px; font-size: 0.84rem; font-weight: 600; }
.fill-blank-feedback.ok { color: #047857; }
.fill-blank-feedback.bad { color: #be123c; }
/* ── ordering block ── */
.block-ordering {
background: #fff; border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 16px; padding: 20px 22px;
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
}
.ordering-question {
font-weight: 700; font-size: 0.95rem; color: #0F172A; margin-bottom: 16px;
}
.ordering-list {
display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px;
}
.ordering-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px; border: 1.5px solid rgba(15,23,42,0.1); border-radius: 12px;
font-size: 0.88rem; font-weight: 600; color: #3D4F6B;
background: #fff; cursor: grab; transition: all 0.15s;
user-select: none;
}
.ordering-item:active { cursor: grabbing; }
.ordering-item.drag-over {
border-color: var(--violet); background: rgba(155,93,229,0.08);
}
.ordering-item.dragging { opacity: 0.4; }
.ordering-grip {
color: var(--text-3); flex-shrink: 0; display: flex; align-items: center;
}
.ordering-item.correct {
border-color: #06D6A0 !important; background: rgba(6,214,160,0.07) !important; color: #047857 !important;
}
.ordering-item.wrong {
border-color: #EF476F !important; background: rgba(239,71,111,0.07) !important; color: #be123c !important;
}
.ordering-check-btn {
padding: 9px 22px; border: none; border-radius: 999px;
background: var(--violet); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 700;
cursor: pointer; transition: all 0.15s;
}
.ordering-check-btn:hover { background: #8a47d8; }
.ordering-check-btn:disabled { opacity: 0.5; cursor: default; }
.ordering-feedback { margin-top: 12px; font-size: 0.84rem; font-weight: 600; }
.ordering-feedback.ok { color: #047857; }
.ordering-feedback.bad { color: #be123c; }
/* ── comments section ── */
.comments-section {
max-width: 720px; margin: 0 auto; padding: 0 28px 60px;
}
.comments-title {
font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 800;
color: #0F172A; margin-bottom: 18px; display: flex; align-items: center; gap: 8px;
padding-top: 24px; border-top: 1.5px solid rgba(15,23,42,0.07);
}
.comments-count {
font-family: 'Manrope', sans-serif; font-size: 0.72rem; font-weight: 700;
color: var(--text-3); background: rgba(15,23,42,0.06); padding: 2px 8px; border-radius: 99px;
}
/* compose */
.comment-compose {
display: flex; gap: 12px; margin-bottom: 24px; align-items: flex-start;
}
.comment-avatar {
width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.72rem; font-weight: 800; color: #fff;
background: var(--violet);
}
.comment-compose-body { flex: 1; }
.comment-textarea {
width: 100%; min-height: 60px; padding: 10px 14px;
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 12px;
font-family: 'Manrope', sans-serif; font-size: 0.86rem; color: #1E293B;
background: #f8f9fc; resize: vertical; box-sizing: border-box;
transition: border-color 0.15s;
}
.comment-textarea:focus { outline: none; border-color: var(--violet); background: #fff; }
.comment-submit-row {
display: flex; justify-content: flex-end; margin-top: 8px;
}
.comment-submit-btn {
padding: 7px 18px; border: none; border-radius: 999px;
background: var(--violet); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
cursor: pointer; transition: all 0.15s;
}
.comment-submit-btn:hover { background: #8a47d8; }
.comment-submit-btn:disabled { opacity: 0.45; cursor: default; }
/* comment items */
.comment-list { display: flex; flex-direction: column; gap: 0; }
.comment-item {
display: flex; gap: 12px; padding: 14px 0;
border-bottom: 1px solid rgba(15,23,42,0.06);
}
.comment-item:last-child { border-bottom: none; }
.ci-avatar {
width: 32px; height: 32px; border-radius: 10px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.68rem; font-weight: 800;
}
.ci-avatar-student { background: rgba(155,93,229,0.12); color: var(--violet); }
.ci-avatar-teacher { background: rgba(6,214,160,0.12); color: #059652; }
.ci-body { flex: 1; min-width: 0; }
.ci-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; flex-wrap: wrap; }
.ci-name { font-size: 0.82rem; font-weight: 700; color: #0F172A; }
.ci-role {
font-size: 0.65rem; font-weight: 700; padding: 1px 6px; border-radius: 99px;
}
.ci-role-teacher { background: rgba(6,214,160,0.12); color: #059652; }
.ci-role-admin { background: rgba(239,71,111,0.1); color: #EF476F; }
.ci-time { font-size: 0.7rem; color: var(--text-3); }
.ci-text { font-size: 0.86rem; line-height: 1.6; color: #3D4F6B; white-space: pre-wrap; word-wrap: break-word; }
.ci-actions { display: flex; gap: 12px; margin-top: 6px; }
.ci-action-btn {
background: none; border: none; font-size: 0.74rem; font-weight: 600;
color: var(--text-3); cursor: pointer; padding: 0;
font-family: 'Manrope', sans-serif; transition: color 0.12s;
}
.ci-action-btn:hover { color: var(--violet); }
.ci-action-btn.danger:hover { color: #EF476F; }
/* replies */
.ci-replies {
margin-top: 10px; margin-left: 0; padding-left: 16px;
border-left: 2px solid rgba(155,93,229,0.12);
}
.ci-replies .comment-item { padding: 10px 0; }
.ci-reply-compose {
display: flex; gap: 8px; margin-top: 8px; padding-left: 16px;
border-left: 2px solid rgba(155,93,229,0.12);
}
.ci-reply-textarea {
flex: 1; min-height: 36px; padding: 8px 12px;
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; color: #1E293B;
background: #f8f9fc; resize: none; box-sizing: border-box;
}
.ci-reply-textarea:focus { outline: none; border-color: var(--violet); }
.ci-reply-send {
padding: 6px 14px; border: none; border-radius: 999px;
background: var(--violet); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700;
cursor: pointer; align-self: flex-end; white-space: nowrap;
}
.ci-reply-send:disabled { opacity: 0.4; }
.comments-empty {
text-align: center; padding: 28px; color: var(--text-3); font-size: 0.84rem;
}
/* ── Mobile responsive ── */
@media (max-width: 768px) {
/* Topbar */
.lesson-topbar { padding: 10px 12px; gap: 8px; }
.topbar-read-time { display: none; }
.lesson-topbar-title { font-size: 0.72rem; }
.lesson-edit-btn span:not([data-lucide]) { display: none; }
/* Lesson body — tighter padding, smaller heading */
.lesson-body { padding: 20px 14px 0; }
.lesson-heading-block h1 { font-size: 1.2rem; }
.lesson-heading-block { margin-bottom: 24px; }
/* Nav buttons */
.lesson-nav { padding: 20px 14px 0; }
.lesson-nav-btn { padding: 12px 12px; font-size: 0.78rem; }
.lesson-nav-btn-title { max-width: 110px; }
/* Sections with 28px padding <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> 14px */
.lesson-complete-wrap { padding: 0 14px; }
.notes-panel { padding: 0 14px; }
.comments-section { padding: 0 14px 60px; }
/* Matching block: side-by-side <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> stacked */
.matching-game { grid-template-columns: 1fr; gap: 10px; }
/* Sim embed: reduce height */
.sim-embed-frame { height: 280px; }
/* Formula: smaller font */
.block-formula { font-size: 0.95rem; }
/* Code block */
.block-code pre { font-size: 0.78rem; padding: 14px 12px; }
/* Flashcard: smaller min-height */
.flashcard-face { min-height: 90px; padding: 18px 16px; font-size: 0.88rem; }
/* Reply compose on narrow screens */
.ci-reply-compose { padding-left: 8px; gap: 6px; }
.ci-reply-send { padding: 6px 10px; font-size: 0.72rem; }
}
@media (max-width: 480px) {
.lesson-body { padding: 14px 10px 0; }
.lesson-nav { padding: 16px 10px 0; }
.lesson-complete-wrap, .notes-panel, .comments-section { padding-left: 10px; padding-right: 10px; }
.lesson-heading-block h1 { font-size: 1.05rem; }
.lesson-nav-btn { padding: 10px 8px; font-size: 0.74rem; }
.lesson-nav-btn-title { max-width: 80px; }
}
/* ── Print / PDF ── */
@media print {
.sidebar, .mob-bar, .sb-backdrop, .notif-drop,
.lesson-topbar, .lesson-sidebar-toc, .lesson-nav,
.lesson-complete-wrap, .notes-panel, .comments-section,
.block-quiz .quiz-opt, .block-quiz .quiz-feedback { break-inside: avoid; }
.sidebar, .mob-bar, .sb-backdrop, .notif-drop { display: none !important; }
.lesson-topbar { display: none !important; }
.lesson-sidebar-toc { display: none !important; }
.lesson-nav { display: none !important; }
.lesson-complete-wrap { display: none !important; }
.notes-panel { display: none !important; }
.comments-section { display: none !important; }
.app-layout { display: block !important; }
.sb-content { padding: 0 !important; }
.lesson-layout { display: block !important; }
.lesson-main { padding: 0 !important; }
.lesson-body { max-width: none; padding: 0 20px; }
.lesson-heading-block { margin-bottom: 24px; }
.lesson-block { break-inside: avoid; }
.block-code pre { white-space: pre-wrap; word-break: break-word; }
.block-image img { max-height: 400px; object-fit: contain; }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="lesson-layout">
<!-- Main lesson content -->
<div class="lesson-main" id="lesson-main">
<!-- Topbar -->
<div class="lesson-topbar">
<a href="#" id="topbar-back" class="lesson-topbar-back">
<i data-lucide="arrow-left" style="width:14px;height:14px"></i>
<span id="topbar-course">Курс</span>
</a>
<div class="lesson-topbar-title" id="topbar-title">Загрузка…</div>
<div class="lesson-topbar-actions">
<span class="topbar-read-time" id="topbar-read-time" style="display:none">
<i data-lucide="clock" style="width:12px;height:12px"></i>
<span id="topbar-read-time-val"></span>
</span>
<button class="lesson-bm-btn" id="btn-bookmark" onclick="toggleBookmark()" title="В закладки">
<i data-lucide="bookmark" style="width:14px;height:14px" id="bm-icon"></i>
</button>
<button class="lesson-edit-btn" id="btn-edit-lesson" style="display:none"
onclick="goToEditor()">
<i data-lucide="pencil" style="width:13px;height:13px"></i> Редактировать
</button>
</div>
<div class="lesson-progress-bar-wrap">
<div class="lesson-progress-bar-fill" id="read-progress"></div>
</div>
</div>
<!-- Body -->
<div class="lesson-body" id="lesson-body">
<div class="spinner"></div>
</div>
<!-- Complete + Navigation (populated after load) -->
<div id="lesson-footer"></div>
<!-- Comments section -->
<div class="comments-section" id="comments-section" style="display:none">
<div class="comments-title" id="comments-anchor">
<i data-lucide="message-circle" style="width:16px;height:16px;opacity:0.5"></i>
Обсуждение
<span class="comments-count" id="comments-count">0</span>
</div>
<div id="comment-compose-wrap"></div>
<div class="comment-list" id="comment-list"></div>
</div>
</div><!-- /lesson-main -->
<!-- TOC sidebar -->
<div class="lesson-sidebar-toc" id="toc-sidebar"></div>
</div><!-- /lesson-layout -->
</div><!-- /sb-content -->
</div><!-- /app-layout -->
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/svg-sanitize.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
const user = LS.getUser();
document.getElementById('nav-user').textContent = user?.name || user?.email || '';
LS.renderNavAvatar(document.getElementById('nav-avatar'), user);
const isTeacher = ['admin','teacher'].includes(user?.role);
LS.showBoardIfAllowed();
LS.applyRoleSidebar(user);
if (isTeacher) {
document.getElementById('btn-classes').style.display = '';
document.getElementById('btn-admin').style.display = '';
document.getElementById('btn-edit-lesson').style.display = '';
}
lucide.createIcons();
/* ── sidebar collapse ── */
function toggleSidebar() {
const layout = document.querySelector('.app-layout');
const collapsed = layout.classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', collapsed ? '1' : '');
lucide.createIcons();
}
if (localStorage.getItem('ls_sb_collapsed'))
document.querySelector('.app-layout').classList.add('sb-collapsed');
/* ── notif ── */
function toggleNotifDrop() {
const btn = document.getElementById('notif-btn');
const drop = document.getElementById('notif-drop');
const r = btn.getBoundingClientRect();
drop.style.left = (r.right + 8) + 'px';
drop.style.top = r.top + 'px';
if (drop.classList.toggle('open')) loadNotifs();
}
async function loadNotifs() {
const drop = document.getElementById('notif-drop');
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div><div class="notif-empty">Загрузка…</div>';
try {
const data = await LS.api('/api/notifications?limit=20');
const items = data.items || [];
const badge = document.getElementById('notif-badge');
const unread = items.filter(n => !n.is_read).length;
badge.textContent = unread; badge.style.display = unread ? '' : 'none';
if (!items.length) { drop.querySelector('.notif-empty').textContent = 'Нет уведомлений'; return; }
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div>' +
items.map(n => `<a class="notif-item${n.is_read ? '' : ' unread'}" href="${LS.safeHref(n.link)}" onclick="markRead(${n.id})">
<div class="notif-dot${n.is_read ? ' read' : ''}"></div>
<div><div class="notif-msg">${esc(n.message)}</div><div class="notif-time">${fmtTime(n.created_at)}</div></div>
</a>`).join('');
} catch {}
}
async function markRead(id) { try { await LS.api('/api/notifications/' + id + '/read', { method:'POST' }); } catch {} }
async function readAllNotifs() { try { await LS.api('/api/notifications/read-all', { method:'POST' }); loadNotifs(); } catch {} }
document.addEventListener('click', e => {
const drop = document.getElementById('notif-drop');
if (drop.classList.contains('open') && !drop.contains(e.target) && !document.getElementById('notif-btn').contains(e.target))
drop.classList.remove('open');
});
/* ── helpers ── */
function escAll(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
/* Санитайзер rich-HTML (блок columns хранит форматированный HTML из мини-редактора).
Парсим в инертный <template> (картинки/скрипты НЕ исполняются), вырезаем опасное,
сериализуем обратно. Блокирует on*-обработчики, script/iframe, javascript:/data: URL. */
function sanitizeRichHtml(html) {
const tpl = document.createElement('template');
tpl.innerHTML = String(html || '');
tpl.content.querySelectorAll('script,style,iframe,object,embed,link,meta,form,base').forEach(n => n.remove());
tpl.content.querySelectorAll('*').forEach(el => {
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) el.removeAttribute(attr.name);
else if (name === 'style') el.removeAttribute(attr.name);
else if (/^(href|src|xlink:href)$/.test(name) && /^\s*(javascript|data|vbscript):/i.test(attr.value || '')) el.removeAttribute(attr.name);
}
});
return tpl.innerHTML;
}
function fmtTime(s) {
const d = new Date(s && s.includes('T') ? s : (s||'').replace(' ','T')+'Z');
const diff = Date.now() - d.getTime();
if (diff < 60000) return 'только что';
if (diff < 3600000) return Math.floor(diff/60000) + ' мин назад';
return d.toLocaleDateString('ru', { day:'numeric', month:'short' });
}
/* ── KaTeX render ── */
let _katexQueue = null;
window._katexReady = function() {
if (_katexQueue) { _renderKatex(_katexQueue); _katexQueue = null; }
};
function renderMath(el) {
if (window.renderMathInElement) {
_renderKatex(el);
} else {
_katexQueue = el;
}
}
function _renderKatex(el) {
try {
renderMathInElement(el, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '\\(', right: '\\)', display: false },
],
throwOnError: false,
});
} catch {}
}
/* ── read-progress scroll bar ── */
function updateReadProgress() {
const main = document.getElementById('lesson-main');
const scrollTop = main.scrollTop || document.documentElement.scrollTop;
const scrollHeight = (main.scrollHeight || document.documentElement.scrollHeight) - window.innerHeight;
const pct = scrollHeight > 0 ? Math.min(100, (scrollTop / scrollHeight) * 100) : 0;
document.getElementById('read-progress').style.width = pct + '%';
}
window.addEventListener('scroll', updateReadProgress, { passive: true });
/* ── quiz handling ── */
function answerQuiz(blockId, optIdx, correctIdx) {
const block = document.querySelector(`.block-quiz[data-bid="${blockId}"]`);
if (!block || block.dataset.answered) return;
block.dataset.answered = '1';
const opts = block.querySelectorAll('.quiz-opt');
opts.forEach((o, i) => {
o.disabled = true;
if (i === correctIdx) o.classList.add('correct');
if (i === optIdx && optIdx !== correctIdx) o.classList.add('wrong');
});
const fb = block.querySelector('.quiz-feedback');
if (fb) {
fb.classList.add('show');
if (optIdx === correctIdx) { fb.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Верно!'; fb.classList.add('ok'); }
else { fb.innerHTML = '<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> Неверно. Правильный ответ выделен.'; fb.classList.add('bad'); }
}
}
/* ── matching drag-and-drop ── */
function initMatchingDnD() {
document.querySelectorAll('.block-matching').forEach(block => {
if (block.dataset.answered) return;
const rightCol = block.querySelector('.matching-right');
if (!rightCol) return;
const items = rightCol.querySelectorAll('.match-right');
items.forEach(item => {
item.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', '');
item.classList.add('dragging');
});
item.addEventListener('dragend', () => item.classList.remove('dragging'));
item.addEventListener('dragover', e => {
e.preventDefault();
item.classList.add('drag-over');
});
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', e => {
e.preventDefault();
item.classList.remove('drag-over');
const dragging = rightCol.querySelector('.dragging');
if (!dragging || dragging === item) return;
// swap positions
const parent = rightCol;
const children = [...parent.children];
const fromIdx = children.indexOf(dragging);
const toIdx = children.indexOf(item);
if (fromIdx < toIdx) {
parent.insertBefore(dragging, item.nextSibling);
} else {
parent.insertBefore(dragging, item);
}
});
});
});
}
function checkMatching(bid) {
const block = document.querySelector(`.block-matching[data-bid="${bid}"]`);
if (!block || block.dataset.answered) return;
block.dataset.answered = '1';
const rightItems = block.querySelectorAll('.matching-right .match-right');
const leftItems = block.querySelectorAll('.matching-left .match-left');
let allCorrect = true;
rightItems.forEach((item, i) => {
const origIdx = parseInt(item.dataset.orig);
if (origIdx === i) {
item.classList.add('correct');
leftItems[i]?.classList.add('correct');
} else {
item.classList.add('wrong');
leftItems[i]?.classList.add('wrong');
allCorrect = false;
}
item.removeAttribute('draggable');
item.style.cursor = 'default';
});
const fb = document.getElementById('mfb-' + bid);
if (fb) {
fb.className = 'match-feedback ' + (allCorrect ? 'ok' : 'bad');
fb.innerHTML = allCorrect ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Все пары верны!' : '<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> Есть ошибки. Верные пары выделены зелёным.';
}
block.querySelector('.match-check-btn').disabled = true;
}
/* ── fill-blank check ── */
function checkFillBlanks(bid) {
const block = document.querySelector(`.block-fill-blank[data-bid="${bid}"]`);
if (!block || block.dataset.answered) return;
block.dataset.answered = '1';
const inputs = block.querySelectorAll('.blank-input');
let allCorrect = true;
inputs.forEach(inp => {
const expected = inp.dataset.answer.trim().toLowerCase();
const given = inp.value.trim().toLowerCase();
if (given === expected) {
inp.classList.add('correct');
} else {
inp.classList.add('wrong');
allCorrect = false;
}
inp.disabled = true;
});
const fb = document.getElementById('ffb-' + bid);
if (fb) {
fb.className = 'fill-blank-feedback ' + (allCorrect ? 'ok' : 'bad');
fb.innerHTML = allCorrect ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Все пропуски заполнены верно!' : '<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> Есть ошибки. Верные ответы выделены зелёным.';
}
block.querySelector('.match-check-btn').disabled = true;
}
/* ── ordering drag-and-drop ── */
function initOrderingDnD() {
document.querySelectorAll('.block-ordering').forEach(block => {
if (block.dataset.answered) return;
const list = block.querySelector('.ordering-list');
if (!list) return;
const items = list.querySelectorAll('.ordering-item');
items.forEach(item => {
item.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', '');
item.classList.add('dragging');
});
item.addEventListener('dragend', () => item.classList.remove('dragging'));
item.addEventListener('dragover', e => {
e.preventDefault();
item.classList.add('drag-over');
});
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', e => {
e.preventDefault();
item.classList.remove('drag-over');
const dragging = list.querySelector('.dragging');
if (!dragging || dragging === item) return;
const children = [...list.children];
const fromIdx = children.indexOf(dragging);
const toIdx = children.indexOf(item);
if (fromIdx < toIdx) {
list.insertBefore(dragging, item.nextSibling);
} else {
list.insertBefore(dragging, item);
}
});
});
});
}
function checkOrdering(bid) {
const block = document.querySelector(`.block-ordering[data-bid="${bid}"]`);
if (!block || block.dataset.answered) return;
block.dataset.answered = '1';
const items = block.querySelectorAll('.ordering-item');
let allCorrect = true;
items.forEach((item, i) => {
const origIdx = parseInt(item.dataset.orig);
if (origIdx === i) {
item.classList.add('correct');
} else {
item.classList.add('wrong');
allCorrect = false;
}
item.removeAttribute('draggable');
item.style.cursor = 'default';
});
const fb = document.getElementById('ofb-' + bid);
if (fb) {
fb.className = 'ordering-feedback ' + (allCorrect ? 'ok' : 'bad');
fb.innerHTML = allCorrect ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Правильный порядок!' : '<svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> Порядок неверный. Верные позиции выделены зелёным.';
}
block.querySelector('.ordering-check-btn').disabled = true;
}
/* ── block renderer ── */
function renderBlock(b, idx) {
const d = b.data || {};
switch (b.type) {
case 'heading':
return `<div class="lesson-block block-heading" id="h${idx}">
<h${d.level === 3 ? '3' : '2'}>${esc(d.text || '')}</h${d.level === 3 ? '3' : '2'}>
</div>`;
case 'text':
// Split by \n\n into paragraphs
return `<div class="lesson-block block-text">
${(d.text || '').split('\n\n').filter(Boolean).map(p =>
`<p>${esc(p).replace(/\n/g, '<br>')}</p>`
).join('')}
</div>`;
case 'formula':
return `<div class="lesson-block block-formula">
${d.label ? `<div class="block-formula-label">${esc(d.label)}</div>` : ''}
<div class="formula-content">$$${d.tex || ''}$$</div>
</div>`;
case 'image':
return `<div class="lesson-block block-image">
<img src="${escAll(d.url || '')}" alt="${escAll(d.alt || '')}" loading="lazy" />
${d.caption ? `<div class="block-image-caption">${esc(d.caption)}</div>` : ''}
</div>`;
case 'svg-draw': {
const safeSvg = (window.SvgSanitize ? SvgSanitize.clean(d.svg || '') : '').replace('<svg ', '<svg style="max-width:100%;height:auto;display:block;margin:0 auto" ');
return `<div class="lesson-block block-image">
${safeSvg}
${d.caption ? `<div class="block-image-caption">${esc(d.caption)}</div>` : ''}
</div>`;
}
case 'divider':
return `<div class="lesson-block block-divider"><hr /></div>`;
case 'code':
return `<div class="lesson-block block-code">
<pre><code class="${d.lang ? 'language-'+escAll(d.lang) : ''}">${esc(d.code || '')}</code></pre>
</div>`;
case 'quiz': {
const opts = d.options || [];
const cIdx = d.correctIndex ?? 0;
return `<div class="lesson-block block-quiz" data-bid="${b.id}">
<div class="quiz-question">${esc(d.question || 'Вопрос')}</div>
<div class="quiz-options">
${opts.map((opt, oi) =>
`<button class="quiz-opt" onclick="answerQuiz('${b.id}',${oi},${cIdx})">${esc(opt)}</button>`
).join('')}
</div>
<div class="quiz-feedback"></div>
</div>`;
}
case 'callout': {
const style = d.style || 'info';
const icons = { info: LS.icon('info',16), warning: LS.icon('warning',16), success: LS.icon('check-circle',16), error: LS.icon('x-close',16) };
return `<div class="lesson-block block-callout block-callout-${style}">
<span class="callout-icon">${icons[style] || LS.icon('info',16)}</span>
<div class="callout-text">${esc(d.text || '')}</div>
</div>`;
}
case 'video': {
const url = d.url || '';
const startSec = d.startSec || 0;
let embedUrl = '';
// YouTube
const ytId = url.match(/(?:youtu\.be\/|[?&]v=|embed\/)([A-Za-z0-9_-]{11})/)?.[1];
if (ytId) {
const ts = url.match(/[?&](?:t|start)=([^&]+)/);
const base = 'https://www.youtube.com/embed/' + ytId;
const sec = startSec || (ts ? parseInt(ts[1]) : 0);
embedUrl = sec ? base + '?start=' + sec : base;
}
// Rutube
if (!embedUrl) {
const rtId = url.match(/rutube\.ru\/(?:video|play\/embed)\/([a-f0-9]+)/i)?.[1];
if (rtId) {
const ts = url.match(/[?&]t=([^&]+)/);
const base = 'https://rutube.ru/play/embed/' + rtId;
const sec = startSec || (ts ? parseInt(ts[1]) : 0);
embedUrl = sec ? base + '?t=' + sec : base;
}
}
// already embed or direct
if (!embedUrl && (url.includes('/embed/') || url.includes('player.'))) embedUrl = url;
if (!embedUrl) return `<div class="lesson-block block-sim"><div style="color:var(--text-3);font-size:0.84rem">Видео: ${esc(url)}</div></div>`;
return `<div class="lesson-block block-video">
<iframe src="${esc(embedUrl)}" allowfullscreen loading="lazy"></iframe>
${d.caption ? `<div class="block-video-caption">${esc(d.caption)}</div>` : ''}
</div>`;
}
case 'table': {
const rows = d.rows || [];
const header = d.header || [];
let tableHtml = '<table>';
if (header.length) {
tableHtml += '<thead><tr>' + header.map(c => `<th>${esc(c)}</th>`).join('') + '</tr></thead>';
}
if (rows.length) {
tableHtml += '<tbody>' + rows.map(r =>
'<tr>' + (r || []).map(c => `<td>${esc(c)}</td>`).join('') + '</tr>'
).join('') + '</tbody>';
}
tableHtml += '</table>';
return `<div class="lesson-block block-table">${tableHtml}</div>`;
}
case 'flashcard': {
const fid = 'fc' + idx;
return `<div class="lesson-block block-flashcard">
<div class="flashcard-inner" id="${fid}" onclick="document.getElementById('${fid}').classList.toggle('flipped')">
<div class="flashcard-face flashcard-front">${esc(d.front || 'Вопрос')}</div>
<div class="flashcard-face flashcard-back">${esc(d.back || 'Ответ')}</div>
</div>
<div class="flashcard-hint"><i data-lucide="refresh-cw" style="width:11px;height:11px"></i> Нажмите, чтобы перевернуть</div>
</div>`;
}
case 'sim': {
const simId = d.simId || '';
const caption = d.caption || '';
const embedUrl = simId ? `/lab?sim=${encodeURIComponent(simId)}&embed=1` : '';
const fullUrl = simId ? `/lab?sim=${encodeURIComponent(simId)}` : '/lab';
return `<div class="lesson-block block-sim">
${embedUrl
? `<iframe class="sim-embed-frame" src="${embedUrl}" loading="lazy" allow="fullscreen"></iframe>`
: `<div style="height:200px;display:flex;align-items:center;justify-content:center;color:var(--text-3)">Симуляция не указана</div>`}
<div class="sim-embed-bar">
<span class="sim-caption">${caption ? esc(caption) : (simId ? '<svg class="ic" viewBox="0 0 24 24"><path d="M6 18h8"/><path d="M3 22h18"/><path d="M14 22a7 7 0 1 0 0-14h-1"/><path d="M9 14l2-7"/><path d="M12 14l2-7"/></svg> Интерактивная симуляция' : '')}</span>
<a class="sim-fullscreen-btn" href="${fullUrl}" target="_blank">
<i data-lucide="maximize-2" style="width:13px;height:13px"></i> На весь экран
</a>
</div>
</div>`;
}
case 'matching': {
const pairs = d.pairs || [];
// shuffle right side
const shuffled = pairs.map((p, i) => ({ right: p.right, _origIdx: i }));
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const bid = b.id || ('mb' + idx);
return `<div class="lesson-block block-matching" data-bid="${bid}" data-correct='${JSON.stringify(pairs.map((_,i)=>i))}'>
${d.question ? `<div class="matching-question">${esc(d.question)}</div>` : ''}
<div class="matching-game">
<div class="matching-left">
${pairs.map((p, i) => `<div class="match-item match-left" data-idx="${i}">${esc(p.left)}</div>`).join('')}
</div>
<div class="matching-right" id="mr-${bid}">
${shuffled.map(p => `<div class="match-item match-right" draggable="true" data-orig="${p._origIdx}">${esc(p.right)}</div>`).join('')}
</div>
</div>
<button class="match-check-btn" onclick="checkMatching('${bid}')">Проверить</button>
<div class="match-feedback" id="mfb-${bid}"></div>
</div>`;
}
case 'fill-blank': {
const bid = b.id || ('fb' + idx);
const txt = d.text || '';
let blankIdx = 0;
const rendered = esc(txt).replace(/\{([^}]+)\}/g, (_, answer) => {
const i = blankIdx++;
return `<input class="blank-input" type="text" data-answer="${escAll(answer)}" data-idx="${i}" placeholder="…" autocomplete="off" />`;
});
return `<div class="lesson-block block-fill-blank" data-bid="${bid}">
<div class="fill-blank-text">${rendered}</div>
<div class="fill-blank-actions">
<button class="match-check-btn" onclick="checkFillBlanks('${bid}')">Проверить</button>
</div>
<div class="fill-blank-feedback" id="ffb-${bid}"></div>
</div>`;
}
case 'ordering': {
const items = d.items || [];
const bid = b.id || ('ob' + idx);
// shuffle
const shuffled = items.map((item, i) => ({ text: item, _origIdx: i }));
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return `<div class="lesson-block block-ordering" data-bid="${bid}">
${d.question ? `<div class="ordering-question">${esc(d.question)}</div>` : ''}
<div class="ordering-list" id="ol-${bid}">
${shuffled.map(s => `<div class="ordering-item" draggable="true" data-orig="${s._origIdx}">
<span class="ordering-grip"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><circle cx="9" cy="5" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="19" r="1"/></svg></span>
<span>${esc(s.text)}</span>
</div>`).join('')}
</div>
<button class="ordering-check-btn" onclick="checkOrdering('${bid}')">Проверить</button>
<div class="ordering-feedback" id="ofb-${bid}"></div>
</div>`;
}
case 'accordion': {
const bid = b.id || ('acc' + idx);
return `<div class="lesson-block block-accordion" id="acc-${bid}">
<div class="accordion-header" onclick="this.parentElement.classList.toggle('open')">
<span class="accordion-title">${esc(d.title || 'Подробнее…')}</span>
<svg class="accordion-chevron" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</div>
<div class="accordion-body">${esc(d.content || '')}</div>
</div>`;
}
case 'timeline': {
const items = Array.isArray(d.items) ? d.items : [];
return `<div class="lesson-block block-timeline">
<div class="timeline-track">
${items.map((it, i) => `<div class="timeline-entry">
<div class="timeline-node">
<div class="timeline-dot-v"></div>
${i < items.length - 1 ? '<div class="timeline-line-v"></div>' : ''}
</div>
<div class="timeline-content">
<div class="timeline-date">${esc(it.date || '')}</div>
<div class="timeline-event-title">${esc(it.title || '')}</div>
${it.text ? `<div class="timeline-event-text">${esc(it.text)}</div>` : ''}
</div>
</div>`).join('')}
</div>
</div>`;
}
case 'diagram': {
const bid = b.id || ('dg' + idx);
return `<div class="lesson-block block-diagram">
<div class="diagram-render" id="diagram-${bid}"><div class="mermaid">${esc(d.code || '')}</div></div>
${d.caption ? `<div class="diagram-caption">${esc(d.caption)}</div>` : ''}
</div>`;
}
case 'geogebra': {
const mid = d.materialId || '';
return `<div class="lesson-block block-geogebra">
${mid ? `<div class="geogebra-embed"><iframe src="https://www.geogebra.org/material/iframe/id/${escAll(mid)}/width/800/height/500/border/888888/sfsb/true/smb/false/stb/false/stbh/false/ai/false/asb/false/sri/false/rc/false/ld/false/sdz/false/ctl/false" allowfullscreen></iframe></div>` : '<div style="text-align:center;padding:20px;color:var(--text-3)">Не указан ID материала GeoGebra</div>'}
${d.caption ? `<div class="geogebra-caption">${esc(d.caption)}</div>` : ''}
</div>`;
}
case 'audio':
return `<div class="lesson-block block-audio">
${d.url ? `<audio controls src="${escAll(d.url)}" style="width:100%"></audio>` : ''}
${d.caption ? `<div class="audio-caption">${esc(d.caption)}</div>` : ''}
</div>`;
case 'columns': {
const cols = Array.isArray(d.cols) ? d.cols : [];
return `<div class="lesson-block block-columns cols-${cols.length}">
${cols.map(c => `<div class="block-col">${sanitizeRichHtml(c.content || '')}</div>`).join('')}
</div>`;
}
case 'alert': {
const alertMap = {
exam: { icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>', label: 'К экзамену' },
homework: { icon: '<svg class="ic" viewBox="0 0 24 24"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>', label: 'Домашнее задание' },
important: { icon: '<svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>', label: 'Важно' },
tip: { icon: '<svg class="ic" viewBox="0 0 24 24"><line x1="9" y1="18" x2="15" y2="18"/><line x1="10" y1="22" x2="14" y2="22"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>', label: 'Совет' },
celebrate: { icon: '<svg class="ic" viewBox="0 0 24 24"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z"/></svg>', label: 'Поздравление' },
};
const a = alertMap[d.style] || alertMap.important;
return `<div class="lesson-block block-alert block-alert-${d.style || 'important'}">
<span class="block-alert-icon">${a.icon}</span>
<div>
<div class="block-alert-label">${a.label}</div>
<div class="block-alert-text">${esc(d.text || '')}</div>
</div>
</div>`;
}
default:
return '';
}
}
/* ── TOC builder ── */
function buildToc(blocks) {
const headings = blocks.filter(b => b.type === 'heading' && b.data?.text);
if (headings.length < 2) return;
const toc = document.getElementById('toc-sidebar');
toc.innerHTML =
`<div class="toc-title"><span class="toc-title-bar"></span>Содержание</div>` +
headings.map(h => {
const idx = blocks.indexOf(h);
const cls = h.data.level === 3 ? ' h3' : '';
return `<a class="toc-item${cls}" href="#h${idx}">${esc(h.data.text)}</a>`;
}).join('');
// Highlight active section while scrolling
const items = toc.querySelectorAll('.toc-item');
const headingEls = headings.map(h => document.getElementById('h' + blocks.indexOf(h)));
const obs = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
const id = e.target.id;
items.forEach(a => a.classList.toggle('active', a.getAttribute('href') === '#' + id));
}
});
}, { rootMargin: '-8% 0px -78% 0px' });
headingEls.forEach(el => { if (el) obs.observe(el); });
}
/* ── load lesson ── */
const lessonId = new URLSearchParams(location.search).get('id');
if (!lessonId) location.href = '/theory';
let lesson = null;
function goToEditor() {
if (lesson) location.href = '/lesson-editor?id=' + lesson.id;
}
/* ── bookmark ── */
let _bmId = null;
async function checkBookmarkState() {
try {
const r = await LS.checkBookmark('lesson', lessonId);
_bmId = r.id;
const btn = document.getElementById('btn-bookmark');
if (btn) btn.classList.toggle('active', r.bookmarked);
} catch {}
}
async function toggleBookmark() {
const btn = document.getElementById('btn-bookmark');
if (!btn) return;
try {
if (_bmId) {
await LS.removeBookmark(_bmId);
_bmId = null;
btn.classList.remove('active');
LS.toast('Убрано из закладок', 'info');
} else {
const r = await LS.addBookmark('lesson', Number(lessonId));
_bmId = r.id;
btn.classList.add('active');
LS.toast('Добавлено в закладки', 'success');
}
} catch (e) {
if (e.status === 409) { btn.classList.add('active'); LS.toast('Уже в закладках', 'info'); }
else LS.toast(e.message || 'Ошибка', 'error');
}
}
async function loadLesson() {
try {
lesson = await LS.api('/api/lessons/' + lessonId);
} catch (e) {
document.getElementById('lesson-body').innerHTML = '<div style="text-align:center;padding:60px;color:var(--text-3)">Урок не найден</div>';
return;
}
document.title = lesson.title + ' — LearnSpace';
document.getElementById('topbar-title').textContent = lesson.title;
document.getElementById('topbar-course').textContent = lesson.courseTitle || 'Курс';
document.getElementById('topbar-back').href = '/course?id=' + lesson.courseId;
// Read time
if (lesson.readTime > 0) {
document.getElementById('topbar-read-time-val').textContent = lesson.readTime + ' мин';
document.getElementById('topbar-read-time').style.display = '';
}
// Render blocks
const body = document.getElementById('lesson-body');
body.innerHTML = `
<div class="lesson-heading-block">
<div class="lesson-course-crumb">
<a href="/course?id=${lesson.courseId}">${esc(lesson.courseTitle || 'Курс')}</a>
<i data-lucide="chevron-right" style="width:12px;height:12px;opacity:0.4"></i>
<span>${esc(lesson.title)}</span>
</div>
<h1>${esc(lesson.title)}</h1>
</div>
${(lesson.blocks || []).map((b, i) => renderBlock(b, i)).join('')}
`;
lucide.createIcons();
// Syntax highlighting
if (window.hljs) {
document.querySelectorAll('.block-code pre code').forEach(el => hljs.highlightElement(el));
}
// Mermaid diagrams
if (window.mermaid) {
try { mermaid.run({ nodes: document.querySelectorAll('.mermaid') }); } catch {}
}
// Init drag-and-drop for interactive blocks
initMatchingDnD();
initOrderingDnD();
// KaTeX
renderMath(body);
// Notify print hook
if (window._onLessonRendered) setTimeout(() => window._onLessonRendered(), 200);
// TOC
buildToc(lesson.blocks || []);
// Footer
const footer = document.getElementById('lesson-footer');
const prevBtn = lesson.prev
? `<a class="lesson-nav-btn lesson-nav-btn-prev" href="/lesson?id=${lesson.prev.id}">
<i data-lucide="arrow-left" style="width:15px;height:15px;flex-shrink:0"></i>
<div><span class="lesson-nav-btn-label">Предыдущий</span><span class="lesson-nav-btn-title">${esc(lesson.prev.title)}</span></div>
</a>`
: '<div></div>';
const nextBtn = lesson.next
? `<a class="lesson-nav-btn lesson-nav-btn-next" href="/lesson?id=${lesson.next.id}">
<div style="text-align:right"><span class="lesson-nav-btn-label">Следующий</span><span class="lesson-nav-btn-title">${esc(lesson.next.title)}</span></div>
<i data-lucide="arrow-right" style="width:15px;height:15px;flex-shrink:0"></i>
</a>`
: '<div></div>';
footer.innerHTML = `
<div class="lesson-complete-wrap">
<button class="btn-complete${lesson.completed ? ' done' : ''}" id="btn-complete"
onclick="markComplete()" ${lesson.completed ? 'disabled' : ''}>
<i data-lucide="${lesson.completed ? 'check-circle' : 'check'}" style="width:18px;height:18px"></i>
${lesson.completed ? 'Урок пройден' : 'Отметить как пройденный'}
</button>
</div>
<div class="notes-panel">
<button class="notes-toggle" onclick="toggleNotes()">
<i data-lucide="pencil-line" style="width:14px;height:14px"></i>
Заметки к уроку
<i data-lucide="chevron-down" style="width:13px;height:13px" id="notes-chevron"></i>
</button>
<div class="notes-area-wrap${lesson.note ? ' open' : ''}" id="notes-area-wrap">
<textarea class="notes-textarea" id="notes-textarea" placeholder="Запишите свои мысли, ключевые моменты…">${esc(lesson.note || '')}</textarea>
<div class="notes-save-row">
<span class="notes-saved-lbl" id="notes-saved-lbl"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Сохранено</span>
<button class="notes-save-btn" onclick="saveNote()">Сохранить</button>
</div>
</div>
</div>
<div class="lesson-nav">${prevBtn}${nextBtn}</div>
`;
lucide.createIcons();
// auto-save notes on input (debounced)
let _notesTimer = null;
document.getElementById('notes-textarea').addEventListener('input', () => {
clearTimeout(_notesTimer);
_notesTimer = setTimeout(saveNote, 2000);
});
// Load comments
loadComments();
// Check bookmark
checkBookmarkState();
}
/* ── notes ── */
function toggleNotes() {
const wrap = document.getElementById('notes-area-wrap');
const chevron = document.getElementById('notes-chevron');
if (wrap) wrap.classList.toggle('open');
if (chevron) chevron.style.transform = wrap?.classList.contains('open') ? 'rotate(180deg)' : '';
}
async function saveNote() {
const ta = document.getElementById('notes-textarea');
if (!ta || !lessonId) return;
const btn = document.querySelector('.notes-save-btn');
const lbl = document.getElementById('notes-saved-lbl');
if (btn) btn.disabled = true;
try {
await LS.api('/api/lessons/' + lessonId + '/note', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: ta.value }),
});
if (lbl) { lbl.style.display = ''; setTimeout(() => { if (lbl) lbl.style.display = 'none'; }, 2000); }
} catch (e) {
LS.toast(e.message || 'Ошибка сохранения', 'error');
} finally {
if (btn) btn.disabled = false;
}
}
/* ── mark complete ── */
async function markComplete() {
const btn = document.getElementById('btn-complete');
if (!btn || btn.disabled) return;
btn.disabled = true;
try {
const res = await LS.api('/api/lessons/' + lessonId + '/complete', { method: 'POST' });
btn.className = 'btn-complete done';
btn.innerHTML = '<i data-lucide="check-circle" style="width:18px;height:18px"></i> Урок пройден';
lucide.createIcons();
if (res.courseComplete) {
setTimeout(() => LS.toast('Поздравляем! Вы прошли весь курс!', 'success'), 400);
}
} catch (e) {
btn.disabled = false;
LS.toast(e.message || 'Ошибка', 'error');
}
}
/* ── comments ── */
const userInitials = (user?.name || 'LS').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
let _replyingTo = null;
async function loadComments() {
if (!lessonId) return;
const section = document.getElementById('comments-section');
section.style.display = '';
// compose box
document.getElementById('comment-compose-wrap').innerHTML = `
<div class="comment-compose">
<div class="comment-avatar">${esc(userInitials)}</div>
<div class="comment-compose-body">
<textarea class="comment-textarea" id="comment-input" placeholder="Задайте вопрос или оставьте комментарий…"></textarea>
<div class="comment-submit-row">
<button class="comment-submit-btn" id="comment-send-btn" onclick="submitComment()">Отправить</button>
</div>
</div>
</div>`;
try {
const comments = await LS.api('/api/lessons/' + lessonId + '/comments');
const list = document.getElementById('comment-list');
const totalCount = comments.reduce((n, c) => n + 1 + (c.replies?.length || 0), 0);
document.getElementById('comments-count').textContent = totalCount;
if (!comments.length) {
list.innerHTML = '<div class="comments-empty">Комментариев пока нет. Будьте первым!</div>';
return;
}
list.innerHTML = comments.map(c => renderComment(c)).join('');
lucide.createIcons();
} catch {
document.getElementById('comment-list').innerHTML = '';
}
}
function renderComment(c) {
const initials = (c.userName || '??').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('');
const avatarCls = ['teacher','admin'].includes(c.userRole) ? 'ci-avatar-teacher' : 'ci-avatar-student';
const roleTag = c.userRole === 'teacher' ? '<span class="ci-role ci-role-teacher">Учитель</span>'
: c.userRole === 'admin' ? '<span class="ci-role ci-role-admin">Админ</span>' : '';
const canDelete = c.userId === user?.id || isTeacher;
let html = `<div class="comment-item" data-cid="${c.id}">
<div class="ci-avatar ${avatarCls}">${esc(initials)}</div>
<div class="ci-body">
<div class="ci-header">
<span class="ci-name">${esc(c.userName)}</span>
${roleTag}
<span class="ci-time">${fmtTime(c.createdAt)}</span>
</div>
<div class="ci-text">${esc(c.text)}</div>
<div class="ci-actions">
<button class="ci-action-btn" onclick="showReplyBox(${c.id})">Ответить</button>
${canDelete ? `<button class="ci-action-btn danger" onclick="deleteComment(${c.id})">Удалить</button>` : ''}
</div>
<div id="reply-box-${c.id}"></div>`;
// replies
if (c.replies?.length) {
html += `<div class="ci-replies">`;
c.replies.forEach(r => {
const ri = (r.userName || '??').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('');
const raCls = ['teacher','admin'].includes(r.userRole) ? 'ci-avatar-teacher' : 'ci-avatar-student';
const rRole = r.userRole === 'teacher' ? '<span class="ci-role ci-role-teacher">Учитель</span>'
: r.userRole === 'admin' ? '<span class="ci-role ci-role-admin">Админ</span>' : '';
const rDel = r.userId === user?.id || isTeacher;
html += `<div class="comment-item" data-cid="${r.id}">
<div class="ci-avatar ${raCls}" style="width:26px;height:26px;font-size:0.6rem">${esc(ri)}</div>
<div class="ci-body">
<div class="ci-header">
<span class="ci-name">${esc(r.userName)}</span>
${rRole}
<span class="ci-time">${fmtTime(r.createdAt)}</span>
</div>
<div class="ci-text">${esc(r.text)}</div>
<div class="ci-actions">
${rDel ? `<button class="ci-action-btn danger" onclick="deleteComment(${r.id})">Удалить</button>` : ''}
</div>
</div>
</div>`;
});
html += `</div>`;
}
html += `</div></div>`;
return html;
}
function showReplyBox(parentId) {
// remove any existing reply box
const old = document.querySelector('.ci-reply-compose');
if (old) old.remove();
const box = document.getElementById('reply-box-' + parentId);
if (!box) return;
box.innerHTML = `
<div class="ci-reply-compose">
<textarea class="ci-reply-textarea" id="reply-input-${parentId}" placeholder="Ваш ответ…"></textarea>
<button class="ci-reply-send" onclick="submitReply(${parentId})">Ответить</button>
</div>`;
document.getElementById('reply-input-' + parentId).focus();
}
async function submitComment() {
const input = document.getElementById('comment-input');
const text = (input?.value || '').trim();
if (!text) return;
const btn = document.getElementById('comment-send-btn');
btn.disabled = true;
try {
await LS.api('/api/lessons/' + lessonId + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
input.value = '';
loadComments();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
finally { btn.disabled = false; }
}
async function submitReply(parentId) {
const input = document.getElementById('reply-input-' + parentId);
const text = (input?.value || '').trim();
if (!text) return;
const btn = input.nextElementSibling;
if (btn) btn.disabled = true;
try {
await LS.api('/api/lessons/' + lessonId + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, parentId }),
});
loadComments();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function deleteComment(commentId) {
const ok = await LS.confirm('Удалить комментарий?', { title: 'Удаление', confirmText: 'Удалить', danger: true });
if (!ok) return;
try {
await LS.api('/api/lessons/' + lessonId + '/comments/' + commentId, { method: 'DELETE' });
loadComments();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
loadLesson();
// Auto-print when opened with ?print=1 (from editor PDF export)
if (new URLSearchParams(location.search).get('print') === '1') {
// Wait for content + KaTeX + hljs to render
const _origLoad = window._onLessonRendered || null;
window._onLessonRendered = () => {
if (_origLoad) _origLoad();
setTimeout(() => window.print(), 800);
};
// Fallback if _onLessonRendered isn't called
setTimeout(() => window.print(), 3000);
}
</script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>