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 @@ Загрузка доски...