6be8a505eb
Учитель жмёт «Быстрый урок» в каталоге (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>
748 lines
38 KiB
HTML
748 lines
38 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 ── */
|
||
.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>
|