ux: /textbook-progress и /admin-textbooks → вкладки в /textbooks
Раньше: 3 отдельные страницы со своими сайдбарами, header'ами и
скриптами. /textbook-progress был доступен только через кнопку в углу,
/admin-textbooks — только по прямому URL.
Теперь: одна страница /textbooks с тремя вкладками:
• Каталог (все)
• Прогресс класса (учитель/админ)
• Управление (только админ)
URL hash routing: /textbooks#progress, /textbooks#manage. Lazy-init
для каждой вкладки (грузится при первом клике).
Старые страницы превращены в 312-байтные redirect-стабы для
сохранения старых ссылок и закладок:
/textbook-progress → /textbooks#progress
/admin-textbooks → /textbooks#manage
Effect:
- Один header, один сайдбар-load, одна загрузка api.js/sidebar.js
- HTML-страниц сокращено на ~530 строк (textbook-progress.html был
248 строк, admin-textbooks.html — 219; сейчас ~10 каждая)
- /textbooks.html: 467 → 945 строк (+478, поглотил функционал двух
страниц с собственными стилями)
- Чистый UX: всё про учебники в одном месте, переключение
мгновенное (нет полной перезагрузки страницы)
This commit is contained in:
@@ -2,204 +2,8 @@
|
||||
<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>
|
||||
<title>Перенаправление…</title>
|
||||
<script>location.replace('/textbooks#manage');</script>
|
||||
</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>
|
||||
<body><noscript><a href="/textbooks#manage">Перейти к управлению учебниками</a></noscript></body>
|
||||
</html>
|
||||
|
||||
@@ -2,225 +2,8 @@
|
||||
<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>
|
||||
<title>Перенаправление…</title>
|
||||
<script>location.replace('/textbooks#progress');</script>
|
||||
</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>
|
||||
<body><noscript><a href="/textbooks#progress">Перейти к прогрессу класса</a></noscript></body>
|
||||
</html>
|
||||
|
||||
+347
-12
@@ -143,6 +143,117 @@
|
||||
}
|
||||
.tb-empty svg { width:48px; height:48px; opacity:.5; margin-bottom:14px; stroke:var(--text-3); }
|
||||
|
||||
/* ── Tabs ── */
|
||||
.tb-tabs {
|
||||
display:flex; gap:4px; margin-bottom:24px;
|
||||
background:var(--surface); border:1.5px solid var(--border);
|
||||
border-radius:12px; padding:4px;
|
||||
}
|
||||
.tb-tab {
|
||||
flex:0 0 auto; padding:9px 18px; border-radius:8px;
|
||||
border:none; background:transparent; color:var(--text-2);
|
||||
font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
|
||||
cursor:pointer; transition:all .14s;
|
||||
display:inline-flex; align-items:center; gap:7px;
|
||||
}
|
||||
.tb-tab:hover { color:var(--text); }
|
||||
.tb-tab.active {
|
||||
background:var(--violet); color:#fff;
|
||||
box-shadow: 0 2px 8px rgba(155,93,229,.25);
|
||||
}
|
||||
.tb-tab svg { width:14px; height:14px; }
|
||||
.tb-panel { display:none; }
|
||||
.tb-panel.active { display:block; }
|
||||
|
||||
/* ── Class-progress tab styles ── */
|
||||
.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; }
|
||||
.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); }
|
||||
@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; }
|
||||
}
|
||||
|
||||
/* ── Admin manage tab styles ── */
|
||||
.am-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);
|
||||
}
|
||||
.am-row:last-child { border-bottom:none; }
|
||||
.am-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;
|
||||
}
|
||||
.am-row.inactive { opacity:.55; }
|
||||
.am-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;
|
||||
}
|
||||
.am-input:focus { outline:none; border-color:var(--violet); }
|
||||
.am-pill {
|
||||
display:inline-block; padding:3px 10px; border-radius:99px;
|
||||
font-size:.72rem; font-weight:700;
|
||||
background:rgba(155,93,229,.12); color:var(--violet);
|
||||
}
|
||||
.am-toggle {
|
||||
position:relative; width:38px; height:22px; border-radius:99px;
|
||||
background:var(--border); cursor:pointer; transition:background .15s;
|
||||
display:inline-block;
|
||||
}
|
||||
.am-toggle.on { background:#06D6A0; }
|
||||
.am-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);
|
||||
}
|
||||
.am-toggle.on::after { transform:translateX(16px); }
|
||||
.am-link { color:var(--violet); text-decoration:none; font-weight:700; font-size:.82rem; }
|
||||
.am-link:hover { text-decoration:underline; }
|
||||
.am-saved { color:#06D6A0; font-size:.72rem; margin-left:6px; opacity:0; transition:opacity .3s; }
|
||||
.am-saved.show { opacity:1; }
|
||||
|
||||
/* ── Assign modal (reused styling from exam9) ── */
|
||||
.ex-overlay {
|
||||
display:none; position:fixed; inset:0;
|
||||
@@ -259,13 +370,51 @@
|
||||
<div id="tb-header-actions"></div>
|
||||
</header>
|
||||
|
||||
<div class="tb-grid" id="tb-grid">
|
||||
<div class="tb-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
<div>Загрузка…</div>
|
||||
<div class="tb-tabs" id="tb-tabs">
|
||||
<button class="tb-tab active" data-tab="catalog" onclick="setTab('catalog')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
Каталог
|
||||
</button>
|
||||
<button class="tb-tab tb-tab-teacher" data-tab="progress" onclick="setTab('progress')" style="display:none">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
Прогресс класса
|
||||
</button>
|
||||
<button class="tb-tab tb-tab-admin" data-tab="manage" onclick="setTab('manage')" style="display:none">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Управление
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB: catalog -->
|
||||
<div class="tb-panel active" id="tb-panel-catalog">
|
||||
<div class="tb-grid" id="tb-grid">
|
||||
<div class="tb-empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
<div>Загрузка…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: class progress (teacher/admin) -->
|
||||
<div class="tb-panel" id="tb-panel-progress">
|
||||
<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="tb-empty">Выберите учебник и класс</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: manage (admin only) -->
|
||||
<div class="tb-panel" id="tb-panel-manage">
|
||||
<div id="am-content" class="tb-empty">Загрузка…</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,17 +477,35 @@
|
||||
LS.hideDisabledFeatures();
|
||||
|
||||
const isTeacher = user && (user.role === 'teacher' || user.role === 'admin');
|
||||
const isAdmin = user && user.role === 'admin';
|
||||
let textbooks = [];
|
||||
let teacherClasses = null;
|
||||
|
||||
// Teacher-only: "Class progress" button in header
|
||||
if (isTeacher) {
|
||||
document.getElementById('tb-header-actions').innerHTML = `
|
||||
<a href="/textbook-progress" class="tb-btn" style="display:inline-flex;width:auto;text-decoration:none">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
Прогресс класса
|
||||
</a>`;
|
||||
}
|
||||
// Reveal teacher/admin tabs
|
||||
if (isTeacher) document.querySelectorAll('.tb-tab-teacher').forEach(el => el.style.display = '');
|
||||
if (isAdmin) document.querySelectorAll('.tb-tab-admin').forEach(el => el.style.display = '');
|
||||
|
||||
/* ── Tab routing ── */
|
||||
const VALID_TABS = ['catalog', 'progress', 'manage'];
|
||||
let _progressInited = false;
|
||||
let _manageInited = false;
|
||||
|
||||
window.setTab = function (name) {
|
||||
if (!VALID_TABS.includes(name)) name = 'catalog';
|
||||
if (name === 'progress' && !isTeacher) name = 'catalog';
|
||||
if (name === 'manage' && !isAdmin) name = 'catalog';
|
||||
|
||||
document.querySelectorAll('.tb-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||||
document.querySelectorAll('.tb-panel').forEach(p => p.classList.toggle('active', p.id === 'tb-panel-' + name));
|
||||
history.replaceState(null, '', '#' + name);
|
||||
|
||||
if (name === 'progress' && !_progressInited) { _progressInited = true; initProgressTab(); }
|
||||
if (name === 'manage' && !_manageInited) { _manageInited = true; initManageTab(); }
|
||||
};
|
||||
|
||||
// Initial tab from URL hash
|
||||
const initialTab = (location.hash || '').replace('#', '') || 'catalog';
|
||||
setTimeout(() => setTab(initialTab), 0);
|
||||
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
@@ -580,6 +747,174 @@
|
||||
}
|
||||
};
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
TAB: Class progress (teacher/admin)
|
||||
════════════════════════════════════════════════ */
|
||||
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; }
|
||||
}
|
||||
|
||||
async function initProgressTab() {
|
||||
const tbSel = document.getElementById('tp-textbook');
|
||||
const clsSel = document.getElementById('tp-class');
|
||||
const resEl = document.getElementById('tp-result');
|
||||
|
||||
// Load classes
|
||||
const classes = await LS.api('/api/classes').catch(() => []);
|
||||
const list = Array.isArray(classes) ? classes : [];
|
||||
if (!list.length) {
|
||||
resEl.innerHTML = '<div class="tb-empty">У вас нет классов</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate selects (textbooks already loaded)
|
||||
textbooks.forEach((t, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = t.slug;
|
||||
o.textContent = `${t.title} (§1–${t.para_count})`;
|
||||
if (i === 0) o.selected = true;
|
||||
tbSel.appendChild(o);
|
||||
});
|
||||
list.forEach((c, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = c.id;
|
||||
o.textContent = `${c.name} (${c.member_count || 0} учеников)`;
|
||||
if (i === 0) o.selected = true;
|
||||
clsSel.appendChild(o);
|
||||
});
|
||||
|
||||
const colorMap = { amber:'#d97706', blue:'#2563eb', green:'#059669', violet:'#7c3aed', pink:'#db2777' };
|
||||
|
||||
async function refresh() {
|
||||
const tbSlug = tbSel.value;
|
||||
const classId = clsSel.value;
|
||||
if (!tbSlug || !classId) return;
|
||||
resEl.className = 'tb-empty';
|
||||
resEl.innerHTML = 'Загрузка…';
|
||||
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 color = colorMap[tb?.color] || '#7c3aed';
|
||||
if (!r.students.length) {
|
||||
resEl.innerHTML = '<div class="tb-empty">В классе нет учеников</div>'; return;
|
||||
}
|
||||
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><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 = 'tb-empty';
|
||||
resEl.innerHTML = 'Ошибка: ' + esc(e.message);
|
||||
}
|
||||
}
|
||||
tbSel.addEventListener('change', refresh);
|
||||
clsSel.addEventListener('change', refresh);
|
||||
refresh();
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
TAB: Manage textbooks (admin only)
|
||||
════════════════════════════════════════════════ */
|
||||
const SUBJECTS = { chemistry:'Химия', physics:'Физика', math:'Математика', biology:'Биология' };
|
||||
let allTextbooks = [];
|
||||
|
||||
async function initManageTab() {
|
||||
const el = document.getElementById('am-content');
|
||||
try {
|
||||
const r = await LS.api('/api/textbooks/admin/all');
|
||||
allTextbooks = r.textbooks || [];
|
||||
renderManage();
|
||||
} catch (e) {
|
||||
el.innerHTML = 'Ошибка: ' + esc(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderManage() {
|
||||
const el = document.getElementById('am-content');
|
||||
if (!allTextbooks.length) {
|
||||
el.innerHTML = '<div class="tb-empty">Учебники не добавлены</div>'; return;
|
||||
}
|
||||
const html = `
|
||||
<div class="tb-list" style="background:var(--surface);border:1.5px solid var(--border);border-radius:14px;overflow:hidden">
|
||||
<div class="am-row head">
|
||||
<div>Учебник</div><div>Автор</div><div>Предмет</div>
|
||||
<div>Класс</div><div>Читателей</div><div>Активен</div>
|
||||
</div>
|
||||
${allTextbooks.map(t => `
|
||||
<div class="am-row ${t.is_active ? '' : 'inactive'}" data-id="${t.id}">
|
||||
<div>
|
||||
<input class="am-input" data-field="title" value="${esc(t.title)}" />
|
||||
<div style="margin-top:4px">
|
||||
<a class="am-link" href="/textbook/${t.slug}" target="_blank">/${t.slug}</a>
|
||||
<span class="am-saved" id="am-saved-${t.id}">Сохранено</span>
|
||||
</div>
|
||||
</div>
|
||||
<div><input class="am-input" data-field="author" value="${esc(t.author)}" /></div>
|
||||
<div><span class="am-pill">${esc(SUBJECTS[t.subject] || t.subject)}</span></div>
|
||||
<div>${t.grade}</div>
|
||||
<div>${t.readers || 0}</div>
|
||||
<div><span class="am-toggle ${t.is_active ? 'on' : ''}" data-field="is_active"></span></div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
el.className = '';
|
||||
el.innerHTML = html;
|
||||
wireManageEvents();
|
||||
}
|
||||
|
||||
function wireManageEvents() {
|
||||
document.querySelectorAll('#am-content .am-toggle').forEach(t => {
|
||||
t.addEventListener('click', async () => {
|
||||
const row = t.closest('.am-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);
|
||||
row.classList.toggle('inactive', !newVal);
|
||||
flashManageSaved(id);
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('#am-content .am-input').forEach(inp => {
|
||||
let timer;
|
||||
inp.addEventListener('input', () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const row = inp.closest('.am-row');
|
||||
const id = Number(row.dataset.id);
|
||||
LS.api('/api/textbooks/admin/' + id, { method: 'PATCH', body: { [inp.dataset.field]: inp.value } })
|
||||
.then(() => flashManageSaved(id))
|
||||
.catch(e => alert(e.message));
|
||||
}, 600);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function flashManageSaved(id) {
|
||||
const el = document.getElementById('am-saved-' + id);
|
||||
if (!el) return;
|
||||
el.classList.add('show');
|
||||
setTimeout(() => el.classList.remove('show'), 1500);
|
||||
}
|
||||
|
||||
await loadTextbooks();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user