diff --git a/backend/src/controllers/fileController.js b/backend/src/controllers/fileController.js index 74b35da..3b207a1 100644 --- a/backend/src/controllers/fileController.js +++ b/backend/src/controllers/fileController.js @@ -1,6 +1,7 @@ const db = require('../db/db'); const path = require('path'); const fs = require('fs'); +const sharp = require('sharp'); const { UPLOADS_DIR } = require('../config'); const { checkMagicBytes } = require('../utils/magic'); @@ -173,25 +174,41 @@ function uploadFile(req, res) { * teacher library upload above. Image-only; saved into uploads/materials and * served statically (public), so the returned URL renders in , opens in * a new tab and downloads without an auth header. Returns { url }. */ -function uploadPersonalFile(req, res) { - if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); +async function uploadPersonalFile(req, res) { + try { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); - const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename); - if (!checkMagicBytes(filePath, req.file.mimetype)) { - try { fs.unlinkSync(filePath); } catch {} - return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' }); + const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename); + if (!checkMagicBytes(filePath, req.file.mimetype)) { + try { fs.unlinkSync(filePath); } catch {} + return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' }); + } + + // Per-user storage quota: reject before the file becomes usable. Accounting + // is by student_materials.bytes (the uploaded file is not a material yet). + const used = db.prepare('SELECT COALESCE(SUM(bytes),0) AS b FROM student_materials WHERE user_id = ?').get(req.user.id); + const maxBytes = Number(process.env.MATERIALS_MAX_BYTES) || 300 * 1024 * 1024; // 300 MB + if (used.b + (req.file.size || 0) > maxBytes) { + try { fs.unlinkSync(filePath); } catch {} + return res.status(413).json({ error: 'Превышен лимит хранилища материалов' }); + } + + const url = '/uploads/materials/' + req.file.filename; + // Server-side thumbnail (downscaled webp) for fast grid rendering; the full + // image stays for viewing/annotating/download. Best-effort — on any failure + // (animated gif, decode error) thumbUrl is null and the client uses `url`. + let thumbUrl = null; + try { + const thumbName = path.basename(req.file.filename, path.extname(req.file.filename)) + '_thumb.webp'; + const thumbPath = path.resolve(UPLOADS_DIR, 'materials', thumbName); + await sharp(filePath).rotate().resize(480, 480, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 78 }).toFile(thumbPath); + thumbUrl = '/uploads/materials/' + thumbName; + } catch (e) { thumbUrl = null; } + + return res.status(201).json({ url, thumbUrl }); + } catch (e) { + return res.status(500).json({ error: 'Upload failed' }); } - - // Per-user storage quota: reject before the file becomes usable. Accounting - // is by student_materials.bytes (the uploaded file is not a material yet). - const used = db.prepare('SELECT COALESCE(SUM(bytes),0) AS b FROM student_materials WHERE user_id = ?').get(req.user.id); - const maxBytes = Number(process.env.MATERIALS_MAX_BYTES) || 300 * 1024 * 1024; // 300 MB - if (used.b + (req.file.size || 0) > maxBytes) { - try { fs.unlinkSync(filePath); } catch {} - return res.status(413).json({ error: 'Превышен лимит хранилища материалов' }); - } - - res.status(201).json({ url: '/uploads/materials/' + req.file.filename }); } /* ── GET /api/files/:id/download ─────────────────────────────────────── */ diff --git a/backend/src/controllers/studentMaterialsController.js b/backend/src/controllers/studentMaterialsController.js index 60446c2..124a451 100644 --- a/backend/src/controllers/studentMaterialsController.js +++ b/backend/src/controllers/studentMaterialsController.js @@ -30,22 +30,27 @@ function safeUrl(raw) { return undefined; } -// Bytes attributed to a material for quota accounting (server-measured). -function measureBytes(kind, url, body) { +// Size on disk of a local materials file (0 if absent / non-local). +function fileBytes(u) { + if (typeof u !== 'string' || !u.startsWith('/uploads/materials/')) return 0; + try { return fs.statSync(path.join(MATERIALS_DIR, path.basename(u))).size; } catch (e) { return 0; } +} + +// Bytes attributed to a material for quota accounting (server-measured): for +// image/board it's the full file + its thumbnail. +function measureBytes(kind, url, body, thumbUrl) { if (kind === 'note') return Buffer.byteLength(String(body || ''), 'utf8'); - if ((kind === 'image' || kind === 'board') && typeof url === 'string' && url.startsWith('/uploads/materials/')) { - try { return fs.statSync(path.join(MATERIALS_DIR, path.basename(url))).size; } catch (e) { return 0; } - } + if (kind === 'image' || kind === 'board') return fileBytes(url) + fileBytes(thumbUrl); return 0; } // Reference-counted cleanup: unlink the file backing `url` only when NO material -// row references it any more (share/annotate can alias one physical file across -// rows). Call AFTER the delete/url-update so the freed row no longer counts. -// No-op for non-local urls. Exported for tests. +// row references it any more — as either its `url` OR its `thumb_url` (share/ +// annotate can alias one physical file across rows and columns). Call AFTER the +// delete/url-update so the freed row no longer counts. Exported for tests. function releaseFileForUrl(url) { if (typeof url !== 'string' || !url.startsWith('/uploads/materials/')) return; - if (db.prepare('SELECT 1 FROM student_materials WHERE url = ? LIMIT 1').get(url)) return; + if (db.prepare('SELECT 1 FROM student_materials WHERE url = ? OR thumb_url = ? LIMIT 1').get(url, url)) return; const fp = path.join(MATERIALS_DIR, path.basename(url)); if (fp.startsWith(MATERIALS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch (e) { /* already gone */ } } } @@ -59,7 +64,7 @@ function list(req, res) { const materials = db.prepare(` SELECT id, kind, title, substr(body, 1, 1000) AS body, (CASE WHEN body IS NOT NULL AND length(body) > 1000 THEN 1 ELSE 0 END) AS body_trunc, - url, source_session_id, source_title, collection_id, tags, created_at + url, thumb_url, source_session_id, source_title, collection_id, tags, created_at FROM student_materials WHERE user_id = ? ORDER BY created_at DESC, id DESC @@ -78,7 +83,7 @@ function list(req, res) { client when a note's preview was truncated). Owner-only. */ function getOne(req, res) { const row = db.prepare(` - SELECT id, user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags, created_at + SELECT id, user_id, kind, title, body, url, thumb_url, source_session_id, source_title, collection_id, tags, created_at FROM student_materials WHERE id = ? `).get(req.params.id); if (!row) return res.status(404).json({ error: 'not found' }); @@ -113,6 +118,11 @@ function create(req, res) { url = safeUrl(b.url); if (url === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' }); } + let thumbUrl = null; + if (b.thumbUrl != null && b.thumbUrl !== '') { + thumbUrl = safeUrl(b.thumbUrl); + if (thumbUrl === undefined) return res.status(400).json({ error: 'Недопустимый адрес миниатюры' }); + } 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' }); @@ -126,18 +136,18 @@ function create(req, res) { const collectionId = ownCollectionId(b.collection_id, req.user.id); const tags = b.tags != null ? String(b.tags).slice(0, 500) : null; - const bytes = measureBytes(kind, url, body); + const bytes = measureBytes(kind, url, body, thumbUrl); const r = db.prepare(` - INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags, bytes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle, collectionId, tags, bytes); + INSERT INTO student_materials (user_id, kind, title, body, url, thumb_url, source_session_id, source_title, collection_id, tags, bytes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(req.user.id, kind, title, body, url, thumbUrl, sourceSessionId, sourceTitle, collectionId, tags, bytes); res.status(201).json({ id: Number(r.lastInsertRowid) }); } /* PATCH /api/materials/:id — rename / edit one of the current user's items. Editable: title, body, url (e.g. re-saving an annotated image), collection_id, tags. */ function update(req, res) { - const row = db.prepare('SELECT user_id, url FROM student_materials WHERE id = ?').get(req.params.id); + const row = db.prepare('SELECT user_id, url, thumb_url 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' }); const b = req.body || {}; @@ -149,26 +159,33 @@ function update(req, res) { if (b.url != null && b.url !== '') { nu = safeUrl(b.url); if (nu === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' }); } fields.push('url = ?'); args.push(nu); } + if (b.thumbUrl !== undefined) { + let nt = null; + if (b.thumbUrl != null && b.thumbUrl !== '') { nt = safeUrl(b.thumbUrl); if (nt === undefined) return res.status(400).json({ error: 'Недопустимый адрес миниатюры' }); } + fields.push('thumb_url = ?'); args.push(nt); + } 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); - // Recompute quota bytes from the persisted row; free the old file if the url - // changed (annotate overwrites url) and nothing else references it. - const cur = db.prepare('SELECT kind, url, body FROM student_materials WHERE id = ?').get(req.params.id); - db.prepare('UPDATE student_materials SET bytes = ? WHERE id = ?').run(measureBytes(cur.kind, cur.url, cur.body), req.params.id); + // Recompute quota bytes from the persisted row; free the old file(s) if the url + // or thumbnail changed (annotate overwrites both) and nothing else references them. + const cur = db.prepare('SELECT kind, url, thumb_url, body FROM student_materials WHERE id = ?').get(req.params.id); + db.prepare('UPDATE student_materials SET bytes = ? WHERE id = ?').run(measureBytes(cur.kind, cur.url, cur.body, cur.thumb_url), req.params.id); if (b.url !== undefined && row.url && row.url !== cur.url) releaseFileForUrl(row.url); + if (b.thumbUrl !== undefined && row.thumb_url && row.thumb_url !== cur.thumb_url) releaseFileForUrl(row.thumb_url); res.json({ ok: true }); } /* DELETE /api/materials/:id — remove one of the current user's items */ function remove(req, res) { - const row = db.prepare('SELECT user_id, url FROM student_materials WHERE id = ?').get(req.params.id); + const row = db.prepare('SELECT user_id, url, thumb_url 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); - releaseFileForUrl(row.url); // unlink the backing file if no other material aliases it + releaseFileForUrl(row.url); // unlink the full image if no other material aliases it + releaseFileForUrl(row.thumb_url); // …and its thumbnail res.json({ ok: true }); } @@ -214,7 +231,7 @@ function deleteCollection(req, res) { a student. Each recipient gets an independent COPY (survives later edits/ deletes by the teacher). Body: { classId } | { userId }. */ function share(req, res) { - const mat = db.prepare('SELECT user_id, kind, title, body, url FROM student_materials WHERE id = ?').get(req.params.id); + const mat = db.prepare('SELECT user_id, kind, title, body, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id); if (!mat) return res.status(404).json({ error: 'not found' }); if (mat.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' }); @@ -243,13 +260,13 @@ function share(req, res) { const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель'; const srcTitle = 'Раздатка: ' + teacherName; - const bytes = measureBytes(mat.kind, mat.url, mat.body); // each copy counts toward the recipient's quota - const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, bytes) VALUES (?,?,?,?,?,NULL,?,?)`); + const bytes = measureBytes(mat.kind, mat.url, mat.body, mat.thumb_url); // each copy counts toward the recipient's quota + const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, thumb_url, source_session_id, source_title, bytes) VALUES (?,?,?,?,?,?,NULL,?,?)`); let sent = 0; db.transaction(() => { for (const uid of recipients) { if (!uid || uid === req.user.id) continue; - ins.run(uid, mat.kind, mat.title, mat.body, mat.url, srcTitle, bytes); + ins.run(uid, mat.kind, mat.title, mat.body, mat.url, mat.thumb_url, srcTitle, bytes); try { emit(uid, { type: 'notification', notif_type: 'material_shared', message: `Новый материал от ${teacherName}: «${mat.title || 'материал'}»`, link: '/my-materials' }); diff --git a/backend/src/db/migrations/074_material_thumb.sql b/backend/src/db/migrations/074_material_thumb.sql new file mode 100644 index 0000000..901a4d7 --- /dev/null +++ b/backend/src/db/migrations/074_material_thumb.sql @@ -0,0 +1,10 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 074: Thumbnail URL for image/board materials +-- +-- Server-generated downscaled preview (sharp → webp, ≤480px) shown in the grid; +-- the full image is still used for viewing / annotating / download. NULL when no +-- thumb exists (generation failed, animated gif, or a non-uploaded url) — the +-- client falls back to the full `url`. +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE student_materials ADD COLUMN thumb_url TEXT; diff --git a/backend/tests/materials.test.js b/backend/tests/materials.test.js index 8db988d..7320d90 100644 --- a/backend/tests/materials.test.js +++ b/backend/tests/materials.test.js @@ -194,4 +194,49 @@ describe('/api/materials', () => { assert.equal(ctrl.measureBytes('note', null, 'abc'), 3); assert.equal(ctrl.measureBytes('link', 'https://x', null), 0); }); + + it('thumb_url: create stores it, list/getOne return it; bad scheme → 400', async () => { + const ok = await inject('POST', '/api/materials', + { kind: 'image', url: '/uploads/materials/a.png', thumbUrl: '/uploads/materials/a_thumb.webp' }, studentToken); + assert.equal(ok.status, 201, JSON.stringify(ok.body)); + const id = ok.body.id; + const l = await inject('GET', '/api/materials', null, studentToken); + assert.equal(l.body.materials.find(m => m.id === id).thumb_url, '/uploads/materials/a_thumb.webp'); + const one = await inject('GET', `/api/materials/${id}`, null, studentToken); + assert.equal(one.body.thumb_url, '/uploads/materials/a_thumb.webp'); + const bad = await inject('POST', '/api/materials', + { kind: 'image', url: '/uploads/materials/b.png', thumbUrl: 'javascript:alert(1)' }, studentToken); + assert.equal(bad.status, 400, JSON.stringify(bad.body)); + }); + + it('releaseFileForUrl ref-counts files referenced as a thumbnail (thumb_url column)', () => { + const dir = path.join(__dirname, '..', 'uploads', 'materials'); + fs.mkdirSync(dir, { recursive: true }); + const fname = 'th_' + Date.now() + '.webp'; + const fpath = path.join(dir, fname); + const url = '/uploads/materials/' + fname; + fs.writeFileSync(fpath, Buffer.from([0x52, 0x49, 0x46, 0x46])); + const rid = db.prepare('INSERT INTO student_materials (user_id, kind, thumb_url) VALUES (?, ?, ?)').run(studentId, 'image', url).lastInsertRowid; + ctrl.releaseFileForUrl(url); + assert.ok(fs.existsSync(fpath), 'kept while a row references it as thumb_url'); + db.prepare('DELETE FROM student_materials WHERE id = ?').run(rid); + ctrl.releaseFileForUrl(url); + assert.ok(!fs.existsSync(fpath), 'unlinked once orphaned'); + }); + + it('DELETE removes the material\'s full image AND thumbnail files', async () => { + const dir = path.join(__dirname, '..', 'uploads', 'materials'); + fs.mkdirSync(dir, { recursive: true }); + const base = 'del_' + Date.now(); + const fFull = path.join(dir, base + '.png'), fThumb = path.join(dir, base + '_thumb.webp'); + fs.writeFileSync(fFull, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + fs.writeFileSync(fThumb, Buffer.from([0x52, 0x49, 0x46, 0x46])); + const c = await inject('POST', '/api/materials', + { kind: 'image', url: '/uploads/materials/' + base + '.png', thumbUrl: '/uploads/materials/' + base + '_thumb.webp' }, studentToken); + assert.equal(c.status, 201); + const d = await inject('DELETE', `/api/materials/${c.body.id}`, null, studentToken); + assert.equal(d.status, 200); + assert.ok(!fs.existsSync(fFull), 'full image unlinked'); + assert.ok(!fs.existsSync(fThumb), 'thumbnail unlinked'); + }); }); diff --git a/frontend/js/board-clip.js b/frontend/js/board-clip.js index f767789..63d143a 100644 --- a/frontend/js/board-clip.js +++ b/frontend/js/board-clip.js @@ -20,14 +20,14 @@ async function uploadBlob(blob, name) { const fd = new FormData(); fd.append('file', blob, name); - const up = await LS.uploadMaterialFile(fd); - return up.url; + return await LS.uploadMaterialFile(fd); // { url, thumbUrl } } - async function persist(meta, kind, url) { + async function persist(meta, kind, url, thumbUrl) { await LS.saveMaterial({ kind: kind, url: url, + thumbUrl: thumbUrl || null, title: titleFor(meta, kind === 'image' ? ' · фрагмент' : ''), sourceSessionId: meta && meta.sourceSessionId, sourceTitle: meta && meta.sourceTitle, @@ -40,8 +40,8 @@ wb.exportBlob(async function (blob) { try { if (!blob) throw new Error('Не удалось снять доску'); - const url = await uploadBlob(blob, 'board.png'); - await persist(meta, 'board', url); + const up = await uploadBlob(blob, 'board.png'); + await persist(meta, 'board', up.url, up.thumbUrl); LS.toast('Страница сохранена в «Мои материалы»', 'success'); } catch (e) { LS.toast(e.message || 'Ошибка сохранения', 'error'); @@ -147,8 +147,8 @@ off.getContext('2d').drawImage(img, Math.round(rect.x * sx), Math.round(rect.y * sy), cw, ch, 0, 0, cw, ch); const cblob = await new Promise(function (res) { off.toBlob(res, 'image/png'); }); if (!cblob) throw new Error('Не удалось обрезать область'); - const cropUrl = await uploadBlob(cblob, 'board-region.png'); - await persist(meta, 'image', cropUrl); + const up = await uploadBlob(cblob, 'board-region.png'); + await persist(meta, 'image', up.url, up.thumbUrl); close(); LS.toast('Фрагмент сохранён в «Мои материалы»', 'success'); } catch (e) { diff --git a/frontend/js/material-save.js b/frontend/js/material-save.js index 4cebf54..85263ca 100644 --- a/frontend/js/material-save.js +++ b/frontend/js/material-save.js @@ -39,14 +39,16 @@ if (btn) btn.disabled = true; try { let url = o.url; + let thumbUrl = o.thumbUrl || null; if (o.blob) { const fd = new FormData(); fd.append('file', o.blob, o.name || 'image.png'); const up = await LS.uploadMaterialFile(fd); url = up.url; + thumbUrl = up.thumbUrl || null; } if (!url) throw new Error('Нет изображения'); - await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, sourceTitle: o.sourceTitle || null }); + await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, thumbUrl: thumbUrl, sourceTitle: o.sourceTitle || null }); ok(); } catch (e) { err(e); } finally { if (btn) btn.disabled = false; } } diff --git a/frontend/js/textbook-clip.js b/frontend/js/textbook-clip.js index 38d4182..e99cc10 100644 --- a/frontend/js/textbook-clip.js +++ b/frontend/js/textbook-clip.js @@ -185,6 +185,7 @@ kind: 'image', title: input.value.trim() || sectionTitle(), url: up.url, + thumbUrl: up.thumbUrl || null, sourceTitle: chapterTitle() }); toast('Сохранено в «Мои материалы»', 'success'); diff --git a/frontend/my-materials.html b/frontend/my-materials.html index a0f04d1..e91ca83 100644 --- a/frontend/my-materials.html +++ b/frontend/my-materials.html @@ -89,6 +89,7 @@ .mm-swatch-none { background: repeating-linear-gradient(45deg,#fff,#fff 4px,#e2e8f0 4px,#e2e8f0 8px); } .mm-preview { min-height: 22px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 8px; font-size: .86rem; color: var(--text-2); background: rgba(148,163,184,0.06); white-space: pre-wrap; word-break: break-word; line-height: 1.5; } .mm-preview:empty::before { content: 'Превью формул появится здесь…'; color: var(--text-3); } + .mm-more { display: flex; justify-content: center; padding: 8px 0 2px; } @media (max-width: 768px) { .mm-check { opacity: .85; } } @media (max-width: 768px) { .mm-body { flex-direction: column; } @@ -234,6 +235,8 @@ let _cols = []; const _filter = { col: 'all', kind: 'all', q: '', sort: 'new', tag: '' }; const _sel = new Set(); // ids selected for bulk actions + const PAGE_SIZE = 60; // cards rendered to the DOM at once ("Показать ещё" adds more) + let _shown = PAGE_SIZE; /* ── Move-to-collection select ── */ function moveSelect(m) { @@ -261,7 +264,7 @@ if (m.kind === 'board' || m.kind === 'image') { return `
${cb} - +
${chip}
${esc(m.title || kind)}
@@ -400,12 +403,20 @@ return; } const rows = filtered(); - grid.innerHTML = rows.length - ? rows.map(card).join('') - : `

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

`; + if (!rows.length) { + grid.innerHTML = `

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

`; + lucide.createIcons(); renderBulk(); return; + } + let html = rows.slice(0, _shown).map(card).join(''); + if (rows.length > _shown) { + html += `
`; + } + grid.innerHTML = html; lucide.createIcons(); renderBulk(); } + function showMore() { _shown += PAGE_SIZE; renderGrid(); } + window.showMore = showMore; async function load() { try { @@ -421,12 +432,12 @@ } /* ── 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(); } - function onSort(v) { _filter.sort = v; renderGrid(); } - function filterTag(t) { _filter.tag = String(t || ''); renderTagFilter(); renderGrid(); } - function clearTag() { _filter.tag = ''; renderTagFilter(); renderGrid(); } + function setCol(key) { _filter.col = key; _shown = PAGE_SIZE; renderCols(); renderGrid(); } + function onKind(v) { _filter.kind = v; _shown = PAGE_SIZE; renderGrid(); } + function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); _shown = PAGE_SIZE; renderGrid(); } + function onSort(v) { _filter.sort = v; _shown = PAGE_SIZE; renderGrid(); } + function filterTag(t) { _filter.tag = String(t || ''); _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); } + function clearTag() { _filter.tag = ''; _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); } function renderTagFilter() { const el = document.getElementById('mm-tagfilter'); if (!el) return; @@ -685,10 +696,10 @@ const up = await LS.uploadMaterialFile(fd); if (o.materialId) { // Аннотация существующего материала — перезаписываем его, а не плодим копии - await LS.updateMaterial(o.materialId, { url: up.url }); + await LS.updateMaterial(o.materialId, { url: up.url, thumbUrl: up.thumbUrl || null }); close(); load(); LS.toast('Изменения сохранены', 'success'); } else { - await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, sourceTitle: o.sourceTitle || null }); + await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, thumbUrl: up.thumbUrl || null, sourceTitle: o.sourceTitle || null }); close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success'); } } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; } diff --git a/plans/my-materials/PLAN_V2.md b/plans/my-materials/PLAN_V2.md index 2e37408..86f0553 100644 --- a/plans/my-materials/PLAN_V2.md +++ b/plans/my-materials/PLAN_V2.md @@ -27,12 +27,17 @@ 4. **`backend/tests/materials.test.js`** — CRUD, владелец (403/404), коллекции, share-копия + роль/owner, валидация URL, лимит числа, ссылочная чистка (прямой вызов хелпера на временном файле). -## Фаза 2 — Производительность ✅ (частично) +## Фаза 2 — Производительность ✅ - ✅ `GET /api/materials` отдаёт **обрезанный** `body` (первые 1000 симв.) + флаг `body_trunc`; полный текст — ленивый `GET /api/materials/:id` (`getOne`, owner-only). Клиент `ensureFullBody()` подгружает перед просмотром/правкой/флешкартой (иначе правка сохранила бы усечённый текст). -- ⬜ Пагинация/keyset — отложено (клиент пока фильтрует в памяти; включить при росте объёмов). -- ⬜ Серверные миниатюры `board/image` — отложено (нужна обработка картинок; пока `loading=lazy`). +- ✅ Пагинация рендера: клиент держит весь список (поиск/фильтр/сортировка в памяти), но в DOM рисует + `PAGE_SIZE=60` карточек + «Показать ещё»; `_shown` сбрасывается на смену фильтра. Снимает стоимость + рендера тысяч узлов, не ломая клиентский поиск (keyset на сервере не нужен на текущих объёмах). +- ✅ Серверные миниатюры `board/image`: `uploadPersonalFile` (sharp → webp ≤480px) возвращает `{url, thumbUrl}`; + колонка `thumb_url` (мигр. **074**); грид рисует ``, просмотр/скачивание/аннотация — + полный `url`. Чистится по ссылкам (releaseFileForUrl теперь матчит url **и** thumb_url); share копирует thumb; + квота считает файл+миниатюру. Клиентские сейверы (board-clip/material-save/textbook-clip/draw) пробрасывают `thumbUrl`. ## Фаза 3 — Доводка заложенных фич ✅ - ✅ UI тегов: ввод в модалках создания/правки + чипы на карточке (клик → фильтр) + пилюля активного фильтра. @@ -45,10 +50,11 @@ - ✅ Множественный выбор (чекбокс на карточке) + панель массовых действий (переместить/удалить, reuse per-item API). - ✅ Живое превью KaTeX в редакторе заметки (oninput → `mmPreview` → `mathHtml`). -### Статус -Сделано и проверено: **Ф1 целиком** (16 backend-тестов), **Ф2/Ф3/Ф4 ✅** (headless-смоук `my-materials.html`: -синтаксис + рендер карточек с deep-link/тегами/чекбоксом + фильтр по тегу + bulk-bar + тинт папки). -Осталось ⬜ (инфра, отложено): пагинация/keyset списка, серверные миниатюры board/image. +### Статус — ПЛАН V2 ВЫПОЛНЕН +**Ф1–Ф4 ✅.** Backend: 19 тестов `materials.test.js` (CRUD/владелец/коллекции/share/URL-allowlist/квота/ +ссылочная чистка url+thumb/round-trip thumb_url). Frontend: headless-смоук `my-materials.html` (синтаксис + +deep-link/теги/чекбокс/bulk/тинт папки + `` на thumb_url + пагинация «Показать ещё»). sharp-пайплайн и +client-сейверы (board-clip/material-save/textbook-clip) проверены. Открытого из плана не осталось. ---