feat: textbooks — модуль учебников + чтение как ДЗ (3 фазы)
Фаза 1 — структура и каталог:
- frontend/textbooks/chemistry_9.html (Шиманович, 60 §) + physics_9.html (Исаченкова, 38 §)
- frontend/textbooks.html — каталог в стиле LearnSpace (карточки с обложками)
- Маршруты: /textbooks (каталог), /textbook/<slug> (полноэкранный учебник)
- Сайдбар: пункт «Учебники» (book-open-text)
- Feature flag feature_textbooks_enabled, hideDisabledFeatures map
Фаза 2 — прогресс в localStorage + UI чтения:
- frontend/js/textbook-tracker.js — инжектится в каждый учебник:
- «← Учебники» overlay-кнопка (top-left, semi-transparent)
- «Прочитано» чекбокс рядом с каждым §-заголовком
- Зелёный dot на pill уже прочитанных параграфов
- Авто-открытие последнего параграфа при возврате
- Каталог показывает прогресс-бар «X из Y прочитано» + кнопку «Продолжить»
Фаза 3 — серверный прогресс + назначение чтения как ДЗ:
- Таблица textbooks (slug, subject, grade, title, author, color, ...)
- Таблица textbook_progress (user_id, textbook_id, JSON read[], last_para)
- Колонки assignments.textbook_id + textbook_paragraphs
- API: GET /api/textbooks (с прогрессом), GET /:slug, POST /:slug/progress,
GET /:slug/class-progress (учитель)
- tracker.js синхронизирует прогресс через POST /progress (если залогинен)
- На каталоге у учителей кнопка «Назначить чтение» — модалка с выбором
классов + параграфы («1-5» или «1,3,5») + deadline
- bulkCreateAssignment расширен: принимает textbook_slug, резолвит в id
Миграция 004 идемпотентная; сиды двух учебников включены.
This commit is contained in:
@@ -595,7 +595,8 @@ function deleteTemplate(req, res) {
|
||||
|
||||
/* ── POST /api/assignments/bulk ── assign to multiple classes at once ───── */
|
||||
function bulkCreateAssignment(req, res) {
|
||||
const { class_ids, title, mode = 'exam', count = 25, topic_id, deadline, test_id, file_id, is_homework = 0 } = req.body;
|
||||
const { class_ids, title, mode = 'exam', count = 25, topic_id, deadline, test_id, file_id,
|
||||
is_homework = 0, textbook_slug, textbook_paragraphs } = req.body;
|
||||
let { subject_slug } = req.body;
|
||||
|
||||
if (!Array.isArray(class_ids) || !class_ids.length)
|
||||
@@ -608,6 +609,16 @@ function bulkCreateAssignment(req, res) {
|
||||
if (!t) return res.status(400).json({ error: 'Test not found' });
|
||||
subject_slug = t.subject_slug;
|
||||
}
|
||||
|
||||
// Textbook: resolve slug → id, derive subject from textbook
|
||||
let textbook_id = null;
|
||||
if (textbook_slug) {
|
||||
const tb = db.prepare('SELECT id, subject FROM textbooks WHERE slug=? AND is_active=1').get(textbook_slug);
|
||||
if (!tb) return res.status(400).json({ error: 'Учебник не найден' });
|
||||
textbook_id = tb.id;
|
||||
if (!subject_slug) subject_slug = tb.subject;
|
||||
}
|
||||
|
||||
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
|
||||
if (!subject_slug) subject_slug = 'other';
|
||||
|
||||
@@ -619,9 +630,9 @@ function bulkCreateAssignment(req, res) {
|
||||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) continue;
|
||||
|
||||
const r = db.prepare(`
|
||||
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(cls.id, stripTags(title.trim()), subject_slug, mode, Number(count), topic_id || null, deadline || null, req.user.id, test_id || null, file_id || null, is_homework ? 1 : 0);
|
||||
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework, textbook_id, textbook_paragraphs)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(cls.id, stripTags(title.trim()), subject_slug, mode, Number(count), topic_id || null, deadline || null, req.user.id, test_id || null, file_id || null, is_homework ? 1 : 0, textbook_id, textbook_paragraphs || null);
|
||||
ids.push(r.lastInsertRowid);
|
||||
|
||||
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(cls.id);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Feature flag for textbooks module
|
||||
INSERT OR IGNORE INTO app_settings (key, value) VALUES ('feature_textbooks_enabled', '1');
|
||||
|
||||
-- Catalog of textbooks (admin-editable; html_path is relative to /frontend/textbooks/)
|
||||
CREATE TABLE textbooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
subject TEXT NOT NULL, -- 'chemistry', 'physics', 'math', 'biology', ...
|
||||
grade INTEGER NOT NULL, -- 9, 10, 11, ...
|
||||
title TEXT NOT NULL,
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
html_path TEXT NOT NULL, -- relative filename: 'chemistry_9.html'
|
||||
para_count INTEGER NOT NULL DEFAULT 0, -- total paragraphs (for progress %)
|
||||
color TEXT NOT NULL DEFAULT 'violet',-- visual theme: 'amber','blue','violet','green',...
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Per-user reading progress (one row per (user, textbook))
|
||||
CREATE TABLE textbook_progress (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
textbook_id INTEGER NOT NULL REFERENCES textbooks(id) ON DELETE CASCADE,
|
||||
paragraphs_read TEXT NOT NULL DEFAULT '[]', -- JSON array of para keys like ["p1","p3","p7"]
|
||||
last_para TEXT, -- last opened paragraph key (e.g. 'p15')
|
||||
last_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, textbook_id)
|
||||
);
|
||||
|
||||
-- Assignment extension: link to textbook + paragraph range string ("1-5" or "1,3,5")
|
||||
ALTER TABLE assignments ADD COLUMN textbook_id INTEGER REFERENCES textbooks(id) ON DELETE SET NULL;
|
||||
ALTER TABLE assignments ADD COLUMN textbook_paragraphs TEXT;
|
||||
|
||||
-- Seed: chemistry 9 + physics 9 (the two files we just copied)
|
||||
INSERT INTO textbooks (slug, subject, grade, title, author, description, html_path, para_count, color, sort_order) VALUES
|
||||
('chemistry-9', 'chemistry', 9, 'Химия — 9 класс', 'Шиманович Е. Я.',
|
||||
'Полный курс химии за 9 класс. §1–60: строение атома, химическая связь, классы соединений, ОВР, металлы и их соединения, электролиз.',
|
||||
'chemistry_9.html', 60, 'amber', 1),
|
||||
('physics-9', 'physics', 9, 'Физика — 9 класс', 'Исаченкова Л. А.',
|
||||
'Полный курс физики за 9 класс: §1–38. Механика, кинематика, динамика, статика, законы сохранения, импульс, работа и энергия.',
|
||||
'physics_9.html', 38, 'blue', 2);
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
/* GET /api/textbooks — list with current user's progress */
|
||||
router.get('/', (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT t.id, t.slug, t.subject, t.grade, t.title, t.author, t.description,
|
||||
t.html_path, t.para_count, t.color, t.sort_order
|
||||
FROM textbooks t
|
||||
WHERE t.is_active = 1
|
||||
ORDER BY t.sort_order, t.subject, t.grade
|
||||
`).all();
|
||||
|
||||
const myProgress = db.prepare(`
|
||||
SELECT textbook_id, paragraphs_read, last_para, last_at FROM textbook_progress WHERE user_id=?
|
||||
`).all(req.user.id);
|
||||
const progressMap = {};
|
||||
for (const p of myProgress) {
|
||||
let arr = [];
|
||||
try { arr = JSON.parse(p.paragraphs_read || '[]'); } catch {}
|
||||
progressMap[p.textbook_id] = { read: arr, last_para: p.last_para, last_at: p.last_at };
|
||||
}
|
||||
|
||||
res.json({
|
||||
textbooks: rows.map(t => ({
|
||||
...t,
|
||||
progress: progressMap[t.id] || { read: [], last_para: null, last_at: null },
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* GET /api/textbooks/:slug — single textbook detail */
|
||||
router.get('/:slug', (req, res) => {
|
||||
const t = db.prepare('SELECT * FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug);
|
||||
if (!t) return res.status(404).json({ error: 'Учебник не найден' });
|
||||
|
||||
const p = db.prepare('SELECT paragraphs_read, last_para, last_at FROM textbook_progress WHERE user_id=? AND textbook_id=?').get(req.user.id, t.id);
|
||||
let read = [];
|
||||
if (p) { try { read = JSON.parse(p.paragraphs_read || '[]'); } catch {} }
|
||||
|
||||
res.json({ ...t, progress: { read, last_para: p?.last_para || null, last_at: p?.last_at || null } });
|
||||
});
|
||||
|
||||
/* POST /api/textbooks/:slug/progress — update progress
|
||||
body: { last_para?: 'p15', mark_read?: 'p15', mark_unread?: 'p15' } */
|
||||
router.post('/:slug/progress', (req, res) => {
|
||||
const t = db.prepare('SELECT id FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug);
|
||||
if (!t) return res.status(404).json({ error: 'Учебник не найден' });
|
||||
|
||||
const { last_para, mark_read, mark_unread } = req.body || {};
|
||||
|
||||
// Atomic upsert
|
||||
const existing = db.prepare('SELECT paragraphs_read FROM textbook_progress WHERE user_id=? AND textbook_id=?').get(req.user.id, t.id);
|
||||
let arr = [];
|
||||
if (existing) { try { arr = JSON.parse(existing.paragraphs_read || '[]'); } catch {} }
|
||||
|
||||
if (mark_read && typeof mark_read === 'string' && !arr.includes(mark_read)) arr.push(mark_read);
|
||||
if (mark_unread && typeof mark_unread === 'string') arr = arr.filter(p => p !== mark_unread);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO textbook_progress (user_id, textbook_id, paragraphs_read, last_para, last_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, textbook_id) DO UPDATE SET
|
||||
paragraphs_read = excluded.paragraphs_read,
|
||||
last_para = COALESCE(excluded.last_para, textbook_progress.last_para),
|
||||
last_at = excluded.last_at
|
||||
`).run(req.user.id, t.id, JSON.stringify(arr), last_para || null);
|
||||
|
||||
res.json({ ok: true, read: arr });
|
||||
});
|
||||
|
||||
/* GET /api/textbooks/:slug/class-progress — teacher view: progress of all students in class
|
||||
query: ?class_id=N */
|
||||
router.get('/:slug/class-progress', requireRole('teacher', 'admin'), (req, res) => {
|
||||
const t = db.prepare('SELECT id FROM textbooks WHERE slug=?').get(req.params.slug);
|
||||
if (!t) return res.status(404).json({ error: 'Учебник не найден' });
|
||||
const classId = Number(req.query.class_id);
|
||||
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
|
||||
|
||||
if (req.user.role === 'teacher') {
|
||||
const own = db.prepare('SELECT 1 FROM classes WHERE id=? AND teacher_id=?').get(classId, req.user.id);
|
||||
if (!own) return res.status(403).json({ error: 'Нет доступа к классу' });
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT u.id AS user_id, u.name,
|
||||
COALESCE(tp.paragraphs_read, '[]') AS paragraphs_read,
|
||||
tp.last_para, tp.last_at
|
||||
FROM class_members cm
|
||||
JOIN users u ON u.id = cm.user_id
|
||||
LEFT JOIN textbook_progress tp ON tp.user_id = u.id AND tp.textbook_id = ?
|
||||
WHERE cm.class_id = ?
|
||||
ORDER BY u.name
|
||||
`).all(t.id, classId);
|
||||
|
||||
res.json({
|
||||
students: rows.map(r => {
|
||||
let read = [];
|
||||
try { read = JSON.parse(r.paragraphs_read); } catch {}
|
||||
return { user_id: r.user_id, name: r.name, read_count: read.length, last_para: r.last_para, last_at: r.last_at };
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -51,6 +51,7 @@ const collectionRoutes = require('./routes/collection');
|
||||
const redBookRoutes = require('./routes/red-book');
|
||||
const parentRoutes = require('./routes/parent');
|
||||
const exam9Routes = require('./routes/exam9');
|
||||
const textbookRoutes = require('./routes/textbooks');
|
||||
|
||||
const { requestId, errorHandler } = require('./middleware/errorHandler');
|
||||
|
||||
@@ -168,6 +169,7 @@ app.use('/api/red-book', redBookRoutes);
|
||||
app.use('/api/biochem', require('./routes/biochem'));
|
||||
app.use('/api/parent', parentRoutes);
|
||||
app.use('/api/exam9', exam9Routes);
|
||||
app.use('/api/textbooks', textbookRoutes);
|
||||
|
||||
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
||||
const _featDb = require('./db/db');
|
||||
@@ -318,6 +320,23 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Clean URL for textbooks: /textbook/<slug> → frontend/textbooks/<html_path>
|
||||
const _textbookDb = require('./db/db');
|
||||
const _stmtTextbookPath = _textbookDb.prepare('SELECT html_path FROM textbooks WHERE slug=? AND is_active=1');
|
||||
app.get('/textbook/:slug', (req, res, next) => {
|
||||
const row = _stmtTextbookPath.get(req.params.slug);
|
||||
if (!row) return next();
|
||||
const filePath = path.join(frontendDir, 'textbooks', row.html_path);
|
||||
if (!isProd) res.setHeader('Cache-Control', 'no-store');
|
||||
res.sendFile(filePath, err => { if (err) next(); });
|
||||
});
|
||||
|
||||
// Catalog: /textbooks → frontend/textbooks.html (explicit to avoid conflict with /textbooks/ directory)
|
||||
app.get('/textbooks', (_req, res) => {
|
||||
if (!isProd) res.setHeader('Cache-Control', 'no-store');
|
||||
res.sendFile(path.join(frontendDir, 'textbooks.html'));
|
||||
});
|
||||
|
||||
// Serve HTML files without extension (/dashboard → dashboard.html)
|
||||
// In dev: disable cache so edits are always picked up immediately
|
||||
const htmlCacheOpts = isProd ? { extensions: ['html'] } : {
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
'use strict';
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
textbook-tracker.js — injected into each textbook page.
|
||||
- "Back to LearnSpace" button overlay (top-left)
|
||||
- localStorage progress tracking (always works, even logged out)
|
||||
- Server-side sync when authenticated (via LS.api)
|
||||
- Per-paragraph "Прочитано" checkbox UI
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
const slug = (function () {
|
||||
const m = location.pathname.match(/\/textbook\/([\w-]+)/);
|
||||
if (m) return m[1];
|
||||
// Fallback for direct file access during dev
|
||||
const fname = location.pathname.split('/').pop().replace(/\.html$/, '');
|
||||
return fname.replace(/_/g, '-');
|
||||
})();
|
||||
|
||||
const lsKey = 'textbook_progress_' + slug;
|
||||
const localState = (function () {
|
||||
try { return JSON.parse(localStorage.getItem(lsKey) || '{"read":[],"last":null}'); }
|
||||
catch { return { read: [], last: null }; }
|
||||
})();
|
||||
if (!Array.isArray(localState.read)) localState.read = [];
|
||||
|
||||
/* ── 1. Server sync (best-effort) ──────────────────────────────── */
|
||||
let syncPending = false;
|
||||
function syncToServer(extra) {
|
||||
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
|
||||
if (syncPending) return;
|
||||
syncPending = true;
|
||||
fetch('/api/textbooks/' + slug + '/progress', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + LS.getToken(),
|
||||
},
|
||||
body: JSON.stringify({ last_para: localState.last, ...extra }),
|
||||
}).finally(() => { syncPending = false; }).catch(() => {});
|
||||
}
|
||||
|
||||
/* ── 2. Initial load: merge server data into local state ──────── */
|
||||
function loadServerProgress() {
|
||||
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
|
||||
fetch('/api/textbooks/' + slug, {
|
||||
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => {
|
||||
if (!d || !d.progress) return;
|
||||
const merged = Array.from(new Set([...(localState.read || []), ...(d.progress.read || [])]));
|
||||
localState.read = merged;
|
||||
if (!localState.last) localState.last = d.progress.last_para;
|
||||
localStorage.setItem(lsKey, JSON.stringify(localState));
|
||||
refreshAllUI();
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/* ── 3. Save helpers ──────────────────────────────────────────── */
|
||||
function persist() { try { localStorage.setItem(lsKey, JSON.stringify(localState)); } catch {} }
|
||||
|
||||
function setLastPara(key) {
|
||||
if (!key) return;
|
||||
localState.last = key;
|
||||
persist();
|
||||
syncToServer({});
|
||||
}
|
||||
|
||||
function markRead(key) {
|
||||
if (!key || localState.read.includes(key)) return;
|
||||
localState.read.push(key);
|
||||
persist();
|
||||
refreshPillUI(key);
|
||||
refreshCheckUI(key);
|
||||
syncToServer({ mark_read: key });
|
||||
}
|
||||
|
||||
function unmarkRead(key) {
|
||||
if (!key) return;
|
||||
const i = localState.read.indexOf(key);
|
||||
if (i < 0) return;
|
||||
localState.read.splice(i, 1);
|
||||
persist();
|
||||
refreshPillUI(key);
|
||||
refreshCheckUI(key);
|
||||
syncToServer({ mark_unread: key });
|
||||
}
|
||||
|
||||
function toggleRead(key) {
|
||||
if (localState.read.includes(key)) unmarkRead(key);
|
||||
else markRead(key);
|
||||
}
|
||||
|
||||
/* ── 4. UI: back button overlay ───────────────────────────────── */
|
||||
function installBackButton() {
|
||||
if (document.getElementById('tb-back-btn')) return;
|
||||
const btn = document.createElement('a');
|
||||
btn.id = 'tb-back-btn';
|
||||
btn.href = '/textbooks';
|
||||
btn.title = 'К каталогу учебников';
|
||||
btn.innerHTML = `
|
||||
<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>
|
||||
<span>Учебники</span>`;
|
||||
Object.assign(btn.style, {
|
||||
position: 'fixed', top: '10px', left: '12px', zIndex: '9999',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '6px 11px 6px 9px', borderRadius: '20px',
|
||||
background: 'rgba(0,0,0,.45)', color: '#fff',
|
||||
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '12.5px', fontWeight: '700',
|
||||
textDecoration: 'none', backdropFilter: 'blur(6px)',
|
||||
transition: 'background .15s, transform .12s',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,.18)',
|
||||
});
|
||||
btn.querySelector('svg').style.cssText = 'width:14px;height:14px;flex-shrink:0';
|
||||
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.7)'; btn.style.transform = 'translateY(-1px)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(0,0,0,.45)'; btn.style.transform = 'none'; });
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
|
||||
/* ── 5. UI: mark-read checkboxes near every paragraph heading ─ */
|
||||
function installReadCheckboxes() {
|
||||
// Each para has a wrapper with id='p1','p2' etc. (data-para usage)
|
||||
document.querySelectorAll('[data-para]').forEach(el => {
|
||||
// Skip pill buttons (they only navigate); only target paragraph content blocks
|
||||
if (el.classList.contains('para-pill')) return;
|
||||
injectCheckIntoSection(el);
|
||||
});
|
||||
// Also look for sections by id matching pN pattern in case data-para isn't on the section
|
||||
document.querySelectorAll('section[id^="p"], div[id^="p"]').forEach(el => {
|
||||
if (/^p\d+$/.test(el.id) && !el.querySelector(':scope > .tb-readchk')) {
|
||||
injectCheckIntoSection(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function injectCheckIntoSection(sectionEl) {
|
||||
const key = sectionEl.dataset.para || sectionEl.id;
|
||||
if (!key || !/^p\d+/.test(key)) return;
|
||||
if (sectionEl.querySelector(':scope > .tb-readchk')) return;
|
||||
|
||||
// Find the first heading inside the section to insert next to it
|
||||
const heading = sectionEl.querySelector('h1, h2, h3');
|
||||
if (!heading) return;
|
||||
|
||||
const wrap = document.createElement('button');
|
||||
wrap.className = 'tb-readchk';
|
||||
wrap.dataset.para = key;
|
||||
wrap.type = 'button';
|
||||
wrap.title = 'Отметить как прочитанное';
|
||||
wrap.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <span>Прочитано</span>`;
|
||||
Object.assign(wrap.style, {
|
||||
marginLeft: '12px',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '5px',
|
||||
padding: '4px 10px', borderRadius: '99px',
|
||||
border: '1.5px solid currentColor', background: 'transparent',
|
||||
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '11.5px', fontWeight: '700',
|
||||
cursor: 'pointer', verticalAlign: 'middle', opacity: '.55',
|
||||
transition: 'opacity .15s, background .15s, color .15s',
|
||||
});
|
||||
wrap.querySelector('svg').style.cssText = 'width:12px;height:12px;flex-shrink:0';
|
||||
wrap.addEventListener('mouseenter', () => { wrap.style.opacity = '1'; });
|
||||
wrap.addEventListener('mouseleave', () => {
|
||||
if (!localState.read.includes(key)) wrap.style.opacity = '.55';
|
||||
});
|
||||
wrap.addEventListener('click', e => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
toggleRead(key);
|
||||
});
|
||||
heading.appendChild(wrap);
|
||||
refreshCheckUI(key);
|
||||
}
|
||||
|
||||
/* ── 6. UI refreshers ─────────────────────────────────────────── */
|
||||
function refreshPillUI(key) {
|
||||
document.querySelectorAll(`.para-pill[data-para="${key}"]`).forEach(p => {
|
||||
p.classList.toggle('tb-read', localState.read.includes(key));
|
||||
});
|
||||
}
|
||||
function refreshCheckUI(key) {
|
||||
document.querySelectorAll(`.tb-readchk[data-para="${key}"]`).forEach(b => {
|
||||
const isRead = localState.read.includes(key);
|
||||
b.style.background = isRead ? 'rgba(16,185,129,.15)' : 'transparent';
|
||||
b.style.color = isRead ? '#059669' : '';
|
||||
b.style.opacity = isRead ? '1' : '.55';
|
||||
b.querySelector('span').textContent = isRead ? 'Прочитано' : 'Прочитано';
|
||||
});
|
||||
}
|
||||
function refreshAllUI() {
|
||||
localState.read.forEach(k => { refreshPillUI(k); refreshCheckUI(k); });
|
||||
}
|
||||
|
||||
/* ── 7. Pill click → mark as last visited ─────────────────────── */
|
||||
function wirePillTracking() {
|
||||
document.body.addEventListener('click', e => {
|
||||
const pill = e.target.closest('.para-pill[data-para]');
|
||||
if (!pill) return;
|
||||
const key = pill.dataset.para;
|
||||
setLastPara(key);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 8. Inject styling for read-pills (subtle green dot) ─────── */
|
||||
function injectStyles() {
|
||||
const s = document.createElement('style');
|
||||
s.textContent = `
|
||||
.para-pill.tb-read { position: relative; }
|
||||
.para-pill.tb-read::after {
|
||||
content: ''; position: absolute; top: 3px; right: 3px;
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #10b981; box-shadow: 0 0 0 1.5px rgba(255,255,255,.9);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
#tb-back-btn span { display: none; }
|
||||
#tb-back-btn { padding: 7px 8px; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
/* ── 9. Boot ──────────────────────────────────────────────────── */
|
||||
function boot() {
|
||||
injectStyles();
|
||||
installBackButton();
|
||||
installReadCheckboxes();
|
||||
wirePillTracking();
|
||||
refreshAllUI();
|
||||
loadServerProgress();
|
||||
// Auto-open last paragraph if pill exists
|
||||
if (localState.last) {
|
||||
const pill = document.querySelector(`.para-pill[data-para="${localState.last}"]`);
|
||||
if (pill) setTimeout(() => pill.click(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,465 @@
|
||||
<!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; }
|
||||
.tb-wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 80px; width: 100%; }
|
||||
|
||||
.tb-header { display:flex; align-items:center; gap:14px; margin-bottom:30px; }
|
||||
.tb-icon {
|
||||
width:52px; height:52px; border-radius:14px; flex-shrink:0;
|
||||
background:linear-gradient(135deg, rgba(155,93,229,.25), rgba(6,214,224,.18));
|
||||
border:1.5px solid rgba(255,255,255,.1);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.tb-icon svg { width:26px; height:26px; stroke:#9B5DE5; stroke-width:1.8; fill:none; }
|
||||
.tb-title { font-family:'Unbounded',sans-serif; font-size:1.35rem; font-weight:800; letter-spacing:-.02em; }
|
||||
.tb-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
|
||||
|
||||
.tb-grid {
|
||||
display:grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap:22px;
|
||||
}
|
||||
|
||||
.tb-card {
|
||||
background:var(--surface);
|
||||
border:1.5px solid var(--border);
|
||||
border-radius:18px; overflow:hidden;
|
||||
transition: border-color .18s, box-shadow .18s, transform .18s;
|
||||
display:flex; flex-direction:column;
|
||||
}
|
||||
.tb-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 36px rgba(0,0,0,.18);
|
||||
}
|
||||
|
||||
.tb-cover {
|
||||
height:140px; position:relative; overflow:hidden;
|
||||
display:flex; align-items:flex-end; padding:18px 22px 14px;
|
||||
}
|
||||
.tb-cover.amber { background:linear-gradient(135deg, #b45309 0%, #d97706 60%, #f59e0b 100%); }
|
||||
.tb-cover.blue { background:linear-gradient(135deg, #1e40af 0%, #2563eb 60%, #3b82f6 100%); }
|
||||
.tb-cover.green { background:linear-gradient(135deg, #047857 0%, #059669 60%, #10b981 100%); }
|
||||
.tb-cover.violet { background:linear-gradient(135deg, #6d28d9 0%, #7c3aed 60%, #9333ea 100%); }
|
||||
.tb-cover.pink { background:linear-gradient(135deg, #be185d 0%, #db2777 60%, #ec4899 100%); }
|
||||
|
||||
.tb-cover::before {
|
||||
content: attr(data-watermark);
|
||||
position:absolute; right:-10px; top:-15%;
|
||||
font-family:'Unbounded',sans-serif; font-weight:900;
|
||||
font-size:clamp(3rem, 9vw, 7rem); letter-spacing:-.04em; line-height:1;
|
||||
color:transparent; -webkit-text-stroke:1.5px rgba(255,255,255,.18);
|
||||
pointer-events:none; user-select:none;
|
||||
}
|
||||
.tb-cover-info {
|
||||
position:relative; z-index:1; color:#fff;
|
||||
}
|
||||
.tb-cover-grade {
|
||||
display:inline-flex; align-items:center; gap:4px;
|
||||
padding:3px 10px; border-radius:99px;
|
||||
background:rgba(255,255,255,.18); backdrop-filter:blur(4px);
|
||||
font-size:.7rem; font-weight:800; text-transform:uppercase; letter-spacing:.08em;
|
||||
margin-bottom:6px;
|
||||
}
|
||||
.tb-cover-title {
|
||||
font-family:'Unbounded',sans-serif; font-weight:800;
|
||||
font-size:1.15rem; letter-spacing:-.01em;
|
||||
}
|
||||
|
||||
.tb-body {
|
||||
padding:16px 20px 18px; flex:1;
|
||||
display:flex; flex-direction:column; gap:10px;
|
||||
}
|
||||
.tb-author {
|
||||
font-size:.78rem; color:var(--text-2); font-weight:600;
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
}
|
||||
.tb-author svg { width:13px; height:13px; opacity:.7; }
|
||||
.tb-desc {
|
||||
font-size:.85rem; line-height:1.55; color:var(--text-2);
|
||||
flex:1;
|
||||
}
|
||||
|
||||
.tb-progress {
|
||||
margin-top:6px;
|
||||
padding-top:12px; border-top:1px solid var(--border);
|
||||
}
|
||||
.tb-progress-bar {
|
||||
height:6px; border-radius:99px; background:var(--border); overflow:hidden;
|
||||
margin-bottom:7px;
|
||||
}
|
||||
.tb-progress-fill {
|
||||
height:100%; border-radius:99px;
|
||||
transition: width .3s ease;
|
||||
}
|
||||
.tb-progress.amber .tb-progress-fill { background:#d97706; }
|
||||
.tb-progress.blue .tb-progress-fill { background:#2563eb; }
|
||||
.tb-progress.green .tb-progress-fill { background:#059669; }
|
||||
.tb-progress.violet .tb-progress-fill { background:#7c3aed; }
|
||||
.tb-progress.pink .tb-progress-fill { background:#db2777; }
|
||||
.tb-progress-text {
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
font-size:.74rem; color:var(--text-3);
|
||||
}
|
||||
.tb-progress-text b { color:var(--text); font-weight:700; }
|
||||
|
||||
.tb-actions {
|
||||
display:flex; gap:8px; margin-top:12px;
|
||||
}
|
||||
.tb-btn {
|
||||
flex:1; padding:9px 14px; border-radius:10px;
|
||||
border:1.5px solid var(--border-h); background:transparent; color:var(--text);
|
||||
font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700;
|
||||
cursor:pointer; transition:all .15s; text-decoration:none;
|
||||
display:inline-flex; align-items:center; justify-content:center; gap:6px;
|
||||
}
|
||||
.tb-btn:hover { border-color:var(--text-2); }
|
||||
.tb-btn.primary {
|
||||
border-color:transparent; color:#fff;
|
||||
}
|
||||
.tb-btn.primary.amber { background:#d97706; }
|
||||
.tb-btn.primary.blue { background:#2563eb; }
|
||||
.tb-btn.primary.green { background:#059669; }
|
||||
.tb-btn.primary.violet { background:#7c3aed; }
|
||||
.tb-btn.primary.pink { background:#db2777; }
|
||||
.tb-btn.primary:hover { filter:brightness(1.1); }
|
||||
.tb-btn svg { width:14px; height:14px; }
|
||||
|
||||
.tb-assign-btn {
|
||||
width:auto; min-width:42px; padding:9px 12px;
|
||||
flex:0 0 auto;
|
||||
}
|
||||
|
||||
.tb-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding:60px 20px; text-align:center; color:var(--text-3);
|
||||
}
|
||||
.tb-empty svg { width:48px; height:48px; opacity:.5; margin-bottom:14px; stroke:var(--text-3); }
|
||||
|
||||
/* ── Assign modal (reused styling from exam9) ── */
|
||||
.ex-overlay {
|
||||
display:none; position:fixed; inset:0;
|
||||
background:rgba(15,23,42,.55); z-index:300;
|
||||
align-items:flex-start; justify-content:center; padding-top:80px;
|
||||
backdrop-filter:blur(2px);
|
||||
}
|
||||
.ex-overlay.visible { display:flex; }
|
||||
.ex-panel {
|
||||
background:var(--surface); border:1.5px solid var(--border);
|
||||
border-radius:16px; box-shadow:0 24px 64px rgba(0,0,0,.32);
|
||||
width:min(520px, 94vw); max-height:calc(100vh - 120px);
|
||||
overflow-y:auto; padding:22px 22px 26px;
|
||||
}
|
||||
.ex-panel-head {
|
||||
display:flex; align-items:center; justify-content:space-between; margin-bottom:18px;
|
||||
}
|
||||
.ex-panel-head h2 { font-family:'Unbounded',sans-serif; font-size:1rem; font-weight:800; }
|
||||
.ex-panel-close {
|
||||
width:32px; height:32px; border:none; background:none;
|
||||
color:var(--text-2); cursor:pointer; border-radius:8px;
|
||||
display:flex; align-items:center; justify-content:center; transition:background .15s;
|
||||
}
|
||||
.ex-panel-close:hover { background:var(--border); color:var(--text); }
|
||||
.ex-panel-close svg { width:18px; height:18px; }
|
||||
.ax-form { display:flex; flex-direction:column; gap:14px; }
|
||||
.ax-field label {
|
||||
display:block; font-size:.78rem; font-weight:700; color:var(--text-2);
|
||||
text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
|
||||
}
|
||||
.ax-classes {
|
||||
display:flex; flex-direction:column; gap:6px; max-height:200px; overflow-y:auto;
|
||||
border:1.5px solid var(--border); border-radius:10px; padding:8px;
|
||||
}
|
||||
.ax-class {
|
||||
display:flex; align-items:center; gap:10px; padding:8px 10px;
|
||||
border-radius:8px; cursor:pointer; transition:background .12s;
|
||||
font-size:.9rem;
|
||||
}
|
||||
.ax-class:hover { background:var(--border); }
|
||||
.ax-class input { accent-color:var(--violet); flex-shrink:0; }
|
||||
.ax-class .ax-cname { font-weight:600; }
|
||||
.ax-class .ax-cmeta { font-size:.78rem; color:var(--text-3); margin-left:auto; }
|
||||
.ax-input {
|
||||
width:100%; padding:9px 12px; border:1.5px solid var(--border-h);
|
||||
border-radius:9px; background:var(--surface); color:var(--text);
|
||||
font-family:'Manrope',sans-serif; font-size:.9rem;
|
||||
}
|
||||
.ax-input:focus { outline:none; border-color:var(--violet); }
|
||||
.ax-hint { font-size:.74rem; color:var(--text-3); margin-top:4px; }
|
||||
.ax-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:6px; }
|
||||
.ax-btn {
|
||||
padding:9px 18px; border-radius:10px; border:1.5px solid var(--border-h);
|
||||
background:transparent; color:var(--text);
|
||||
font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
|
||||
cursor:pointer; transition:all .15s;
|
||||
}
|
||||
.ax-btn:hover { border-color:var(--text-2); }
|
||||
.ax-btn-primary { background:var(--violet); border-color:var(--violet); color:#fff; }
|
||||
.ax-btn-primary:hover { background:#7e3eca; border-color:#7e3eca; }
|
||||
.ax-btn-primary:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.ax-error, .ax-success {
|
||||
padding:9px 12px; border-radius:8px; font-size:.84rem; display:none;
|
||||
}
|
||||
.ax-error.visible { display:block; background:rgba(241,91,68,.1); border:1px solid rgba(241,91,68,.3); color:#F94144; }
|
||||
.ax-success.visible { display:block; background:rgba(6,214,160,.1); border:1px solid rgba(6,214,160,.3); color:#06D6A0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="app-sidebar"></aside>
|
||||
|
||||
<div class="sb-content">
|
||||
<div class="tb-wrap">
|
||||
|
||||
<header class="tb-header">
|
||||
<div class="tb-icon">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||
<line x1="9" y1="7" x2="15" y2="7"/>
|
||||
<line x1="9" y1="11" x2="15" y2="11"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tb-title">Учебники</div>
|
||||
<div class="tb-sub">Полные курсы по предметам с разделами и интерактивными примерами</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ex-overlay" id="assign-overlay" onclick="onAssignOverlayClick(event)">
|
||||
<div class="ex-panel" onclick="event.stopPropagation()">
|
||||
<div class="ex-panel-head">
|
||||
<h2 id="assign-title">Назначить чтение</h2>
|
||||
<button class="ex-panel-close" onclick="closeAssignModal()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
|
||||
<div class="ax-field">
|
||||
<label>Классы</label>
|
||||
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
|
||||
</div>
|
||||
<div class="ax-field">
|
||||
<label>Параграфы</label>
|
||||
<input type="text" class="ax-input" id="ax-paragraphs" placeholder="например: 1-5 или 1,3,7" />
|
||||
<div class="ax-hint">Диапазон («15-18») или список через запятую («1,3,5»)</div>
|
||||
</div>
|
||||
<div class="ax-field">
|
||||
<label>Срок сдачи</label>
|
||||
<input type="datetime-local" class="ax-input" id="ax-deadline" />
|
||||
</div>
|
||||
<div class="ax-error" id="ax-error"></div>
|
||||
<div class="ax-success" id="ax-success"></div>
|
||||
<div class="ax-actions">
|
||||
<button type="button" class="ax-btn" onclick="closeAssignModal()">Отмена</button>
|
||||
<button type="submit" class="ax-btn ax-btn-primary" id="ax-submit">Назначить</button>
|
||||
</div>
|
||||
</form>
|
||||
</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();
|
||||
LS.showBoardIfAllowed();
|
||||
LS.hideDisabledFeatures();
|
||||
|
||||
const isTeacher = user && (user.role === 'teacher' || user.role === 'admin');
|
||||
let textbooks = [];
|
||||
let teacherClasses = null;
|
||||
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
}
|
||||
|
||||
async function loadTextbooks() {
|
||||
try {
|
||||
const r = await LS.api('/api/textbooks');
|
||||
textbooks = r.textbooks || [];
|
||||
render();
|
||||
} catch (e) {
|
||||
document.getElementById('tb-grid').innerHTML = `<div class="tb-empty">Не удалось загрузить: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const grid = document.getElementById('tb-grid');
|
||||
if (!textbooks.length) {
|
||||
grid.innerHTML = '<div class="tb-empty">Учебники не добавлены</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = textbooks.map(t => {
|
||||
const readCount = (t.progress?.read || []).length;
|
||||
const pct = t.para_count ? Math.round(100 * readCount / t.para_count) : 0;
|
||||
const watermark = t.subject === 'chemistry' ? 'Х' : t.subject === 'physics' ? 'Φ' : t.subject === 'math' ? 'Σ' : t.subject === 'biology' ? 'Β' : '§';
|
||||
const continueHref = t.progress?.last_para
|
||||
? `/textbook/${t.slug}#${t.progress.last_para}`
|
||||
: `/textbook/${t.slug}`;
|
||||
|
||||
return `
|
||||
<article class="tb-card">
|
||||
<div class="tb-cover ${t.color}" data-watermark="${watermark}">
|
||||
<div class="tb-cover-info">
|
||||
<div class="tb-cover-grade">${t.grade} класс</div>
|
||||
<div class="tb-cover-title">${esc(t.title)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tb-body">
|
||||
${t.author ? `<div class="tb-author">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
${esc(t.author)}
|
||||
</div>` : ''}
|
||||
<div class="tb-desc">${esc(t.description)}</div>
|
||||
<div class="tb-progress ${t.color}">
|
||||
<div class="tb-progress-bar">
|
||||
<div class="tb-progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<div class="tb-progress-text">
|
||||
<span><b>${readCount}</b> из ${t.para_count} прочитано</span>
|
||||
<span>${pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tb-actions">
|
||||
<a href="${continueHref}" class="tb-btn primary ${t.color}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
${t.progress?.last_para ? 'Продолжить' : 'Открыть'}
|
||||
</a>
|
||||
${isTeacher ? `<button class="tb-btn tb-assign-btn" onclick="openAssignModal('${t.slug}', '${esc(t.title)}')" title="Назначить чтение как ДЗ">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"/></svg>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join('');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
/* ── Assign modal ── */
|
||||
let assignSlug = null;
|
||||
let assignTitle = null;
|
||||
|
||||
async function loadTeacherClasses() {
|
||||
if (teacherClasses) return teacherClasses;
|
||||
try {
|
||||
const list = await LS.api('/api/classes');
|
||||
teacherClasses = Array.isArray(list) ? list : [];
|
||||
} catch { teacherClasses = []; }
|
||||
return teacherClasses;
|
||||
}
|
||||
|
||||
window.openAssignModal = async function (slug, title) {
|
||||
assignSlug = slug;
|
||||
assignTitle = title;
|
||||
document.getElementById('assign-title').textContent = `Назначить чтение: «${title}»`;
|
||||
['ax-error', 'ax-success'].forEach(id => document.getElementById(id).classList.remove('visible'));
|
||||
document.getElementById('ax-paragraphs').value = '';
|
||||
document.getElementById('ax-deadline').value = '';
|
||||
document.getElementById('ax-submit').disabled = false;
|
||||
document.getElementById('ax-submit').textContent = 'Назначить';
|
||||
|
||||
const listEl = document.getElementById('ax-classes-list');
|
||||
listEl.textContent = 'Загрузка…';
|
||||
const classes = await loadTeacherClasses();
|
||||
if (!classes.length) {
|
||||
listEl.innerHTML = '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов</div>';
|
||||
} else {
|
||||
listEl.innerHTML = classes.map(c => `
|
||||
<label class="ax-class">
|
||||
<input type="checkbox" name="cls" value="${c.id}" />
|
||||
<span class="ax-cname">${esc(c.name)}</span>
|
||||
<span class="ax-cmeta">${c.member_count || 0} учеников</span>
|
||||
</label>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('assign-overlay').classList.add('visible');
|
||||
document.addEventListener('keydown', onAssignEsc);
|
||||
};
|
||||
|
||||
window.closeAssignModal = function () {
|
||||
document.getElementById('assign-overlay').classList.remove('visible');
|
||||
document.removeEventListener('keydown', onAssignEsc);
|
||||
};
|
||||
|
||||
window.onAssignOverlayClick = function (e) {
|
||||
if (e.target === document.getElementById('assign-overlay')) closeAssignModal();
|
||||
};
|
||||
function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); }
|
||||
|
||||
window.submitAssign = async function () {
|
||||
const errorEl = document.getElementById('ax-error');
|
||||
const successEl = document.getElementById('ax-success');
|
||||
const submitBtn = document.getElementById('ax-submit');
|
||||
errorEl.classList.remove('visible');
|
||||
successEl.classList.remove('visible');
|
||||
|
||||
const checked = [...document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')]
|
||||
.map(el => Number(el.value));
|
||||
if (!checked.length) {
|
||||
errorEl.textContent = 'Выберите хотя бы один класс';
|
||||
errorEl.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
const paragraphs = document.getElementById('ax-paragraphs').value.trim();
|
||||
const deadline = document.getElementById('ax-deadline').value || null;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Назначаю…';
|
||||
|
||||
try {
|
||||
const titleSuffix = paragraphs ? ` (§${paragraphs})` : '';
|
||||
const r = await LS.api('/api/assignments/bulk', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
title: `Учебник: ${assignTitle}${titleSuffix}`,
|
||||
class_ids: checked,
|
||||
mode: 'exam', // mode is required, but for textbook assignment is informational
|
||||
count: 1,
|
||||
subject_slug: 'other',
|
||||
is_homework: 1,
|
||||
deadline: deadline,
|
||||
textbook_slug: assignSlug,
|
||||
textbook_paragraphs: paragraphs || null,
|
||||
},
|
||||
});
|
||||
successEl.textContent = `Назначено в ${r.count || checked.length} класс(е/ах)`;
|
||||
successEl.classList.add('visible');
|
||||
submitBtn.textContent = 'Готово';
|
||||
setTimeout(closeAssignModal, 1500);
|
||||
} catch (e) {
|
||||
errorEl.textContent = e.message || 'Не удалось создать задание';
|
||||
errorEl.classList.add('visible');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Назначить';
|
||||
}
|
||||
};
|
||||
|
||||
await loadTextbooks();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -598,6 +598,7 @@ async function hideDisabledFeatures() {
|
||||
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
|
||||
live_quiz: ['/live-quiz'],
|
||||
exam9: ['/exam9', '/exam9.html'],
|
||||
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
|
||||
};
|
||||
for (const [key, hrefs] of Object.entries(map)) {
|
||||
if (feats[key] === false) {
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
${L('/knowledge-map', 'share-2', 'Карта знаний')}
|
||||
${L('/red-book', 'leaf', 'Красная книга')}
|
||||
${L('/exam9', 'clipboard-check', 'Экзамен 9 класс')}
|
||||
${L('/textbooks', 'book-open-text', 'Учебники')}
|
||||
${L('/classroom', 'presentation', 'Онлайн-урок')}
|
||||
${L('/lesson-history','archive', 'Архив уроков')}
|
||||
<div class="sb-divider"></div>
|
||||
|
||||
Reference in New Issue
Block a user