feat(materials): серверные миниатюры (sharp) + пагинация рендера списка

Миниатюры: uploadPersonalFile генерирует webp ≤480px (sharp), возвращает {url, thumbUrl}; колонка thumb_url (мигр.074); грид рисует <img> на миниатюре, просмотр/скачивание/аннотация — полный url. Ссылочная чистка матчит url И thumb_url; share копирует thumb; квота учитывает файл+миниатюру. Сейверы board-clip/material-save/textbook-clip/draw пробрасывают thumbUrl.

Пагинация: клиент рендерит PAGE_SIZE=60 карточек + «Показать ещё» (сброс на смену фильтра), сохраняя клиентский поиск/сортировку над полным списком.

Тесты: materials.test.js 16→19. План V2 выполнен.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-13 14:40:23 +03:00
parent abe84b9f90
commit 786419ce01
9 changed files with 179 additions and 70 deletions
+19 -2
View File
@@ -1,6 +1,7 @@
const db = require('../db/db'); const db = require('../db/db');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const sharp = require('sharp');
const { UPLOADS_DIR } = require('../config'); const { UPLOADS_DIR } = require('../config');
const { checkMagicBytes } = require('../utils/magic'); const { checkMagicBytes } = require('../utils/magic');
@@ -173,7 +174,8 @@ function uploadFile(req, res) {
* teacher library upload above. Image-only; saved into uploads/materials and * teacher library upload above. Image-only; saved into uploads/materials and
* served statically (public), so the returned URL renders in <img>, opens in * served statically (public), so the returned URL renders in <img>, opens in
* a new tab and downloads without an auth header. Returns { url }. */ * a new tab and downloads without an auth header. Returns { url }. */
function uploadPersonalFile(req, res) { async function uploadPersonalFile(req, res) {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename); const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename);
@@ -191,7 +193,22 @@ function uploadPersonalFile(req, res) {
return res.status(413).json({ error: 'Превышен лимит хранилища материалов' }); return res.status(413).json({ error: 'Превышен лимит хранилища материалов' });
} }
res.status(201).json({ url: '/uploads/materials/' + req.file.filename }); 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' });
}
} }
/* ── GET /api/files/:id/download ─────────────────────────────────────── */ /* ── GET /api/files/:id/download ─────────────────────────────────────── */
@@ -30,22 +30,27 @@ function safeUrl(raw) {
return undefined; return undefined;
} }
// Bytes attributed to a material for quota accounting (server-measured). // Size on disk of a local materials file (0 if absent / non-local).
function measureBytes(kind, url, body) { function fileBytes(u) {
if (kind === 'note') return Buffer.byteLength(String(body || ''), 'utf8'); if (typeof u !== 'string' || !u.startsWith('/uploads/materials/')) return 0;
if ((kind === 'image' || kind === 'board') && typeof url === 'string' && url.startsWith('/uploads/materials/')) { try { return fs.statSync(path.join(MATERIALS_DIR, path.basename(u))).size; } catch (e) { return 0; }
try { return fs.statSync(path.join(MATERIALS_DIR, path.basename(url))).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') return fileBytes(url) + fileBytes(thumbUrl);
return 0; return 0;
} }
// Reference-counted cleanup: unlink the file backing `url` only when NO material // 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 // row references it any more — as either its `url` OR its `thumb_url` (share/
// rows). Call AFTER the delete/url-update so the freed row no longer counts. // annotate can alias one physical file across rows and columns). Call AFTER the
// No-op for non-local urls. Exported for tests. // delete/url-update so the freed row no longer counts. Exported for tests.
function releaseFileForUrl(url) { function releaseFileForUrl(url) {
if (typeof url !== 'string' || !url.startsWith('/uploads/materials/')) return; 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)); const fp = path.join(MATERIALS_DIR, path.basename(url));
if (fp.startsWith(MATERIALS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch (e) { /* already gone */ } } 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(` const materials = db.prepare(`
SELECT id, kind, title, substr(body, 1, 1000) AS body, 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, (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 FROM student_materials
WHERE user_id = ? WHERE user_id = ?
ORDER BY created_at DESC, id DESC 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. */ client when a note's preview was truncated). Owner-only. */
function getOne(req, res) { function getOne(req, res) {
const row = db.prepare(` 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 = ? FROM student_materials WHERE id = ?
`).get(req.params.id); `).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not found' }); if (!row) return res.status(404).json({ error: 'not found' });
@@ -113,6 +118,11 @@ function create(req, res) {
url = safeUrl(b.url); url = safeUrl(b.url);
if (url === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' }); 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 === 'note') && !body) return res.status(400).json({ error: 'body required for note' });
if ((kind === 'board' || kind === 'image' || kind === 'link') && !url) if ((kind === 'board' || kind === 'image' || kind === 'link') && !url)
return res.status(400).json({ error: 'url required' }); 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 collectionId = ownCollectionId(b.collection_id, req.user.id);
const tags = b.tags != null ? String(b.tags).slice(0, 500) : null; 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(` const r = db.prepare(`
INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags, bytes) INSERT INTO student_materials (user_id, kind, title, body, url, thumb_url, source_session_id, source_title, collection_id, tags, bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle, collectionId, tags, bytes); `).run(req.user.id, kind, title, body, url, thumbUrl, sourceSessionId, sourceTitle, collectionId, tags, bytes);
res.status(201).json({ id: Number(r.lastInsertRowid) }); res.status(201).json({ id: Number(r.lastInsertRowid) });
} }
/* PATCH /api/materials/:id — rename / edit one of the current user's items. /* 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. */ Editable: title, body, url (e.g. re-saving an annotated image), collection_id, tags. */
function update(req, res) { 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) return res.status(404).json({ error: 'not found' });
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
const b = req.body || {}; 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: 'Недопустимый адрес ссылки' }); } 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); 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.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 (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 }); if (!fields.length) return res.json({ ok: true });
args.push(req.params.id); args.push(req.params.id);
db.prepare(`UPDATE student_materials SET ${fields.join(', ')} WHERE id = ?`).run(...args); 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 // Recompute quota bytes from the persisted row; free the old file(s) if the url
// changed (annotate overwrites url) and nothing else references it. // or thumbnail changed (annotate overwrites both) and nothing else references them.
const cur = db.prepare('SELECT kind, url, body FROM student_materials WHERE id = ?').get(req.params.id); 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), 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.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 }); res.json({ ok: true });
} }
/* DELETE /api/materials/:id — remove one of the current user's items */ /* DELETE /api/materials/:id — remove one of the current user's items */
function remove(req, res) { 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) return res.status(404).json({ error: 'not found' });
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); 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); 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 }); res.json({ ok: true });
} }
@@ -214,7 +231,7 @@ function deleteCollection(req, res) {
a student. Each recipient gets an independent COPY (survives later edits/ a student. Each recipient gets an independent COPY (survives later edits/
deletes by the teacher). Body: { classId } | { userId }. */ deletes by the teacher). Body: { classId } | { userId }. */
function share(req, res) { 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) 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' }); 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 teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
const srcTitle = 'Раздатка: ' + teacherName; const srcTitle = 'Раздатка: ' + teacherName;
const bytes = measureBytes(mat.kind, mat.url, mat.body); // each copy counts toward the recipient's quota 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, source_session_id, source_title, bytes) VALUES (?,?,?,?,?,NULL,?,?)`); 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; let sent = 0;
db.transaction(() => { db.transaction(() => {
for (const uid of recipients) { for (const uid of recipients) {
if (!uid || uid === req.user.id) continue; 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 { try {
emit(uid, { type: 'notification', notif_type: 'material_shared', emit(uid, { type: 'notification', notif_type: 'material_shared',
message: `Новый материал от ${teacherName}: «${mat.title || 'материал'}»`, link: '/my-materials' }); message: `Новый материал от ${teacherName}: «${mat.title || 'материал'}»`, link: '/my-materials' });
@@ -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;
+45
View File
@@ -194,4 +194,49 @@ describe('/api/materials', () => {
assert.equal(ctrl.measureBytes('note', null, 'abc'), 3); assert.equal(ctrl.measureBytes('note', null, 'abc'), 3);
assert.equal(ctrl.measureBytes('link', 'https://x', null), 0); 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');
});
}); });
+7 -7
View File
@@ -20,14 +20,14 @@
async function uploadBlob(blob, name) { async function uploadBlob(blob, name) {
const fd = new FormData(); const fd = new FormData();
fd.append('file', blob, name); fd.append('file', blob, name);
const up = await LS.uploadMaterialFile(fd); return await LS.uploadMaterialFile(fd); // { url, thumbUrl }
return up.url;
} }
async function persist(meta, kind, url) { async function persist(meta, kind, url, thumbUrl) {
await LS.saveMaterial({ await LS.saveMaterial({
kind: kind, kind: kind,
url: url, url: url,
thumbUrl: thumbUrl || null,
title: titleFor(meta, kind === 'image' ? ' · фрагмент' : ''), title: titleFor(meta, kind === 'image' ? ' · фрагмент' : ''),
sourceSessionId: meta && meta.sourceSessionId, sourceSessionId: meta && meta.sourceSessionId,
sourceTitle: meta && meta.sourceTitle, sourceTitle: meta && meta.sourceTitle,
@@ -40,8 +40,8 @@
wb.exportBlob(async function (blob) { wb.exportBlob(async function (blob) {
try { try {
if (!blob) throw new Error('Не удалось снять доску'); if (!blob) throw new Error('Не удалось снять доску');
const url = await uploadBlob(blob, 'board.png'); const up = await uploadBlob(blob, 'board.png');
await persist(meta, 'board', url); await persist(meta, 'board', up.url, up.thumbUrl);
LS.toast('Страница сохранена в «Мои материалы»', 'success'); LS.toast('Страница сохранена в «Мои материалы»', 'success');
} catch (e) { } catch (e) {
LS.toast(e.message || 'Ошибка сохранения', 'error'); 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); 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'); }); const cblob = await new Promise(function (res) { off.toBlob(res, 'image/png'); });
if (!cblob) throw new Error('Не удалось обрезать область'); if (!cblob) throw new Error('Не удалось обрезать область');
const cropUrl = await uploadBlob(cblob, 'board-region.png'); const up = await uploadBlob(cblob, 'board-region.png');
await persist(meta, 'image', cropUrl); await persist(meta, 'image', up.url, up.thumbUrl);
close(); close();
LS.toast('Фрагмент сохранён в «Мои материалы»', 'success'); LS.toast('Фрагмент сохранён в «Мои материалы»', 'success');
} catch (e) { } catch (e) {
+3 -1
View File
@@ -39,14 +39,16 @@
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
try { try {
let url = o.url; let url = o.url;
let thumbUrl = o.thumbUrl || null;
if (o.blob) { if (o.blob) {
const fd = new FormData(); const fd = new FormData();
fd.append('file', o.blob, o.name || 'image.png'); fd.append('file', o.blob, o.name || 'image.png');
const up = await LS.uploadMaterialFile(fd); const up = await LS.uploadMaterialFile(fd);
url = up.url; url = up.url;
thumbUrl = up.thumbUrl || null;
} }
if (!url) throw new Error('Нет изображения'); 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(); ok();
} catch (e) { err(e); } finally { if (btn) btn.disabled = false; } } catch (e) { err(e); } finally { if (btn) btn.disabled = false; }
} }
+1
View File
@@ -185,6 +185,7 @@
kind: 'image', kind: 'image',
title: input.value.trim() || sectionTitle(), title: input.value.trim() || sectionTitle(),
url: up.url, url: up.url,
thumbUrl: up.thumbUrl || null,
sourceTitle: chapterTitle() sourceTitle: chapterTitle()
}); });
toast('Сохранено в «Мои материалы»', 'success'); toast('Сохранено в «Мои материалы»', 'success');
+23 -12
View File
@@ -89,6 +89,7 @@
.mm-swatch-none { background: repeating-linear-gradient(45deg,#fff,#fff 4px,#e2e8f0 4px,#e2e8f0 8px); } .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 { 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-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-check { opacity: .85; } }
@media (max-width: 768px) { @media (max-width: 768px) {
.mm-body { flex-direction: column; } .mm-body { flex-direction: column; }
@@ -234,6 +235,8 @@
let _cols = []; let _cols = [];
const _filter = { col: 'all', kind: 'all', q: '', sort: 'new', tag: '' }; const _filter = { col: 'all', kind: 'all', q: '', sort: 'new', tag: '' };
const _sel = new Set(); // ids selected for bulk actions 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 ── */ /* ── Move-to-collection select ── */
function moveSelect(m) { function moveSelect(m) {
@@ -261,7 +264,7 @@
if (m.kind === 'board' || m.kind === 'image') { if (m.kind === 'board' || m.kind === 'image') {
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb} return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
<a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.url)}" alt="" loading="lazy" draggable="false"/></a> <a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.thumb_url || m.url)}" alt="" loading="lazy" decoding="async" draggable="false"/></a>
<div class="mm-card-body"> <div class="mm-card-body">
${chip} ${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div> <div class="mm-card-title">${esc(m.title || kind)}</div>
@@ -400,12 +403,20 @@
return; return;
} }
const rows = filtered(); const rows = filtered();
grid.innerHTML = rows.length if (!rows.length) {
? rows.map(card).join('') grid.innerHTML = `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`; lucide.createIcons(); renderBulk(); return;
}
let html = rows.slice(0, _shown).map(card).join('');
if (rows.length > _shown) {
html += `<div class="mm-more" style="grid-column:1/-1"><button class="mm-btn" onclick="showMore()"><i data-lucide="chevron-down"></i> Показать ещё (${rows.length - _shown})</button></div>`;
}
grid.innerHTML = html;
lucide.createIcons(); lucide.createIcons();
renderBulk(); renderBulk();
} }
function showMore() { _shown += PAGE_SIZE; renderGrid(); }
window.showMore = showMore;
async function load() { async function load() {
try { try {
@@ -421,12 +432,12 @@
} }
/* ── Filters ── */ /* ── Filters ── */
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); } function setCol(key) { _filter.col = key; _shown = PAGE_SIZE; renderCols(); renderGrid(); }
function onKind(v) { _filter.kind = v; renderGrid(); } function onKind(v) { _filter.kind = v; _shown = PAGE_SIZE; renderGrid(); }
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); } function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); _shown = PAGE_SIZE; renderGrid(); }
function onSort(v) { _filter.sort = v; renderGrid(); } function onSort(v) { _filter.sort = v; _shown = PAGE_SIZE; renderGrid(); }
function filterTag(t) { _filter.tag = String(t || ''); renderTagFilter(); renderGrid(); } function filterTag(t) { _filter.tag = String(t || ''); _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); }
function clearTag() { _filter.tag = ''; renderTagFilter(); renderGrid(); } function clearTag() { _filter.tag = ''; _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); }
function renderTagFilter() { function renderTagFilter() {
const el = document.getElementById('mm-tagfilter'); const el = document.getElementById('mm-tagfilter');
if (!el) return; if (!el) return;
@@ -685,10 +696,10 @@
const up = await LS.uploadMaterialFile(fd); const up = await LS.uploadMaterialFile(fd);
if (o.materialId) { 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'); close(); load(); LS.toast('Изменения сохранены', 'success');
} else { } 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'); close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success');
} }
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; } } catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
+13 -7
View File
@@ -27,12 +27,17 @@
4. **`backend/tests/materials.test.js`** — CRUD, владелец (403/404), коллекции, share-копия + роль/owner, 4. **`backend/tests/materials.test.js`** — CRUD, владелец (403/404), коллекции, share-копия + роль/owner,
валидация URL, лимит числа, ссылочная чистка (прямой вызов хелпера на временном файле). валидация URL, лимит числа, ссылочная чистка (прямой вызов хелпера на временном файле).
## Фаза 2 — Производительность ✅ (частично) ## Фаза 2 — Производительность ✅
-`GET /api/materials` отдаёт **обрезанный** `body` (первые 1000 симв.) + флаг `body_trunc`; полный текст — -`GET /api/materials` отдаёт **обрезанный** `body` (первые 1000 симв.) + флаг `body_trunc`; полный текст —
ленивый `GET /api/materials/:id` (`getOne`, owner-only). Клиент `ensureFullBody()` подгружает перед ленивый `GET /api/materials/:id` (`getOne`, owner-only). Клиент `ensureFullBody()` подгружает перед
просмотром/правкой/флешкартой (иначе правка сохранила бы усечённый текст). просмотром/правкой/флешкартой (иначе правка сохранила бы усечённый текст).
- Пагинация/keyset — отложено (клиент пока фильтрует в памяти; включить при росте объёмов). - Пагинация рендера: клиент держит весь список (поиск/фильтр/сортировка в памяти), но в DOM рисует
- ⬜ Серверные миниатюры `board/image` — отложено (нужна обработка картинок; пока `loading=lazy`). `PAGE_SIZE=60` карточек + «Показать ещё»; `_shown` сбрасывается на смену фильтра. Снимает стоимость
рендера тысяч узлов, не ломая клиентский поиск (keyset на сервере не нужен на текущих объёмах).
- ✅ Серверные миниатюры `board/image`: `uploadPersonalFile` (sharp → webp ≤480px) возвращает `{url, thumbUrl}`;
колонка `thumb_url` (мигр. **074**); грид рисует `<img src=thumb_url||url>`, просмотр/скачивание/аннотация —
полный `url`. Чистится по ссылкам (releaseFileForUrl теперь матчит url **и** thumb_url); share копирует thumb;
квота считает файл+миниатюру. Клиентские сейверы (board-clip/material-save/textbook-clip/draw) пробрасывают `thumbUrl`.
## Фаза 3 — Доводка заложенных фич ✅ ## Фаза 3 — Доводка заложенных фич ✅
- ✅ UI тегов: ввод в модалках создания/правки + чипы на карточке (клик → фильтр) + пилюля активного фильтра. - ✅ UI тегов: ввод в модалках создания/правки + чипы на карточке (клик → фильтр) + пилюля активного фильтра.
@@ -45,10 +50,11 @@
- ✅ Множественный выбор (чекбокс на карточке) + панель массовых действий (переместить/удалить, reuse per-item API). - ✅ Множественный выбор (чекбокс на карточке) + панель массовых действий (переместить/удалить, reuse per-item API).
- ✅ Живое превью KaTeX в редакторе заметки (oninput → `mmPreview``mathHtml`). - ✅ Живое превью KaTeX в редакторе заметки (oninput → `mmPreview``mathHtml`).
### Статус ### Статус — ПЛАН V2 ВЫПОЛНЕН
Сделано и проверено: **Ф1 целиком** (16 backend-тестов), **Ф2/Ф3/Ф4 ✅** (headless-смоук `my-materials.html`: **Ф1–Ф4 ✅.** Backend: 19 тестов `materials.test.js` (CRUD/владелец/коллекции/share/URL-allowlist/квота/
синтаксис + рендер карточек с deep-link/тегами/чекбоксом + фильтр по тегу + bulk-bar + тинт папки). ссылочная чистка url+thumb/round-trip thumb_url). Frontend: headless-смоук `my-materials.html` (синтаксис +
Осталось ⬜ (инфра, отложено): пагинация/keyset списка, серверные миниатюры board/image. deep-link/теги/чекбокс/bulk/тинт папки + `<img>` на thumb_url + пагинация «Показать ещё»). sharp-пайплайн и
client-сейверы (board-clip/material-save/textbook-clip) проверены. Открытого из плана не осталось.
--- ---