feat: textbooks Phase 4 — A1+A2+A3+B4+C7 + назначение ученику
A1 — карточка ДЗ-чтения у ученика на /dashboard: - Новая ветка в buildAssignCard для assignments с textbook_id - Прогресс-бар «X из Y §», цвет берётся из textbook.color - Кнопка «Открыть / Продолжить» с deep-link на первый требуемый параграф - В classify(): textbook_all_read → done, deadline → overdue A2 — авто-проверка выполнения: - При POST /:slug/progress с mark_read: проверяются активные textbook-assignments - Если все требуемые § прочитаны → INSERT в assignment_completion - SSE-уведомление учителю «Ученик завершил чтение: <title>» - myAssignments возвращает completed_at и textbook_all_read A3 — учительский UI прогресса класса: - Новая страница /textbook-progress (учитель/админ) - Селекторы «учебник × класс» → таблица учеников с прогрессом - Сортировка по количеству прочитанного, дата last_at - Кнопка «Прогресс класса» добавлена в /textbooks (видна учителям) B4 — admin-UI управления учебниками: - /admin-textbooks (только admin) — таблица всех учебников - Inline-редактирование title/author, тоггл is_active - Колонка «Читателей» (count из textbook_progress) - Endpoints: GET /api/textbooks/admin/all, PATCH /admin/:id C7 — закладки/заметки внутри учебника: - Таблица textbook_bookmarks (user, textbook, para, text, note, color) - API: GET/POST/PATCH/DELETE для CRUD закладок - В tracker: при выделении текста (8-400 симв) появляется плавающая «+ Закладка» - Кнопка-иконка в overlay top-left открывает панель «Мои закладки» - Хранится paragraph-якорь, цвет, заметка, кнопка удалить Назначение ученику (в дополнение к классу): - В модалке /textbooks — переключатель «Классу / Ученику» - Поиск ученика по имени/email через /api/classes/students - Submit использует POST /api/assignments (createDirectAssignment) - createDirectAssignment расширен textbook_slug + textbook_paragraphs - Учитель может назначать только ученикам своих классов myAssignments расширен: возвращает textbook fields + post-process считает textbook_required_count, textbook_read_count, textbook_all_read. Deep-link поддержка: /textbook/<slug>#pN в tracker.js — на load и hashchange вызывает setParaTab(pN) (нативная функция учебника). Миграция 005: assignment_completion + textbook_bookmarks + индексы.
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
<!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" />
|
||||
<style>
|
||||
.sb-content { padding: 0; overflow-y: auto; }
|
||||
.tp-wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 80px; width: 100%; }
|
||||
|
||||
.tp-header { display:flex; align-items:center; gap:14px; margin-bottom:26px; }
|
||||
.tp-back {
|
||||
width:38px; height:38px; border-radius:10px;
|
||||
border:1.5px solid var(--border-h); background:transparent; color:var(--text-2);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
cursor:pointer; transition:all .15s; text-decoration:none;
|
||||
}
|
||||
.tp-back:hover { border-color:var(--violet); color:var(--violet); }
|
||||
.tp-back svg { width:18px; height:18px; }
|
||||
.tp-title { font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:800; }
|
||||
.tp-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
|
||||
|
||||
.tp-pickers {
|
||||
display:flex; gap:12px; margin-bottom:24px; flex-wrap:wrap;
|
||||
}
|
||||
.tp-picker {
|
||||
flex:1; min-width:200px;
|
||||
}
|
||||
.tp-picker label {
|
||||
display:block; font-size:.72rem; font-weight:700; color:var(--text-2);
|
||||
text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
|
||||
}
|
||||
.tp-picker select {
|
||||
width:100%; padding:10px 12px; border:1.5px solid var(--border-h);
|
||||
border-radius:10px; background:var(--surface); color:var(--text);
|
||||
font-family:'Manrope',sans-serif; font-size:.9rem; font-weight:600;
|
||||
cursor:pointer;
|
||||
}
|
||||
.tp-picker select:focus { outline:none; border-color:var(--violet); }
|
||||
|
||||
.tp-table {
|
||||
background:var(--surface); border:1.5px solid var(--border); border-radius:14px;
|
||||
overflow:hidden;
|
||||
}
|
||||
.tp-row {
|
||||
display:grid; grid-template-columns: 1.5fr 2fr 1fr 1fr;
|
||||
padding:13px 18px; align-items:center; gap:14px;
|
||||
border-bottom:1px solid var(--border);
|
||||
transition: background .12s;
|
||||
}
|
||||
.tp-row:last-child { border-bottom:none; }
|
||||
.tp-row:hover { background:rgba(155,93,229,.04); }
|
||||
.tp-row.head {
|
||||
background:rgba(155,93,229,.06); font-family:'Unbounded',sans-serif;
|
||||
font-size:.72rem; font-weight:800; color:var(--text-2);
|
||||
text-transform:uppercase; letter-spacing:.05em;
|
||||
}
|
||||
.tp-row.head:hover { background:rgba(155,93,229,.06); }
|
||||
.tp-name { font-weight:700; font-size:.92rem; }
|
||||
.tp-bar {
|
||||
height:8px; border-radius:99px; background:var(--border); overflow:hidden;
|
||||
position:relative;
|
||||
}
|
||||
.tp-bar-fill { height:100%; border-radius:99px; transition:width .3s; background:var(--violet); }
|
||||
.tp-bar-text { font-size:.76rem; color:var(--text-3); margin-top:4px; }
|
||||
.tp-last { font-size:.82rem; color:var(--text-2); }
|
||||
.tp-last small { color:var(--text-3); }
|
||||
.tp-stats {
|
||||
display:flex; align-items:center; gap:6px; font-size:.82rem;
|
||||
}
|
||||
.tp-stats svg { width:13px; height:13px; opacity:.6; }
|
||||
.tp-empty { padding:60px 20px; text-align:center; color:var(--text-3); }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.tp-row { grid-template-columns: 1.5fr 1fr; gap:8px; }
|
||||
.tp-row > :nth-child(3), .tp-row > :nth-child(4) { display:none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="app-sidebar"></aside>
|
||||
<div class="sb-content">
|
||||
<div class="tp-wrap">
|
||||
<header class="tp-header">
|
||||
<a href="/textbooks" class="tp-back" title="К каталогу"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg></a>
|
||||
<div>
|
||||
<div class="tp-title">Прогресс класса по учебнику</div>
|
||||
<div class="tp-sub">Кто сколько параграфов прочитал</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tp-pickers">
|
||||
<div class="tp-picker">
|
||||
<label>Учебник</label>
|
||||
<select id="tp-textbook"></select>
|
||||
</div>
|
||||
<div class="tp-picker">
|
||||
<label>Класс</label>
|
||||
<select id="tp-class"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tp-result" class="tp-empty">Выберите учебник и класс</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script src="/js/search.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
<script>
|
||||
(async function () {
|
||||
const user = LS.initPage();
|
||||
if (!user || (user.role !== 'teacher' && user.role !== 'admin')) {
|
||||
location.href = '/dashboard'; return;
|
||||
}
|
||||
LS.showBoardIfAllowed();
|
||||
LS.hideDisabledFeatures();
|
||||
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
}
|
||||
function fmtDate(s) {
|
||||
if (!s) return '';
|
||||
try {
|
||||
return new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z').toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' });
|
||||
} catch { return s; }
|
||||
}
|
||||
|
||||
/* Load lookups */
|
||||
const [tbRes, classes] = await Promise.all([
|
||||
LS.api('/api/textbooks').catch(() => ({ textbooks: [] })),
|
||||
LS.api('/api/classes').catch(() => []),
|
||||
]);
|
||||
const textbooks = tbRes.textbooks || [];
|
||||
|
||||
const tbSel = document.getElementById('tp-textbook');
|
||||
textbooks.forEach((t, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.slug;
|
||||
opt.textContent = `${t.title} (§1–${t.para_count})`;
|
||||
if (i === 0) opt.selected = true;
|
||||
tbSel.appendChild(opt);
|
||||
});
|
||||
|
||||
const clsSel = document.getElementById('tp-class');
|
||||
const list = Array.isArray(classes) ? classes : [];
|
||||
if (!list.length) {
|
||||
document.getElementById('tp-result').innerHTML = '<div class="tp-empty">У вас нет классов</div>';
|
||||
return;
|
||||
}
|
||||
list.forEach((c, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = `${c.name} (${c.member_count || 0} учеников)`;
|
||||
if (i === 0) opt.selected = true;
|
||||
clsSel.appendChild(opt);
|
||||
});
|
||||
|
||||
async function loadProgress() {
|
||||
const tbSlug = tbSel.value;
|
||||
const classId = clsSel.value;
|
||||
if (!tbSlug || !classId) return;
|
||||
|
||||
const resEl = document.getElementById('tp-result');
|
||||
resEl.innerHTML = '<div class="tp-empty">Загрузка…</div>';
|
||||
|
||||
try {
|
||||
const r = await LS.api(`/api/textbooks/${tbSlug}/class-progress?class_id=${classId}`);
|
||||
const total = r.total_paragraphs || 0;
|
||||
const tb = textbooks.find(t => t.slug === tbSlug);
|
||||
const colorMap = { amber:'#d97706', blue:'#2563eb', green:'#059669', violet:'#7c3aed', pink:'#db2777' };
|
||||
const color = colorMap[tb?.color] || '#7c3aed';
|
||||
|
||||
if (!r.students.length) {
|
||||
resEl.innerHTML = '<div class="tp-empty">В классе нет учеников</div>';
|
||||
return;
|
||||
}
|
||||
// Sort: most progress first, then alphabetical
|
||||
r.students.sort((a, b) => (b.read_count - a.read_count) || a.name.localeCompare(b.name));
|
||||
|
||||
const rows = r.students.map(s => {
|
||||
const pct = total > 0 ? Math.round(100 * s.read_count / total) : 0;
|
||||
return `
|
||||
<div class="tp-row">
|
||||
<div class="tp-name">${esc(s.name)}</div>
|
||||
<div>
|
||||
<div class="tp-bar"><div class="tp-bar-fill" style="width:${pct}%;background:${color}"></div></div>
|
||||
<div class="tp-bar-text">${s.read_count} из ${total} §</div>
|
||||
</div>
|
||||
<div class="tp-stats"><b style="color:var(--text);font-family:'Unbounded',sans-serif">${pct}%</b></div>
|
||||
<div class="tp-last">
|
||||
${s.last_para ? `<b>§${s.last_para.replace('p','')}</b><br><small>${fmtDate(s.last_at)}</small>` : '<small>—</small>'}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
resEl.className = 'tp-table';
|
||||
resEl.innerHTML = `
|
||||
<div class="tp-row head">
|
||||
<div>Ученик</div>
|
||||
<div>Прогресс</div>
|
||||
<div>%</div>
|
||||
<div>Последний §</div>
|
||||
</div>
|
||||
${rows}`;
|
||||
} catch (e) {
|
||||
resEl.className = 'tp-empty';
|
||||
resEl.innerHTML = 'Ошибка: ' + esc(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
tbSel.addEventListener('change', loadProgress);
|
||||
clsSel.addEventListener('change', loadProgress);
|
||||
loadProgress();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user