From df29675cc7a5a02256e5ade446ce6404d0f29f15 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 16 May 2026 17:39:13 +0300 Subject: [PATCH] =?UTF-8?q?ux:=20/textbook-progress=20=D0=B8=20/admin-text?= =?UTF-8?q?books=20=E2=86=92=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B2=20/textbooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше: 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: всё про учебники в одном месте, переключение мгновенное (нет полной перезагрузки страницы) --- frontend/admin-textbooks.html | 202 +----------------- frontend/textbook-progress.html | 223 +------------------- frontend/textbooks.html | 359 ++++++++++++++++++++++++++++++-- 3 files changed, 353 insertions(+), 431 deletions(-) diff --git a/frontend/admin-textbooks.html b/frontend/admin-textbooks.html index cc4a16a..55bc98a 100644 --- a/frontend/admin-textbooks.html +++ b/frontend/admin-textbooks.html @@ -2,204 +2,8 @@ - - Управление учебниками — LearnSpace - - - - + Перенаправление… + - -
- -
-
-
- -
-
Управление учебниками
-
Редактирование каталога · включение/отключение отдельных учебников
-
-
- -
Загрузка…
-
-
-
- - - - - - - - - + diff --git a/frontend/textbook-progress.html b/frontend/textbook-progress.html index b0ad270..e976649 100644 --- a/frontend/textbook-progress.html +++ b/frontend/textbook-progress.html @@ -2,225 +2,8 @@ - - Прогресс по учебникам — LearnSpace - - - - + Перенаправление… + - -
- -
-
-
- -
-
Прогресс класса по учебнику
-
Кто сколько параграфов прочитал
-
-
- -
-
- - -
-
- - -
-
- -
Выберите учебник и класс
-
-
-
- - - - - - - - - + diff --git a/frontend/textbooks.html b/frontend/textbooks.html index 1ce3929..06ac793 100644 --- a/frontend/textbooks.html +++ b/frontend/textbooks.html @@ -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 @@
-
-
- -
Загрузка…
+
+ + + +
+ + +
+
+
+ +
Загрузка…
+
+ +
+
+
+ + +
+
+ + +
+
+
Выберите учебник и класс
+
+ + +
+
Загрузка…
+
+
@@ -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 = ` - - - Прогресс класса - `; - } + // 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 = '
У вас нет классов
'; + 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 = '
В классе нет учеников
'; 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 ` +
+
${esc(s.name)}
+
+
+
${s.read_count} из ${total} §
+
+
${pct}%
+
${s.last_para ? `§${s.last_para.replace('p','')}
${fmtDate(s.last_at)}` : ''}
+
`; + }).join(''); + resEl.className = 'tp-table'; + resEl.innerHTML = ` +
+
Ученик
Прогресс
%
Последний §
+
${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 = '
Учебники не добавлены
'; return; + } + const html = ` +
+
+
Учебник
Автор
Предмет
+
Класс
Читателей
Активен
+
+ ${allTextbooks.map(t => ` +
+
+ +
+ /${t.slug} + Сохранено +
+
+
+
${esc(SUBJECTS[t.subject] || t.subject)}
+
${t.grade}
+
${t.readers || 0}
+
+
`).join('')} +
`; + 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(); })();