Files
Learn_System/frontend/course.html
T
Maxim Dolgolyov fd29acbbdd feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 18:04:59 +03:00

1169 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
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" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #f4f5f8; }
/* ── page header ── */
.course-header {
padding: 36px 28px 32px;
position: relative; overflow: hidden;
}
.course-header-bio { background: linear-gradient(140deg, #0a0220 0%, #1a0a3c 60%, #080818 100%); }
.course-header-chem { background: linear-gradient(140deg, #001a12 0%, #042e20 60%, #010f0c 100%); }
.course-header-math { background: linear-gradient(140deg, #00080f 0%, #031828 60%, #000f1c 100%); }
.course-header-phys { background: linear-gradient(140deg, #140c00 0%, #2a1800 60%, #0e0900 100%); }
.course-header-other { background: linear-gradient(140deg, #050505 0%, #0f0f1a 60%, #050510 100%); }
.ch-bg-dots {
position: absolute; inset: 0;
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 22px 22px; pointer-events: none;
}
.ch-inner {
position: relative; z-index: 1;
max-width: 860px; margin: 0 auto;
}
.ch-back {
display: inline-flex; align-items: center; gap: 6px;
font-size: 0.78rem; font-weight: 700; color: rgba(255,255,255,0.45);
text-decoration: none; margin-bottom: 18px;
transition: color 0.15s;
}
.ch-back:hover { color: rgba(255,255,255,0.8); }
.ch-emoji { font-size: 3rem; line-height: 1; margin-bottom: 10px; }
.ch-subj {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
margin-bottom: 8px;
}
.ch-subj-bio { color: #9B5DE5; }
.ch-subj-chem { color: #06D6A0; }
.ch-subj-math { color: #06B6D4; }
.ch-subj-phys { color: #F59E0B; }
.ch-title {
font-family: 'Unbounded', sans-serif; font-size: 1.55rem; font-weight: 800;
color: #fff; letter-spacing: -0.03em; margin-bottom: 8px; line-height: 1.25;
}
.ch-desc { font-size: 0.9rem; color: rgba(255,255,255,0.5); max-width: 600px; line-height: 1.6; margin-bottom: 18px; }
.ch-meta { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.ch-meta-item { font-size: 0.8rem; color: rgba(255,255,255,0.45); display: flex; align-items: center; gap: 5px; }
.ch-progress-wrap { display: flex; align-items: center; gap: 10px; }
.ch-progress-bar { height: 6px; border-radius: 99px; background: rgba(255,255,255,0.1); width: 160px; }
.ch-progress-fill { height: 100%; border-radius: 99px; transition: width 0.5s; }
.ch-progress-fill-bio { background: #9B5DE5; }
.ch-progress-fill-chem { background: #06D6A0; }
.ch-progress-fill-math { background: #06B6D4; }
.ch-progress-fill-phys { background: #F59E0B; }
.ch-pct { font-size: 0.82rem; font-weight: 700; color: rgba(255,255,255,0.7); }
.ch-bm-btn {
padding: 5px 8px; border-radius: 999px; border: 1.5px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.5); cursor: pointer;
display: flex; align-items: center; transition: all 0.15s;
}
.ch-bm-btn:hover { border-color: #FFD166; color: #FFD166; }
.ch-bm-btn.active { border-color: #FFD166; color: #FFD166; background: rgba(255,209,102,0.15); }
.ch-teacher-actions {
position: absolute; top: 28px; right: 28px; z-index: 2;
display: flex; gap: 8px;
}
.ch-action-btn {
padding: 7px 16px; border-radius: 99px; font-family: 'Manrope', sans-serif;
font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; gap: 6px;
}
.ch-action-edit {
border: 1.5px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.07);
color: rgba(255,255,255,0.7);
}
.ch-action-edit:hover { background: rgba(255,255,255,0.14); color: #fff; }
.ch-action-pub {
border: none; background: #06D6A0; color: #fff;
box-shadow: 0 2px 8px rgba(6,214,160,0.35);
}
.ch-action-pub:hover { background: #05bc8c; }
.ch-action-unpub {
border: none; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.6);
}
.ch-action-unpub:hover { background: rgba(255,255,255,0.2); }
.ch-draft-tag {
display: inline-block; font-size: 0.68rem; font-weight: 700; font-family: 'Manrope', sans-serif;
color: rgba(255,255,255,0.4); background: rgba(0,0,0,0.25); padding: 3px 10px; border-radius: 99px;
}
/* ── content ── */
.container { max-width: 860px; margin: 0 auto; padding: 28px 24px 80px; }
/* ── lesson list ── */
.lessons-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 18px;
}
.lessons-title {
font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 800;
color: #0F172A;
}
.lessons-header-btns { display: flex; gap: 8px; }
.btn-add-lesson {
padding: 7px 16px; 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.8rem; font-weight: 700;
cursor: pointer; display: flex; align-items: center; gap: 6px;
transition: all 0.15s;
}
.btn-add-lesson:hover { background: rgba(155,93,229,0.12); border-color: var(--violet); }
/* section header */
.section-header {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 4px 10px; margin-top: 6px;
}
.section-title {
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
color: #8898AA; text-transform: uppercase; letter-spacing: 0.07em;
display: flex; align-items: center; gap: 7px;
}
.section-title::before {
content: ''; display: inline-block; width: 20px; height: 1.5px; background: #CBD5E1;
}
.lesson-list { display: flex; flex-direction: column; gap: 10px; }
.lesson-item {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 16px; padding: 14px 18px;
display: flex; align-items: center; gap: 14px;
text-decoration: none; color: inherit;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
}
.lesson-item:hover {
transform: translateX(4px);
box-shadow: 0 4px 16px rgba(15,23,42,0.09);
border-color: rgba(155,93,229,0.2);
}
.lesson-num {
width: 32px; height: 32px; border-radius: 10px;
background: rgba(15,23,42,0.05); border: 1.5px solid rgba(15,23,42,0.08);
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
color: #8898AA; flex-shrink: 0;
}
.lesson-num.done {
background: rgba(5,150,82,0.1); border-color: rgba(5,150,82,0.2); color: #059652;
}
.lesson-info { flex: 1; }
.lesson-title { font-size: 0.92rem; font-weight: 700; color: #0F172A; }
.lesson-meta-row { display: flex; align-items: center; gap: 8px; margin-top: 3px; }
.lesson-draft-lbl {
font-size: 0.68rem; font-weight: 700; color: #8898AA;
background: rgba(15,23,42,0.05); padding: 2px 7px; border-radius: 99px;
}
.lesson-read-time {
font-size: 0.68rem; color: #8898AA; display: flex; align-items: center; gap: 3px;
}
.lesson-stat-lbl {
font-size: 0.7rem; font-weight: 700; color: #06D6A0;
background: rgba(6,214,160,0.08); padding: 2px 7px; border-radius: 99px;
}
.lesson-arrow { color: #CBD5E1; flex-shrink: 0; }
.lesson-item:hover .lesson-arrow { color: var(--violet); }
.lesson-del-btn {
width: 28px; height: 28px; border-radius: 8px; border: none;
background: transparent; color: #CBD5E1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: all 0.15s;
}
.lesson-del-btn:hover { background: rgba(239,71,111,0.1); color: #EF476F; }
.section-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.15s; }
.section-header:hover .section-actions { opacity: 1; }
.section-act-btn {
width: 26px; height: 26px; border-radius: 7px; border: none;
background: transparent; color: #CBD5E1; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.12s;
}
.section-act-btn:hover { background: rgba(155,93,229,0.08); color: var(--violet); }
.section-act-btn.danger:hover { background: rgba(239,71,111,0.08); color: #EF476F; }
/* ── stats panel (teacher) ── */
.stats-panel {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 20px; padding: 22px 24px; margin-bottom: 28px;
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
}
.stats-panel-title {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: #0F172A; margin-bottom: 16px; display: flex; align-items: center; gap: 8px;
}
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px,1fr)); gap: 12px; }
.stat-chip {
background: #f8f9fc; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 14px; padding: 14px 16px;
}
.stat-chip-label { font-size: 0.72rem; color: #8898AA; font-weight: 600; margin-bottom: 6px; }
.stat-chip-val { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; color: #0F172A; }
.stat-chip-sub { font-size: 0.7rem; color: #8898AA; margin-top: 2px; }
/* ── analytics dashboard ── */
.analytics-panel {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 20px; padding: 24px; margin-bottom: 28px;
box-shadow: 0 1px 4px rgba(15,23,42,0.04);
}
.analytics-title {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
color: #0F172A; margin-bottom: 20px; display: flex; align-items: center; gap: 8px;
}
.analytics-summary {
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px;
margin-bottom: 24px;
}
.an-chip {
background: #f8f9fc; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 14px; padding: 14px 16px; text-align: center;
}
.an-chip-val {
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800;
}
.an-chip-label { font-size: 0.72rem; color: #8898AA; font-weight: 600; margin-top: 4px; }
/* lesson bars */
.an-lessons-title {
font-size: 0.76rem; font-weight: 700; color: #8898AA; text-transform: uppercase;
letter-spacing: 0.06em; margin-bottom: 12px;
}
.an-lesson-row {
display: flex; align-items: center; gap: 12px; margin-bottom: 8px;
}
.an-lesson-name {
width: 180px; flex-shrink: 0; font-size: 0.8rem; font-weight: 600; color: #3D4F6B;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.an-lesson-bar {
flex: 1; height: 8px; background: rgba(15,23,42,0.06); border-radius: 99px;
overflow: hidden;
}
.an-lesson-fill {
height: 100%; border-radius: 99px; transition: width 0.5s;
background: linear-gradient(90deg, var(--violet), #06D6A0);
}
.an-lesson-pct {
width: 42px; text-align: right; font-size: 0.76rem; font-weight: 700; color: #3D4F6B;
}
/* stuck students */
.an-stuck-section { margin-top: 20px; }
.an-stuck-title {
font-size: 0.76rem; font-weight: 700; color: #EF476F; text-transform: uppercase;
letter-spacing: 0.06em; margin-bottom: 10px;
display: flex; align-items: center; gap: 6px;
}
.an-stuck-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px; background: rgba(239,71,111,0.04);
border: 1px solid rgba(239,71,111,0.1); border-radius: 12px;
margin-bottom: 6px;
}
.an-stuck-avatar {
width: 30px; height: 30px; border-radius: 10px;
background: rgba(239,71,111,0.12); color: #EF476F;
font-size: 0.7rem; font-weight: 800;
display: flex; align-items: center; justify-content: center;
}
.an-stuck-info { flex: 1; }
.an-stuck-name { font-size: 0.82rem; font-weight: 700; color: #0F172A; }
.an-stuck-detail { font-size: 0.72rem; color: #8898AA; margin-top: 2px; }
/* students table */
.an-students-section { margin-top: 20px; }
.an-students-toggle {
background: none; border: 1.5px solid rgba(15,23,42,0.1); border-radius: 10px;
padding: 8px 16px; font-family: 'Manrope', sans-serif; font-size: 0.78rem;
font-weight: 700; color: #8898AA; cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; gap: 6px;
}
.an-students-toggle:hover { border-color: var(--violet); color: var(--violet); }
.an-students-table {
width: 100%; border-collapse: collapse; margin-top: 12px;
font-size: 0.8rem;
}
.an-students-table th {
text-align: left; padding: 8px 10px; font-size: 0.7rem; font-weight: 700;
color: #8898AA; text-transform: uppercase; letter-spacing: 0.05em;
border-bottom: 1.5px solid rgba(15,23,42,0.08);
}
.an-students-table td {
padding: 9px 10px; border-bottom: 1px solid rgba(15,23,42,0.05);
color: #3D4F6B; font-weight: 600;
}
.an-students-table tr:hover td { background: rgba(155,93,229,0.03); }
.an-pct-badge {
display: inline-block; padding: 2px 8px; border-radius: 99px;
font-size: 0.72rem; font-weight: 700;
}
.an-pct-low { background: rgba(239,71,111,0.1); color: #EF476F; }
.an-pct-mid { background: rgba(255,209,102,0.15); color: #B8860B; }
.an-pct-high { background: rgba(6,214,160,0.1); color: #059652; }
/* class selector for analytics */
.an-class-select {
padding: 7px 12px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 600;
color: #3D4F6B; background: #f8f9fc; cursor: pointer;
}
.an-class-select:focus { outline: none; border-color: var(--violet); }
/* ── 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-bell { position: relative; }
.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; line-height: 1; }
.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; }
/* ── modals ── */
.modal-overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.4); backdrop-filter: blur(6px); z-index: 200; display: none; align-items: center; justify-content: center; padding: 20px; }
.modal-overlay.open { display: flex; }
.modal { background: #fff; border-radius: 24px; padding: 36px; width: 100%; max-width: 460px; box-shadow: 0 32px 80px rgba(15,23,42,0.22); }
.modal-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; margin-bottom: 22px; }
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 0.8rem; font-weight: 700; color: #3D4F6B; margin-bottom: 6px; }
.form-input { width: 100%; padding: 11px 14px; border: 1.5px solid rgba(15,23,42,0.15); border-radius: 12px; font-family: 'Manrope', sans-serif; font-size: 0.92rem; color: var(--text); background: #f8f9fc; transition: border-color 0.2s; box-sizing: border-box; }
.form-input:focus { outline: none; border-color: var(--violet); background: #fff; }
.modal-footer { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
.btn-cancel { padding: 10px 22px; border: 1.5px solid rgba(15,23,42,0.15); border-radius: 999px; background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 600; color: #8898AA; cursor: pointer; transition: all 0.18s; }
.btn-cancel:hover { border-color: rgba(15,23,42,0.3); color: var(--text); }
.btn-primary { padding: 10px 28px; border: none; border-radius: 999px; background: var(--violet); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700; cursor: pointer; box-shadow: 0 2px 10px rgba(155,93,229,0.3); transition: all 0.18s; }
.btn-primary:hover { background: #8a47d8; }
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
/* ── Mobile responsive ── */
@media (max-width: 768px) {
/* Course header */
.course-header { padding: 24px 16px 20px; }
.ch-title { font-size: 1.1rem; }
.ch-desc { font-size: 0.82rem; margin-bottom: 12px; }
.ch-emoji { font-size: 2rem; }
/* Teacher action buttons: take them out of absolute corner */
.ch-teacher-actions { position: static; display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
/* Progress bar: don't let it overflow */
.ch-progress-bar { width: 100px; }
/* Container */
.container { padding: 18px 12px 80px; }
/* Analytics lesson name: fixed 180px <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> shrinkable */
.an-lesson-name { width: 100px; }
/* Stats grid: ensure 2 columns on tablet, 1 on very small */
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
/* Lessons header: wrap if buttons don't fit */
.lessons-header { flex-wrap: wrap; gap: 8px; }
/* Modal: bottom sheet */
.modal-overlay { align-items: flex-end; padding: 0; }
.modal { border-radius: 22px 22px 0 0; padding: 28px 20px 36px; max-height: 90vh; overflow-y: auto; }
.modal-footer { flex-wrap: wrap; }
.modal-footer .btn-cancel,
.modal-footer .btn-primary { flex: 1; text-align: center; }
}
@media (max-width: 480px) {
.ch-title { font-size: 0.95rem; }
.stats-grid { grid-template-columns: 1fr; }
.an-lesson-name { width: 80px; font-size: 0.72rem; }
.container { padding: 14px 10px 80px; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" onclick="toggleSidebar()" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link" id="btn-board" style="display:none"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="btn-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link active"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="btn-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div style="padding: 4px 2px">
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="toggleNotifDrop()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<!-- Course header (populated by JS) -->
<div id="course-header" class="course-header course-header-other">
<div class="ch-bg-dots"></div>
<div class="ch-inner">
<a href="/theory" class="ch-back"><i data-lucide="arrow-left" style="width:14px;height:14px"></i> Все курсы</a>
<div id="header-body" style="color:#fff">
<div class="spinner" style="border-color:rgba(255,255,255,0.1);border-top-color:#fff"></div>
</div>
</div>
</div>
<div class="container">
<!-- Analytics dashboard (teacher only, hidden initially) -->
<div class="analytics-panel" id="analytics-panel" style="display:none">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:20px">
<div class="analytics-title" style="margin-bottom:0">
<i data-lucide="bar-chart-2" style="width:16px;height:16px;opacity:0.5"></i> Аналитика курса
</div>
<select class="an-class-select" id="an-class-select" onchange="loadAnalytics()">
<option value="">Все ученики</option>
</select>
</div>
<div id="analytics-body"><div class="spinner"></div></div>
</div>
<!-- Lesson list -->
<div class="lessons-header">
<div class="lessons-title" id="lessons-count">Уроки</div>
<div class="lessons-header-btns">
<button class="btn-add-lesson" id="btn-add-section" style="display:none" onclick="openAddSectionModal()">
<i data-lucide="folder-plus" style="width:14px;height:14px"></i> Раздел
</button>
<button class="btn-add-lesson" id="btn-add-lesson" style="display:none" onclick="openAddLessonModal()">
<i data-lucide="plus" style="width:14px;height:14px"></i> Урок
</button>
</div>
</div>
<div class="lesson-list" id="lesson-list">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<!-- Add section modal -->
<div class="modal-overlay" id="add-section-modal" onclick="if(event.target===this)closeAddSectionModal()">
<div class="modal">
<div class="modal-title">Новый раздел</div>
<div class="form-group">
<label class="form-label">Название раздела</label>
<input class="form-input" id="as-title" placeholder="Например: Часть 1. Введение" />
</div>
<div class="modal-footer">
<button class="btn-cancel" onclick="closeAddSectionModal()">Отмена</button>
<button class="btn-primary" id="btn-do-add-section" onclick="doAddSection()">Создать</button>
</div>
</div>
</div>
<!-- Edit course modal -->
<div class="modal-overlay" id="edit-course-modal" onclick="if(event.target===this)closeEditModal()">
<div class="modal">
<div class="modal-title">Редактировать курс</div>
<div class="form-group">
<label class="form-label">Название</label>
<input class="form-input" id="ec-title" placeholder="Название курса" />
</div>
<div class="form-group" style="margin-top:12px">
<label class="form-label">Описание</label>
<textarea class="form-input" id="ec-desc" rows="3" placeholder="Краткое описание курса" style="resize:vertical"></textarea>
</div>
<div style="display:flex;gap:12px;margin-top:12px">
<div class="form-group" style="flex:0 0 80px">
<label class="form-label">Эмодзи</label>
<input class="form-input" id="ec-emoji" placeholder="" maxlength="4" style="text-align:center;font-size:1.4rem" />
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Предмет</label>
<select class="form-input" id="ec-subject">
<option value="">— Не указан —</option>
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" onclick="closeEditModal()">Отмена</button>
<button class="btn-primary" id="btn-do-edit-course" onclick="doEditCourse()">Сохранить</button>
</div>
</div>
</div>
<!-- Add lesson modal -->
<div class="modal-overlay" id="add-lesson-modal" onclick="if(event.target===this)closeAddLessonModal()">
<div class="modal">
<div class="modal-title">Новый урок</div>
<div class="form-group">
<label class="form-label">Название урока</label>
<input class="form-input" id="al-title" placeholder="Например: Строение клетки" />
</div>
<div class="modal-footer">
<button class="btn-cancel" onclick="closeAddLessonModal()">Отмена</button>
<button class="btn-primary" id="btn-do-add-lesson" onclick="doAddLesson()">Создать</button>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
const user = LS.getUser();
document.getElementById('nav-user').textContent = user?.name || user?.email || '';
document.getElementById('nav-avatar').textContent =
(user?.name || 'LS').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
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 = '';
}
lucide.createIcons();
/* ── sidebar ── */
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 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' });
}
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
const HEADER_CLASS = { bio:'course-header-bio', chem:'course-header-chem', math:'course-header-math', phys:'course-header-phys' };
const SUBJ_CLASS = { bio:'ch-subj-bio', chem:'ch-subj-chem', math:'ch-subj-math', phys:'ch-subj-phys' };
const FILL_CLASS = { bio:'ch-progress-fill-bio', chem:'ch-progress-fill-chem', math:'ch-progress-fill-math', phys:'ch-progress-fill-phys' };
/* ── load course ── */
const courseId = new URLSearchParams(location.search).get('id');
if (!courseId) location.href = '/theory';
let course = null;
async function loadCourse() {
try {
course = await LS.api('/api/courses/' + courseId);
} catch (e) {
document.getElementById('header-body').innerHTML = '<div style="color:rgba(255,255,255,0.5)">Курс не найден</div>';
return;
}
// update header bg
const hdr = document.getElementById('course-header');
hdr.className = 'course-header ' + (HEADER_CLASS[course.subjectSlug] || 'course-header-other');
const pct = course.lessonCount > 0 ? Math.round(course.doneCount / course.lessonCount * 100) : 0;
const fillCls = FILL_CLASS[course.subjectSlug] || 'ch-progress-fill-bio';
document.title = esc(course.title) + ' — LearnSpace';
document.getElementById('header-body').innerHTML = `
${!course.isPublished ? '<span class="ch-draft-tag">Черновик</span><br><br>' : ''}
<div class="ch-emoji">${course.coverEmoji || LS.icon('book-open',24)}</div>
<div class="ch-subj ${SUBJ_CLASS[course.subjectSlug] || ''}">${esc(SUBJ_LABEL[course.subjectSlug] || course.subjectSlug)}</div>
<div class="ch-title">${esc(course.title)}</div>
${course.description ? `<div class="ch-desc">${esc(course.description)}</div>` : ''}
<div class="ch-meta">
<div class="ch-meta-item"><i data-lucide="book-open" style="width:13px;height:13px"></i> ${course.lessonCount} уроков</div>
${course.lessonCount > 0 ? `
<div class="ch-progress-wrap">
<div class="ch-progress-bar"><div class="ch-progress-fill ${fillCls}" style="width:${pct}%"></div></div>
<span class="ch-pct">${pct}% пройдено</span>
</div>` : ''}
<button class="ch-bm-btn" id="btn-bookmark-course" onclick="toggleCourseBookmark()" title="В закладки">
<i data-lucide="bookmark" style="width:14px;height:14px"></i>
</button>
</div>
`;
if (isTeacher) {
document.getElementById('header-body').insertAdjacentHTML('afterend', `
<div class="ch-teacher-actions">
<button class="ch-action-btn ch-action-edit" onclick="openSaveCourseTplModal()" title="Сохранить как шаблон">
<i data-lucide="bookmark-plus" style="width:13px;height:13px"></i>
</button>
<button class="ch-action-btn ch-action-edit" onclick="duplicateCourse()" title="Дублировать курс">
<i data-lucide="copy" style="width:13px;height:13px"></i>
</button>
<button class="ch-action-btn ch-action-edit" onclick="openEditModal()">
<i data-lucide="pencil" style="width:13px;height:13px"></i> Редактировать
</button>
<button class="ch-action-btn ${course.isPublished ? 'ch-action-unpub' : 'ch-action-pub'}"
onclick="togglePublish()">
<i data-lucide="${course.isPublished ? 'eye-off' : 'eye'}" style="width:13px;height:13px"></i>
${course.isPublished ? 'Снять' : 'Опубликовать'}
</button>
<button class="ch-action-btn ch-action-del" onclick="deleteCourse()" style="color:#EF476F;border-color:rgba(239,71,111,0.25)">
<i data-lucide="trash-2" style="width:13px;height:13px"></i> Удалить
</button>
</div>
`);
document.getElementById('btn-add-lesson').style.display = '';
}
lucide.createIcons();
renderLessons(course.lessons || [], course.sections || []);
checkCourseBookmark();
if (isTeacher) {
document.getElementById('btn-add-section').style.display = '';
loadClassesForAnalytics();
loadAnalytics();
}
}
/* ── course bookmark ── */
let _courseBmId = null;
async function checkCourseBookmark() {
try {
const r = await LS.checkBookmark('course', courseId);
_courseBmId = r.id;
const btn = document.getElementById('btn-bookmark-course');
if (btn) btn.classList.toggle('active', r.bookmarked);
} catch {}
}
async function toggleCourseBookmark() {
const btn = document.getElementById('btn-bookmark-course');
if (!btn) return;
try {
if (_courseBmId) {
await LS.removeBookmark(_courseBmId);
_courseBmId = null;
btn.classList.remove('active');
LS.toast('Убрано из закладок', 'info');
} else {
const r = await LS.addBookmark('course', Number(courseId));
_courseBmId = 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');
}
}
function renderLessons(lessons, sections) {
const list = document.getElementById('lesson-list');
document.getElementById('lessons-count').textContent = `Уроки · ${lessons.length}`;
if (!lessons.length) {
list.innerHTML = `<div style="text-align:center;padding:40px;color:#8898AA;font-size:0.86rem">
${isTeacher ? 'Нажмите «Урок», чтобы создать первый урок' : 'В курсе пока нет уроков'}
</div>`;
return;
}
// Build section map
const sectionMap = {};
(sections || []).forEach(s => { sectionMap[s.id] = s.title; });
// Group lessons by section
const groups = []; // [{sectionId, sectionTitle, lessons:[]}]
const seenSections = new Set();
lessons.forEach(l => {
const sid = l.sectionId || null;
if (!seenSections.has(sid)) {
seenSections.add(sid);
groups.push({ sectionId: sid, sectionTitle: sid ? (sectionMap[sid] || 'Раздел') : null, lessons: [] });
}
groups[groups.length - 1].lessons.push(l);
});
let html = '';
let globalIdx = 0;
groups.forEach(g => {
if (g.sectionTitle) {
html += `<div class="section-header">
<span class="section-title">${esc(g.sectionTitle)}</span>
${isTeacher ? `<div class="section-actions">
<button class="section-act-btn" onclick="editSection(${g.sectionId},'${esc(g.sectionTitle).replace(/'/g, "\\'")}')" title="Переименовать">
<i data-lucide="pencil" style="width:12px;height:12px"></i>
</button>
<button class="section-act-btn danger" onclick="deleteSection(${g.sectionId},'${esc(g.sectionTitle).replace(/'/g, "\\'")}')" title="Удалить раздел">
<i data-lucide="trash-2" style="width:12px;height:12px"></i>
</button>
</div>` : ''}
</div>`;
}
g.lessons.forEach(l => {
const i = globalIdx++;
const rt = l.readTime ? `<span class="lesson-read-time"><i data-lucide="clock" style="width:10px;height:10px"></i> ${l.readTime} мин</span>` : '';
const draft = !l.isPublished && isTeacher ? '<span class="lesson-draft-lbl">Черновик</span>' : '';
const meta = (rt || draft) ? `<div class="lesson-meta-row">${draft}${rt}</div>` : '';
html += `
<a class="lesson-item stagger-item" href="/lesson?id=${l.id}" style="--i:${i}">
<div class="lesson-num${l.completed ? ' done' : ''}">
${l.completed ? '<i data-lucide="check" style="width:14px;height:14px"></i>' : (i + 1)}
</div>
<div class="lesson-info">
<span class="lesson-title">${esc(l.title)}</span>
${meta}
</div>
${isTeacher ? `<button class="lesson-del-btn" onclick="deleteLesson(event,${l.id},'${esc(l.title).replace(/'/g, "\\'")}')" title="Удалить урок"><i data-lucide="trash-2" style="width:13px;height:13px"></i></button>` : ''}
<i data-lucide="chevron-right" class="lesson-arrow" style="width:16px;height:16px"></i>
</a>`;
});
});
list.innerHTML = html;
lucide.createIcons();
}
/* ── analytics ── */
async function loadClassesForAnalytics() {
try {
const classes = await LS.api('/api/classes');
const sel = document.getElementById('an-class-select');
(classes || []).forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
// pre-select from URL
const urlClass = new URLSearchParams(location.search).get('classId');
if (urlClass) sel.value = urlClass;
} catch {}
}
let _studentsExpanded = false;
async function loadAnalytics() {
const panel = document.getElementById('analytics-panel');
const body = document.getElementById('analytics-body');
const classId = document.getElementById('an-class-select').value;
panel.style.display = '';
body.innerHTML = '<div class="spinner"></div>';
try {
const url = `/api/courses/${courseId}/analytics` + (classId ? `?classId=${classId}` : '');
const data = await LS.api(url);
if (!data.totalStudents && !data.lessons?.length) {
body.innerHTML = '<div style="text-align:center;padding:20px;color:#8898AA;font-size:0.84rem">Нет данных. Назначьте курс классу, чтобы видеть аналитику.</div>';
return;
}
let html = '';
// Summary chips
html += `<div class="analytics-summary">
<div class="an-chip">
<div class="an-chip-val" style="color:var(--violet)">${data.totalStudents}</div>
<div class="an-chip-label">Учеников</div>
</div>
<div class="an-chip">
<div class="an-chip-val" style="color:#06D6A0">${data.avgPct}%</div>
<div class="an-chip-label">Средний прогресс</div>
</div>
<div class="an-chip">
<div class="an-chip-val" style="color:#06B6D4">${data.totalLessons}</div>
<div class="an-chip-label">Уроков в курсе</div>
</div>
<div class="an-chip">
<div class="an-chip-val" style="color:#EF476F">${data.stuckStudents?.length || 0}</div>
<div class="an-chip-label">Застряли</div>
</div>
</div>`;
// Lesson progress bars
if (data.lessons?.length) {
html += `<div class="an-lessons-title">Прохождение по урокам</div>`;
data.lessons.forEach(l => {
html += `<div class="an-lesson-row">
<div class="an-lesson-name" title="${esc(l.title)}">${esc(l.title)}</div>
<div class="an-lesson-bar"><div class="an-lesson-fill" style="width:${l.pct}%"></div></div>
<div class="an-lesson-pct">${l.pct}%</div>
</div>`;
});
}
// Stuck students
if (data.stuckStudents?.length) {
html += `<div class="an-stuck-section">
<div class="an-stuck-title"><i data-lucide="alert-triangle" style="width:13px;height:13px"></i> Ученики, которые застряли</div>`;
data.stuckStudents.forEach(s => {
const initials = (s.name || '??').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('');
const ago = s.lastActivity ? fmtTime(s.lastActivity) : '';
html += `<div class="an-stuck-item">
<div class="an-stuck-avatar">${esc(initials)}</div>
<div class="an-stuck-info">
<div class="an-stuck-name">${esc(s.name)}</div>
<div class="an-stuck-detail">
Застрял на: <b>${esc(s.stuckLessonTitle || '—')}</b> · Прогресс: ${s.pct}%
${ago ? ' · Последняя активность: ' + ago : ''}
</div>
</div>
</div>`;
});
html += `</div>`;
}
// Students table (collapsed by default)
if (data.students?.length) {
html += `<div class="an-students-section">
<button class="an-students-toggle" onclick="toggleStudentsTable()">
<i data-lucide="users" style="width:13px;height:13px"></i>
Все ученики (${data.students.length})
<i data-lucide="chevron-down" style="width:13px;height:13px" id="an-students-chevron"></i>
</button>
<div id="an-students-wrap" style="display:${_studentsExpanded ? 'block' : 'none'}">
<table class="an-students-table">
<thead><tr>
<th>Ученик</th><th>Пройдено</th><th>Прогресс</th><th>Статус</th>
</tr></thead>
<tbody>`;
data.students.forEach(s => {
const cls = s.pct >= 80 ? 'an-pct-high' : s.pct >= 40 ? 'an-pct-mid' : 'an-pct-low';
const status = s.pct >= 100 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Завершил'
: s.stuck ? '<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> Застрял'
: s.doneCount > 0 ? 'В процессе' : 'Не начал';
html += `<tr>
<td>${esc(s.name)}</td>
<td>${s.doneCount} / ${data.totalLessons}</td>
<td><span class="an-pct-badge ${cls}">${s.pct}%</span></td>
<td>${status}</td>
</tr>`;
});
html += `</tbody></table></div></div>`;
}
body.innerHTML = html;
lucide.createIcons();
} catch (e) {
body.innerHTML = '<div style="text-align:center;padding:20px;color:#8898AA;font-size:0.84rem">Ошибка загрузки аналитики</div>';
}
}
function toggleStudentsTable() {
const wrap = document.getElementById('an-students-wrap');
const chev = document.getElementById('an-students-chevron');
if (!wrap) return;
_studentsExpanded = !_studentsExpanded;
wrap.style.display = _studentsExpanded ? 'block' : 'none';
if (chev) chev.style.transform = _studentsExpanded ? 'rotate(180deg)' : '';
}
/* ── edit course modal ── */
function openEditModal() {
if (!course) return;
document.getElementById('ec-title').value = course.title || '';
document.getElementById('ec-desc').value = course.description || '';
document.getElementById('ec-emoji').value = course.coverEmoji || '';
document.getElementById('ec-subject').value = course.subjectSlug || '';
document.getElementById('edit-course-modal').classList.add('open');
setTimeout(() => document.getElementById('ec-title').focus(), 50);
}
function closeEditModal() {
document.getElementById('edit-course-modal').classList.remove('open');
}
async function doEditCourse() {
const btn = document.getElementById('btn-do-edit-course');
btn.disabled = true;
try {
await LS.api('/api/courses/' + courseId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: document.getElementById('ec-title').value.trim(),
description: document.getElementById('ec-desc').value.trim(),
coverEmoji: document.getElementById('ec-emoji').value.trim(),
subjectSlug: document.getElementById('ec-subject').value || null,
}),
});
closeEditModal();
loadCourse();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
finally { btn.disabled = false; }
}
/* ── toggle publish ── */
async function togglePublish() {
try {
await LS.api('/api/courses/' + courseId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isPublished: !course.isPublished }),
});
loadCourse();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── edit section ── */
async function editSection(sectionId, currentTitle) {
const newTitle = prompt('Название раздела:', currentTitle);
if (!newTitle || newTitle.trim() === currentTitle) return;
try {
await LS.api('/api/courses/' + courseId + '/sections/' + sectionId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle.trim() }),
});
loadCourse();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── delete section ── */
async function deleteSection(sectionId, sectionTitle) {
const ok = await LS.confirm('Удалить раздел «' + sectionTitle + '»? Уроки останутся без раздела.', { title:'Удаление раздела', confirmText:'Удалить', danger:true });
if (!ok) return;
try {
await LS.api('/api/courses/' + courseId + '/sections/' + sectionId, { method: 'DELETE' });
loadCourse();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── delete course ── */
async function deleteCourse() {
const ok = await LS.confirm('Удалить курс «' + esc(course?.title || '') + '» и все его уроки?', { title:'Удаление курса', confirmText:'Удалить', danger:true });
if (!ok) return;
try {
await LS.api('/api/courses/' + courseId, { method: 'DELETE' });
location.href = '/theory';
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── delete lesson ── */
async function deleteLesson(e, lessonId, lessonTitle) {
e.preventDefault();
e.stopPropagation();
const ok = await LS.confirm('Удалить урок «' + esc(lessonTitle) + '»?', { title:'Удаление урока', confirmText:'Удалить', danger:true });
if (!ok) return;
try {
await LS.api('/api/lessons/' + lessonId, { method: 'DELETE' });
loadCourse();
} catch (e2) { LS.toast(e2.message || 'Ошибка', 'error'); }
}
/* ── duplicate course ── */
async function duplicateCourse() {
if (!await LS.confirm('Создать копию курса «' + esc(course?.title || '') + '»?', { title: 'Дублирование', confirmText: 'Создать копию' })) return;
try {
const res = await LS.api('/api/courses/' + courseId + '/duplicate', { method: 'POST' });
location.href = '/course?id=' + res.id;
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── add section modal ── */
function openAddSectionModal() {
document.getElementById('as-title').value = '';
document.getElementById('add-section-modal').classList.add('open');
setTimeout(() => document.getElementById('as-title').focus(), 50);
}
function closeAddSectionModal() {
document.getElementById('add-section-modal').classList.remove('open');
}
async function doAddSection() {
const title = document.getElementById('as-title').value.trim();
if (!title) { document.getElementById('as-title').focus(); return; }
const btn = document.getElementById('btn-do-add-section');
btn.disabled = true;
try {
await LS.api('/api/courses/' + courseId + '/sections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, orderIndex: (course?.sections?.length || 0) }),
});
closeAddSectionModal();
loadCourse();
} catch (e) {
LS.toast(e.message || 'Ошибка', 'error');
} finally { btn.disabled = false; }
}
document.getElementById('as-title').addEventListener('keydown', e => {
if (e.key === 'Enter') doAddSection();
});
/* ── add lesson modal ── */
function openAddLessonModal() {
document.getElementById('al-title').value = '';
document.getElementById('add-lesson-modal').classList.add('open');
setTimeout(() => document.getElementById('al-title').focus(), 50);
}
function closeAddLessonModal() {
document.getElementById('add-lesson-modal').classList.remove('open');
}
async function doAddLesson() {
const title = document.getElementById('al-title').value.trim();
if (!title) { document.getElementById('al-title').focus(); return; }
const btn = document.getElementById('btn-do-add-lesson');
btn.disabled = true;
try {
const res = await LS.api('/api/lessons', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ courseId: parseInt(courseId), title }),
});
closeAddLessonModal();
location.href = '/lesson-editor?id=' + res.id;
} catch (e) {
LS.toast(e.message || 'Ошибка', 'error');
} finally { btn.disabled = false; }
}
document.getElementById('al-title').addEventListener('keydown', e => {
if (e.key === 'Enter') doAddLesson();
});
/* ── save course as template ── */
function openSaveCourseTplModal() {
if (!course) return;
document.getElementById('ct-title').value = course.title || '';
document.getElementById('ct-desc').value = course.description || '';
document.getElementById('save-course-tpl-modal').classList.add('open');
setTimeout(() => document.getElementById('ct-title').focus(), 50);
}
function closeSaveCourseTplModal() {
document.getElementById('save-course-tpl-modal').classList.remove('open');
}
async function doSaveCourseTpl() {
const title = document.getElementById('ct-title').value.trim();
if (!title) { document.getElementById('ct-title').focus(); return; }
const btn = document.getElementById('btn-do-save-ct');
btn.disabled = true;
try {
await LS.saveCourseTemplate({
title,
description: document.getElementById('ct-desc').value.trim(),
category: document.getElementById('ct-cat').value,
subject_slug: course.subjectSlug || null,
courseId: parseInt(courseId),
});
closeSaveCourseTplModal();
LS.toast('Шаблон курса сохранён', 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
finally { btn.disabled = false; }
}
loadCourse();
</script>
<!-- Save course template modal -->
<div class="modal-overlay" id="save-course-tpl-modal" onclick="if(event.target===this)closeSaveCourseTplModal()">
<div class="modal">
<div class="modal-title">Сохранить курс как шаблон</div>
<div class="form-group">
<label class="form-label">Название шаблона</label>
<input class="form-input" id="ct-title" placeholder="Название шаблона" />
</div>
<div class="form-group">
<label class="form-label">Описание</label>
<textarea class="form-input" id="ct-desc" rows="2" placeholder="Краткое описание" style="resize:vertical"></textarea>
</div>
<div class="form-group">
<label class="form-label">Категория</label>
<select class="form-input" id="ct-cat">
<option value="general">Общее</option>
<option value="lecture">Лекционный курс</option>
<option value="practice">Практикум</option>
<option value="exam">Подготовка к экзамену</option>
</select>
</div>
<div class="modal-footer">
<button class="btn-cancel" onclick="closeSaveCourseTplModal()">Отмена</button>
<button class="btn-primary" id="btn-do-save-ct" onclick="doSaveCourseTpl()">Сохранить</button>
</div>
</div>
</div>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>