Files
Maxim Dolgolyov 6be8a505eb feat(lessons): «Быстрый урок» — одиночный урок без ручного создания курса
Учитель жмёт «Быстрый урок» в каталоге (theory.html) → урок создаётся в скрытом личном
курсе-контейнере и сразу открывается редактор. Возни с курсом нет.

- Миграция 059: courses.is_personal (ADD COLUMN).
- POST /api/lessons/quick (teacher/admin): get-or-create личный контейнер (is_personal=1,
  один на учителя, опубликован) + создаёт урок, возвращает lessonId.
- Каталог курсов скрывает личные контейнеры от всех, кроме владельца (courseController.list).
- Свои быстрые уроки учитель видит как курс «Мои материалы» (открыв его в каталоге).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:42:14 +03:00

748 lines
38 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 ── */
.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-quick-lesson" style="display:none;background:rgba(255,255,255,0.14);box-shadow:none;color:#fff" onclick="createQuickLesson()" title="Создать отдельный урок без курса">
<i data-lucide="file-plus" 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 = '';
document.getElementById('btn-quick-lesson').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;
}
}
/* Быстрый урок: создаёт отдельный урок в личном курсе-контейнере и сразу
открывает редактор — без ручного создания курса. */
async function createQuickLesson() {
const btn = document.getElementById('btn-quick-lesson');
if (btn) btn.disabled = true;
try {
const res = await LS.api('/api/lessons/quick', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
location.href = '/lesson-editor.html?id=' + res.lessonId;
} catch (e) {
LS.toast(e.message || 'Ошибка', 'error');
if (btn) 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>