6fcdafed50
Питомец: кастомный фон (миграция 068 pet_bg_custom, POST /api/pet/bg/custom, карточка «Свой фон (ИИ)» в гардеробной, применение картинкой). Курсы: обложка-картинка (миграция 069 cover_image, генерация в модалке редактирования, рендер вместо эмодзи). Аватар: кнопка «Сгенерировать (ИИ)» в загрузке → кадрирование → модерация. Доска (classroom): кнопка-инструмент «Сгенерировать картинку (ИИ)». Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1113 lines
53 KiB
HTML
1113 lines
53 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Курс — LearnSpace</title>
|
||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||
<link rel="stylesheet" href="/css/ls.css" />
|
||
<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>
|