feat: textbooks Phase 4 — A1+A2+A3+B4+C7 + назначение ученику
A1 — карточка ДЗ-чтения у ученика на /dashboard: - Новая ветка в buildAssignCard для assignments с textbook_id - Прогресс-бар «X из Y §», цвет берётся из textbook.color - Кнопка «Открыть / Продолжить» с deep-link на первый требуемый параграф - В classify(): textbook_all_read → done, deadline → overdue A2 — авто-проверка выполнения: - При POST /:slug/progress с mark_read: проверяются активные textbook-assignments - Если все требуемые § прочитаны → INSERT в assignment_completion - SSE-уведомление учителю «Ученик завершил чтение: <title>» - myAssignments возвращает completed_at и textbook_all_read A3 — учительский UI прогресса класса: - Новая страница /textbook-progress (учитель/админ) - Селекторы «учебник × класс» → таблица учеников с прогрессом - Сортировка по количеству прочитанного, дата last_at - Кнопка «Прогресс класса» добавлена в /textbooks (видна учителям) B4 — admin-UI управления учебниками: - /admin-textbooks (только admin) — таблица всех учебников - Inline-редактирование title/author, тоггл is_active - Колонка «Читателей» (count из textbook_progress) - Endpoints: GET /api/textbooks/admin/all, PATCH /admin/:id C7 — закладки/заметки внутри учебника: - Таблица textbook_bookmarks (user, textbook, para, text, note, color) - API: GET/POST/PATCH/DELETE для CRUD закладок - В tracker: при выделении текста (8-400 симв) появляется плавающая «+ Закладка» - Кнопка-иконка в overlay top-left открывает панель «Мои закладки» - Хранится paragraph-якорь, цвет, заметка, кнопка удалить Назначение ученику (в дополнение к классу): - В модалке /textbooks — переключатель «Классу / Ученику» - Поиск ученика по имени/email через /api/classes/students - Submit использует POST /api/assignments (createDirectAssignment) - createDirectAssignment расширен textbook_slug + textbook_paragraphs - Учитель может назначать только ученикам своих классов myAssignments расширен: возвращает textbook fields + post-process считает textbook_required_count, textbook_read_count, textbook_all_read. Deep-link поддержка: /textbook/<slug>#pN в tracker.js — на load и hashchange вызывает setParaTab(pN) (нативная функция учебника). Миграция 005: assignment_completion + textbook_bookmarks + индексы.
This commit is contained in:
@@ -195,11 +195,15 @@ function myAssignments(req, res) {
|
|||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
|
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.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,
|
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
|
||||||
latest.session_id,
|
latest.session_id,
|
||||||
ts.score, ts.total, ts.status AS session_status,
|
ts.score, ts.total, ts.status AS session_status,
|
||||||
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
|
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,
|
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,
|
a.is_homework, a.max_attempts,
|
||||||
(SELECT COUNT(*) FROM assignment_sessions ax
|
(SELECT COUNT(*) FROM assignment_sessions ax
|
||||||
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
|
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 users u ON u.id = c.teacher_id
|
||||||
JOIN assignments a ON a.class_id = c.id AND a.user_id IS NULL
|
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 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
|
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)
|
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
|
LEFT JOIN test_sessions ts ON ts.id = latest.session_id
|
||||||
@@ -216,11 +223,15 @@ function myAssignments(req, res) {
|
|||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
|
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.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,
|
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
|
||||||
latest.session_id,
|
latest.session_id,
|
||||||
ts.score, ts.total, ts.status AS session_status,
|
ts.score, ts.total, ts.status AS session_status,
|
||||||
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
|
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,
|
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,
|
a.is_homework, a.max_attempts,
|
||||||
(SELECT COUNT(*) FROM assignment_sessions ax
|
(SELECT COUNT(*) FROM assignment_sessions ax
|
||||||
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
|
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
|
FROM assignments a
|
||||||
JOIN users u ON u.id = a.created_by
|
JOIN users u ON u.id = a.created_by
|
||||||
LEFT JOIN files f ON f.id = a.file_id
|
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 = ?
|
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 = ?)
|
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
|
LEFT JOIN test_sessions ts ON ts.id = latest.session_id
|
||||||
WHERE a.user_id = ?
|
WHERE a.user_id = ?
|
||||||
) ORDER BY done ASC, deadline ASC, created_at DESC
|
) 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);
|
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 ─────────── */
|
/* ── POST /api/assignments/:id/start ── student starts session ─────────── */
|
||||||
function startAssignment(req, res) {
|
function startAssignment(req, res) {
|
||||||
const uid = req.user.id;
|
const uid = req.user.id;
|
||||||
@@ -452,7 +506,8 @@ function assignmentQuestionStats(req, res) {
|
|||||||
|
|
||||||
/* ── POST /api/assignments ── direct assignment to a single student ──────── */
|
/* ── POST /api/assignments ── direct assignment to a single student ──────── */
|
||||||
function createDirectAssignment(req, res) {
|
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 mode = req.body.mode || 'exam';
|
||||||
const count = Number(req.body.count) || 25;
|
const count = Number(req.body.count) || 25;
|
||||||
let { title, subject_slug, test_id } = req.body;
|
let { title, subject_slug, test_id } = req.body;
|
||||||
@@ -465,11 +520,11 @@ function createDirectAssignment(req, res) {
|
|||||||
|
|
||||||
let student;
|
let student;
|
||||||
if (student_id) {
|
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: 'Ученик не найден' });
|
if (!student) return res.status(404).json({ error: 'Ученик не найден' });
|
||||||
} else {
|
} else {
|
||||||
if (!student_email?.trim()) return res.status(400).json({ error: 'student_email required' });
|
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());
|
.get(student_email.trim().toLowerCase());
|
||||||
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
|
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' });
|
if (!t) return res.status(400).json({ error: 'Test not found' });
|
||||||
subject_slug = t.subject_slug;
|
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) {
|
if (file_id && !subject_slug) {
|
||||||
const f = db.prepare('SELECT subject_slug FROM files WHERE id = ?').get(file_id);
|
const f = db.prepare('SELECT subject_slug FROM files WHERE id = ?').get(file_id);
|
||||||
if (f?.subject_slug) subject_slug = f.subject_slug;
|
if (f?.subject_slug) subject_slug = f.subject_slug;
|
||||||
@@ -498,9 +562,9 @@ function createDirectAssignment(req, res) {
|
|||||||
if (!subject_slug) subject_slug = 'other';
|
if (!subject_slug) subject_slug = 'other';
|
||||||
|
|
||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework)
|
INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework, textbook_id, textbook_paragraphs)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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);
|
`).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');
|
pushNotif(student.id, 'assignment', `Для вас задание: «${title.trim()}»`, '/dashboard');
|
||||||
|
|||||||
@@ -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);
|
||||||
+175
-26
@@ -2,62 +2,146 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
|
const { emit } = require('../sse');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
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 */
|
/* GET /api/textbooks — list with current user's progress */
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT t.id, t.slug, t.subject, t.grade, t.title, t.author, t.description,
|
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.html_path, t.para_count, t.color, t.sort_order
|
||||||
FROM textbooks t
|
FROM textbooks t WHERE t.is_active = 1
|
||||||
WHERE t.is_active = 1
|
|
||||||
ORDER BY t.sort_order, t.subject, t.grade
|
ORDER BY t.sort_order, t.subject, t.grade
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
const myProgress = db.prepare(`
|
const myProgress = db.prepare(`
|
||||||
SELECT textbook_id, paragraphs_read, last_para, last_at FROM textbook_progress WHERE user_id=?
|
SELECT textbook_id, paragraphs_read, last_para, last_at FROM textbook_progress WHERE user_id=?
|
||||||
`).all(req.user.id);
|
`).all(req.user.id);
|
||||||
const progressMap = {};
|
const map = {};
|
||||||
for (const p of myProgress) {
|
for (const p of myProgress) {
|
||||||
let arr = [];
|
let arr = [];
|
||||||
try { arr = JSON.parse(p.paragraphs_read || '[]'); } catch {}
|
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: map[t.id] || { read: [], last_para: null, last_at: null } })) });
|
||||||
res.json({
|
|
||||||
textbooks: rows.map(t => ({
|
|
||||||
...t,
|
|
||||||
progress: progressMap[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 */
|
/* GET /api/textbooks/:slug — single textbook detail */
|
||||||
router.get('/:slug', (req, res) => {
|
router.get('/:slug', (req, res) => {
|
||||||
const t = db.prepare('SELECT * FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug);
|
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: 'Учебник не найден' });
|
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);
|
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 = [];
|
let read = [];
|
||||||
if (p) { try { read = JSON.parse(p.paragraphs_read || '[]'); } catch {} }
|
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 } });
|
res.json({ ...t, progress: { read, last_para: p?.last_para || null, last_at: p?.last_at || null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
/* POST /api/textbooks/:slug/progress — update progress
|
/* POST /api/textbooks/:slug/progress — update progress */
|
||||||
body: { last_para?: 'p15', mark_read?: 'p15', mark_unread?: 'p15' } */
|
|
||||||
router.post('/:slug/progress', (req, res) => {
|
router.post('/:slug/progress', (req, res) => {
|
||||||
const t = db.prepare('SELECT id FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug);
|
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: 'Учебник не найден' });
|
if (!t) return res.status(404).json({ error: 'Учебник не найден' });
|
||||||
|
|
||||||
const { last_para, mark_read, mark_unread } = req.body || {};
|
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);
|
const existing = db.prepare('SELECT paragraphs_read FROM textbook_progress WHERE user_id=? AND textbook_id=?').get(req.user.id, t.id);
|
||||||
let arr = [];
|
let arr = [];
|
||||||
if (existing) { try { arr = JSON.parse(existing.paragraphs_read || '[]'); } catch {} }
|
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_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);
|
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
|
last_at = excluded.last_at
|
||||||
`).run(req.user.id, t.id, JSON.stringify(arr), last_para || null);
|
`).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 });
|
res.json({ ok: true, read: arr });
|
||||||
});
|
});
|
||||||
|
|
||||||
/* GET /api/textbooks/:slug/class-progress — teacher view: progress of all students in class
|
/* GET /api/textbooks/:slug/bookmarks — list my bookmarks for this textbook */
|
||||||
query: ?class_id=N */
|
router.get('/:slug/bookmarks', (req, res) => {
|
||||||
router.get('/:slug/class-progress', requireRole('teacher', 'admin'), (req, res) => {
|
|
||||||
const t = db.prepare('SELECT id FROM textbooks WHERE slug=?').get(req.params.slug);
|
const t = db.prepare('SELECT id FROM textbooks WHERE slug=?').get(req.params.slug);
|
||||||
if (!t) return res.status(404).json({ error: 'Учебник не найден' });
|
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);
|
const classId = Number(req.query.class_id);
|
||||||
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
|
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
|
||||||
|
|
||||||
if (req.user.role === 'teacher') {
|
if (req.user.role === 'teacher') {
|
||||||
const own = db.prepare('SELECT 1 FROM classes WHERE id=? AND teacher_id=?').get(classId, req.user.id);
|
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: 'Нет доступа к классу' });
|
if (!own) return res.status(403).json({ error: 'Нет доступа к классу' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT u.id AS user_id, u.name,
|
SELECT u.id AS user_id, u.name,
|
||||||
COALESCE(tp.paragraphs_read, '[]') AS paragraphs_read,
|
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 = ?
|
WHERE cm.class_id = ?
|
||||||
ORDER BY u.name
|
ORDER BY u.name
|
||||||
`).all(t.id, classId);
|
`).all(t.id, classId);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
total_paragraphs: t.para_count,
|
||||||
students: rows.map(r => {
|
students: rows.map(r => {
|
||||||
let read = [];
|
let read = [];
|
||||||
try { read = JSON.parse(r.paragraphs_read); } catch {}
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Управление учебниками — LearnSpace</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/css/ls.css" />
|
||||||
|
<style>
|
||||||
|
.sb-content { padding: 0; overflow-y: auto; }
|
||||||
|
.at-wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 80px; width: 100%; }
|
||||||
|
.at-header { display:flex; align-items:center; gap:14px; margin-bottom:24px; }
|
||||||
|
.at-back {
|
||||||
|
width:38px; height:38px; border-radius:10px;
|
||||||
|
border:1.5px solid var(--border-h); background:transparent; color:var(--text-2);
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
cursor:pointer; transition:all .15s; text-decoration:none;
|
||||||
|
}
|
||||||
|
.at-back:hover { border-color:var(--violet); color:var(--violet); }
|
||||||
|
.at-back svg { width:18px; height:18px; }
|
||||||
|
.at-title { font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:800; }
|
||||||
|
.at-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
|
||||||
|
|
||||||
|
.at-table {
|
||||||
|
background:var(--surface); border:1.5px solid var(--border); border-radius:14px;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.at-row {
|
||||||
|
display:grid; grid-template-columns: 2.5fr 1.2fr 1fr 1fr 0.8fr 0.8fr;
|
||||||
|
padding:14px 18px; align-items:center; gap:14px;
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.at-row:last-child { border-bottom:none; }
|
||||||
|
.at-row.head {
|
||||||
|
background:rgba(155,93,229,.06);
|
||||||
|
font-family:'Unbounded',sans-serif;
|
||||||
|
font-size:.72rem; font-weight:800; color:var(--text-2);
|
||||||
|
text-transform:uppercase; letter-spacing:.05em;
|
||||||
|
}
|
||||||
|
.at-row.inactive { opacity:.55; }
|
||||||
|
.at-title-cell { font-weight:700; font-size:.92rem; }
|
||||||
|
.at-author { font-size:.78rem; color:var(--text-2); margin-top:2px; }
|
||||||
|
.at-input {
|
||||||
|
width:100%; padding:6px 10px; border:1.5px solid var(--border);
|
||||||
|
border-radius:7px; background:transparent; color:var(--text);
|
||||||
|
font-family:'Manrope',sans-serif; font-size:.85rem;
|
||||||
|
}
|
||||||
|
.at-input:focus { outline:none; border-color:var(--violet); }
|
||||||
|
.at-pill {
|
||||||
|
display:inline-block; padding:3px 10px; border-radius:99px;
|
||||||
|
font-size:.72rem; font-weight:700;
|
||||||
|
}
|
||||||
|
.at-pill.subject { background:rgba(155,93,229,.12); color:var(--violet); }
|
||||||
|
.at-toggle {
|
||||||
|
position:relative; width:38px; height:22px; border-radius:99px;
|
||||||
|
background:var(--border); cursor:pointer; transition:background .15s;
|
||||||
|
display:inline-block;
|
||||||
|
}
|
||||||
|
.at-toggle.on { background:#06D6A0; }
|
||||||
|
.at-toggle::after {
|
||||||
|
content:''; position:absolute; top:2px; left:2px;
|
||||||
|
width:18px; height:18px; border-radius:50%;
|
||||||
|
background:#fff; transition:transform .15s;
|
||||||
|
box-shadow:0 1px 3px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
.at-toggle.on::after { transform:translateX(16px); }
|
||||||
|
.at-link {
|
||||||
|
color:var(--violet); text-decoration:none; font-weight:700; font-size:.85rem;
|
||||||
|
}
|
||||||
|
.at-link:hover { text-decoration:underline; }
|
||||||
|
.at-empty { padding:60px 20px; text-align:center; color:var(--text-3); }
|
||||||
|
.at-saved { color:#06D6A0; font-size:.75rem; margin-left:6px; opacity:0; transition:opacity .3s; }
|
||||||
|
.at-saved.show { opacity:1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="app-sidebar"></aside>
|
||||||
|
<div class="sb-content">
|
||||||
|
<div class="at-wrap">
|
||||||
|
<header class="at-header">
|
||||||
|
<a href="/admin" class="at-back" title="К админ-панели"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg></a>
|
||||||
|
<div>
|
||||||
|
<div class="at-title">Управление учебниками</div>
|
||||||
|
<div class="at-sub">Редактирование каталога · включение/отключение отдельных учебников</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="at-content" class="at-empty">Загрузка…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/sidebar.js"></script>
|
||||||
|
<script src="/js/notifications.js"></script>
|
||||||
|
<script src="/js/search.js"></script>
|
||||||
|
<script src="/js/mobile.js"></script>
|
||||||
|
<script>
|
||||||
|
(async function () {
|
||||||
|
const user = LS.initPage();
|
||||||
|
if (!user || user.role !== 'admin') { location.href = '/dashboard'; return; }
|
||||||
|
LS.showBoardIfAllowed();
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
|
const SUBJECTS = { chemistry:'Химия', physics:'Физика', math:'Математика', biology:'Биология' };
|
||||||
|
|
||||||
|
let textbooks = [];
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const r = await LS.api('/api/textbooks/admin/all');
|
||||||
|
textbooks = r.textbooks || [];
|
||||||
|
render();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('at-content').innerHTML = 'Ошибка: ' + esc(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!textbooks.length) {
|
||||||
|
document.getElementById('at-content').innerHTML = '<div class="at-empty">Учебники не добавлены</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = `
|
||||||
|
<div class="at-table">
|
||||||
|
<div class="at-row head">
|
||||||
|
<div>Учебник</div>
|
||||||
|
<div>Автор</div>
|
||||||
|
<div>Предмет</div>
|
||||||
|
<div>Класс</div>
|
||||||
|
<div>Читателей</div>
|
||||||
|
<div>Активен</div>
|
||||||
|
</div>
|
||||||
|
${textbooks.map(t => `
|
||||||
|
<div class="at-row ${t.is_active ? '' : 'inactive'}" data-id="${t.id}">
|
||||||
|
<div>
|
||||||
|
<input class="at-input" data-field="title" value="${esc(t.title)}" />
|
||||||
|
<div class="at-author">
|
||||||
|
<a class="at-link" href="/textbook/${t.slug}" target="_blank">/${t.slug}</a> ·
|
||||||
|
<span class="at-saved" id="saved-${t.id}">Сохранено</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><input class="at-input" data-field="author" value="${esc(t.author)}" /></div>
|
||||||
|
<div><span class="at-pill subject">${esc(SUBJECTS[t.subject] || t.subject)}</span></div>
|
||||||
|
<div>${t.grade}</div>
|
||||||
|
<div>${t.readers || 0}</div>
|
||||||
|
<div>
|
||||||
|
<span class="at-toggle ${t.is_active ? 'on' : ''}" data-field="is_active" data-val="${t.is_active}"></span>
|
||||||
|
</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
document.getElementById('at-content').className = '';
|
||||||
|
document.getElementById('at-content').innerHTML = html;
|
||||||
|
wireEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireEvents() {
|
||||||
|
document.querySelectorAll('.at-toggle').forEach(t => {
|
||||||
|
t.addEventListener('click', async () => {
|
||||||
|
const row = t.closest('.at-row');
|
||||||
|
const id = Number(row.dataset.id);
|
||||||
|
const newVal = t.classList.contains('on') ? 0 : 1;
|
||||||
|
try {
|
||||||
|
await LS.api('/api/textbooks/admin/' + id, { method: 'PATCH', body: { is_active: newVal } });
|
||||||
|
t.classList.toggle('on', newVal === 1);
|
||||||
|
t.dataset.val = newVal;
|
||||||
|
row.classList.toggle('inactive', !newVal);
|
||||||
|
flashSaved(id);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.at-input').forEach(inp => {
|
||||||
|
let timer;
|
||||||
|
inp.addEventListener('input', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const row = inp.closest('.at-row');
|
||||||
|
const id = Number(row.dataset.id);
|
||||||
|
const field = inp.dataset.field;
|
||||||
|
const val = inp.value;
|
||||||
|
LS.api('/api/textbooks/admin/' + id, { method: 'PATCH', body: { [field]: val } })
|
||||||
|
.then(() => flashSaved(id))
|
||||||
|
.catch(e => alert(e.message));
|
||||||
|
}, 600);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashSaved(id) {
|
||||||
|
const el = document.getElementById('saved-' + id);
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('show');
|
||||||
|
setTimeout(() => el.classList.remove('show'), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await load();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2108,6 +2108,60 @@
|
|||||||
</div></div>`;
|
</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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 ? `<span class="ar-tag-hw">ДЗ</span>` : null,
|
||||||
|
dl ? `до ${dl}` : null,
|
||||||
|
isUrgent ? `<span class="ar-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> ${hoursLeft} ч</span>` : null,
|
||||||
|
over ? `<span class="ar-tag-over">просрочено</span>` : 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
|
||||||
|
? `<span class="ar-score hi" style="background:#06D6A018;color:#059669">${lci('check')} Прочитано</span>`
|
||||||
|
: `<a class="ar-btn" href="${openHref}" onclick="event.stopPropagation()" style="background:${tbColor};color:#fff;text-decoration:none">${readCount > 0 ? 'Продолжить' : 'Открыть'}</a>`;
|
||||||
|
|
||||||
|
return `<div class="asgn-wrap"><div class="asgn-row stagger-item ${cardCls}${isFirst && !allRead ? ' spotlight' : ''}" style="--i:${idx};--ac:${tbColor}" id="asgn-row-${a.id}" onclick="toggleAsgn(${a.id},event)">
|
||||||
|
<div class="ar-icon" style="background:${tbColor}18;color:${tbColor}">${lci('book-open-text')}</div>
|
||||||
|
<div class="ar-body"><div class="ar-title">${esc(a.title)}</div><div class="ar-meta">${metaParts.join(' · ')}</div></div>
|
||||||
|
<div class="ar-progress">
|
||||||
|
<div class="ar-prog-bar"><div class="ar-prog-fill" style="width:${tbPct}%;background:${tbColor}"></div></div>
|
||||||
|
<span class="ar-prog-text">${readCount} / ${reqCount} §</span>
|
||||||
|
</div>
|
||||||
|
<div class="ar-right">${actionBtn}</div>
|
||||||
|
</div>
|
||||||
|
<div class="asgn-expand" id="asgn-exp-${a.id}">
|
||||||
|
<div class="ae-row">
|
||||||
|
<div class="ae-pills">
|
||||||
|
<span class="ae-pill">${lci('book-open')} ${esc(a.textbook_title || 'Учебник')}</span>
|
||||||
|
<span class="ae-pill">${lci('layers')} ${parasText}</span>
|
||||||
|
${a.is_homework ? `<span class="ae-pill">${lci('book-open')} Домашнее задание</span>` : ''}
|
||||||
|
${allRead ? `<span class="ae-pill" style="color:#059669">${lci('check')} Завершено</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<a class="ae-btn" href="${openHref}" onclick="event.stopPropagation()" style="background:${tbColor}">Открыть учебник</a>
|
||||||
|
</div>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Test assignment ── */
|
/* ── Test assignment ── */
|
||||||
const isDone = a.session_status === 'completed';
|
const isDone = a.session_status === 'completed';
|
||||||
const inProgress = a.session_status === 'in_progress';
|
const inProgress = a.session_status === 'in_progress';
|
||||||
@@ -2248,6 +2302,11 @@
|
|||||||
function classify(a) {
|
function classify(a) {
|
||||||
const maxAtt = a.max_attempts || 0;
|
const maxAtt = a.max_attempts || 0;
|
||||||
const usedAtt = a.attempts_used ?? 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 (maxAtt > 0 && usedAtt >= maxAtt) return 'done';
|
||||||
if (a.session_status === 'completed' && a.mode !== 'repeat') 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';
|
if (!a.file_id && a.deadline && new Date(a.deadline) < now && a.session_status !== 'in_progress') return 'overdue';
|
||||||
|
|||||||
@@ -217,19 +217,225 @@
|
|||||||
document.head.appendChild(s);
|
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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>`;
|
||||||
|
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 = `<div style="padding:14px;text-align:center;color:#78716c">
|
||||||
|
Закладок нет<br><small>Выдели любой текст в учебнике и нажми «+ Закладка»</small>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div style="font-weight:800;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#78716c;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid rgba(0,0,0,.08)">
|
||||||
|
Мои закладки (${bookmarks.length})
|
||||||
|
</div>
|
||||||
|
${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 ? `<a href="#${b.para}" style="text-decoration:none;color:${bd};font-weight:700;margin-right:6px">§${b.para.replace('p','')}</a>` : '';
|
||||||
|
return `<div style="background:${bg};border-left:3px solid ${bd};padding:8px 10px;border-radius:6px;margin-bottom:7px;position:relative">
|
||||||
|
${paraLink}<span style="font-size:12.5px">«${escHtml(b.text)}»</span>
|
||||||
|
${b.note ? `<div style="margin-top:5px;color:#44403c;font-style:italic;font-size:12px">${escHtml(b.note)}</div>` : ''}
|
||||||
|
<button onclick="window.__tbDeleteBookmark(${b.id})" style="position:absolute;top:5px;right:5px;border:none;background:none;cursor:pointer;color:#78716c;font-size:13px;line-height:1">×</button>
|
||||||
|
</div>`;
|
||||||
|
}).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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg><span>Закладка</span>`;
|
||||||
|
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 ──────────────────────────────────────────────────── */
|
/* ── 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() {
|
function boot() {
|
||||||
injectStyles();
|
injectStyles();
|
||||||
installBackButton();
|
installBackButton();
|
||||||
|
installBookmarksBtn();
|
||||||
|
installBookmarksPanel();
|
||||||
|
installSelectionHandler();
|
||||||
installReadCheckboxes();
|
installReadCheckboxes();
|
||||||
wirePillTracking();
|
wirePillTracking();
|
||||||
refreshAllUI();
|
refreshAllUI();
|
||||||
loadServerProgress();
|
loadServerProgress();
|
||||||
// Auto-open last paragraph if pill exists
|
loadBookmarks();
|
||||||
if (localState.last) {
|
|
||||||
const pill = document.querySelector(`.para-pill[data-para="${localState.last}"]`);
|
// Priority: URL hash > last visited paragraph
|
||||||
if (pill) setTimeout(() => pill.click(), 50);
|
if (!handleHashNav() && localState.last) {
|
||||||
|
setTimeout(() => openParaByKey(localState.last), 50);
|
||||||
}
|
}
|
||||||
|
window.addEventListener('hashchange', handleHashNav);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<!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; }
|
||||||
|
.tp-wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 80px; width: 100%; }
|
||||||
|
|
||||||
|
.tp-header { display:flex; align-items:center; gap:14px; margin-bottom:26px; }
|
||||||
|
.tp-back {
|
||||||
|
width:38px; height:38px; border-radius:10px;
|
||||||
|
border:1.5px solid var(--border-h); background:transparent; color:var(--text-2);
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
cursor:pointer; transition:all .15s; text-decoration:none;
|
||||||
|
}
|
||||||
|
.tp-back:hover { border-color:var(--violet); color:var(--violet); }
|
||||||
|
.tp-back svg { width:18px; height:18px; }
|
||||||
|
.tp-title { font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:800; }
|
||||||
|
.tp-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
|
||||||
|
|
||||||
|
.tp-pickers {
|
||||||
|
display:flex; gap:12px; margin-bottom:24px; flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
.tp-picker {
|
||||||
|
flex:1; min-width:200px;
|
||||||
|
}
|
||||||
|
.tp-picker label {
|
||||||
|
display:block; font-size:.72rem; font-weight:700; color:var(--text-2);
|
||||||
|
text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
|
||||||
|
}
|
||||||
|
.tp-picker select {
|
||||||
|
width:100%; padding:10px 12px; border:1.5px solid var(--border-h);
|
||||||
|
border-radius:10px; background:var(--surface); color:var(--text);
|
||||||
|
font-family:'Manrope',sans-serif; font-size:.9rem; font-weight:600;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.tp-picker select:focus { outline:none; border-color:var(--violet); }
|
||||||
|
|
||||||
|
.tp-table {
|
||||||
|
background:var(--surface); border:1.5px solid var(--border); border-radius:14px;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
.tp-row {
|
||||||
|
display:grid; grid-template-columns: 1.5fr 2fr 1fr 1fr;
|
||||||
|
padding:13px 18px; align-items:center; gap:14px;
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
transition: background .12s;
|
||||||
|
}
|
||||||
|
.tp-row:last-child { border-bottom:none; }
|
||||||
|
.tp-row:hover { background:rgba(155,93,229,.04); }
|
||||||
|
.tp-row.head {
|
||||||
|
background:rgba(155,93,229,.06); font-family:'Unbounded',sans-serif;
|
||||||
|
font-size:.72rem; font-weight:800; color:var(--text-2);
|
||||||
|
text-transform:uppercase; letter-spacing:.05em;
|
||||||
|
}
|
||||||
|
.tp-row.head:hover { background:rgba(155,93,229,.06); }
|
||||||
|
.tp-name { font-weight:700; font-size:.92rem; }
|
||||||
|
.tp-bar {
|
||||||
|
height:8px; border-radius:99px; background:var(--border); overflow:hidden;
|
||||||
|
position:relative;
|
||||||
|
}
|
||||||
|
.tp-bar-fill { height:100%; border-radius:99px; transition:width .3s; background:var(--violet); }
|
||||||
|
.tp-bar-text { font-size:.76rem; color:var(--text-3); margin-top:4px; }
|
||||||
|
.tp-last { font-size:.82rem; color:var(--text-2); }
|
||||||
|
.tp-last small { color:var(--text-3); }
|
||||||
|
.tp-stats {
|
||||||
|
display:flex; align-items:center; gap:6px; font-size:.82rem;
|
||||||
|
}
|
||||||
|
.tp-stats svg { width:13px; height:13px; opacity:.6; }
|
||||||
|
.tp-empty { padding:60px 20px; text-align:center; color:var(--text-3); }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.tp-row { grid-template-columns: 1.5fr 1fr; gap:8px; }
|
||||||
|
.tp-row > :nth-child(3), .tp-row > :nth-child(4) { display:none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<aside class="sidebar" id="app-sidebar"></aside>
|
||||||
|
<div class="sb-content">
|
||||||
|
<div class="tp-wrap">
|
||||||
|
<header class="tp-header">
|
||||||
|
<a href="/textbooks" class="tp-back" title="К каталогу"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg></a>
|
||||||
|
<div>
|
||||||
|
<div class="tp-title">Прогресс класса по учебнику</div>
|
||||||
|
<div class="tp-sub">Кто сколько параграфов прочитал</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tp-pickers">
|
||||||
|
<div class="tp-picker">
|
||||||
|
<label>Учебник</label>
|
||||||
|
<select id="tp-textbook"></select>
|
||||||
|
</div>
|
||||||
|
<div class="tp-picker">
|
||||||
|
<label>Класс</label>
|
||||||
|
<select id="tp-class"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tp-result" class="tp-empty">Выберите учебник и класс</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/sidebar.js"></script>
|
||||||
|
<script src="/js/notifications.js"></script>
|
||||||
|
<script src="/js/search.js"></script>
|
||||||
|
<script src="/js/mobile.js"></script>
|
||||||
|
<script>
|
||||||
|
(async function () {
|
||||||
|
const user = LS.initPage();
|
||||||
|
if (!user || (user.role !== 'teacher' && user.role !== 'admin')) {
|
||||||
|
location.href = '/dashboard'; return;
|
||||||
|
}
|
||||||
|
LS.showBoardIfAllowed();
|
||||||
|
LS.hideDisabledFeatures();
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
|
}
|
||||||
|
function fmtDate(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
try {
|
||||||
|
return new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z').toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' });
|
||||||
|
} catch { return s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load lookups */
|
||||||
|
const [tbRes, classes] = await Promise.all([
|
||||||
|
LS.api('/api/textbooks').catch(() => ({ textbooks: [] })),
|
||||||
|
LS.api('/api/classes').catch(() => []),
|
||||||
|
]);
|
||||||
|
const textbooks = tbRes.textbooks || [];
|
||||||
|
|
||||||
|
const tbSel = document.getElementById('tp-textbook');
|
||||||
|
textbooks.forEach((t, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.slug;
|
||||||
|
opt.textContent = `${t.title} (§1–${t.para_count})`;
|
||||||
|
if (i === 0) opt.selected = true;
|
||||||
|
tbSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const clsSel = document.getElementById('tp-class');
|
||||||
|
const list = Array.isArray(classes) ? classes : [];
|
||||||
|
if (!list.length) {
|
||||||
|
document.getElementById('tp-result').innerHTML = '<div class="tp-empty">У вас нет классов</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach((c, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = c.id;
|
||||||
|
opt.textContent = `${c.name} (${c.member_count || 0} учеников)`;
|
||||||
|
if (i === 0) opt.selected = true;
|
||||||
|
clsSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadProgress() {
|
||||||
|
const tbSlug = tbSel.value;
|
||||||
|
const classId = clsSel.value;
|
||||||
|
if (!tbSlug || !classId) return;
|
||||||
|
|
||||||
|
const resEl = document.getElementById('tp-result');
|
||||||
|
resEl.innerHTML = '<div class="tp-empty">Загрузка…</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await LS.api(`/api/textbooks/${tbSlug}/class-progress?class_id=${classId}`);
|
||||||
|
const total = r.total_paragraphs || 0;
|
||||||
|
const tb = textbooks.find(t => t.slug === tbSlug);
|
||||||
|
const colorMap = { amber:'#d97706', blue:'#2563eb', green:'#059669', violet:'#7c3aed', pink:'#db2777' };
|
||||||
|
const color = colorMap[tb?.color] || '#7c3aed';
|
||||||
|
|
||||||
|
if (!r.students.length) {
|
||||||
|
resEl.innerHTML = '<div class="tp-empty">В классе нет учеников</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Sort: most progress first, then alphabetical
|
||||||
|
r.students.sort((a, b) => (b.read_count - a.read_count) || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const rows = r.students.map(s => {
|
||||||
|
const pct = total > 0 ? Math.round(100 * s.read_count / total) : 0;
|
||||||
|
return `
|
||||||
|
<div class="tp-row">
|
||||||
|
<div class="tp-name">${esc(s.name)}</div>
|
||||||
|
<div>
|
||||||
|
<div class="tp-bar"><div class="tp-bar-fill" style="width:${pct}%;background:${color}"></div></div>
|
||||||
|
<div class="tp-bar-text">${s.read_count} из ${total} §</div>
|
||||||
|
</div>
|
||||||
|
<div class="tp-stats"><b style="color:var(--text);font-family:'Unbounded',sans-serif">${pct}%</b></div>
|
||||||
|
<div class="tp-last">
|
||||||
|
${s.last_para ? `<b>§${s.last_para.replace('p','')}</b><br><small>${fmtDate(s.last_at)}</small>` : '<small>—</small>'}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
resEl.className = 'tp-table';
|
||||||
|
resEl.innerHTML = `
|
||||||
|
<div class="tp-row head">
|
||||||
|
<div>Ученик</div>
|
||||||
|
<div>Прогресс</div>
|
||||||
|
<div>%</div>
|
||||||
|
<div>Последний §</div>
|
||||||
|
</div>
|
||||||
|
${rows}`;
|
||||||
|
} catch (e) {
|
||||||
|
resEl.className = 'tp-empty';
|
||||||
|
resEl.innerHTML = 'Ошибка: ' + esc(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbSel.addEventListener('change', loadProgress);
|
||||||
|
clsSel.addEventListener('change', loadProgress);
|
||||||
|
loadProgress();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+150
-28
@@ -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-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-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; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -227,10 +252,11 @@
|
|||||||
<line x1="9" y1="11" x2="15" y2="11"/>
|
<line x1="9" y1="11" x2="15" y2="11"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style="flex:1">
|
||||||
<div class="tb-title">Учебники</div>
|
<div class="tb-title">Учебники</div>
|
||||||
<div class="tb-sub">Полные курсы по предметам с разделами и интерактивными примерами</div>
|
<div class="tb-sub">Полные курсы по предметам с разделами и интерактивными примерами</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tb-header-actions"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="tb-grid" id="tb-grid">
|
<div class="tb-grid" id="tb-grid">
|
||||||
@@ -254,13 +280,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
|
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
|
||||||
<div class="ax-field">
|
<div class="ax-field">
|
||||||
|
<label>Кому</label>
|
||||||
|
<div class="ax-tabs">
|
||||||
|
<button type="button" class="ax-tab active" data-tab="class" onclick="setAssignTab('class')">Классу</button>
|
||||||
|
<button type="button" class="ax-tab" data-tab="student" onclick="setAssignTab('student')">Ученику</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ax-field" id="ax-class-field">
|
||||||
<label>Классы</label>
|
<label>Классы</label>
|
||||||
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
|
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ax-field" id="ax-student-field" style="display:none">
|
||||||
|
<label>Ученик</label>
|
||||||
|
<input type="text" class="ax-input" id="ax-student-search" placeholder="Поиск по имени или email…" autocomplete="off" />
|
||||||
|
<div class="ax-student-results" id="ax-student-results"></div>
|
||||||
|
<input type="hidden" id="ax-student-id" />
|
||||||
|
</div>
|
||||||
<div class="ax-field">
|
<div class="ax-field">
|
||||||
<label>Параграфы</label>
|
<label>Параграфы</label>
|
||||||
<input type="text" class="ax-input" id="ax-paragraphs" placeholder="например: 1-5 или 1,3,7" />
|
<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 class="ax-hint">Диапазон («15-18») или список через запятую («1,3,5»). Пустое = весь учебник.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ax-field">
|
<div class="ax-field">
|
||||||
<label>Срок сдачи</label>
|
<label>Срок сдачи</label>
|
||||||
@@ -292,6 +331,15 @@
|
|||||||
let textbooks = [];
|
let textbooks = [];
|
||||||
let teacherClasses = null;
|
let teacherClasses = null;
|
||||||
|
|
||||||
|
// Teacher-only: "Class progress" button in header
|
||||||
|
if (isTeacher) {
|
||||||
|
document.getElementById('tb-header-actions').innerHTML = `
|
||||||
|
<a href="/textbook-progress" class="tb-btn" style="display:inline-flex;width:auto;text-decoration:none">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||||
|
Прогресс класса
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
}
|
}
|
||||||
@@ -359,8 +407,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Assign modal ── */
|
/* ── Assign modal ── */
|
||||||
let assignSlug = null;
|
let assignSlug = null;
|
||||||
let assignTitle = null;
|
let assignTitle = null;
|
||||||
|
let assignTab = 'class'; // 'class' or 'student'
|
||||||
|
let teacherStudents = null; // cached list of students-in-teacher's-classes
|
||||||
|
|
||||||
async function loadTeacherClasses() {
|
async function loadTeacherClasses() {
|
||||||
if (teacherClasses) return teacherClasses;
|
if (teacherClasses) return teacherClasses;
|
||||||
@@ -371,6 +421,25 @@
|
|||||||
return teacherClasses;
|
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) {
|
window.openAssignModal = async function (slug, title) {
|
||||||
assignSlug = slug;
|
assignSlug = slug;
|
||||||
assignTitle = title;
|
assignTitle = title;
|
||||||
@@ -380,6 +449,7 @@
|
|||||||
document.getElementById('ax-deadline').value = '';
|
document.getElementById('ax-deadline').value = '';
|
||||||
document.getElementById('ax-submit').disabled = false;
|
document.getElementById('ax-submit').disabled = false;
|
||||||
document.getElementById('ax-submit').textContent = 'Назначить';
|
document.getElementById('ax-submit').textContent = 'Назначить';
|
||||||
|
setAssignTab('class');
|
||||||
|
|
||||||
const listEl = document.getElementById('ax-classes-list');
|
const listEl = document.getElementById('ax-classes-list');
|
||||||
listEl.textContent = 'Загрузка…';
|
listEl.textContent = 'Загрузка…';
|
||||||
@@ -409,6 +479,45 @@
|
|||||||
};
|
};
|
||||||
function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); }
|
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 = '<div class="ax-student-row" style="color:var(--text-3);cursor:default">Не найдено</div>';
|
||||||
|
} else {
|
||||||
|
resultsEl.innerHTML = matches.map(s => `
|
||||||
|
<div class="ax-student-row" data-id="${s.id}" data-name="${esc(s.name)}">
|
||||||
|
<span>${esc(s.name)}</span>
|
||||||
|
<span class="ax-student-email">${esc(s.email || '')}</span>
|
||||||
|
</div>`).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 () {
|
window.submitAssign = async function () {
|
||||||
const errorEl = document.getElementById('ax-error');
|
const errorEl = document.getElementById('ax-error');
|
||||||
const successEl = document.getElementById('ax-success');
|
const successEl = document.getElementById('ax-success');
|
||||||
@@ -416,37 +525,50 @@
|
|||||||
errorEl.classList.remove('visible');
|
errorEl.classList.remove('visible');
|
||||||
successEl.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 paragraphs = document.getElementById('ax-paragraphs').value.trim();
|
||||||
const deadline = document.getElementById('ax-deadline').value || null;
|
const deadline = document.getElementById('ax-deadline').value || null;
|
||||||
|
const titleSuffix = paragraphs ? ` (§${paragraphs})` : '';
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = 'Назначаю…';
|
submitBtn.textContent = 'Назначаю…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const titleSuffix = paragraphs ? ` (§${paragraphs})` : '';
|
let resultMsg;
|
||||||
const r = await LS.api('/api/assignments/bulk', {
|
if (assignTab === 'class') {
|
||||||
method: 'POST',
|
const checked = [...document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')]
|
||||||
body: {
|
.map(el => Number(el.value));
|
||||||
title: `Учебник: ${assignTitle}${titleSuffix}`,
|
if (!checked.length) throw new Error('Выберите хотя бы один класс');
|
||||||
class_ids: checked,
|
|
||||||
mode: 'exam', // mode is required, but for textbook assignment is informational
|
const r = await LS.api('/api/assignments/bulk', {
|
||||||
count: 1,
|
method: 'POST',
|
||||||
subject_slug: 'other',
|
body: {
|
||||||
is_homework: 1,
|
title: `Учебник: ${assignTitle}${titleSuffix}`,
|
||||||
deadline: deadline,
|
class_ids: checked,
|
||||||
textbook_slug: assignSlug,
|
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
|
||||||
textbook_paragraphs: paragraphs || null,
|
deadline,
|
||||||
},
|
textbook_slug: assignSlug,
|
||||||
});
|
textbook_paragraphs: paragraphs || null,
|
||||||
successEl.textContent = `Назначено в ${r.count || checked.length} класс(е/ах)`;
|
},
|
||||||
|
});
|
||||||
|
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');
|
successEl.classList.add('visible');
|
||||||
submitBtn.textContent = 'Готово';
|
submitBtn.textContent = 'Готово';
|
||||||
setTimeout(closeAssignModal, 1500);
|
setTimeout(closeAssignModal, 1500);
|
||||||
|
|||||||
Reference in New Issue
Block a user