8091b48e1c
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>
1817 lines
82 KiB
HTML
1817 lines
82 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
/* Санитайзер 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>
|