Files
Maxim Dolgolyov 6fcdafed50 feat(imggen): фон питомца, обложки курсов, аватары и доска через ИИ
Питомец: кастомный фон (миграция 068 pet_bg_custom, POST /api/pet/bg/custom,
  карточка «Свой фон (ИИ)» в гардеробной, применение картинкой).
Курсы: обложка-картинка (миграция 069 cover_image, генерация в модалке
  редактирования, рендер вместо эмодзи).
Аватар: кнопка «Сгенерировать (ИИ)» в загрузке → кадрирование → модерация.
Доска (classroom): кнопка-инструмент «Сгенерировать картинку (ИИ)».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:59:26 +03:00

1113 lines
53 KiB
HTML
Raw Permalink 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: var(--text-3); 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: var(--text-3); 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: var(--text-3);
background: rgba(15,23,42,0.05); padding: 2px 7px; border-radius: 99px;
}
.lesson-read-time {
font-size: 0.68rem; color: var(--text-3); 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: var(--text-3); 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: var(--text-3); 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: var(--text-3); font-weight: 600; margin-top: 4px; }
/* lesson bars */
.an-lessons-title {
font-size: 0.76rem; font-weight: 700; color: var(--text-3); 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: var(--text-3); 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: var(--text-3); 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: var(--text-3); 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: var(--text-3); 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" id="app-sidebar"></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 -->
<script src="/js/api.js"></script>
<script src="/js/imggen.js"></script>
<script src="/js/sidebar.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 = '';
}
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>' : ''}
${course.coverImage
? `<div class="ch-cover" style="width:100%;max-width:440px;height:160px;margin:0 auto 14px;border-radius:16px;background:center/cover url('${esc(course.coverImage)}');box-shadow:0 8px 24px rgba(0,0,0,.18)"></div>`
: `<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:var(--text-3);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:var(--text-3);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:var(--text-3);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 ── */
let _editCourseModal = null;
function openEditModal() {
if (!course) return;
const body = `
<div class="form-group">
<label class="form-label">Название</label>
<input class="form-input" id="ec-title" value="${LS.esc(course.title || '')}" />
</div>
<div class="form-group" style="margin-top:12px">
<label class="form-label">Описание</label>
<textarea class="form-input" id="ec-desc" rows="3" style="resize:vertical">${LS.esc(course.description || '')}</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" maxlength="4" style="text-align:center;font-size:1.4rem" value="${LS.esc(course.coverEmoji || '')}" />
</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="form-group" style="margin-top:12px">
<label class="form-label">Обложка (картинка)</label>
<div id="ec-cover-prev" style="${course.coverImage ? '' : 'display:none;'}height:120px;border-radius:12px;margin-bottom:8px;background:center/cover url('${LS.esc(course.coverImage || '')}')"></div>
<input type="hidden" id="ec-cover" value="${LS.esc(course.coverImage || '')}" />
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button type="button" class="btn-primary" id="ec-cover-gen" style="padding:8px 16px;min-height:auto;font-size:.82rem">Сгенерировать ИИ</button>
<button type="button" class="btn-ghost" id="ec-cover-clear" style="padding:8px 16px;min-height:auto;${course.coverImage ? '' : 'display:none'}">Убрать</button>
</div>
</div>`;
_editCourseModal = LS.modal({
title: 'Редактировать курс', content: body, size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => _editCourseModal.close() },
{ label: 'Сохранить', primary: true, id: 'btn-do-edit-course', onClick: doEditCourse },
],
});
_editCourseModal.body.querySelector('#ec-subject').value = course.subjectSlug || '';
const coverInput = _editCourseModal.body.querySelector('#ec-cover');
const coverPrev = _editCourseModal.body.querySelector('#ec-cover-prev');
const coverClear = _editCourseModal.body.querySelector('#ec-cover-clear');
_editCourseModal.body.querySelector('#ec-cover-gen').onclick = () => {
if (!LS.imagePromptModal) { LS.toast('Модуль генерации не загружен'); return; }
LS.imagePromptModal({
title: 'Обложка курса',
placeholder: 'Обложка для курса «' + (course.title || '') + '»: яркая иллюстрация',
useLabel: 'Поставить обложкой',
onUse: (url) => {
coverInput.value = url;
coverPrev.style.cssText = "height:120px;border-radius:12px;margin-bottom:8px;background:center/cover url('" + url + "')";
coverClear.style.display = '';
},
});
};
coverClear.onclick = () => { coverInput.value = ''; coverPrev.style.display = 'none'; coverClear.style.display = 'none'; };
}
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(),
coverImage: document.getElementById('ec-cover').value.trim(),
subjectSlug: document.getElementById('ec-subject').value || null,
}),
});
_editCourseModal?.close();
loadCourse();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); 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 ── */
let _addSectionModal = null;
function openAddSectionModal() {
const body = `
<div class="form-group">
<label class="form-label">Название раздела</label>
<input class="form-input" id="as-title" placeholder="Например: Часть 1. Введение" />
</div>`;
_addSectionModal = LS.modal({
title: 'Новый раздел', content: body, size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => _addSectionModal.close() },
{ label: 'Создать', primary: true, id: 'btn-do-add-section', onClick: doAddSection },
],
});
_addSectionModal.body.querySelector('#as-title').addEventListener('keydown', e => { if (e.key === 'Enter') doAddSection(); });
}
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) }),
});
_addSectionModal?.close();
loadCourse();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
}
/* ── add lesson modal ── */
let _addLessonModal = null;
function openAddLessonModal() {
const body = `
<div class="form-group">
<label class="form-label">Название урока</label>
<input class="form-input" id="al-title" placeholder="Например: Строение клетки" />
</div>`;
_addLessonModal = LS.modal({
title: 'Новый урок', content: body, size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => _addLessonModal.close() },
{ label: 'Создать', primary: true, id: 'btn-do-add-lesson', onClick: doAddLesson },
],
});
_addLessonModal.body.querySelector('#al-title').addEventListener('keydown', e => { if (e.key === 'Enter') doAddLesson(); });
}
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 }),
});
_addLessonModal?.close();
location.href = '/lesson-editor?id=' + res.id;
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
}
/* ── save course as template ── */
let _saveCtModal = null;
function openSaveCourseTplModal() {
if (!course) return;
const body = `
<div class="form-group">
<label class="form-label">Название шаблона</label>
<input class="form-input" id="ct-title" value="${LS.esc(course.title || '')}" />
</div>
<div class="form-group">
<label class="form-label">Описание</label>
<textarea class="form-input" id="ct-desc" rows="2" style="resize:vertical">${LS.esc(course.description || '')}</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>`;
_saveCtModal = LS.modal({
title: 'Сохранить курс как шаблон', content: body, size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => _saveCtModal.close() },
{ label: 'Сохранить', primary: true, id: 'btn-do-save-ct', onClick: doSaveCourseTpl },
],
});
}
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),
});
_saveCtModal?.close();
LS.toast('Шаблон курса сохранён', 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
}
loadCourse();
</script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>