Files
Learn_System/frontend/theory.html
T
Maxim Dolgolyov 5c66105fc2 refactor: ещё 6 модалок → LS.modal (dashboard, theory, course)
dashboard.html: 2 → 0 
  - join-modal — вступить в класс
  - qs-modal — быстрый тест с выбором предмета + режим + кол-во

theory.html: 1 → 0 
  - new-course-modal — создание нового курса учителем

course.html: 4 → 0 
  - add-section-modal — новый раздел курса
  - edit-course-modal — редактирование курса
  - add-lesson-modal — новый урок
  - save-course-tpl-modal — сохранить курс как шаблон

Везде:
  - Inline <div class=\"modal-overlay\">...</div> → удалён
  - openX(): создаёт modal через LS.modal({content, actions})
  - closeX() удалена — _xModal.close()
  - Глобальный selectQsSubject() inline'нут как listener на body модалки
  - Enter-handler на главных inputs сохранён

Не трогаю:
  - biochem.html#lib-modal — кастомная тёмная тема, не подходит под
    светлый LS.modal без редизайна
  - library.html — 3 сложные модалки (folder-access, assign, upload)
    с tabs и dynamic state — отдельный заход
  - classes.html — modal-assign (128 строк, complex) + review-modal
  - flashcards.html — fc-modal (не modal-overlay, своя CSS)

Прогресс миграции: 12 простых модалок → LS.modal за серию (4 ранее
+ 2 ранее + 6 сейчас). 4 страницы полностью очищены от
modal-overlay. Унифицированы:
  - ESC/backdrop/focus поведение
  - z-index (9000)
  - Анимация (scale .22s)
  - Адаптив на мобилке
2026-05-16 19:33:39 +03:00

726 lines
37 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Теория — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #f4f5f8; }
/* ── page header ── */
.page-header {
background: linear-gradient(140deg, #0a0818 0%, #1a1040 50%, #0d1a30 100%);
padding: 40px 28px 36px;
position: relative; overflow: hidden;
}
.page-header::before {
content: '';
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;
}
.page-header::after {
content: '';
position: absolute; width: 400px; height: 400px; border-radius: 50%;
background: radial-gradient(circle, rgba(155,93,229,0.25), transparent 65%);
top: -180px; right: -60px; pointer-events: none;
}
.ph-inner {
position: relative; z-index: 1;
max-width: 960px; margin: 0 auto;
display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap;
}
.ph-title {
font-family: 'Unbounded', sans-serif; font-size: 1.6rem; font-weight: 800;
color: #fff; letter-spacing: -0.03em; margin-bottom: 6px;
}
.ph-sub { font-size: 0.88rem; color: rgba(255,255,255,0.5); font-weight: 500; }
.btn-new-course {
padding: 10px 22px; border: none; border-radius: 999px;
background: var(--violet); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.88rem; font-weight: 700;
cursor: pointer; display: flex; align-items: center; gap: 8px;
box-shadow: 0 4px 18px rgba(155,93,229,0.4);
transition: all 0.18s; flex-shrink: 0;
}
.btn-new-course:hover { background: #8a47d8; box-shadow: 0 6px 24px rgba(155,93,229,0.5); }
/* ── content ── */
.container { max-width: 960px; margin: 0 auto; padding: 28px 24px 80px; }
/* ── subject filter ── */
.subj-filter {
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 28px;
}
.sf-btn {
padding: 8px 18px; border-radius: 999px;
border: 1.5px solid rgba(15,23,42,0.1);
background: #fff; color: #6B7A8E;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; gap: 6px;
}
.sf-btn:hover { border-color: var(--violet); color: var(--violet); }
.sf-btn.active { background: var(--violet); color: #fff; border-color: var(--violet); }
/* ── course card grid ── */
.courses-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.course-card {
background: #fff;
border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 20px;
overflow: hidden;
cursor: pointer;
text-decoration: none; color: inherit;
transition: transform 0.18s, box-shadow 0.18s, border-color 0.18s;
display: flex; flex-direction: column;
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
}
.course-card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 36px rgba(15,23,42,0.12);
border-color: rgba(155,93,229,0.2);
}
.cc-banner {
height: 80px;
display: flex; align-items: center; justify-content: center;
font-size: 2.8rem;
position: relative;
}
.cc-banner-bio { background: linear-gradient(135deg, #1a0a3c 0%, #2d0856 100%); }
.cc-banner-chem { background: linear-gradient(135deg, #062a20 0%, #064a38 100%); }
.cc-banner-math { background: linear-gradient(135deg, #061828 0%, #062848 100%); }
.cc-banner-phys { background: linear-gradient(135deg, #1c1400 0%, #3a2800 100%); }
.cc-banner-other { background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%); }
.cc-draft-badge {
position: absolute; top: 8px; right: 10px;
font-size: 0.64rem; font-weight: 700; font-family: 'Manrope', sans-serif;
color: rgba(255,255,255,0.5);
background: rgba(0,0,0,0.35); padding: 2px 8px; border-radius: 99px;
}
.cc-del-btn {
position: absolute; top: 8px; left: 10px; z-index: 2;
width: 28px; height: 28px; border-radius: 8px; border: none;
background: rgba(0,0,0,0.35); color: rgba(255,255,255,0.5);
cursor: pointer; display: flex; align-items: center; justify-content: center;
opacity: 0; transition: all 0.15s;
}
.course-card:hover .cc-del-btn { opacity: 1; }
.cc-del-btn:hover { background: rgba(239,71,111,0.7); color: #fff; }
.cc-body { padding: 18px 18px 16px; flex: 1; display: flex; flex-direction: column; }
.cc-subj {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em;
margin-bottom: 6px;
}
.cc-subj-bio { color: #9B5DE5; }
.cc-subj-chem { color: #06D6A0; }
.cc-subj-math { color: #06B6D4; }
.cc-subj-phys { color: #F59E0B; }
.cc-subj-other { color: var(--text-3); }
.cc-title {
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
color: #0F172A; margin-bottom: 8px; line-height: 1.35;
}
.cc-desc {
font-size: 0.8rem; color: #6B7A8E; line-height: 1.6;
flex: 1; margin-bottom: 14px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.cc-footer {
display: flex; align-items: center; justify-content: space-between;
border-top: 1px solid rgba(15,23,42,0.07); padding-top: 12px;
}
.cc-meta { font-size: 0.76rem; color: var(--text-3); display: flex; align-items: center; gap: 5px; }
.cc-progress-bar {
height: 4px; border-radius: 99px;
background: rgba(15,23,42,0.07); flex: 1; max-width: 80px;
}
.cc-progress-fill { height: 100%; border-radius: 99px; background: var(--violet); transition: width 0.4s; }
.cc-pct { font-size: 0.72rem; font-weight: 700; 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; }
/* ── new course modal ── */
.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; }
select.form-input { cursor: pointer; }
.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; box-shadow: none; }
/* ── search ── */
.search-wrap {
position: relative; max-width: 400px;
}
.search-input {
width: 100%; padding: 9px 16px 9px 38px;
border: 1.5px solid rgba(255,255,255,0.15); border-radius: 999px;
background: rgba(255,255,255,0.09); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.88rem;
backdrop-filter: blur(10px); box-sizing: border-box;
transition: border-color 0.2s, background 0.2s;
}
.search-input::placeholder { color: rgba(255,255,255,0.35); }
.search-input:focus { outline: none; border-color: rgba(155,93,229,0.6); background: rgba(255,255,255,0.13); }
.search-icon {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
pointer-events: none; color: rgba(255,255,255,0.35);
}
.search-results-count {
font-size: 0.76rem; color: rgba(255,255,255,0.4); margin-top: 6px;
}
/* ── continue banner ── */
.continue-banner {
background: linear-gradient(135deg, rgba(155,93,229,0.12), rgba(6,182,212,0.08));
border: 1.5px solid rgba(155,93,229,0.2); border-radius: 18px;
padding: 18px 20px; margin-bottom: 24px;
display: flex; align-items: center; gap: 16px;
text-decoration: none; color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.continue-banner:hover { border-color: rgba(155,93,229,0.4); box-shadow: 0 4px 20px rgba(155,93,229,0.12); }
.cb-icon {
width: 42px; height: 42px; border-radius: 12px; flex-shrink: 0;
background: linear-gradient(135deg, var(--violet), #06B6D4);
display: flex; align-items: center; justify-content: center; font-size: 1.25rem;
}
.cb-body { flex: 1; }
.cb-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--violet); margin-bottom: 3px; }
.cb-title { font-size: 0.92rem; font-weight: 700; color: #0F172A; }
.cb-course { font-size: 0.76rem; color: #6B7A8E; margin-top: 2px; }
.cb-arrow { color: #CBD5E1; }
.continue-banner:hover .cb-arrow { color: var(--violet); }
/* ── Mobile adaptation ── */
@media (max-width: 768px) {
/* Page header: compact padding, smaller title */
.page-header { padding: 20px 16px 20px; }
.ph-title { font-size: 1.15rem; }
.ph-sub { font-size: 0.8rem; }
.ph-inner { gap: 12px; }
/* Search: full width below title */
.search-wrap { max-width: 100%; }
.search-input { font-size: 0.82rem; }
/* Header buttons: icon-only to save space */
.btn-new-course { padding: 8px 14px; font-size: 0.8rem; gap: 6px; }
/* Container: tighter padding */
.container { padding: 18px 12px 80px; }
/* Course grid: 1 column on small screens */
.courses-grid { grid-template-columns: 1fr; gap: 12px; }
/* Continue banner: allow body to shrink */
.cb-body { min-width: 0; overflow: hidden; }
.cb-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cb-icon { width: 36px; height: 36px; font-size: 1rem; }
/* Modal: less padding, full width */
.modal { padding: 22px 18px; border-radius: 18px; }
.modal-title { font-size: 0.9rem; margin-bottom: 16px; }
.modal-footer { flex-wrap: wrap; }
.modal-footer .btn-cancel,
.modal-footer .btn-primary { flex: 1; text-align: center; justify-content: center; }
/* Subject filter: smaller pills */
.sf-btn { padding: 6px 13px; font-size: 0.78rem; }
/* Empty state: reduce padding */
.empty-state { padding: 36px 20px; }
}
/* ── empty ── */
.empty-state {
text-align: center; padding: 60px 28px;
background: #fff; border: 2px dashed rgba(155,93,229,0.15);
border-radius: 24px;
}
.empty-state-icon { font-size: 3.5rem; margin-bottom: 12px; }
.empty-state-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; color: #0F172A; margin-bottom: 8px; }
.empty-state-desc { font-size: 0.86rem; color: #6B7A8E; max-width: 300px; margin: 0 auto; line-height: 1.7; }
</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">
<!-- Page header -->
<div class="page-header">
<div class="ph-inner">
<div>
<div class="ph-title">Теория</div>
<div class="ph-sub">Изучай курсы и прокачивай знания</div>
</div>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<div class="search-wrap">
<i data-lucide="search" class="search-icon" style="width:15px;height:15px"></i>
<input class="search-input" id="search-input" placeholder="Поиск курсов и уроков…"
oninput="onSearchInput(this.value)" />
</div>
<button class="btn-new-course" id="btn-from-tpl" style="display:none;background:transparent;border:1.5px solid rgba(255,255,255,0.2);box-shadow:none;color:rgba(255,255,255,0.7)" onclick="openTplBrowser()">
<i data-lucide="clipboard" style="width:16px;height:16px"></i> Из шаблона
</button>
<button class="btn-new-course" id="btn-new-course" style="display:none" onclick="openNewCourseModal()">
<i data-lucide="plus" style="width:16px;height:16px"></i> Новый курс
</button>
</div>
</div>
</div>
<div class="container">
<!-- Continue reading banner -->
<div id="continue-wrap" style="display:none"></div>
<!-- Subject filter -->
<div class="subj-filter" id="subj-filter">
<button class="sf-btn active" data-subj="" onclick="setFilter(this,'')">Все</button>
<button class="sf-btn" data-subj="bio" onclick="setFilter(this,'bio')">Биология</button>
<button class="sf-btn" data-subj="chem" onclick="setFilter(this,'chem')">Химия</button>
<button class="sf-btn" data-subj="math" onclick="setFilter(this,'math')">Математика</button>
<button class="sf-btn" data-subj="phys" onclick="setFilter(this,'phys')"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg> Физика</button>
</div>
<!-- Search results count -->
<div id="search-results-info" style="display:none;margin-bottom:12px" class="search-results-count"></div>
<!-- Courses grid -->
<div class="courses-grid" id="courses-grid">
<div style="grid-column:1/-1">
<div class="spinner"></div>
</div>
</div>
</div><!-- /container -->
</div><!-- /sb-content -->
</div><!-- /app-layout -->
<!-- New course modal -->
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script>
const { user, isTeacher, isAdmin } = LS.initPage();
LS.showBoardIfAllowed();
if (isTeacher || isAdmin) {
document.getElementById('btn-classes').style.display = '';
document.getElementById('btn-new-course').style.display = '';
document.getElementById('btn-from-tpl').style.display = '';
}
if (isAdmin) {
document.getElementById('btn-admin').style.display = '';
}
lucide.createIcons();
LS.notif.init();
/* ── filter + search ── */
let currentFilter = '';
let allCourses = [];
let searchQuery = '';
let _searchTimer = null;
function setFilter(btn, subj) {
document.querySelectorAll('.sf-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = subj;
searchQuery = '';
document.getElementById('search-input').value = '';
document.getElementById('search-results-info').style.display = 'none';
renderCourses();
}
function onSearchInput(val) {
clearTimeout(_searchTimer);
searchQuery = val.trim();
if (!searchQuery) {
document.getElementById('search-results-info').style.display = 'none';
renderCourses();
return;
}
_searchTimer = setTimeout(doSearch, 350);
}
async function doSearch() {
if (!searchQuery) return;
const grid = document.getElementById('courses-grid');
const info = document.getElementById('search-results-info');
grid.innerHTML = '<div style="grid-column:1/-1"><div class="spinner"></div></div>';
try {
const res = await LS.api('/api/courses/search?q=' + encodeURIComponent(searchQuery));
const courses = res.courses || [];
const lessons = res.lessons || [];
info.textContent = `Найдено: ${courses.length} курс(ов), ${lessons.length} урок(ов)`;
info.style.display = '';
let html = courses.map((c, i) => renderCourseCard(c, i)).join('');
if (lessons.length) {
html += `<div style="grid-column:1/-1;margin-top:8px;margin-bottom:2px">
<div style="font-family:'Unbounded',sans-serif;font-size:0.72rem;font-weight:800;color:var(--text-3);text-transform:uppercase;letter-spacing:0.07em">Уроки</div>
</div>`;
html += lessons.map((l, i) => `
<a class="course-card stagger-item" href="/lesson?id=${l.id}" style="--i:${i};flex-direction:row;padding:16px;gap:14px;border-radius:16px">
<div style="width:36px;height:36px;border-radius:10px;background:rgba(155,93,229,0.1);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem"><svg class="ic" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></div>
<div style="flex:1">
<div style="font-size:0.88rem;font-weight:700;color:#0F172A">${esc(l.title)}</div>
<div style="font-size:0.74rem;color:var(--text-3);margin-top:3px">${esc(l.courseTitle || l.course_title || '')}</div>
</div>
<i data-lucide="chevron-right" style="width:15px;height:15px;color:#CBD5E1;align-self:center;flex-shrink:0"></i>
</a>`).join('');
}
if (!html) {
grid.innerHTML = `<div style="grid-column:1/-1">
<div class="empty-state">
<div class="empty-state-icon"><svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg></div>
<div class="empty-state-title">Ничего не найдено</div>
<div class="empty-state-desc">Попробуйте другой запрос</div>
</div>
</div>`;
} else {
grid.innerHTML = html;
}
lucide.createIcons();
} catch {
grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;color:var(--text-3);padding:40px">Ошибка поиска</div>';
}
}
/* ── render ── */
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
const BANNER_CLASS = { bio:'cc-banner-bio', chem:'cc-banner-chem', math:'cc-banner-math', phys:'cc-banner-phys' };
const SUBJ_CLASS = { bio:'cc-subj-bio', chem:'cc-subj-chem', math:'cc-subj-math', phys:'cc-subj-phys' };
function renderCourseCard(c, i) {
const bannerClass = BANNER_CLASS[c.subjectSlug] || 'cc-banner-other';
const subjClass = SUBJ_CLASS[c.subjectSlug] || 'cc-subj-other';
const subjLabel = SUBJ_LABEL[c.subjectSlug] || c.subjectSlug;
const pct = c.lessonCount > 0 ? Math.round(c.doneCount / c.lessonCount * 100) : 0;
return `<a class="course-card stagger-item" href="/course?id=${c.id}" style="--i:${i}">
<div class="cc-banner ${bannerClass}">
${c.coverEmoji || LS.icon('book-open',20)}
${!c.isPublished && isTeacher ? '<span class="cc-draft-badge">Черновик</span>' : ''}
${isTeacher ? `<button class="cc-del-btn" onclick="delCourse(event,${c.id},'${esc(c.title).replace(/'/g, "\\'")}')" title="Удалить курс">${LS.icon('trash-2',14)}</button>` : ''}
</div>
<div class="cc-body">
<div class="cc-subj ${subjClass}">${esc(subjLabel)}</div>
<div class="cc-title">${esc(c.title)}</div>
${c.description ? `<div class="cc-desc">${esc(c.description)}</div>` : '<div class="cc-desc" style="opacity:0.4">Нет описания</div>'}
<div class="cc-footer">
<div class="cc-meta"><i data-lucide="book-open" style="width:13px;height:13px"></i> ${c.lessonCount} уроков</div>
${c.lessonCount > 0 ? `
<div style="display:flex;align-items:center;gap:7px">
<div class="cc-progress-bar"><div class="cc-progress-fill" style="width:${pct}%"></div></div>
<span class="cc-pct">${pct}%</span>
</div>` : ''}
</div>
</div>
</a>`;
}
function renderCourses() {
const grid = document.getElementById('courses-grid');
const filtered = currentFilter ? allCourses.filter(c => c.subjectSlug === currentFilter) : allCourses;
if (!filtered.length) {
grid.innerHTML = `<div style="grid-column:1/-1">
<div class="empty-state">
<div class="empty-state-icon"><svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2V3zm20 0h-6a4 4 0 00-4 4v14a3 3 0 013-3h7V3z"/></svg></div>
<div class="empty-state-title">Курсов пока нет</div>
<div class="empty-state-desc">${isTeacher ? 'Создайте первый курс нажав кнопку «Новый курс»' : 'Здесь появятся курсы, когда учитель их добавит'}</div>
</div>
</div>`;
return;
}
grid.innerHTML = filtered.map((c, i) => renderCourseCard(c, i)).join('');
lucide.createIcons();
}
/* ── continue reading ── */
async function loadContinue() {
try {
const res = await LS.api('/api/courses/continue');
if (!res || !res.lessonId) return;
const wrap = document.getElementById('continue-wrap');
wrap.innerHTML = `
<a class="continue-banner" href="/lesson?id=${res.lessonId}">
<div class="cb-icon"><svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg></div>
<div class="cb-body">
<div class="cb-label">Продолжить обучение</div>
<div class="cb-title">${esc(res.lessonTitle || 'Урок')}</div>
<div class="cb-course">${esc(res.courseTitle || '')}</div>
</div>
<i data-lucide="arrow-right" class="cb-arrow" style="width:18px;height:18px;flex-shrink:0"></i>
</a>`;
wrap.style.display = '';
lucide.createIcons();
} catch {}
}
/* ── delete course ── */
async function delCourse(e, id, title) {
e.preventDefault();
e.stopPropagation();
const ok = await LS.confirm('Удалить курс «' + title + '» и все его уроки?', { title:'Удаление курса', confirmText:'Удалить', danger:true });
if (!ok) return;
try {
await LS.api('/api/courses/' + id, { method: 'DELETE' });
loadCourses();
} catch (err) { LS.toast(err.message || 'Ошибка', 'error'); }
}
/* ── load courses ── */
async function loadCourses() {
try {
allCourses = await LS.api('/api/courses');
renderCourses();
} catch (e) {
document.getElementById('courses-grid').innerHTML =
'<div style="grid-column:1/-1;text-align:center;color:var(--text-3);padding:40px">Ошибка загрузки</div>';
}
}
loadCourses();
loadContinue();
/* ── modal ── */
let _newCourseModal = null;
function openNewCourseModal() {
const body = `
<div class="form-group">
<label class="form-label">Предмет</label>
<select class="form-input" id="nc-subject">
<option value="bio">Биология</option>
<option value="chem">Химия</option>
<option value="math">Математика</option>
<option value="phys">Физика</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Название</label>
<input class="form-input" id="nc-title" placeholder="Например: Молекулярная биология" />
</div>
<div class="form-group">
<label class="form-label">Описание (необязательно)</label>
<textarea class="form-input" id="nc-desc" rows="2" placeholder="Кратко о курсе…" style="resize:vertical"></textarea>
</div>
<div class="form-group">
<label class="form-label">Эмодзи обложки</label>
<input class="form-input" id="nc-emoji" placeholder="" maxlength="4" style="max-width:90px" />
</div>`;
_newCourseModal = LS.modal({
title: 'Новый курс', content: body, size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => _newCourseModal.close() },
{ label: 'Создать', primary: true, id: 'btn-do-create', onClick: doCreateCourse },
],
});
const titleEl = _newCourseModal.body.querySelector('#nc-title');
titleEl.addEventListener('keydown', e => { if (e.key === 'Enter') doCreateCourse(); });
}
async function doCreateCourse() {
const title = document.getElementById('nc-title').value.trim();
if (!title) { document.getElementById('nc-title').focus(); return; }
const btn = document.getElementById('btn-do-create');
btn.disabled = true;
try {
const res = await LS.api('/api/courses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subjectSlug: document.getElementById('nc-subject').value,
title,
description: document.getElementById('nc-desc').value.trim() || null,
coverEmoji: document.getElementById('nc-emoji').value.trim() || '',
}),
});
_newCourseModal?.close();
location.href = '/course?id=' + res.id;
} catch (e) {
LS.toast(e.message || 'Ошибка', 'error');
btn.disabled = false;
}
}
/* ══════════════════════════════════════════════════════════════════
TEMPLATE BROWSER
══════════════════════════════════════════════════════════════════ */
function openTplBrowser() {
document.getElementById('tpl-browser-modal').classList.add('open');
loadCourseTpls();
}
function closeTplBrowser() {
document.getElementById('tpl-browser-modal').classList.remove('open');
}
let _tplFilter = '';
function setTplFilter(btn, val) {
document.querySelectorAll('.tpl-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_tplFilter = val;
loadCourseTpls();
}
async function loadCourseTpls() {
const grid = document.getElementById('tpl-grid');
grid.innerHTML = '<div style="grid-column:1/-1"><div class="spinner"></div></div>';
try {
const params = {};
if (_tplFilter) params.subject = _tplFilter;
const list = await LS.getCourseTemplates(params);
if (!list.length) {
grid.innerHTML = '<div style="grid-column:1/-1" class="tpl-empty">' + LS.icon('clipboard', 32) + '<br>Нет доступных шаблонов курсов</div>';
return;
}
grid.innerHTML = list.map(t => {
const structure = t.structure || {};
const secCount = (structure.sections || []).length;
const lesCount = (structure.sections || []).reduce((sum, s) => sum + (s.lessons || []).length, 0);
const subjLabel = SUBJ_LABEL[t.subjectSlug] || t.subjectSlug || '';
const canDelete = t.createdBy === user.id || user.role === 'admin';
return '<div class="tpl-card">' +
'<div class="tpl-card-banner ' + (t.subjectSlug ? 'tpl-banner-' + t.subjectSlug : '') + '">' +
LS.icon('clipboard', 24) +
'</div>' +
'<div class="tpl-card-body">' +
(subjLabel ? '<div class="tpl-card-subj">' + esc(subjLabel) + '</div>' : '') +
'<div class="tpl-card-title">' + esc(t.title) + '</div>' +
(t.description ? '<div class="tpl-card-desc">' + esc(t.description) + '</div>' : '') +
'<div class="tpl-card-meta">' +
'<span>' + secCount + ' разд.</span>' +
'<span>' + lesCount + ' уроков</span>' +
'<span>' + esc(t.creatorName) + '</span>' +
'</div>' +
'<div class="tpl-card-actions">' +
'<button class="tpl-use-btn" onclick="useCourseTpl(' + t.id + ')">Использовать</button>' +
(canDelete ? '<button class="tpl-del-btn" onclick="delCourseTpl(event,' + t.id + ')" title="Удалить">' + LS.icon('trash',13) + '</button>' : '') +
'</div>' +
'</div>' +
'</div>';
}).join('');
} catch (e) {
grid.innerHTML = '<div style="grid-column:1/-1" class="tpl-empty">Ошибка загрузки</div>';
}
}
async function useCourseTpl(tplId) {
try {
const res = await LS.createFromCourseTemplate(tplId, {});
closeTplBrowser();
LS.toast('Курс создан из шаблона', 'success');
location.href = '/course?id=' + res.id;
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function delCourseTpl(e, tplId) {
e.stopPropagation();
const ok = await LS.confirm('Удалить этот шаблон курса?', { title:'Удаление шаблона', confirmText:'Удалить', danger:true });
if (!ok) return;
try {
await LS.deleteCourseTemplate(tplId);
LS.toast('Шаблон удалён', 'success');
loadCourseTpls();
} catch (e2) { LS.toast(e2.message || 'Ошибка', 'error'); }
}
</script>
<!-- Template browser modal -->
<style>
.tpl-modal{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;}
.tpl-modal.open{display:flex;}
.tpl-browser{background:#fff;border-radius:24px;padding:28px 32px;width:100%;max-width:780px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 32px 80px rgba(15,23,42,0.22);}
.tpl-browser-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;flex-wrap:wrap;gap:10px;}
.tpl-browser-title{font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:#0F172A;}
.tpl-filter-bar{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:16px;}
.tpl-filter-btn{padding:6px 14px;border-radius:999px;border:1.5px solid rgba(15,23,42,0.1);background:#fff;color:#6B7A8E;font-family:'Manrope',sans-serif;font-size:0.78rem;font-weight:700;cursor:pointer;transition:all .15s;}
.tpl-filter-btn:hover{border-color:var(--violet);color:var(--violet);}
.tpl-filter-btn.active{background:var(--violet);color:#fff;border-color:var(--violet);}
.tpl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px;overflow-y:auto;flex:1;padding-right:4px;}
.tpl-card{background:#fff;border:1.5px solid rgba(15,23,42,0.07);border-radius:18px;overflow:hidden;transition:transform .18s,box-shadow .18s,border-color .18s;display:flex;flex-direction:column;box-shadow:0 2px 8px rgba(15,23,42,0.05);}
.tpl-card:hover{transform:translateY(-3px);box-shadow:0 8px 28px rgba(15,23,42,0.1);border-color:rgba(155,93,229,0.2);}
.tpl-card-banner{height:56px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#0a0a1a 0%,#1a1a2e 100%);color:rgba(255,255,255,0.4);}
.tpl-banner-bio{background:linear-gradient(135deg,#1a0a3c 0%,#2d0856 100%);}
.tpl-banner-chem{background:linear-gradient(135deg,#062a20 0%,#064a38 100%);}
.tpl-banner-math{background:linear-gradient(135deg,#061828 0%,#062848 100%);}
.tpl-banner-phys{background:linear-gradient(135deg,#1c1400 0%,#3a2800 100%);}
.tpl-card-body{padding:14px 16px 12px;flex:1;display:flex;flex-direction:column;}
.tpl-card-subj{font-size:0.66rem;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--violet);margin-bottom:4px;}
.tpl-card-title{font-family:'Unbounded',sans-serif;font-size:0.82rem;font-weight:800;color:#0F172A;margin-bottom:6px;line-height:1.35;}
.tpl-card-desc{font-size:0.76rem;color:#6B7A8E;line-height:1.5;margin-bottom:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.tpl-card-meta{font-size:0.7rem;color:var(--text-3);display:flex;gap:8px;margin-bottom:10px;}
.tpl-card-actions{display:flex;gap:6px;margin-top:auto;}
.tpl-use-btn{flex:1;padding:7px 14px;border:none;border-radius:999px;background:var(--violet);color:#fff;font-family:'Manrope',sans-serif;font-size:0.78rem;font-weight:700;cursor:pointer;transition:all .15s;}
.tpl-use-btn:hover{background:#8a47d8;}
.tpl-del-btn{width:30px;height:30px;border:1.5px solid rgba(241,91,181,0.2);border-radius:999px;background:rgba(241,91,181,0.06);color:#E0335E;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .12s;flex-shrink:0;}
.tpl-del-btn:hover{background:#E0335E;color:#fff;}
.tpl-empty{text-align:center;padding:40px;color:var(--text-3);font-size:0.86rem;}
</style>
<div class="tpl-modal" id="tpl-browser-modal" onclick="if(event.target===this)closeTplBrowser()">
<div class="tpl-browser">
<div class="tpl-browser-header">
<div class="tpl-browser-title">Шаблоны курсов</div>
<button class="btn-cancel" onclick="closeTplBrowser()" style="padding:6px 16px">Закрыть</button>
</div>
<div class="tpl-filter-bar">
<button class="tpl-filter-btn active" onclick="setTplFilter(this,'')">Все</button>
<button class="tpl-filter-btn" onclick="setTplFilter(this,'bio')">Биология</button>
<button class="tpl-filter-btn" onclick="setTplFilter(this,'chem')">Химия</button>
<button class="tpl-filter-btn" onclick="setTplFilter(this,'math')">Математика</button>
<button class="tpl-filter-btn" onclick="setTplFilter(this,'phys')">Физика</button>
</div>
<div class="tpl-grid" id="tpl-grid">
<div style="grid-column:1/-1"><div class="spinner"></div></div>
</div>
</div>
</div>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>