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
+593
View File
@@ -0,0 +1,593 @@
<!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; }
.container { max-width: 960px; margin: 0 auto; padding: 28px 32px 100px; }
.page-title {
font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800;
color: #0F172A; margin-bottom: 6px;
}
.page-sub { font-size: 0.82rem; color: #8898AA; margin-bottom: 24px; }
/* ── top bar with class selector ── */
.hw-top { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; flex-wrap: wrap; }
.hw-class-sel {
padding: 8px 14px; border-radius: 12px; border: 1.5px solid rgba(15,23,42,0.12);
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
font-weight: 600; color: #0F172A; cursor: pointer; min-width: 180px;
}
.hw-status-filters { display: flex; gap: 4px; flex-wrap: wrap; }
.hw-sf-btn {
padding: 6px 14px; border-radius: 999px; border: 1.5px solid rgba(15,23,42,0.1);
background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.75rem;
font-weight: 600; color: #8898AA; cursor: pointer; transition: all .15s;
}
.hw-sf-btn:hover { border-color: var(--violet); color: var(--violet); }
.hw-sf-btn.active { background: rgba(155,93,229,0.08); border-color: var(--violet); color: var(--violet); }
/* ── submission cards ── */
.hw-list { display: flex; flex-direction: column; gap: 10px; }
.hw-card {
background: #fff; border-radius: 16px; padding: 18px 20px;
border: 1px solid rgba(15,23,42,0.06);
display: flex; align-items: flex-start; gap: 14px;
transition: all .15s;
}
.hw-card:hover { border-color: rgba(155,93,229,0.15); box-shadow: 0 2px 12px rgba(15,23,42,0.05); }
.hw-card-icon {
width: 42px; height: 42px; border-radius: 12px; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
.hw-card-body { flex: 1; min-width: 0; }
.hw-card-title { font-size: 0.88rem; font-weight: 700; color: #0F172A; margin-bottom: 4px; }
.hw-card-meta { font-size: 0.75rem; color: #8898AA; display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; }
.hw-card-note { font-size: 0.78rem; color: #3D4F6B; background: rgba(15,23,42,0.03); padding: 8px 12px; border-radius: 10px; margin-top: 8px; line-height: 1.5; }
.hw-card-note strong { color: #0F172A; }
.hw-card-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.hw-card-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex-shrink: 0; }
/* status badges */
.hw-badge {
padding: 4px 10px; border-radius: 999px; font-size: 0.68rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.03em; white-space: nowrap;
}
.hw-badge-new { background: rgba(6,214,224,0.12); color: #06B6D4; }
.hw-badge-reviewed { background: rgba(155,93,229,0.12); color: #9B5DE5; }
.hw-badge-revision { background: rgba(245,158,11,0.12); color: #F59E0B; }
.hw-badge-resubmitted{ background: rgba(6,214,100,0.12); color: #06D664; }
.hw-badge-accepted { background: rgba(16,185,129,0.12); color: #10B981; }
.hw-grade {
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
padding: 4px 10px; border-radius: 10px; min-width: 50px; text-align: center;
}
.hw-grade-high { background: rgba(16,185,129,0.1); color: #10B981; }
.hw-grade-mid { background: rgba(245,158,11,0.1); color: #F59E0B; }
.hw-grade-low { background: rgba(239,71,111,0.1); color: #EF476F; }
/* action buttons */
.hw-btn {
padding: 6px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12);
background: #fff; font-family: 'Manrope', sans-serif; font-size: 0.75rem;
font-weight: 600; color: #3D4F6B; cursor: pointer; transition: all .15s;
display: flex; align-items: center; gap: 5px;
}
.hw-btn:hover { border-color: var(--violet); color: var(--violet); }
.hw-btn-primary { background: var(--grad-1); color: #fff; border-color: transparent; }
.hw-btn-primary:hover { opacity: .88; color: #fff; }
.hw-btn-danger { border-color: rgba(239,71,111,0.2); color: #EF476F; }
.hw-btn-danger:hover { background: rgba(239,71,111,0.06); }
.hw-btn-accept { border-color: rgba(16,185,129,0.3); color: #10B981; }
.hw-btn-accept:hover { background: rgba(16,185,129,0.06); }
.hw-btn-revision { border-color: rgba(245,158,11,0.3); color: #F59E0B; }
.hw-btn-revision:hover { background: rgba(245,158,11,0.06); }
/* upload area */
.hw-upload-area {
background: #fff; border-radius: 16px; border: 2px dashed rgba(15,23,42,0.12);
padding: 28px; text-align: center; margin-bottom: 24px;
transition: border-color .15s, background .15s; cursor: pointer;
}
.hw-upload-area:hover, .hw-upload-area.dragover {
border-color: var(--violet); background: rgba(155,93,229,0.02);
}
.hw-upload-icon { color: #8898AA; margin-bottom: 8px; }
.hw-upload-text { font-size: 0.85rem; font-weight: 600; color: #3D4F6B; margin-bottom: 4px; }
.hw-upload-hint { font-size: 0.72rem; color: #8898AA; }
/* review modal (inline) */
.hw-review-panel {
background: rgba(255,255,255,0.95); border: 1.5px solid rgba(15,23,42,0.08);
border-radius: 14px; padding: 16px; margin-top: 10px;
}
.hw-review-panel label { font-size: 0.75rem; font-weight: 700; color: #3D4F6B; display: block; margin-bottom: 4px; }
.hw-review-panel textarea, .hw-review-panel input {
width: 100%; padding: 8px 12px; border: 1.5px solid rgba(15,23,42,0.1); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; margin-bottom: 10px;
outline: none; transition: border-color .15s;
}
.hw-review-panel textarea:focus, .hw-review-panel input:focus { border-color: var(--violet); }
.hw-review-panel textarea { min-height: 60px; resize: vertical; }
/* empty state */
.hw-empty { text-align: center; padding: 60px 20px; color: #8898AA; }
.hw-empty-icon { font-size: 3rem; margin-bottom: 12px; opacity: 0.3; }
.hw-empty-text { font-size: 0.88rem; font-weight: 600; }
/* student name in teacher view */
.hw-student-name { font-size: 0.78rem; font-weight: 700; color: var(--violet); }
@media (max-width: 768px) {
.container { padding: 16px 14px 80px; }
.hw-top { gap: 8px; }
.hw-class-sel { flex: 1; min-width: 0; }
.hw-card { flex-direction: column; gap: 10px; }
.hw-card-right { flex-direction: row; align-items: center; justify-content: flex-start; width: 100%; }
.hw-card-actions { flex-wrap: wrap; }
.hw-upload-area { padding: 20px 16px; }
}
@media (max-width: 480px) {
.container { padding: 12px 10px 80px; }
.hw-status-filters { gap: 4px; }
.hw-sf-btn { font-size: 0.72rem; padding: 5px 10px; }
.hw-card { padding: 14px 14px; }
.hw-btn { font-size: 0.72rem; padding: 5px 10px; }
}
</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" 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" style="display:none"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="btn-classes" style="display:none"><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 sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><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="LS.notif.toggle()">
<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">
<div class="container">
<div class="page-title">Домашние задания</div>
<div class="page-sub" id="hw-sub">Загрузка…</div>
<!-- Student: upload area -->
<div id="hw-upload-wrap" style="display:none">
<div class="hw-upload-area" id="hw-upload-area" onclick="document.getElementById('hw-file-input').click()">
<div class="hw-upload-icon"><i data-lucide="upload-cloud" style="width:36px;height:36px"></i></div>
<div class="hw-upload-text">Загрузить работу</div>
<div class="hw-upload-hint">PDF, Word, изображения · до 50 МБ</div>
<div class="hw-upload-hint" id="hw-selected-file" style="color:var(--violet);font-weight:600;margin-top:6px"></div>
</div>
<input type="file" id="hw-file-input" style="display:none"
accept=".pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp,.txt" />
<div style="display:flex;gap:10px;margin-bottom:24px;flex-wrap:wrap;align-items:flex-end">
<div style="flex:1;min-width:200px">
<label style="font-size:.72rem;font-weight:700;color:#8898AA;display:block;margin-bottom:4px">Задание (необязательно)</label>
<select id="hw-assignment-sel" class="hw-class-sel" style="width:100%">
<option value="">— Без привязки к заданию —</option>
</select>
</div>
<div style="flex:1;min-width:200px">
<label style="font-size:.72rem;font-weight:700;color:#8898AA;display:block;margin-bottom:4px">Комментарий</label>
<input type="text" id="hw-message" class="hw-class-sel" style="width:100%" placeholder="Опишите работу…" />
</div>
<button class="hw-btn hw-btn-primary" id="hw-submit-btn" onclick="submitHomework()" disabled>
<i data-lucide="send" style="width:13px;height:13px"></i> Отправить
</button>
</div>
</div>
<!-- Teacher: class selector -->
<div class="hw-top" id="hw-top-teacher" style="display:none">
<select class="hw-class-sel" id="hw-class-sel" onchange="loadTeacherSubmissions()">
<option value="">Выберите класс</option>
</select>
<div class="hw-status-filters">
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
<button class="hw-sf-btn" onclick="filterStatus('new',this)">Новые</button>
<button class="hw-sf-btn" onclick="filterStatus('resubmitted',this)">Повторные</button>
<button class="hw-sf-btn" onclick="filterStatus('revision',this)">На доработке</button>
<button class="hw-sf-btn" onclick="filterStatus('reviewed',this)">Проверены</button>
<button class="hw-sf-btn" onclick="filterStatus('accepted',this)">Приняты</button>
</div>
</div>
<!-- Student: status filters -->
<div class="hw-top" id="hw-top-student" style="display:none">
<div class="hw-status-filters">
<button class="hw-sf-btn active" onclick="filterStatus(null,this)">Все</button>
<button class="hw-sf-btn" onclick="filterStatus('new',this)">Отправлено</button>
<button class="hw-sf-btn" onclick="filterStatus('revision',this)">На доработке</button>
<button class="hw-sf-btn" onclick="filterStatus('reviewed',this)">Проверено</button>
<button class="hw-sf-btn" onclick="filterStatus('accepted',this)">Принято</button>
</div>
</div>
<div class="hw-list" id="hw-list"></div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/notifications.js"></script>
<script>
const { user, isTeacher, isAdmin } = LS.initPage();
if (!user) throw new Error('Not logged in');
function fmtDate(s) {
if (!s) return '—';
const d = new Date(s.includes('T') ? s : s.replace(' ','T')+'Z');
return d.toLocaleDateString('ru',{day:'numeric',month:'short'}) + ' ' + d.toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
}
function fmtSize(b) {
if (b < 1024) return b + ' Б';
if (b < 1024*1024) return (b/1024).toFixed(1) + ' КБ';
return (b/1024/1024).toFixed(1) + ' МБ';
}
if (isAdmin) {
const btnAdmin = document.getElementById('btn-admin');
if (btnAdmin) btnAdmin.style.display = '';
}
LS.showBoardIfAllowed();
LS.notif.init();
/* ── state ── */
let _submissions = [];
let _statusFilter = null;
let _selectedFile = null;
let _studentClassId = null;
const STATUS_LABELS = {
new: 'Новое', reviewed: 'Проверено', revision: 'На доработке',
resubmitted: 'Повторно', accepted: 'Принято'
};
/* ── filter ── */
function filterStatus(st, btn) {
_statusFilter = st;
document.querySelectorAll('.hw-sf-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
renderSubmissions();
}
/* ── STUDENT VIEW ── */
async function initStudent() {
document.getElementById('hw-sub').textContent = 'Сдавайте работы и отслеживайте оценки';
document.getElementById('hw-top-student').style.display = '';
// Find student's class
try {
const classes = await LS.myClasses();
if (classes.length) {
_studentClassId = classes[0].id;
document.getElementById('hw-upload-wrap').style.display = '';
// Load assignments for selector
try {
const feed = await LS.classFeed(classes[0].id);
const sel = document.getElementById('hw-assignment-sel');
(feed.assignments || []).forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.title;
sel.appendChild(opt);
});
} catch {}
}
} catch {}
// Load submissions
try {
_submissions = await LS.getMySubmissions();
renderSubmissions();
} catch {
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
}
// File input
const fileInput = document.getElementById('hw-file-input');
fileInput.addEventListener('change', () => {
_selectedFile = fileInput.files[0] || null;
document.getElementById('hw-selected-file').textContent = _selectedFile ? _selectedFile.name : '';
document.getElementById('hw-submit-btn').disabled = !_selectedFile;
});
// Drag and drop
const area = document.getElementById('hw-upload-area');
area.addEventListener('dragover', e => { e.preventDefault(); area.classList.add('dragover'); });
area.addEventListener('dragleave', () => area.classList.remove('dragover'));
area.addEventListener('drop', e => {
e.preventDefault(); area.classList.remove('dragover');
if (e.dataTransfer.files.length) {
_selectedFile = e.dataTransfer.files[0];
document.getElementById('hw-selected-file').textContent = _selectedFile.name;
document.getElementById('hw-submit-btn').disabled = false;
}
});
}
async function submitHomework() {
if (!_selectedFile || !_studentClassId) return;
const btn = document.getElementById('hw-submit-btn');
btn.disabled = true;
try {
const fd = new FormData();
fd.append('file', _selectedFile);
fd.append('class_id', _studentClassId);
const assignId = document.getElementById('hw-assignment-sel').value;
if (assignId) fd.append('assignment_id', assignId);
const msg = document.getElementById('hw-message').value.trim();
if (msg) fd.append('message', msg);
await LS.submitWork(fd);
LS.toast('Работа отправлена!', 'success');
// Reset
_selectedFile = null;
document.getElementById('hw-file-input').value = '';
document.getElementById('hw-selected-file').textContent = '';
document.getElementById('hw-message').value = '';
document.getElementById('hw-assignment-sel').value = '';
// Reload
_submissions = await LS.getMySubmissions();
renderSubmissions();
} catch (e) {
LS.toast(e.message || 'Ошибка отправки', 'error');
} finally { btn.disabled = !_selectedFile; }
}
async function resubmitHomework(subId) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp,.txt';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
try {
const fd = new FormData();
fd.append('file', file);
await LS.resubmitWork(subId, fd);
LS.toast('Работа отправлена повторно!', 'success');
_submissions = await LS.getMySubmissions();
renderSubmissions();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
};
input.click();
}
async function deleteHomework(id) {
if (!await LS.confirm('Удалить эту работу?', { title: 'Удаление', confirmText: 'Удалить' })) return;
try {
await LS.deleteSubmission(id);
_submissions = _submissions.filter(s => s.id !== id);
renderSubmissions();
LS.toast('Удалено', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function deleteSubmissionAdmin(id) {
if (!await LS.confirm('Удалить работу ученика? Это действие будет записано в журнал.', { title: 'Удаление работы', confirmText: 'Удалить', danger: true })) return;
try {
await LS.deleteSubmission(id);
_submissions = _submissions.filter(s => s.id !== id);
renderSubmissions();
LS.toast('Работа удалена', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── TEACHER VIEW ── */
async function initTeacher() {
document.getElementById('hw-sub').textContent = 'Проверяйте работы учеников и ставьте оценки';
document.getElementById('hw-top-teacher').style.display = '';
try {
const classes = await LS.getClasses();
const sel = document.getElementById('hw-class-sel');
classes.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
if (classes.length) {
sel.value = classes[0].id;
loadTeacherSubmissions();
}
} catch {}
}
async function loadTeacherSubmissions() {
const classId = document.getElementById('hw-class-sel').value;
if (!classId) { _submissions = []; renderSubmissions(); return; }
document.getElementById('hw-list').innerHTML = LS.skeleton(3);
try {
_submissions = await LS.getClassSubmissions(classId);
renderSubmissions();
} catch {
document.getElementById('hw-list').innerHTML = '<div class="hw-empty"><div class="hw-empty-text">Ошибка загрузки</div></div>';
}
}
function openReviewPanel(subId) {
const el = document.getElementById('review-' + subId);
if (el) { el.style.display = el.style.display === 'none' ? '' : 'none'; return; }
}
async function submitReview(subId, action) {
const noteEl = document.getElementById('note-' + subId);
const gradeEl = document.getElementById('grade-' + subId);
const teacher_note = noteEl?.value?.trim() || undefined;
const grade = gradeEl?.value ? Number(gradeEl.value) : undefined;
let status;
if (action === 'accept') status = 'accepted';
else if (action === 'revision') status = 'revision';
else status = 'reviewed';
try {
await LS.reviewSubmission(subId, { status, teacher_note, grade });
LS.toast(action === 'accept' ? 'Работа принята!' : action === 'revision' ? 'Отправлено на доработку' : 'Проверено', 'success');
loadTeacherSubmissions();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── RENDER ── */
function renderSubmissions() {
const list = document.getElementById('hw-list');
const filtered = _statusFilter ? _submissions.filter(s => s.status === _statusFilter) : _submissions;
if (!filtered.length) {
list.innerHTML = `<div class="hw-empty">
<div class="hw-empty-icon"><i data-lucide="inbox" style="width:48px;height:48px"></i></div>
<div class="hw-empty-text">${_submissions.length ? 'Нет работ с этим статусом' : 'Работ пока нет'}</div>
</div>`;
lucide.createIcons();
return;
}
list.innerHTML = filtered.map(s => {
const badge = `<span class="hw-badge hw-badge-${s.status}">${STATUS_LABELS[s.status] || s.status}</span>`;
const gradeHtml = s.grade != null
? `<div class="hw-grade ${s.grade >= 80 ? 'hw-grade-high' : s.grade >= 50 ? 'hw-grade-mid' : 'hw-grade-low'}">${s.grade}</div>`
: '';
const iconBg = s.status === 'accepted' ? 'rgba(16,185,129,0.1)' :
s.status === 'revision' ? 'rgba(245,158,11,0.1)' :
'rgba(155,93,229,0.1)';
const iconColor = s.status === 'accepted' ? '#10B981' :
s.status === 'revision' ? '#F59E0B' : '#9B5DE5';
const iconName = s.status === 'accepted' ? 'check-circle' :
s.status === 'revision' ? 'alert-circle' : 'file-text';
let noteHtml = '';
if (s.teacher_note) noteHtml = `<div class="hw-card-note"><strong>Учитель:</strong> ${esc(s.teacher_note)}</div>`;
if (s.message) noteHtml += `<div class="hw-card-note" style="margin-top:6px"><strong>Комментарий:</strong> ${esc(s.message)}</div>`;
let actionsHtml = '';
if (isTeacher || isAdmin) {
actionsHtml = `
<div class="hw-card-actions">
<a class="hw-btn" href="${LS.submissionDownloadUrl(s.id)}" target="_blank">
<i data-lucide="download" style="width:12px;height:12px"></i> Скачать
</a>
<button class="hw-btn" onclick="openReviewPanel(${s.id})">
<i data-lucide="pencil" style="width:12px;height:12px"></i> Проверить
</button>
<button class="hw-btn hw-btn-danger" onclick="deleteSubmissionAdmin(${s.id})">
<i data-lucide="trash-2" style="width:12px;height:12px"></i>
</button>
</div>
<div class="hw-review-panel" id="review-${s.id}" style="display:none">
<label>Комментарий</label>
<textarea id="note-${s.id}" placeholder="Замечания, рекомендации…">${esc(s.teacher_note || '')}</textarea>
<label>Оценка (0100)</label>
<input type="number" id="grade-${s.id}" min="0" max="100" value="${s.grade != null ? s.grade : ''}" placeholder="—" />
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="hw-btn hw-btn-accept" onclick="submitReview(${s.id},'accept')">
<i data-lucide="check" style="width:12px;height:12px"></i> Принять
</button>
<button class="hw-btn hw-btn-revision" onclick="submitReview(${s.id},'revision')">
<i data-lucide="rotate-ccw" style="width:12px;height:12px"></i> На доработку
</button>
<button class="hw-btn" onclick="submitReview(${s.id},'reviewed')">Проверено</button>
</div>
</div>`;
} else {
// Student actions
const canResubmit = s.status === 'revision';
const canDelete = ['new', 'revision', 'resubmitted'].includes(s.status);
actionsHtml = `<div class="hw-card-actions">
<a class="hw-btn" href="${LS.submissionDownloadUrl(s.id)}" target="_blank">
<i data-lucide="download" style="width:12px;height:12px"></i> Скачать
</a>
${canResubmit ? `<button class="hw-btn hw-btn-primary" onclick="resubmitHomework(${s.id})"><i data-lucide="upload" style="width:12px;height:12px"></i> Пересдать</button>` : ''}
${canDelete ? `<button class="hw-btn hw-btn-danger" onclick="deleteHomework(${s.id})"><i data-lucide="trash-2" style="width:12px;height:12px"></i></button>` : ''}
</div>`;
}
const studentLine = (isTeacher || isAdmin) && s.student_name
? `<span class="hw-student-name">${esc(s.student_name)}</span> · `
: '';
return `<div class="hw-card">
<div class="hw-card-icon" style="background:${iconBg};color:${iconColor}">
<i data-lucide="${iconName}" style="width:20px;height:20px"></i>
</div>
<div class="hw-card-body">
<div class="hw-card-title">${esc(s.assignment_title || s.original_name)}</div>
<div class="hw-card-meta">
<span>${studentLine}${fmtDate(s.submitted_at)}</span>
<span>${esc(s.original_name)} · ${fmtSize(s.size || 0)}</span>
</div>
${noteHtml}
${actionsHtml}
</div>
<div class="hw-card-right">
${badge}
${gradeHtml}
</div>
</div>`;
}).join('');
lucide.createIcons();
}
/* ── init ── */
if (isTeacher || isAdmin) initTeacher();
else initStudent();
if (window.lucide) lucide.createIcons();
</script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>