LearnSpace: full-stack educational whiteboard platform

Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+554
View File
@@ -0,0 +1,554 @@
<!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; overflow-y: auto; }
/* ── page header ── */
.gb-header {
background: linear-gradient(140deg, #0a0220 0%, #1a0a3c 60%, #080818 100%);
padding: 32px 28px 28px; position: relative; overflow: hidden;
}
.gb-header-dots {
position: absolute; inset: 0;
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 22px 22px; pointer-events: none;
}
.gb-header-inner { position: relative; z-index: 1; max-width: 1200px; margin: 0 auto; }
.gb-header-back {
display: inline-flex; align-items: center; gap: 6px;
font-size: 0.78rem; font-weight: 700; color: rgba(255,255,255,0.45);
text-decoration: none; margin-bottom: 14px; transition: color 0.15s;
}
.gb-header-back:hover { color: rgba(255,255,255,0.8); }
.gb-title-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.gb-title {
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800;
color: #fff; flex: 1;
}
.gb-actions { display: flex; gap: 8px; flex-shrink: 0; }
.gb-action-btn {
padding: 8px 18px; border-radius: 999px; border: 1.5px solid rgba(255,255,255,0.2);
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.7);
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
cursor: pointer; display: flex; align-items: center; gap: 6px; transition: all 0.15s;
}
.gb-action-btn:hover { background: rgba(255,255,255,0.14); color: #fff; }
/* class selector */
.gb-class-select {
padding: 8px 14px; border: 1.5px solid rgba(255,255,255,0.15); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 600;
color: #fff; background: rgba(255,255,255,0.08); cursor: pointer;
}
.gb-class-select option { color: #0F172A; background: #fff; }
/* ── summary chips ── */
.gb-summary {
display: flex; gap: 12px; flex-wrap: wrap; max-width: 1200px;
margin: -20px auto 0; padding: 0 28px; position: relative; z-index: 2;
}
.gb-chip {
flex: 1; min-width: 150px; background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 16px; padding: 16px 18px; box-shadow: 0 2px 8px rgba(15,23,42,0.06);
}
.gb-chip-val {
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800;
}
.gb-chip-label { font-size: 0.72rem; color: #8898AA; font-weight: 600; margin-top: 4px; }
/* ── table container ── */
.gb-container { max-width: 1200px; margin: 0 auto; padding: 24px 28px 80px; }
/* ── gradebook table ── */
.gb-table-wrap {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 20px; overflow: hidden;
box-shadow: 0 2px 8px rgba(15,23,42,0.05);
}
.gb-table-scroll { overflow-x: auto; }
.gb-table {
width: 100%; border-collapse: collapse; font-size: 0.82rem;
min-width: 600px;
}
.gb-table thead th {
position: sticky; top: 0; z-index: 10;
background: #f8f9fc; border-bottom: 1.5px solid rgba(15,23,42,0.08);
padding: 12px 14px; text-align: left;
font-size: 0.7rem; font-weight: 700; color: #8898AA;
text-transform: uppercase; letter-spacing: 0.04em;
white-space: nowrap;
}
.gb-table thead th.gb-th-student {
position: sticky; left: 0; z-index: 15; background: #f8f9fc;
min-width: 180px;
}
.gb-table thead th.gb-th-avg {
background: rgba(155,93,229,0.06); color: var(--violet);
}
.gb-table thead th.gb-th-rotate {
max-width: 80px; min-width: 60px;
}
.gb-th-text {
display: block; max-width: 90px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.gb-table tbody td {
padding: 10px 14px; border-bottom: 1px solid rgba(15,23,42,0.05);
text-align: center; font-weight: 600; color: #3D4F6B;
}
.gb-table tbody tr:hover td { background: rgba(155,93,229,0.02); }
.gb-table tbody td.gb-td-student {
position: sticky; left: 0; z-index: 5;
background: #fff; text-align: left; font-weight: 700; color: #0F172A;
}
.gb-table tbody tr:hover td.gb-td-student { background: rgba(155,93,229,0.02); }
.gb-table tbody td.gb-td-avg {
background: rgba(155,93,229,0.04); font-weight: 800;
}
/* grade cells */
.gb-grade {
display: inline-block; min-width: 40px; padding: 3px 8px;
border-radius: 8px; font-size: 0.76rem; font-weight: 700;
text-align: center;
}
.gb-grade-high { background: rgba(6,214,160,0.12); color: #059652; }
.gb-grade-mid { background: rgba(255,209,102,0.18); color: #B8860B; }
.gb-grade-low { background: rgba(239,71,111,0.12); color: #EF476F; }
.gb-grade-none { color: #CBD5E1; font-weight: 500; }
.gb-grade-full { background: rgba(6,214,160,0.15); color: #059652; font-weight: 800; }
/* student name row */
.gb-student-row { display: flex; align-items: center; gap: 10px; }
.gb-student-avatar {
width: 28px; height: 28px; border-radius: 8px;
background: rgba(155,93,229,0.1); color: var(--violet);
font-size: 0.65rem; font-weight: 800;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.gb-student-name { font-weight: 700; font-size: 0.82rem; }
.gb-student-email { font-size: 0.68rem; color: #8898AA; font-weight: 500; }
/* ── footer row ── */
.gb-table tfoot td {
padding: 10px 14px; border-top: 1.5px solid rgba(15,23,42,0.08);
font-weight: 800; font-size: 0.78rem; color: #0F172A;
background: #f8f9fc; text-align: center;
}
.gb-table tfoot td.gb-td-student {
position: sticky; left: 0; z-index: 5;
text-align: left; background: #f8f9fc;
}
/* ── course progress section ── */
.gb-section-title {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
color: #0F172A; margin: 28px 0 14px; display: flex; align-items: center; gap: 8px;
}
/* ── empty state ── */
.gb-empty {
text-align: center; padding: 60px 20px; color: #8898AA; font-size: 0.88rem;
}
.gb-empty-icon { font-size: 3rem; margin-bottom: 12px; opacity: 0.3; }
/* ── nav ── */
.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-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; }
.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; }
@media (max-width: 768px) {
.gb-header { padding: 20px 16px 36px; }
.gb-title-row { gap: 10px; }
.gb-title { font-size: 1rem; }
.gb-class-select { width: 100%; }
.gb-summary { padding: 0 14px; gap: 8px; margin-top: -24px; }
.gb-chip { min-width: 0; flex: 1 1 calc(50% - 8px); padding: 12px 14px; }
.gb-chip-val { font-size: 1rem; }
.gb-container { padding: 18px 12px 80px; }
}
@media (max-width: 480px) {
.gb-chip { flex: 1 1 100%; }
.gb-action-btn { width: 100%; justify-content: center; }
.gb-title { font-size: 0.9rem; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" onclick="toggleSidebar()" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link" id="btn-board"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="btn-classes"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link nav-active"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="btn-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div style="padding: 4px 2px">
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="toggleNotifDrop()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<!-- Header -->
<div class="gb-header">
<div class="gb-header-dots"></div>
<div class="gb-header-inner">
<a href="/classes" class="gb-header-back"><i data-lucide="arrow-left" style="width:14px;height:14px"></i> Классы</a>
<div class="gb-title-row">
<div class="gb-title"><i data-lucide="table" style="width:22px;height:22px;opacity:0.4"></i> Журнал оценок</div>
<select class="gb-class-select" id="class-select" onchange="loadJournal()">
<option value="">Выберите класс…</option>
</select>
<div class="gb-actions">
<button class="gb-action-btn" id="btn-csv" onclick="exportCsv()" style="display:none">
<i data-lucide="download" style="width:14px;height:14px"></i> Экспорт CSV
</button>
</div>
</div>
</div>
</div>
<!-- Summary -->
<div class="gb-summary" id="gb-summary" style="display:none">
<div class="gb-chip">
<div class="gb-chip-val" id="gs-students" style="color:var(--violet)">0</div>
<div class="gb-chip-label">Учеников</div>
</div>
<div class="gb-chip">
<div class="gb-chip-val" id="gs-assignments" style="color:#06B6D4">0</div>
<div class="gb-chip-label">Заданий</div>
</div>
<div class="gb-chip">
<div class="gb-chip-val" id="gs-avg" style="color:#06D6A0"></div>
<div class="gb-chip-label">Средний балл</div>
</div>
<div class="gb-chip">
<div class="gb-chip-val" id="gs-completion" style="color:#FFD166"></div>
<div class="gb-chip-label">Выполнение</div>
</div>
</div>
<!-- Content -->
<div class="gb-container" id="gb-container">
<div class="gb-empty">
<div class="gb-empty-icon"><i data-lucide="table" style="width:48px;height:48px"></i></div>
Выберите класс, чтобы увидеть журнал оценок
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
const user = LS.getUser();
document.getElementById('nav-user').textContent = user?.name || user?.email || '';
document.getElementById('nav-avatar').textContent =
(user?.name || 'LS').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
const isTeacher = ['admin','teacher'].includes(user?.role);
if (!isTeacher) { location.href = '/dashboard'; throw new Error(); }
if (user?.role === 'admin') document.getElementById('btn-admin').style.display = '';
lucide.createIcons();
/* ── sidebar ── */
function toggleSidebar() {
const layout = document.querySelector('.app-layout');
const collapsed = layout.classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', collapsed ? '1' : '');
lucide.createIcons();
}
if (localStorage.getItem('ls_sb_collapsed'))
document.querySelector('.app-layout').classList.add('sb-collapsed');
/* ── notif ── */
function toggleNotifDrop() {
const btn = document.getElementById('notif-btn');
const drop = document.getElementById('notif-drop');
const r = btn.getBoundingClientRect();
drop.style.left = (r.right + 8) + 'px';
drop.style.top = r.top + 'px';
if (drop.classList.toggle('open')) loadNotifs();
}
async function loadNotifs() {
const drop = document.getElementById('notif-drop');
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div><div class="notif-empty">Загрузка…</div>';
try {
const data = await LS.api('/api/notifications?limit=20');
const items = data.items || [];
const badge = document.getElementById('notif-badge');
const unread = items.filter(n => !n.is_read).length;
badge.textContent = unread; badge.style.display = unread ? '' : 'none';
if (!items.length) { drop.querySelector('.notif-empty').textContent = 'Нет уведомлений'; return; }
drop.innerHTML = '<div class="notif-drop-header"><span class="notif-drop-title">Уведомления</span><button class="notif-read-all" onclick="readAllNotifs()">Прочитать все</button></div>' +
items.map(n => `<a class="notif-item${n.is_read ? '' : ' unread'}" href="${LS.safeHref(n.link)}" onclick="markRead(${n.id})">
<div class="notif-dot${n.is_read ? ' read' : ''}"></div>
<div><div class="notif-msg">${esc(n.message)}</div><div class="notif-time">${fmtTime(n.created_at)}</div></div>
</a>`).join('');
} catch {}
}
async function markRead(id) { try { await LS.api('/api/notifications/' + id + '/read', { method:'POST' }); } catch {} }
async function readAllNotifs() { try { await LS.api('/api/notifications/read-all', { method:'POST' }); loadNotifs(); } catch {} }
document.addEventListener('click', e => {
const drop = document.getElementById('notif-drop');
if (drop.classList.contains('open') && !drop.contains(e.target) && !document.getElementById('notif-btn').contains(e.target))
drop.classList.remove('open');
});
/* ── helpers ── */
function fmtTime(s) {
const d = new Date(s && s.includes('T') ? s : (s||'').replace(' ','T')+'Z');
const diff = Date.now() - d.getTime();
if (diff < 60000) return 'только что';
if (diff < 3600000) return Math.floor(diff/60000) + ' мин назад';
return d.toLocaleDateString('ru', { day:'numeric', month:'short' });
}
function initials(name) {
return (name || '??').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('');
}
function gradeClass(pct) {
if (pct >= 80) return 'gb-grade-high';
if (pct >= 50) return 'gb-grade-mid';
return 'gb-grade-low';
}
function gradeCell(pct) {
if (pct === null || pct === undefined || pct === '') return '<span class="gb-grade gb-grade-none">—</span>';
if (pct === 100) return `<span class="gb-grade gb-grade-full">${pct}%</span>`;
return `<span class="gb-grade ${gradeClass(pct)}">${pct}%</span>`;
}
/* ── load classes ── */
let currentClassId = null;
async function loadClasses() {
try {
const classes = await LS.api('/api/classes');
const sel = document.getElementById('class-select');
(classes || []).forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
// auto-select from URL
const urlClass = new URLSearchParams(location.search).get('classId');
if (urlClass) {
sel.value = urlClass;
loadJournal();
}
} catch {}
}
/* ── load journal ── */
async function loadJournal() {
const classId = document.getElementById('class-select').value;
if (!classId) {
document.getElementById('gb-summary').style.display = 'none';
document.getElementById('btn-csv').style.display = 'none';
document.getElementById('gb-container').innerHTML = `<div class="gb-empty"><div class="gb-empty-icon"><i data-lucide="table" style="width:48px;height:48px"></i></div>Выберите класс, чтобы увидеть журнал оценок</div>`;
lucide.createIcons();
return;
}
currentClassId = classId;
document.getElementById('gb-container').innerHTML = '<div class="spinner" style="margin:60px auto"></div>';
try {
const data = await LS.api('/api/classes/' + classId + '/journal');
renderJournal(data);
} catch (e) {
document.getElementById('gb-container').innerHTML = `<div class="gb-empty">${esc(e.message || 'Ошибка загрузки')}</div>`;
}
}
function renderJournal(data) {
const { members, assignments, results, courses, courseProgress, studentStats } = data;
const container = document.getElementById('gb-container');
const summary = document.getElementById('gb-summary');
if (!members.length) {
summary.style.display = 'none';
document.getElementById('btn-csv').style.display = 'none';
container.innerHTML = '<div class="gb-empty"><div class="gb-empty-icon"><i data-lucide="users" style="width:48px;height:48px"></i></div>В классе пока нет учеников</div>';
lucide.createIcons();
return;
}
// Build result map
const rmap = {};
results.forEach(r => { rmap[r.user_id + '_' + r.assignment_id] = r; });
// Summary stats
summary.style.display = '';
document.getElementById('btn-csv').style.display = '';
document.getElementById('gs-students').textContent = members.length;
document.getElementById('gs-assignments').textContent = assignments.length;
const allPcts = studentStats.filter(s => s.avgPct !== null);
const classAvg = allPcts.length > 0
? Math.round(allPcts.reduce((s, x) => s + x.avgPct, 0) / allPcts.length)
: 0;
document.getElementById('gs-avg').textContent = classAvg + '%';
const totalSlots = members.length * assignments.length;
const filledSlots = results.length;
const completionPct = totalSlots > 0 ? Math.round(filledSlots / totalSlots * 100) : 0;
document.getElementById('gs-completion').textContent = completionPct + '%';
// Assignments table
let html = '';
if (assignments.length) {
html += `<div class="gb-table-wrap"><div class="gb-table-scroll"><table class="gb-table">
<thead><tr>
<th class="gb-th-student">Ученик</th>
${assignments.map(a => `<th class="gb-th-rotate" title="${esc(a.title)}"><span class="gb-th-text">${esc(a.title)}</span></th>`).join('')}
<th class="gb-th-avg">Среднее</th>
</tr></thead>
<tbody>`;
members.forEach(m => {
const stats = studentStats.find(s => s.userId === m.id);
html += `<tr>
<td class="gb-td-student">
<div class="gb-student-row">
<div class="gb-student-avatar">${esc(initials(m.name))}</div>
<div>
<div class="gb-student-name">${esc(m.name)}</div>
<div class="gb-student-email">${esc(m.email)}</div>
</div>
</div>
</td>`;
assignments.forEach(a => {
const r = rmap[m.id + '_' + a.id];
html += `<td>${gradeCell(r ? r.percent : null)}</td>`;
});
html += `<td class="gb-td-avg">${stats?.avgPct !== null && stats?.avgPct !== undefined ? gradeCell(stats.avgPct) : gradeCell(null)}</td>`;
html += `</tr>`;
});
// Footer: averages per assignment
html += `</tbody><tfoot><tr><td class="gb-td-student" style="font-weight:800">Среднее по заданию</td>`;
assignments.forEach(a => {
const aResults = results.filter(r => r.assignment_id === a.id);
const avg = aResults.length > 0
? Math.round(aResults.reduce((s, r) => s + r.percent, 0) / aResults.length)
: null;
html += `<td>${gradeCell(avg)}</td>`;
});
html += `<td class="gb-td-avg">${gradeCell(classAvg)}</td></tr></tfoot>`;
html += `</table></div></div>`;
} else {
html += `<div style="background:#fff;border:1.5px solid rgba(15,23,42,0.07);border-radius:16px;padding:40px;text-align:center;color:#8898AA;margin-bottom:20px">
Нет заданий в этом классе. <a href="/classes" style="color:var(--violet)">Создать задание <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></a>
</div>`;
}
// Course progress section
if (courses && courses.length) {
html += `<div class="gb-section-title"><i data-lucide="brain" style="width:15px;height:15px;opacity:0.5"></i> Прогресс по курсам теории</div>`;
html += `<div class="gb-table-wrap"><div class="gb-table-scroll"><table class="gb-table">
<thead><tr>
<th class="gb-th-student">Ученик</th>
${courses.map(c => `<th class="gb-th-rotate" title="${esc(c.title)}"><span class="gb-th-text">${esc(c.title)}</span></th>`).join('')}
</tr></thead><tbody>`;
const cpMap = {};
(courseProgress || []).forEach(p => { cpMap[p.userId + '_' + p.courseId] = p; });
members.forEach(m => {
html += `<tr><td class="gb-td-student"><div class="gb-student-row"><div class="gb-student-avatar">${esc(initials(m.name))}</div><div class="gb-student-name">${esc(m.name)}</div></div></td>`;
courses.forEach(c => {
const p = cpMap[m.id + '_' + c.id];
html += `<td>${p ? gradeCell(p.percent) : gradeCell(null)}</td>`;
});
html += `</tr>`;
});
html += `</tbody></table></div></div>`;
}
container.innerHTML = html;
lucide.createIcons();
}
/* ── CSV export ── */
function exportCsv() {
if (!currentClassId) return;
const token = localStorage.getItem('ls_token');
const url = `/api/classes/${currentClassId}/journal/csv`;
// Use fetch with auth header then trigger download
fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => {
if (!r.ok) throw new Error('Export failed');
return r.blob();
})
.then(blob => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'gradebook.csv';
a.click();
URL.revokeObjectURL(a.href);
})
.catch(e => LS.toast(e.message || 'Ошибка экспорта', 'error'));
}
loadClasses();
</script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>