diff --git a/backend/src/controllers/studentMaterialsController.js b/backend/src/controllers/studentMaterialsController.js index a7cca86..24f773a 100644 --- a/backend/src/controllers/studentMaterialsController.js +++ b/backend/src/controllers/studentMaterialsController.js @@ -6,15 +6,32 @@ const db = require('../db/db'); const KINDS = ['board', 'note', 'link', 'image']; -/* GET /api/materials — list the current user's saved materials */ +/* GET /api/materials — list the current user's saved materials + their collections */ function list(req, res) { - const rows = db.prepare(` - SELECT id, kind, title, body, url, source_session_id, source_title, created_at + const uid = req.user.id; + const materials = db.prepare(` + SELECT id, kind, title, body, url, source_session_id, source_title, collection_id, tags, created_at FROM student_materials WHERE user_id = ? ORDER BY created_at DESC, id DESC - `).all(req.user.id); - res.json({ materials: rows }); + `).all(uid); + const collections = db.prepare(` + SELECT c.id, c.name, c.color, c.sort_order, + (SELECT COUNT(*) FROM student_materials m WHERE m.collection_id = c.id) AS count + FROM material_collections c + WHERE c.user_id = ? + ORDER BY c.sort_order, c.id + `).all(uid); + res.json({ materials, collections }); +} + +/* Validate that a collection id belongs to the user; returns null if unset/invalid. */ +function ownCollectionId(raw, uid) { + if (raw === null || raw === '' || raw === undefined) return null; + const cid = Number(raw); + if (!Number.isFinite(cid)) return null; + const own = db.prepare('SELECT 1 FROM material_collections WHERE id = ? AND user_id = ?').get(cid, uid); + return own ? cid : null; } /* POST /api/materials — save a new item to the current user's collection. @@ -37,11 +54,13 @@ function create(req, res) { 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 collectionId = ownCollectionId(b.collection_id, req.user.id); + const tags = b.tags != null ? String(b.tags).slice(0, 500) : 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); + INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle, collectionId, tags); res.status(201).json({ id: Number(r.lastInsertRowid) }); } @@ -55,6 +74,8 @@ function update(req, res) { const fields = [], args = []; if (b.title !== undefined) { fields.push('title = ?'); args.push(String(b.title || '').slice(0, 300)); } if (b.body !== undefined) { fields.push('body = ?'); args.push(b.body != null ? String(b.body).slice(0, 60000) : null); } + if (b.collection_id !== undefined) { fields.push('collection_id = ?'); args.push(ownCollectionId(b.collection_id, req.user.id)); } + if (b.tags !== undefined) { fields.push('tags = ?'); args.push(b.tags != null ? String(b.tags).slice(0, 500) : null); } if (!fields.length) return res.json({ ok: true }); args.push(req.params.id); db.prepare(`UPDATE student_materials SET ${fields.join(', ')} WHERE id = ?`).run(...args); @@ -70,4 +91,42 @@ function remove(req, res) { res.json({ ok: true }); } -module.exports = { list, create, update, remove }; +/* ── Collections (folders) ──────────────────────────────────────────── */ + +/* POST /api/materials/collections — create a folder */ +function createCollection(req, res) { + const name = String((req.body && req.body.name) || '').trim().slice(0, 120); + if (!name) return res.status(400).json({ error: 'name required' }); + const color = req.body && req.body.color ? String(req.body.color).slice(0, 20) : null; + const r = db.prepare( + 'INSERT INTO material_collections (user_id, name, color, sort_order) VALUES (?, ?, ?, ?)' + ).run(req.user.id, name, color, Number(req.body && req.body.sortOrder) || 0); + res.status(201).json({ id: Number(r.lastInsertRowid) }); +} + +/* PATCH /api/materials/collections/:id — rename / recolor / reorder */ +function updateCollection(req, res) { + const row = db.prepare('SELECT user_id FROM material_collections 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' }); + const b = req.body || {}; + const fields = [], args = []; + if (b.name !== undefined) { fields.push('name = ?'); args.push(String(b.name || '').trim().slice(0, 120)); } + if (b.color !== undefined) { fields.push('color = ?'); args.push(b.color ? String(b.color).slice(0, 20) : null); } + if (b.sortOrder !== undefined) { fields.push('sort_order = ?'); args.push(Number(b.sortOrder) || 0); } + if (!fields.length) return res.json({ ok: true }); + args.push(req.params.id); + db.prepare(`UPDATE material_collections SET ${fields.join(', ')} WHERE id = ?`).run(...args); + res.json({ ok: true }); +} + +/* DELETE /api/materials/collections/:id — delete folder (materials kept, uncategorised) */ +function deleteCollection(req, res) { + const row = db.prepare('SELECT user_id FROM material_collections 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 material_collections WHERE id = ?').run(req.params.id); // ON DELETE SET NULL + res.json({ ok: true }); +} + +module.exports = { list, create, update, remove, createCollection, updateCollection, deleteCollection }; diff --git a/backend/src/db/migrations/061_material_collections.sql b/backend/src/db/migrations/061_material_collections.sql new file mode 100644 index 0000000..9f93fd5 --- /dev/null +++ b/backend/src/db/migrations/061_material_collections.sql @@ -0,0 +1,21 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 061: Collections (folders) + tags for «Мои материалы» +-- +-- A user groups saved materials into named collections (folders). A material +-- belongs to at most one collection (collection_id, SET NULL when the folder +-- is deleted — the material stays, just becomes "uncategorised"). Optional +-- free-text tags for quick filtering. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE material_collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_material_collections_user ON material_collections(user_id, sort_order, id); + +ALTER TABLE student_materials ADD COLUMN collection_id INTEGER REFERENCES material_collections(id) ON DELETE SET NULL; +ALTER TABLE student_materials ADD COLUMN tags TEXT; diff --git a/backend/src/routes/materials.js b/backend/src/routes/materials.js index e728a97..806f0b3 100644 --- a/backend/src/routes/materials.js +++ b/backend/src/routes/materials.js @@ -8,6 +8,14 @@ router.use(authMiddleware); router.get('/', c.list); router.post('/', c.create); + +// Collections (folders) — literal '/collections' prefix before '/:id' +router.post('/collections', c.createCollection); +// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler +router.patch('/collections/:id', c.updateCollection); +// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler +router.delete('/collections/:id', c.deleteCollection); + // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler router.patch('/:id', c.update); // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler diff --git a/frontend/my-materials.html b/frontend/my-materials.html index 0d71952..e408e7d 100644 --- a/frontend/my-materials.html +++ b/frontend/my-materials.html @@ -12,7 +12,19 @@ .mm-main { padding: 28px 24px; max-width: 1100px; margin: 0 auto; width: 100%; } .mm-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; } .mm-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.5rem; color: var(--text); } - .mm-sub { color: var(--text-3); font-size: 0.9rem; margin-bottom: 22px; } + .mm-sub { color: var(--text-3); font-size: 0.9rem; margin-bottom: 16px; } + .mm-toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; } + .mm-search { flex: 1; min-width: 180px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 9px; font: inherit; background: var(--surface); color: var(--text); } + .mm-kind { padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; font: inherit; background: var(--surface); color: var(--text); } + .mm-cols { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; } + .mm-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1px solid var(--border); border-radius: 99px; background: var(--surface); cursor: pointer; font-size: .8rem; font-weight: 600; color: var(--text-2); } + .mm-chip:hover { border-color: var(--violet); color: var(--violet); } + .mm-chip.active { background: var(--violet); border-color: var(--violet); color: #fff; } + .mm-chip-count { font-size: .7rem; opacity: .7; } + .mm-chip-edit { display: inline-flex; opacity: .65; margin-left: 2px; } + .mm-chip-edit svg { width: 12px; height: 12px; } + .mm-chip-edit:hover { opacity: 1; } + .mm-chip-add { border-style: dashed; } .mm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; } .mm-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; display: flex; flex-direction: column; position: relative; } .mm-card-media { background: #f1f5f9; aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; overflow: hidden; } @@ -21,12 +33,13 @@ .mm-card-body { padding: 12px 14px; border-top: 1px solid var(--border); } .mm-card-title { font-weight: 700; font-size: 0.86rem; color: var(--text); margin-bottom: 3px; } .mm-card-meta { font-size: 0.74rem; color: var(--text-3); } - .mm-card-actions { display: flex; gap: 8px; margin-top: 10px; } + .mm-card-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; } .mm-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; font-size: 0.76rem; font-weight: 600; color: var(--text-2); text-decoration: none; transition: border-color .12s, color .12s; } .mm-btn:hover { border-color: var(--violet); color: var(--violet); } .mm-btn.danger:hover { border-color: #ef4444; color: #ef4444; } .mm-btn svg { width: 13px; height: 13px; } - .mm-kind { position: absolute; top: 8px; left: 8px; font-size: 0.68rem; font-weight: 700; padding: 3px 8px; border-radius: 99px; background: rgba(155,93,229,0.12); color: var(--violet); } + .mm-move { padding: 4px 6px; border: 1px solid var(--border); border-radius: 8px; font-size: .74rem; background: var(--surface); color: var(--text-2); max-width: 130px; margin-right: auto; } + .mm-kind-badge { position: absolute; top: 8px; left: 8px; font-size: 0.68rem; font-weight: 700; padding: 3px 8px; border-radius: 99px; background: rgba(155,93,229,0.12); color: var(--violet); } .mm-empty { padding: 60px 20px; text-align: center; color: var(--text-3); } .mm-empty svg { width: 38px; height: 38px; opacity: 0.4; margin-bottom: 12px; } @media (max-width: 640px) { .mm-grid { grid-template-columns: 1fr 1fr; } .mm-main { padding: 18px 14px; } } @@ -42,6 +55,17 @@
Пока пусто. На странице «Мои уроки» откройте прошлый урок и нажмите «К себе» на странице доски или заметке.
-Пока пусто. На уроке или в «Мои уроки» нажмите «К себе»/«Область», или создайте заметку.
+Ничего не найдено