From 44ab5e045eb4078d25fb21c87b1f468011cab1df Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 11:33:01 +0300 Subject: [PATCH] =?UTF-8?q?feat(lessons):=20=C2=AB=D0=9C=D0=BE=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D1=8B=C2=BB=20?= =?UTF-8?q?=E2=80=94=20=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=BA=20=D1=81=D0=BE?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D1=8F=D0=B5=D1=82=20=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D1=8B=20=D1=83=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=20=D1=81=D0=B5=D0=B1=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ученик на странице «Мои уроки» может сохранить к себе страницу доски (PNG) и свою заметку из прошлой онлайн-сессии. Копия хранится у ученика и переживает удаление сессии учителем. - Миграция 060: student_materials (kind board/note/link/image, denormalized source_title, source_session_id ON DELETE SET NULL). - API /api/materials (GET/POST/DELETE, авторизация + проверка владельца) + helpers в js/api.js. - my-lessons.html: кнопки «К себе» на доске и заметке (Whiteboard.exportBlob → /api/files → saveMaterial). - Новая страница /my-materials (просмотр/открыть/скачать/удалить) + пункт сайдбара (ученик). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/studentMaterialsController.js | 57 ++++++++ .../db/migrations/060_student_materials.sql | 23 +++ backend/src/routes/materials.js | 14 ++ backend/src/server.js | 1 + frontend/js/whiteboard.js | 16 +++ frontend/my-lessons.html | 54 ++++++- frontend/my-materials.html | 136 ++++++++++++++++++ js/api.js | 4 + js/sidebar.js | 1 + 9 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 backend/src/controllers/studentMaterialsController.js create mode 100644 backend/src/db/migrations/060_student_materials.sql create mode 100644 backend/src/routes/materials.js create mode 100644 frontend/my-materials.html diff --git a/backend/src/controllers/studentMaterialsController.js b/backend/src/controllers/studentMaterialsController.js new file mode 100644 index 0000000..69f674c --- /dev/null +++ b/backend/src/controllers/studentMaterialsController.js @@ -0,0 +1,57 @@ +'use strict'; +/* Student-owned personal materials ("Мои материалы"). + * A user keeps copies of items saved from live lessons; the copies are + * independent of the session lifecycle. */ +const db = require('../db/db'); + +const KINDS = ['board', 'note', 'link', 'image']; + +/* GET /api/materials — list the current user's saved materials */ +function list(req, res) { + const rows = db.prepare(` + SELECT id, kind, title, body, url, source_session_id, source_title, created_at + FROM student_materials + WHERE user_id = ? + ORDER BY created_at DESC, id DESC + `).all(req.user.id); + res.json({ materials: rows }); +} + +/* POST /api/materials — save a new item to the current user's collection. + Body: { kind, title?, body?, url?, sourceSessionId?, sourceTitle? } */ +function create(req, res) { + const b = req.body || {}; + const kind = String(b.kind || ''); + if (!KINDS.includes(kind)) return res.status(400).json({ error: 'invalid kind' }); + + const title = String(b.title || '').slice(0, 300); + const body = b.body != null ? String(b.body).slice(0, 60000) : null; + const url = b.url != null ? String(b.url).slice(0, 2000) : null; + if ((kind === 'note') && !body) return res.status(400).json({ error: 'body required for note' }); + if ((kind === 'board' || kind === 'image' || kind === 'link') && !url) + return res.status(400).json({ error: 'url required' }); + + // Soft reference to the source session (kept readable via source_title even + // after the session is deleted). Only store the id if the session exists. + let sourceSessionId = Number(b.sourceSessionId); + if (!Number.isFinite(sourceSessionId)) sourceSessionId = null; + else if (!db.prepare('SELECT 1 FROM classroom_sessions WHERE id = ?').get(sourceSessionId)) sourceSessionId = null; + const sourceTitle = b.sourceTitle != null ? String(b.sourceTitle).slice(0, 300) : null; + + const r = db.prepare(` + INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle); + res.status(201).json({ id: Number(r.lastInsertRowid) }); +} + +/* DELETE /api/materials/:id — remove one of the current user's items */ +function remove(req, res) { + const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'not found' }); + if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); + db.prepare('DELETE FROM student_materials WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +} + +module.exports = { list, create, remove }; diff --git a/backend/src/db/migrations/060_student_materials.sql b/backend/src/db/migrations/060_student_materials.sql new file mode 100644 index 0000000..c9f8257 --- /dev/null +++ b/backend/src/db/migrations/060_student_materials.sql @@ -0,0 +1,23 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 060: Student-owned personal materials ("Мои материалы") +-- +-- A student can save items from a live lesson (a board page image, their +-- note, a chat attachment/link) into their OWN collection. The copy is +-- independent of the session: it survives even if the teacher later deletes +-- the session history. source_session_id is a soft reference (SET NULL on +-- delete); source_title is denormalized so it stays readable afterwards. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE student_materials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('board','note','link','image')), + title TEXT NOT NULL DEFAULT '', + body TEXT, -- note text (kind='note') + url TEXT, -- file/image/link url (board/image/link) + source_session_id INTEGER REFERENCES classroom_sessions(id) ON DELETE SET NULL, + source_title TEXT, -- denormalized session title (survives deletion) + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_student_materials_user ON student_materials(user_id, created_at DESC); diff --git a/backend/src/routes/materials.js b/backend/src/routes/materials.js new file mode 100644 index 0000000..3297d32 --- /dev/null +++ b/backend/src/routes/materials.js @@ -0,0 +1,14 @@ +'use strict'; +const express = require('express'); +const router = express.Router(); +const { authMiddleware } = require('../middleware/auth'); +const c = require('../controllers/studentMaterialsController'); + +router.use(authMiddleware); + +router.get('/', c.list); +router.post('/', c.create); +// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler +router.delete('/:id', c.remove); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 4ac3fa7..3c932e0 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -193,6 +193,7 @@ app.use('/api/textbooks', textbookRoutes); app.use('/api/access', accessRoutes); app.use('/api/teacher-students', teacherStudentsRoutes); app.use('/api/lab', labRoutes); +app.use('/api/materials', require('./routes/materials')); /* ── Public features endpoint (merges global + per-class for authenticated students) ── */ const _featDb = require('./db/db'); diff --git a/frontend/js/whiteboard.js b/frontend/js/whiteboard.js index fbe7388..ace5946 100644 --- a/frontend/js/whiteboard.js +++ b/frontend/js/whiteboard.js @@ -3595,6 +3595,22 @@ class Whiteboard { }, 'image/png'); } + /* Same composite as exportPNG, but hands the PNG Blob to a callback + (used to save a board page into the student's personal materials). */ + exportBlob(cb) { + const off = document.createElement('canvas'); + off.width = Whiteboard.VW; off.height = Whiteboard.VH; + const ctx = off.getContext('2d'); + const [sw, sh, sz, spx, spy] = [this._cssW, this._cssH, this._zoom, this._panVX, this._panVY]; + this._cssW = Whiteboard.VW; this._cssH = Whiteboard.VH; + this._zoom = 1; this._panVX = 0; this._panVY = 0; + this._renderBg(ctx); + if (this._template && this._template !== 'blank') this._renderTemplate(ctx); + for (const s of this._strokes) this._renderStroke(ctx, s); + this._cssW = sw; this._cssH = sh; this._zoom = sz; this._panVX = spx; this._panVY = spy; + off.toBlob(cb, 'image/png'); + } + _renderMinimap() { if (!this._mmCanvas) return; const visible = this._zoom > 1.04; diff --git a/frontend/my-lessons.html b/frontend/my-lessons.html index 885ed65..a42687c 100644 --- a/frontend/my-lessons.html +++ b/frontend/my-lessons.html @@ -585,6 +585,10 @@ Загрузка доски...
+ +
${LS.escapeHtml(content)}
`; } catch { diff --git a/frontend/my-materials.html b/frontend/my-materials.html new file mode 100644 index 0000000..06178a8 --- /dev/null +++ b/frontend/my-materials.html @@ -0,0 +1,136 @@ + + + + + + Мои материалы — LearnSpace + + + + + + + +
+ +
+
+
+ Мои материалы +
+
Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.
+
Загрузка…
+
+
+
+ + + + + + + + diff --git a/js/api.js b/js/api.js index 88c6d34..762abb4 100644 --- a/js/api.js +++ b/js/api.js @@ -1048,6 +1048,7 @@ window.LS = { crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession, crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory, crAdminGetAllHistory, crAdminGetTeachersList, + listMaterials, saveMaterial, deleteMaterial, escapeHtml, esc, parseDate, fmtRelTime, safeHref, initPage, @@ -1242,6 +1243,9 @@ async function uploadFile(formData) { return data; } function downloadFileUrl(id) { return `${API}/files/${id}/download`; } +async function listMaterials() { return req('GET', '/materials'); } +async function saveMaterial(data) { return req('POST', '/materials', data); } +async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); } async function deleteFile(id) { return req('DELETE', `/files/${id}`); } async function getFileAccess(id) { return req('GET', `/files/${id}/access`); } async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); } diff --git a/js/sidebar.js b/js/sidebar.js index ad292b2..a41a3da 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -69,6 +69,7 @@ ${L('/my-students', 'user-plus', 'Мои ученики', { cls: 'sb-teacher-only', hidden: !isTch })} ${L('/classroom', 'presentation', 'Онлайн-урок', { id: 'btn-classroom', cls: 'sb-link-cr' })} ${L('/lesson-history','archive', 'Архив уроков')} + ${L('/my-materials', 'bookmark', 'Мои материалы', { hidden: !isStu })} ${L('/live-quiz', 'radio', 'Live-квиз', { cls: 'sb-teacher-only', hidden: !isTch })} ${L('/board', 'layout-dashboard', 'Доска', { id: 'btn-board', hidden: true })} `)}