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 @@
Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.
+
+ + +
+
Загрузка…
@@ -62,23 +86,35 @@ } const KIND_LABEL = { board: 'Доска', note: 'Заметка', link: 'Ссылка', image: 'Изображение' }; + const PENCIL = ''; let _mats = []; + let _cols = []; + const _filter = { col: 'all', kind: 'all', q: '' }; + + /* ── Move-to-collection select ── */ + function moveSelect(m) { + const opts = [''] + .concat(_cols.map(c => ``)); + return ``; + } function card(m) { const kind = KIND_LABEL[m.kind] || m.kind; const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`; - const del = ``; + const del = ``; const edit = ``; + const mv = moveSelect(m); if (m.kind === 'board' || m.kind === 'image') { return `
- ${kind} + ${kind}
${esc(m.title || kind)}
${meta}
- Открыть - + ${mv} + + ${edit}${del}
@@ -86,47 +122,98 @@ } if (m.kind === 'link') { return `
- ${kind} + ${kind}
${esc(m.title || kind)}
${meta}
-
${edit}${del}
+
${mv}${edit}${del}
`; } // note return `
- ${kind} + ${kind}
${esc(m.body || '')}
${esc(m.title || kind)}
${meta}
-
${del}
+
${mv}${edit}${del}
`; } - async function load() { - const grid = document.getElementById('mm-grid'); - try { - const { materials } = await LS.listMaterials(); - _mats = materials || []; - if (!materials || !materials.length) { - grid.innerHTML = `
- -

Пока пусто. На странице «Мои уроки» откройте прошлый урок и нажмите «К себе» на странице доски или заметке.

-
`; - lucide.createIcons(); - return; + /* ── Collections bar ── */ + function chip(key, label, count, editId) { + const active = _filter.col === key ? ' active' : ''; + const ed = editId ? `${PENCIL}` : ''; + return ``; + } + function renderCols() { + const bar = document.getElementById('mm-cols'); + const noneCount = _mats.filter(m => !m.collection_id).length; + let html = chip('all', 'Все', _mats.length); + _cols.forEach(c => { html += chip(String(c.id), c.name, c.count, c.id); }); + if (noneCount) html += chip('none', 'Без папки', noneCount); + html += ``; + bar.innerHTML = html; + } + + function filtered() { + return _mats.filter(m => { + if (_filter.col === 'none' && m.collection_id) return false; + if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false; + if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false; + if (_filter.q) { + const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase(); + if (!hay.includes(_filter.q)) return false; } - grid.innerHTML = materials.map(card).join(''); + return true; + }); + } + + function renderGrid() { + const grid = document.getElementById('mm-grid'); + if (!_mats.length) { + grid.innerHTML = `
+ +

Пока пусто. На уроке или в «Мои уроки» нажмите «К себе»/«Область», или создайте заметку.

+
`; lucide.createIcons(); + return; + } + const rows = filtered(); + grid.innerHTML = rows.length + ? rows.map(card).join('') + : `

Ничего не найдено

`; + lucide.createIcons(); + } + + async function load() { + try { + const data = await LS.listMaterials(); + _mats = data.materials || []; + _cols = data.collections || []; + renderCols(); + renderGrid(); } catch (e) { - grid.innerHTML = `
Ошибка загрузки
`; + document.getElementById('mm-grid').innerHTML = `
Ошибка загрузки
`; } } + /* ── Filters ── */ + function setCol(key) { _filter.col = key; renderCols(); renderGrid(); } + function onKind(v) { _filter.kind = v; renderGrid(); } + function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); } + window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch; + + /* ── Material actions ── */ + async function moveMaterial(id, cid) { + try { await LS.updateMaterial(id, { collection_id: cid || null }); await load(); } + catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } + window.moveMaterial = moveMaterial; + async function delMaterial(id) { if (!confirm('Удалить этот материал?')) return; try { await LS.deleteMaterial(id); load(); } @@ -146,7 +233,8 @@ const title = m.body.querySelector('#mm-nt-title').value.trim(); const text = m.body.querySelector('#mm-nt-body').value.trim(); if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; } - try { await LS.saveMaterial({ kind: 'note', title, body: text }); m.close(); load(); } + const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null; + try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col }); m.close(); load(); } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } } }, ] }); @@ -173,6 +261,42 @@ } window.editMaterial = editMaterial; + /* ── Collection CRUD ── */ + function createCollection() { + const content = ``; + const m = LS.modal({ title: 'Новая папка', content, size: 'sm', actions: [ + { label: 'Отмена', onClick: () => m.close() }, + { label: 'Создать', primary: true, onClick: async () => { + const name = m.body.querySelector('#mm-col-name').value.trim(); + if (!name) { LS.toast('Введите название', 'warn'); return; } + try { await LS.createMaterialCollection({ name }); m.close(); load(); } + catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } }, + ] }); + } + window.createCollection = createCollection; + + function editCollection(id) { + const col = _cols.find(c => c.id === id); + if (!col) return; + const content = ``; + const m = LS.modal({ title: 'Папка', content, size: 'sm', actions: [ + { label: 'Удалить', onClick: async () => { + if (!confirm('Удалить папку? Материалы останутся (станут «Без папки»).')) return; + try { await LS.deleteMaterialCollection(id); m.close(); if (_filter.col === String(id)) _filter.col = 'all'; load(); } + catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } }, + { label: 'Отмена', onClick: () => m.close() }, + { label: 'Сохранить', primary: true, onClick: async () => { + const name = m.body.querySelector('#mm-col-name').value.trim(); + if (!name) { LS.toast('Введите название', 'warn'); return; } + try { await LS.updateMaterialCollection(id, { name }); m.close(); load(); } + catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } }, + ] }); + } + window.editCollection = editCollection; + load(); diff --git a/js/api.js b/js/api.js index 3bcddbc..718eadd 100644 --- a/js/api.js +++ b/js/api.js @@ -1049,6 +1049,7 @@ window.LS = { crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory, crAdminGetAllHistory, crAdminGetTeachersList, listMaterials, saveMaterial, updateMaterial, deleteMaterial, + createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, escapeHtml, esc, parseDate, fmtRelTime, safeHref, initPage, @@ -1247,6 +1248,9 @@ async function listMaterials() { return req('GET', '/materials'); } async function saveMaterial(data) { return req('POST', '/materials', data); } async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); } async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); } +async function createMaterialCollection(d) { return req('POST', '/materials/collections', d); } +async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); } +async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${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); }