3ff2f01178
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 + индексы.
206 lines
8.1 KiB
HTML
206 lines
8.1 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" />
|
|
<style>
|
|
.sb-content { padding: 0; overflow-y: auto; }
|
|
.at-wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 80px; width: 100%; }
|
|
.at-header { display:flex; align-items:center; gap:14px; margin-bottom:24px; }
|
|
.at-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;
|
|
}
|
|
.at-back:hover { border-color:var(--violet); color:var(--violet); }
|
|
.at-back svg { width:18px; height:18px; }
|
|
.at-title { font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:800; }
|
|
.at-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
|
|
|
|
.at-table {
|
|
background:var(--surface); border:1.5px solid var(--border); border-radius:14px;
|
|
overflow:hidden;
|
|
}
|
|
.at-row {
|
|
display:grid; grid-template-columns: 2.5fr 1.2fr 1fr 1fr 0.8fr 0.8fr;
|
|
padding:14px 18px; align-items:center; gap:14px;
|
|
border-bottom:1px solid var(--border);
|
|
}
|
|
.at-row:last-child { border-bottom:none; }
|
|
.at-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;
|
|
}
|
|
.at-row.inactive { opacity:.55; }
|
|
.at-title-cell { font-weight:700; font-size:.92rem; }
|
|
.at-author { font-size:.78rem; color:var(--text-2); margin-top:2px; }
|
|
.at-input {
|
|
width:100%; padding:6px 10px; border:1.5px solid var(--border);
|
|
border-radius:7px; background:transparent; color:var(--text);
|
|
font-family:'Manrope',sans-serif; font-size:.85rem;
|
|
}
|
|
.at-input:focus { outline:none; border-color:var(--violet); }
|
|
.at-pill {
|
|
display:inline-block; padding:3px 10px; border-radius:99px;
|
|
font-size:.72rem; font-weight:700;
|
|
}
|
|
.at-pill.subject { background:rgba(155,93,229,.12); color:var(--violet); }
|
|
.at-toggle {
|
|
position:relative; width:38px; height:22px; border-radius:99px;
|
|
background:var(--border); cursor:pointer; transition:background .15s;
|
|
display:inline-block;
|
|
}
|
|
.at-toggle.on { background:#06D6A0; }
|
|
.at-toggle::after {
|
|
content:''; position:absolute; top:2px; left:2px;
|
|
width:18px; height:18px; border-radius:50%;
|
|
background:#fff; transition:transform .15s;
|
|
box-shadow:0 1px 3px rgba(0,0,0,.2);
|
|
}
|
|
.at-toggle.on::after { transform:translateX(16px); }
|
|
.at-link {
|
|
color:var(--violet); text-decoration:none; font-weight:700; font-size:.85rem;
|
|
}
|
|
.at-link:hover { text-decoration:underline; }
|
|
.at-empty { padding:60px 20px; text-align:center; color:var(--text-3); }
|
|
.at-saved { color:#06D6A0; font-size:.75rem; margin-left:6px; opacity:0; transition:opacity .3s; }
|
|
.at-saved.show { opacity:1; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app-layout">
|
|
<aside class="sidebar" id="app-sidebar"></aside>
|
|
<div class="sb-content">
|
|
<div class="at-wrap">
|
|
<header class="at-header">
|
|
<a href="/admin" class="at-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="at-title">Управление учебниками</div>
|
|
<div class="at-sub">Редактирование каталога · включение/отключение отдельных учебников</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="at-content" class="at-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 !== 'admin') { location.href = '/dashboard'; return; }
|
|
LS.showBoardIfAllowed();
|
|
|
|
function esc(s) {
|
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
}
|
|
const SUBJECTS = { chemistry:'Химия', physics:'Физика', math:'Математика', biology:'Биология' };
|
|
|
|
let textbooks = [];
|
|
|
|
async function load() {
|
|
try {
|
|
const r = await LS.api('/api/textbooks/admin/all');
|
|
textbooks = r.textbooks || [];
|
|
render();
|
|
} catch (e) {
|
|
document.getElementById('at-content').innerHTML = 'Ошибка: ' + esc(e.message);
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
if (!textbooks.length) {
|
|
document.getElementById('at-content').innerHTML = '<div class="at-empty">Учебники не добавлены</div>';
|
|
return;
|
|
}
|
|
const html = `
|
|
<div class="at-table">
|
|
<div class="at-row head">
|
|
<div>Учебник</div>
|
|
<div>Автор</div>
|
|
<div>Предмет</div>
|
|
<div>Класс</div>
|
|
<div>Читателей</div>
|
|
<div>Активен</div>
|
|
</div>
|
|
${textbooks.map(t => `
|
|
<div class="at-row ${t.is_active ? '' : 'inactive'}" data-id="${t.id}">
|
|
<div>
|
|
<input class="at-input" data-field="title" value="${esc(t.title)}" />
|
|
<div class="at-author">
|
|
<a class="at-link" href="/textbook/${t.slug}" target="_blank">/${t.slug}</a> ·
|
|
<span class="at-saved" id="saved-${t.id}">Сохранено</span>
|
|
</div>
|
|
</div>
|
|
<div><input class="at-input" data-field="author" value="${esc(t.author)}" /></div>
|
|
<div><span class="at-pill subject">${esc(SUBJECTS[t.subject] || t.subject)}</span></div>
|
|
<div>${t.grade}</div>
|
|
<div>${t.readers || 0}</div>
|
|
<div>
|
|
<span class="at-toggle ${t.is_active ? 'on' : ''}" data-field="is_active" data-val="${t.is_active}"></span>
|
|
</div>
|
|
</div>`).join('')}
|
|
</div>`;
|
|
document.getElementById('at-content').className = '';
|
|
document.getElementById('at-content').innerHTML = html;
|
|
wireEvents();
|
|
}
|
|
|
|
function wireEvents() {
|
|
document.querySelectorAll('.at-toggle').forEach(t => {
|
|
t.addEventListener('click', async () => {
|
|
const row = t.closest('.at-row');
|
|
const id = Number(row.dataset.id);
|
|
const newVal = t.classList.contains('on') ? 0 : 1;
|
|
try {
|
|
await LS.api('/api/textbooks/admin/' + id, { method: 'PATCH', body: { is_active: newVal } });
|
|
t.classList.toggle('on', newVal === 1);
|
|
t.dataset.val = newVal;
|
|
row.classList.toggle('inactive', !newVal);
|
|
flashSaved(id);
|
|
} catch (e) { alert(e.message); }
|
|
});
|
|
});
|
|
document.querySelectorAll('.at-input').forEach(inp => {
|
|
let timer;
|
|
inp.addEventListener('input', () => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
const row = inp.closest('.at-row');
|
|
const id = Number(row.dataset.id);
|
|
const field = inp.dataset.field;
|
|
const val = inp.value;
|
|
LS.api('/api/textbooks/admin/' + id, { method: 'PATCH', body: { [field]: val } })
|
|
.then(() => flashSaved(id))
|
|
.catch(e => alert(e.message));
|
|
}, 600);
|
|
});
|
|
});
|
|
}
|
|
|
|
function flashSaved(id) {
|
|
const el = document.getElementById('saved-' + id);
|
|
if (!el) return;
|
|
el.classList.add('show');
|
|
setTimeout(() => el.classList.remove('show'), 1500);
|
|
}
|
|
|
|
await load();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|