diff --git a/backend/src/controllers/assignmentController.js b/backend/src/controllers/assignmentController.js index f0197bc..42698e5 100644 --- a/backend/src/controllers/assignmentController.js +++ b/backend/src/controllers/assignmentController.js @@ -195,11 +195,15 @@ function myAssignments(req, res) { SELECT * FROM ( SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at, a.file_id, f.title AS file_title, + a.textbook_id, a.textbook_paragraphs, + tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count, + tp.paragraphs_read AS textbook_read, c.name AS class_name, c.id AS class_id, u.name AS teacher_name, latest.session_id, ts.score, ts.total, ts.status AS session_status, ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent, CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done, + ac.completed_at AS completed_at, a.is_homework, a.max_attempts, (SELECT COUNT(*) FROM assignment_sessions ax JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed' @@ -209,6 +213,9 @@ function myAssignments(req, res) { JOIN users u ON u.id = c.teacher_id JOIN assignments a ON a.class_id = c.id AND a.user_id IS NULL LEFT JOIN files f ON f.id = a.file_id + LEFT JOIN textbooks tb ON tb.id = a.textbook_id + LEFT JOIN textbook_progress tp ON tp.user_id = cm.user_id AND tp.textbook_id = a.textbook_id + LEFT JOIN assignment_completion ac ON ac.assignment_id = a.id AND ac.user_id = cm.user_id LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = cm.user_id AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = cm.user_id) LEFT JOIN test_sessions ts ON ts.id = latest.session_id @@ -216,11 +223,15 @@ function myAssignments(req, res) { UNION ALL SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at, a.file_id, f.title AS file_title, + a.textbook_id, a.textbook_paragraphs, + tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count, + tp.paragraphs_read AS textbook_read, 'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name, latest.session_id, ts.score, ts.total, ts.status AS session_status, ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent, CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done, + ac.completed_at AS completed_at, a.is_homework, a.max_attempts, (SELECT COUNT(*) FROM assignment_sessions ax JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed' @@ -228,15 +239,58 @@ function myAssignments(req, res) { FROM assignments a JOIN users u ON u.id = a.created_by LEFT JOIN files f ON f.id = a.file_id + LEFT JOIN textbooks tb ON tb.id = a.textbook_id + LEFT JOIN textbook_progress tp ON tp.user_id = ? AND tp.textbook_id = a.textbook_id + LEFT JOIN assignment_completion ac ON ac.assignment_id = a.id AND ac.user_id = ? LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = ? AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = ?) LEFT JOIN test_sessions ts ON ts.id = latest.session_id WHERE a.user_id = ? ) ORDER BY done ASC, deadline ASC, created_at DESC - `).all(uid, uid, uid, uid, uid); + `).all(uid, uid, uid, uid, uid, uid, uid); + + // Post-process: compute textbook reading completion from required vs read paragraphs + for (const r of rows) { + if (r.textbook_id) { + const required = parseTextbookParas(r.textbook_paragraphs, r.textbook_para_count); + let read = []; + try { read = JSON.parse(r.textbook_read || '[]'); } catch {} + const readKeys = new Set(read); + const requiredKeys = required.map(n => 'p' + n); + const readCount = requiredKeys.filter(k => readKeys.has(k)).length; + r.textbook_required_count = requiredKeys.length; + r.textbook_read_count = readCount; + r.textbook_all_read = requiredKeys.length > 0 && readCount === requiredKeys.length; + if (r.textbook_all_read || r.completed_at) r.done = 1; + } + // Strip raw paragraphs_read JSON from response (not needed by client) + delete r.textbook_read; + } + res.json(rows); } +/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc. + If empty/null, returns [1..fallback] (the whole book). */ +function parseTextbookParas(spec, fallback) { + if (!spec || !spec.trim()) { + return Array.from({ length: fallback || 0 }, (_, i) => i + 1); + } + const out = new Set(); + for (const chunk of spec.split(',')) { + const part = chunk.trim(); + if (!part) continue; + const dash = part.match(/^(\d+)\s*[-–]\s*(\d+)$/); + if (dash) { + const a = Number(dash[1]), b = Number(dash[2]); + for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i); + } else if (/^\d+$/.test(part)) { + out.add(Number(part)); + } + } + return [...out].sort((a, b) => a - b); +} + /* ── POST /api/assignments/:id/start ── student starts session ─────────── */ function startAssignment(req, res) { const uid = req.user.id; @@ -452,7 +506,8 @@ function assignmentQuestionStats(req, res) { /* ── POST /api/assignments ── direct assignment to a single student ──────── */ function createDirectAssignment(req, res) { - const { deadline, student_email, student_id, file_id, is_homework = 1 } = req.body; + const { deadline, student_email, student_id, file_id, is_homework = 1, + textbook_slug, textbook_paragraphs } = req.body; const mode = req.body.mode || 'exam'; const count = Number(req.body.count) || 25; let { title, subject_slug, test_id } = req.body; @@ -465,11 +520,11 @@ function createDirectAssignment(req, res) { let student; if (student_id) { - student = db.prepare("SELECT id, name FROM users WHERE id = ? AND role = 'student'").get(Number(student_id)); + student = db.prepare("SELECT id, name FROM users WHERE id = ? AND role IN ('student','free_student')").get(Number(student_id)); if (!student) return res.status(404).json({ error: 'Ученик не найден' }); } else { if (!student_email?.trim()) return res.status(400).json({ error: 'student_email required' }); - student = db.prepare("SELECT id, name FROM users WHERE email = ? AND role = 'student'") + student = db.prepare("SELECT id, name FROM users WHERE email = ? AND role IN ('student','free_student')") .get(student_email.trim().toLowerCase()); if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' }); } @@ -490,6 +545,15 @@ function createDirectAssignment(req, res) { if (!t) return res.status(400).json({ error: 'Test not found' }); subject_slug = t.subject_slug; } + // Textbook: resolve slug → id, derive subject + 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 (file_id && !subject_slug) { const f = db.prepare('SELECT subject_slug FROM files WHERE id = ?').get(file_id); if (f?.subject_slug) subject_slug = f.subject_slug; @@ -498,9 +562,9 @@ function createDirectAssignment(req, res) { if (!subject_slug) subject_slug = 'other'; const r = db.prepare(` - INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(student.id, stripTags(title.trim()), subject_slug, mode, Number(count), deadline || null, req.user.id, test_id, file_id || null, is_homework ? 1 : 0); + INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework, textbook_id, textbook_paragraphs) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(student.id, stripTags(title.trim()), subject_slug, mode, Number(count), deadline || null, req.user.id, test_id, file_id || null, is_homework ? 1 : 0, textbook_id, textbook_paragraphs || null); // Уведомление ученику pushNotif(student.id, 'assignment', `Для вас задание: «${title.trim()}»`, '/dashboard'); diff --git a/backend/src/db/migrations/005_textbook_extras.sql b/backend/src/db/migrations/005_textbook_extras.sql new file mode 100644 index 0000000..e5dd86f --- /dev/null +++ b/backend/src/db/migrations/005_textbook_extras.sql @@ -0,0 +1,22 @@ +-- Per-user-per-assignment completion (textbook reading, future file-read, etc.) +-- Used to mark non-test assignments as done. +CREATE TABLE assignment_completion ( + assignment_id INTEGER NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + completed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (assignment_id, user_id) +); + +-- Bookmarks/highlights inside textbooks (one row per highlight) +CREATE TABLE textbook_bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + textbook_id INTEGER NOT NULL REFERENCES textbooks(id) ON DELETE CASCADE, + para TEXT, -- 'p15' (paragraph anchor, optional) + text TEXT NOT NULL, -- highlighted text snippet (max 400 chars) + note TEXT NOT NULL DEFAULT '', -- user's comment + color TEXT NOT NULL DEFAULT 'yellow', -- 'yellow','green','blue','pink' + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_textbook_bookmarks_user ON textbook_bookmarks (user_id, textbook_id); +CREATE INDEX idx_textbook_bookmarks_textbook ON textbook_bookmarks (textbook_id); diff --git a/backend/src/routes/textbooks.js b/backend/src/routes/textbooks.js index 371578b..c98ffe5 100644 --- a/backend/src/routes/textbooks.js +++ b/backend/src/routes/textbooks.js @@ -2,62 +2,146 @@ const router = require('express').Router(); const db = require('../db/db'); const { authMiddleware, requireRole } = require('../middleware/auth'); +const { emit } = require('../sse'); router.use(authMiddleware); +/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,...]; empty → [1..fallback] */ +function parseTextbookParas(spec, fallback) { + if (!spec || !spec.trim()) return Array.from({ length: fallback || 0 }, (_, i) => i + 1); + const out = new Set(); + for (const chunk of spec.split(',')) { + const part = chunk.trim(); + if (!part) continue; + const dash = part.match(/^(\d+)\s*[-–]\s*(\d+)$/); + if (dash) { + const a = Number(dash[1]), b = Number(dash[2]); + for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i); + } else if (/^\d+$/.test(part)) out.add(Number(part)); + } + return [...out].sort((a, b) => a - b); +} + +/* After a paragraph is marked read, scan user's textbook assignments and mark + completed if all required paragraphs are now in their read set. */ +function checkAssignmentCompletion(userId, textbookId, readSet) { + const candidates = db.prepare(` + SELECT a.id, a.title, a.created_by, a.textbook_paragraphs, t.para_count + FROM assignments a + JOIN textbooks t ON t.id = a.textbook_id + LEFT JOIN assignment_completion ac ON ac.assignment_id = a.id AND ac.user_id = ? + WHERE a.textbook_id = ? + AND ac.assignment_id IS NULL + AND ( + a.user_id = ? + OR (a.user_id IS NULL AND a.class_id IS NOT NULL + AND EXISTS (SELECT 1 FROM class_members cm WHERE cm.class_id = a.class_id AND cm.user_id = ?)) + ) + `).all(userId, textbookId, userId, userId); + + for (const a of candidates) { + const required = parseTextbookParas(a.textbook_paragraphs, a.para_count); + if (!required.length) continue; + const allRead = required.every(n => readSet.has('p' + n)); + if (allRead) { + db.prepare('INSERT OR IGNORE INTO assignment_completion (assignment_id, user_id) VALUES (?, ?)').run(a.id, userId); + try { + emit(a.created_by, { + type: 'notification', notif_type: 'assignment_done', + message: `Ученик завершил чтение: «${a.title}»`, link: '/classes', + }); + } catch {} + } + } +} + +/* ════════════════════════════════════════════════ + LITERAL ROUTES FIRST — must come before /:slug + ════════════════════════════════════════════════ */ + /* 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 + 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 = {}; + const map = {}; 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 }; + map[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 }, - })), - }); + res.json({ textbooks: rows.map(t => ({ ...t, progress: map[t.id] || { read: [], last_para: null, last_at: null } })) }); }); +/* GET /api/textbooks/bookmarks/all — all my bookmarks across textbooks */ +router.get('/bookmarks/all', (req, res) => { + const rows = db.prepare(` + SELECT b.id, b.para, b.text, b.note, b.color, b.created_at, + t.slug AS textbook_slug, t.title AS textbook_title, t.color AS textbook_color + FROM textbook_bookmarks b + JOIN textbooks t ON t.id = b.textbook_id + WHERE b.user_id=? ORDER BY b.id DESC + `).all(req.user.id); + res.json({ bookmarks: rows }); +}); + +/* PATCH /api/textbooks/bookmarks/:id — update note/color */ +router.patch('/bookmarks/:id', (req, res) => { + const id = Number(req.params.id); + const b = db.prepare('SELECT id, user_id FROM textbook_bookmarks WHERE id=?').get(id); + if (!b) return res.status(404).json({ error: 'Закладка не найдена' }); + if (b.user_id !== req.user.id) return res.status(403).json({ error: 'Нет доступа' }); + + const updates = [], params = []; + if (typeof req.body?.note === 'string') { updates.push('note=?'); params.push(req.body.note.slice(0, 1000)); } + if (req.body?.color && ['yellow','green','blue','pink'].includes(req.body.color)) { + updates.push('color=?'); params.push(req.body.color); + } + if (!updates.length) return res.json({ ok: true }); + params.push(id); + db.prepare(`UPDATE textbook_bookmarks SET ${updates.join(', ')} WHERE id=?`).run(...params); + res.json({ ok: true }); +}); + +/* DELETE /api/textbooks/bookmarks/:id */ +router.delete('/bookmarks/:id', (req, res) => { + const id = Number(req.params.id); + const b = db.prepare('SELECT id, user_id FROM textbook_bookmarks WHERE id=?').get(id); + if (!b) return res.status(404).json({ error: 'Закладка не найдена' }); + if (b.user_id !== req.user.id) return res.status(403).json({ error: 'Нет доступа' }); + db.prepare('DELETE FROM textbook_bookmarks WHERE id=?').run(id); + res.json({ ok: true }); +}); + +/* ════════════════════════════════════════════════ + :slug ROUTES (catch-all per textbook) + ════════════════════════════════════════════════ */ + /* 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' } */ +/* POST /api/textbooks/:slug/progress — update progress */ 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); @@ -70,22 +154,48 @@ router.post('/:slug/progress', (req, res) => { last_at = excluded.last_at `).run(req.user.id, t.id, JSON.stringify(arr), last_para || null); + if (mark_read) { + try { checkAssignmentCompletion(req.user.id, t.id, new Set(arr)); } catch {} + } 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) => { +/* GET /api/textbooks/:slug/bookmarks — list my bookmarks for this textbook */ +router.get('/:slug/bookmarks', (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 rows = db.prepare(` + SELECT id, para, text, note, color, created_at FROM textbook_bookmarks + WHERE user_id=? AND textbook_id=? ORDER BY id DESC + `).all(req.user.id, t.id); + res.json({ bookmarks: rows }); +}); + +/* POST /api/textbooks/:slug/bookmarks — create */ +router.post('/:slug/bookmarks', (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 { para, text, note = '', color = 'yellow' } = req.body || {}; + if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' }); + const VALID = new Set(['yellow', 'green', 'blue', 'pink']); + const safeColor = VALID.has(color) ? color : 'yellow'; + const r = db.prepare(` + INSERT INTO textbook_bookmarks (user_id, textbook_id, para, text, note, color) + VALUES (?, ?, ?, ?, ?, ?) + `).run(req.user.id, t.id, para || null, text.slice(0, 400), String(note).slice(0, 1000), safeColor); + res.status(201).json({ id: r.lastInsertRowid }); +}); + +/* GET /api/textbooks/:slug/class-progress — teacher view (per-student progress) */ +router.get('/:slug/class-progress', requireRole('teacher', 'admin'), (req, res) => { + const t = db.prepare('SELECT id, para_count 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, @@ -96,14 +206,53 @@ router.get('/:slug/class-progress', requireRole('teacher', 'admin'), (req, res) WHERE cm.class_id = ? ORDER BY u.name `).all(t.id, classId); - res.json({ + total_paragraphs: t.para_count, 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 }; + return { + user_id: r.user_id, name: r.name, + read_count: read.length, paragraphs_read: read, + last_para: r.last_para, last_at: r.last_at, + }; }), }); }); +/* ════════════════════════════════════════════════ + ADMIN endpoints — full catalog management + ════════════════════════════════════════════════ */ + +/* GET /api/textbooks/admin/all — list ALL textbooks including inactive */ +router.get('/admin/all', requireRole('admin'), (_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, t.is_active, t.created_at, + (SELECT COUNT(*) FROM textbook_progress WHERE textbook_id = t.id) AS readers + FROM textbooks t ORDER BY t.sort_order, t.subject, t.grade + `).all(); + res.json({ textbooks: rows }); +}); + +/* PATCH /api/textbooks/admin/:id — edit textbook (admin only) */ +router.patch('/admin/:id', requireRole('admin'), (req, res) => { + const id = Number(req.params.id); + const t = db.prepare('SELECT id FROM textbooks WHERE id=?').get(id); + if (!t) return res.status(404).json({ error: 'Учебник не найден' }); + + const allowed = ['title', 'author', 'description', 'subject', 'grade', 'color', 'sort_order', 'is_active', 'para_count']; + const updates = [], params = []; + for (const f of allowed) { + if (req.body[f] !== undefined) { + updates.push(`${f} = ?`); + params.push(req.body[f]); + } + } + if (!updates.length) return res.json({ ok: true }); + params.push(id); + db.prepare(`UPDATE textbooks SET ${updates.join(', ')} WHERE id=?`).run(...params); + res.json({ ok: true }); +}); + module.exports = router; diff --git a/frontend/admin-textbooks.html b/frontend/admin-textbooks.html new file mode 100644 index 0000000..cc4a16a --- /dev/null +++ b/frontend/admin-textbooks.html @@ -0,0 +1,205 @@ + + + + + + Управление учебниками — LearnSpace + + + + + + +
+ +
+
+
+ +
+
Управление учебниками
+
Редактирование каталога · включение/отключение отдельных учебников
+
+
+ +
Загрузка…
+
+
+
+ + + + + + + + + + diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 702e9a9..8a32314 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -2108,6 +2108,60 @@ `; } + /* ── Textbook reading assignment ── */ + if (a.textbook_id) { + const reqCount = a.textbook_required_count || 0; + const readCount = a.textbook_read_count || 0; + const allRead = !!a.textbook_all_read || !!a.completed_at; + const tbPct = reqCount > 0 ? Math.round(100 * readCount / reqCount) : 0; + const tbColorMap = { amber:'#d97706', blue:'#2563eb', green:'#059669', violet:'#7c3aed', pink:'#db2777' }; + const tbColor = tbColorMap[a.textbook_color] || '#7c3aed'; + const over = !allRead && a.deadline && new Date(a.deadline) < new Date(); + const cardCls = allRead ? 'done' : over ? 'over' : isUrgent ? 'urgent' : ''; + const parasText = a.textbook_paragraphs ? `§${a.textbook_paragraphs}` : 'весь учебник'; + const metaParts = [ + classStr, + parasText, + a.is_homework ? `ДЗ` : null, + dl ? `до ${dl}` : null, + isUrgent ? ` ${hoursLeft} ч` : null, + over ? `просрочено` : null, + ].filter(Boolean); + + // Find first required paragraph for deep-link + let firstHash = ''; + if (a.textbook_paragraphs) { + const m = String(a.textbook_paragraphs).match(/^\s*(\d+)/); + if (m) firstHash = '#p' + m[1]; + } + const openHref = `/textbook/${a.textbook_slug}${firstHash}`; + + const actionBtn = allRead + ? `${lci('check')} Прочитано` + : `${readCount > 0 ? 'Продолжить' : 'Открыть'}`; + + return `
+
${lci('book-open-text')}
+
${esc(a.title)}
${metaParts.join(' · ')}
+
+
+ ${readCount} / ${reqCount} § +
+
${actionBtn}
+
+
+
+
+ ${lci('book-open')} ${esc(a.textbook_title || 'Учебник')} + ${lci('layers')} ${parasText} + ${a.is_homework ? `${lci('book-open')} Домашнее задание` : ''} + ${allRead ? `${lci('check')} Завершено` : ''} +
+ Открыть учебник +
+
`; + } + /* ── Test assignment ── */ const isDone = a.session_status === 'completed'; const inProgress = a.session_status === 'in_progress'; @@ -2248,6 +2302,11 @@ function classify(a) { const maxAtt = a.max_attempts || 0; const usedAtt = a.attempts_used ?? 0; + if (a.textbook_id) { + if (a.completed_at || a.textbook_all_read) return 'done'; + if (a.deadline && new Date(a.deadline) < now) return 'overdue'; + return 'active'; + } if (maxAtt > 0 && usedAtt >= maxAtt) return 'done'; if (a.session_status === 'completed' && a.mode !== 'repeat') return 'done'; if (!a.file_id && a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue'; diff --git a/frontend/js/textbook-tracker.js b/frontend/js/textbook-tracker.js index aefd127..09a2dab 100644 --- a/frontend/js/textbook-tracker.js +++ b/frontend/js/textbook-tracker.js @@ -217,19 +217,225 @@ document.head.appendChild(s); } + /* ── 9a. Bookmarks (highlights/notes) ─────────────────────────── */ + let bookmarks = []; + + function loadBookmarks() { + if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return Promise.resolve(); + return fetch('/api/textbooks/' + slug + '/bookmarks', { + headers: { 'Authorization': 'Bearer ' + LS.getToken() }, + }) + .then(r => r.ok ? r.json() : { bookmarks: [] }) + .then(d => { bookmarks = d.bookmarks || []; }) + .catch(() => {}); + } + + function escHtml(s) { + return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); + } + + function installBookmarksBtn() { + if (document.getElementById('tb-bm-btn')) return; + const btn = document.createElement('button'); + btn.id = 'tb-bm-btn'; + btn.title = 'Мои закладки'; + btn.innerHTML = ``; + Object.assign(btn.style, { + position: 'fixed', top: '10px', left: '125px', zIndex: '9999', + width: '34px', height: '32px', border: 'none', borderRadius: '20px', + background: 'rgba(0,0,0,.45)', color: '#fff', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + cursor: 'pointer', 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'; + btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.7)'; }); + btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(0,0,0,.45)'; }); + btn.addEventListener('click', toggleBookmarksPanel); + document.body.appendChild(btn); + } + + function installBookmarksPanel() { + if (document.getElementById('tb-bm-panel')) return; + const panel = document.createElement('div'); + panel.id = 'tb-bm-panel'; + Object.assign(panel.style, { + position: 'fixed', top: '50px', left: '12px', zIndex: '9998', + width: '320px', maxHeight: '60vh', overflowY: 'auto', + background: '#fff', color: '#1c1917', + border: '1px solid rgba(0,0,0,.12)', borderRadius: '12px', + boxShadow: '0 8px 32px rgba(0,0,0,.25)', + padding: '12px', display: 'none', + fontFamily: "'Inter',system-ui,sans-serif", fontSize: '13px', + }); + document.body.appendChild(panel); + } + + function toggleBookmarksPanel() { + const panel = document.getElementById('tb-bm-panel'); + if (panel.style.display === 'block') { panel.style.display = 'none'; return; } + renderBookmarksPanel(); + panel.style.display = 'block'; + } + + function renderBookmarksPanel() { + const panel = document.getElementById('tb-bm-panel'); + if (!panel) return; + if (!bookmarks.length) { + panel.innerHTML = `
+ Закладок нет
Выдели любой текст в учебнике и нажми «+ Закладка» +
`; + return; + } + panel.innerHTML = ` +
+ Мои закладки (${bookmarks.length}) +
+ ${bookmarks.map(b => { + const colorMap = { yellow:'#fef08a', green:'#bbf7d0', blue:'#bfdbfe', pink:'#fbcfe8' }; + const borderMap= { yellow:'#ca8a04', green:'#16a34a', blue:'#2563eb', pink:'#db2777' }; + const bg = colorMap[b.color] || colorMap.yellow; + const bd = borderMap[b.color] || borderMap.yellow; + const paraLink = b.para ? `§${b.para.replace('p','')}` : ''; + return `
+ ${paraLink}«${escHtml(b.text)}» + ${b.note ? `
${escHtml(b.note)}
` : ''} + +
`; + }).join('')}`; + } + + window.__tbDeleteBookmark = function (id) { + if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return; + fetch('/api/textbooks/bookmarks/' + id, { + method: 'DELETE', + headers: { 'Authorization': 'Bearer ' + LS.getToken() }, + }).then(r => { + if (r.ok) { + bookmarks = bookmarks.filter(b => b.id !== id); + renderBookmarksPanel(); + } + }).catch(() => {}); + }; + + /* Selection → "+ Закладка" floating button */ + function installSelectionHandler() { + let btn = null; + document.addEventListener('mouseup', () => { + setTimeout(() => { + const sel = window.getSelection(); + const text = (sel ? sel.toString() : '').trim(); + if (!text || text.length < 8 || text.length > 400) { hideBtn(); return; } + if (sel.rangeCount === 0) { hideBtn(); return; } + const rect = sel.getRangeAt(0).getBoundingClientRect(); + if (!rect || rect.width === 0) { hideBtn(); return; } + showBtn(rect, text); + }, 10); + }); + document.addEventListener('mousedown', e => { + if (e.target.closest('#tb-sel-btn')) return; + hideBtn(); + }); + + function showBtn(rect, text) { + if (!btn) { + btn = document.createElement('button'); + btn.id = 'tb-sel-btn'; + btn.innerHTML = `Закладка`; + Object.assign(btn.style, { + position: 'fixed', zIndex: '10000', + display: 'inline-flex', alignItems: 'center', gap: '5px', + padding: '6px 11px', borderRadius: '6px', + border: 'none', background: '#1c1917', color: '#fff', + fontFamily: "'Inter',system-ui,sans-serif", fontSize: '12px', fontWeight: '700', + cursor: 'pointer', boxShadow: '0 4px 14px rgba(0,0,0,.3)', + }); + document.body.appendChild(btn); + } + btn.style.top = (rect.top + window.scrollY - 36) + 'px'; + btn.style.left = (rect.left + window.scrollX + rect.width / 2 - 50) + 'px'; + btn.style.display = 'inline-flex'; + btn.onclick = () => createBookmarkFromSelection(text); + } + function hideBtn() { if (btn) btn.style.display = 'none'; } + } + + function createBookmarkFromSelection(text) { + if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) { + alert('Сохранение закладок доступно после входа в систему'); + return; + } + const note = prompt('Заметка к закладке (опционально):', '') || ''; + // Find current paragraph from selection + const sel = window.getSelection(); + let para = null; + if (sel.rangeCount > 0) { + let node = sel.getRangeAt(0).startContainer; + while (node && node !== document.body) { + if (node.dataset?.para) { para = node.dataset.para; break; } + if (node.id && /^p\d+$/.test(node.id)) { para = node.id; break; } + if (node.id && /^ptab-p\d+$/.test(node.id)) { para = node.id.replace('ptab-', ''); break; } + node = node.parentNode; + } + } + if (!para) para = localState.last; // fallback + + fetch('/api/textbooks/' + slug + '/bookmarks', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + LS.getToken(), + }, + body: JSON.stringify({ text, note, para, color: 'yellow' }), + }) + .then(r => r.ok ? r.json() : null) + .then(d => { + if (!d) return; + bookmarks.unshift({ id: d.id, text, note, para, color: 'yellow', created_at: new Date().toISOString() }); + const btnEl = document.getElementById('tb-sel-btn'); + if (btnEl) btnEl.style.display = 'none'; + window.getSelection()?.removeAllRanges(); + }); + } + /* ── 9. Boot ──────────────────────────────────────────────────── */ + function openParaByKey(key) { + if (!key) return; + if (typeof setParaTab === 'function') { + try { setParaTab(key); return; } catch {} + } + const pill = document.querySelector(`.para-pill[data-para="${key}"]`); + if (pill) pill.click(); + } + + function handleHashNav() { + const m = (location.hash || '').match(/^#(p\d+)$/); + if (m) { + openParaByKey(m[1]); + setLastPara(m[1]); + return true; + } + return false; + } + function boot() { injectStyles(); installBackButton(); + installBookmarksBtn(); + installBookmarksPanel(); + installSelectionHandler(); 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); + loadBookmarks(); + + // Priority: URL hash > last visited paragraph + if (!handleHashNav() && localState.last) { + setTimeout(() => openParaByKey(localState.last), 50); } + window.addEventListener('hashchange', handleHashNav); } if (document.readyState === 'loading') { diff --git a/frontend/textbook-progress.html b/frontend/textbook-progress.html new file mode 100644 index 0000000..b0ad270 --- /dev/null +++ b/frontend/textbook-progress.html @@ -0,0 +1,226 @@ + + + + + + Прогресс по учебникам — LearnSpace + + + + + + +
+ +
+
+
+ +
+
Прогресс класса по учебнику
+
Кто сколько параграфов прочитал
+
+
+ +
+
+ + +
+
+ + +
+
+ +
Выберите учебник и класс
+
+
+
+ + + + + + + + + + diff --git a/frontend/textbooks.html b/frontend/textbooks.html index 9427c13..1ce3929 100644 --- a/frontend/textbooks.html +++ b/frontend/textbooks.html @@ -209,6 +209,31 @@ } .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; } + + .ax-tabs { display:flex; gap:6px; background:var(--border); padding:4px; border-radius:10px; } + .ax-tab { + flex:1; padding:7px 12px; border-radius:7px; + border:none; background:transparent; color:var(--text-2); + font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700; + cursor:pointer; transition:all .12s; + } + .ax-tab:hover { color:var(--text); } + .ax-tab.active { background:var(--surface); color:var(--violet); box-shadow:0 1px 4px rgba(0,0,0,.08); } + + .ax-student-results { + margin-top:6px; max-height:160px; overflow-y:auto; + border:1.5px solid var(--border); border-radius:10px; + display:none; + } + .ax-student-results.visible { display:block; } + .ax-student-row { + padding:8px 12px; cursor:pointer; transition:background .12s; + display:flex; align-items:center; gap:10px; + font-size:.85rem; + } + .ax-student-row:hover { background:var(--border); } + .ax-student-row.selected { background:rgba(155,93,229,.12); color:var(--violet); } + .ax-student-row .ax-student-email { font-size:.75rem; color:var(--text-3); margin-left:auto; } @@ -227,10 +252,11 @@ -
+
Учебники
Полные курсы по предметам с разделами и интерактивными примерами
+
@@ -254,13 +280,26 @@
+ +
+ + +
+
+
Загрузка…
+
-
Диапазон («15-18») или список через запятую («1,3,5»)
+
Диапазон («15-18») или список через запятую («1,3,5»). Пустое = весь учебник.
@@ -292,6 +331,15 @@ let textbooks = []; let teacherClasses = null; + // Teacher-only: "Class progress" button in header + if (isTeacher) { + document.getElementById('tb-header-actions').innerHTML = ` + + + Прогресс класса + `; + } + function esc(s) { return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } @@ -359,8 +407,10 @@ } /* ── Assign modal ── */ - let assignSlug = null; - let assignTitle = null; + let assignSlug = null; + let assignTitle = null; + let assignTab = 'class'; // 'class' or 'student' + let teacherStudents = null; // cached list of students-in-teacher's-classes async function loadTeacherClasses() { if (teacherClasses) return teacherClasses; @@ -371,6 +421,25 @@ return teacherClasses; } + async function loadTeacherStudents() { + if (teacherStudents) return teacherStudents; + try { + const r = await LS.api('/api/classes/students'); + teacherStudents = Array.isArray(r) ? r : (r.students || []); + } catch { teacherStudents = []; } + return teacherStudents; + } + + window.setAssignTab = function (tab) { + assignTab = tab; + document.querySelectorAll('.ax-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); + document.getElementById('ax-class-field').style.display = tab === 'class' ? '' : 'none'; + document.getElementById('ax-student-field').style.display = tab === 'student' ? '' : 'none'; + document.getElementById('ax-student-id').value = ''; + document.getElementById('ax-student-search').value = ''; + document.getElementById('ax-student-results').classList.remove('visible'); + }; + window.openAssignModal = async function (slug, title) { assignSlug = slug; assignTitle = title; @@ -380,6 +449,7 @@ document.getElementById('ax-deadline').value = ''; document.getElementById('ax-submit').disabled = false; document.getElementById('ax-submit').textContent = 'Назначить'; + setAssignTab('class'); const listEl = document.getElementById('ax-classes-list'); listEl.textContent = 'Загрузка…'; @@ -409,6 +479,45 @@ }; function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); } + /* Student search (debounced) */ + let stSearchTimer = null; + document.addEventListener('input', e => { + if (e.target?.id !== 'ax-student-search') return; + clearTimeout(stSearchTimer); + stSearchTimer = setTimeout(() => filterStudents(e.target.value), 200); + }); + + async function filterStudents(q) { + const resultsEl = document.getElementById('ax-student-results'); + q = q.trim().toLowerCase(); + if (q.length < 2) { resultsEl.classList.remove('visible'); return; } + const students = await loadTeacherStudents(); + const matches = students.filter(s => + (s.name && s.name.toLowerCase().includes(q)) || + (s.email && s.email.toLowerCase().includes(q)) + ).slice(0, 12); + if (!matches.length) { + resultsEl.innerHTML = '
Не найдено
'; + } else { + resultsEl.innerHTML = matches.map(s => ` +
+ ${esc(s.name)} + ${esc(s.email || '')} +
`).join(''); + } + resultsEl.classList.add('visible'); + } + + document.addEventListener('click', e => { + const row = e.target.closest('.ax-student-row'); + if (!row || !row.dataset.id) return; + document.querySelectorAll('.ax-student-row').forEach(r => r.classList.remove('selected')); + row.classList.add('selected'); + document.getElementById('ax-student-id').value = row.dataset.id; + document.getElementById('ax-student-search').value = row.dataset.name; + document.getElementById('ax-student-results').classList.remove('visible'); + }); + window.submitAssign = async function () { const errorEl = document.getElementById('ax-error'); const successEl = document.getElementById('ax-success'); @@ -416,37 +525,50 @@ 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; + const titleSuffix = paragraphs ? ` (§${paragraphs})` : ''; 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} класс(е/ах)`; + let resultMsg; + if (assignTab === 'class') { + const checked = [...document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')] + .map(el => Number(el.value)); + if (!checked.length) throw new Error('Выберите хотя бы один класс'); + + const r = await LS.api('/api/assignments/bulk', { + method: 'POST', + body: { + title: `Учебник: ${assignTitle}${titleSuffix}`, + class_ids: checked, + mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1, + deadline, + textbook_slug: assignSlug, + textbook_paragraphs: paragraphs || null, + }, + }); + resultMsg = `Назначено в ${r.count || checked.length} класс(е/ах)`; + } else { + const studentId = Number(document.getElementById('ax-student-id').value); + if (!studentId) throw new Error('Выберите ученика'); + + await LS.api('/api/assignments', { + method: 'POST', + body: { + title: `Учебник: ${assignTitle}${titleSuffix}`, + student_id: studentId, + mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1, + deadline, + textbook_slug: assignSlug, + textbook_paragraphs: paragraphs || null, + }, + }); + resultMsg = 'Личное задание создано'; + } + successEl.textContent = resultMsg; successEl.classList.add('visible'); submitBtn.textContent = 'Готово'; setTimeout(closeAssignModal, 1500);