Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 786419ce01 | |||
| abe84b9f90 |
@@ -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);
|
||||||
@@ -182,7 +184,31 @@ function uploadPersonalFile(req, res) {
|
|||||||
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
|
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json({ url: '/uploads/materials/' + req.file.filename });
|
// 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' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── GET /api/files/:id/download ─────────────────────────────────────── */
|
/* ── GET /api/files/:id/download ─────────────────────────────────────── */
|
||||||
|
|||||||
@@ -2,16 +2,69 @@
|
|||||||
/* Student-owned personal materials ("Мои материалы").
|
/* Student-owned personal materials ("Мои материалы").
|
||||||
* A user keeps copies of items saved from live lessons; the copies are
|
* A user keeps copies of items saved from live lessons; the copies are
|
||||||
* independent of the session lifecycle. */
|
* independent of the session lifecycle. */
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { emit } = require('../sse');
|
const { emit } = require('../sse');
|
||||||
|
|
||||||
const KINDS = ['board', 'note', 'link', 'image'];
|
const KINDS = ['board', 'note', 'link', 'image'];
|
||||||
|
|
||||||
|
// Personal uploads live here (mirrors fileController MATERIALS_DIR). Used for
|
||||||
|
// reference-counted file cleanup when the material(s) pointing at a file go away.
|
||||||
|
const MATERIALS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'materials');
|
||||||
|
|
||||||
|
// Soft per-user cap on the number of materials. Read at call time so tests can
|
||||||
|
// lower it via env. Byte quota is enforced separately at the upload endpoint.
|
||||||
|
function maxItems() { return Number(process.env.MATERIALS_MAX_ITEMS) || 2000; }
|
||||||
|
|
||||||
|
// Storable URLs are app-relative ("/…", not protocol-relative "//host") or
|
||||||
|
// http(s). Everything else (javascript:, data:, mailto:, …) is rejected: a saved
|
||||||
|
// link is rendered as <a href> on the owner's page AND can be handed out to a
|
||||||
|
// whole class via /share, so a bad scheme would be stored XSS.
|
||||||
|
// Returns the (length-capped) url, '' for empty, or undefined when invalid.
|
||||||
|
function safeUrl(raw) {
|
||||||
|
const u = String(raw == null ? '' : raw).trim();
|
||||||
|
if (!u) return '';
|
||||||
|
if (/^https?:\/\//i.test(u)) return u.slice(0, 2000);
|
||||||
|
if (u[0] === '/' && u[1] !== '/') return u.slice(0, 2000);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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') return fileBytes(url) + fileBytes(thumbUrl);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference-counted cleanup: unlink the file backing `url` only when NO material
|
||||||
|
// 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 = ? 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 */ } }
|
||||||
|
}
|
||||||
|
|
||||||
/* GET /api/materials — list the current user's saved materials + their collections */
|
/* GET /api/materials — list the current user's saved materials + their collections */
|
||||||
function list(req, res) {
|
function list(req, res) {
|
||||||
const uid = req.user.id;
|
const uid = req.user.id;
|
||||||
|
// Return only a body PREVIEW (first 1000 chars) to keep the payload small for
|
||||||
|
// note-heavy users; the full text is fetched on demand via GET /:id. body_trunc
|
||||||
|
// tells the client a lazy fetch is needed before viewing/editing the note.
|
||||||
const materials = db.prepare(`
|
const materials = db.prepare(`
|
||||||
SELECT id, kind, title, body, url, source_session_id, source_title, collection_id, tags, created_at
|
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, 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
|
||||||
@@ -26,6 +79,19 @@ function list(req, res) {
|
|||||||
res.json({ materials, collections });
|
res.json({ materials, collections });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GET /api/materials/:id — one material with its FULL body (lazy-loaded by the
|
||||||
|
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, 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' });
|
||||||
|
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
||||||
|
delete row.user_id;
|
||||||
|
res.json(row);
|
||||||
|
}
|
||||||
|
|
||||||
/* Validate that a collection id belongs to the user; returns null if unset/invalid. */
|
/* Validate that a collection id belongs to the user; returns null if unset/invalid. */
|
||||||
function ownCollectionId(raw, uid) {
|
function ownCollectionId(raw, uid) {
|
||||||
if (raw === null || raw === '' || raw === undefined) return null;
|
if (raw === null || raw === '' || raw === undefined) return null;
|
||||||
@@ -42,9 +108,21 @@ function create(req, res) {
|
|||||||
const kind = String(b.kind || '');
|
const kind = String(b.kind || '');
|
||||||
if (!KINDS.includes(kind)) return res.status(400).json({ error: 'invalid kind' });
|
if (!KINDS.includes(kind)) return res.status(400).json({ error: 'invalid kind' });
|
||||||
|
|
||||||
|
if (db.prepare('SELECT COUNT(*) AS n FROM student_materials WHERE user_id = ?').get(req.user.id).n >= maxItems())
|
||||||
|
return res.status(413).json({ error: 'Достигнут лимит числа материалов' });
|
||||||
|
|
||||||
const title = String(b.title || '').slice(0, 300);
|
const title = String(b.title || '').slice(0, 300);
|
||||||
const body = b.body != null ? String(b.body).slice(0, 60000) : null;
|
const body = b.body != null ? String(b.body).slice(0, 60000) : null;
|
||||||
const url = b.url != null ? String(b.url).slice(0, 2000) : null;
|
let url = null;
|
||||||
|
if (b.url != null && b.url !== '') {
|
||||||
|
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 === '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' });
|
||||||
@@ -58,38 +136,56 @@ 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, 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)
|
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);
|
`).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 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 || {};
|
||||||
const fields = [], args = [];
|
const fields = [], args = [];
|
||||||
if (b.title !== undefined) { fields.push('title = ?'); args.push(String(b.title || '').slice(0, 300)); }
|
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.body !== undefined) { fields.push('body = ?'); args.push(b.body != null ? String(b.body).slice(0, 60000) : null); }
|
||||||
if (b.url !== undefined) { fields.push('url = ?'); args.push(b.url != null ? String(b.url).slice(0, 2000) : null); }
|
if (b.url !== undefined) {
|
||||||
|
let nu = null;
|
||||||
|
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.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(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 });
|
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 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 full image if no other material aliases it
|
||||||
|
releaseFileForUrl(row.thumb_url); // …and its thumbnail
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,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' });
|
||||||
|
|
||||||
@@ -164,12 +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 ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title) 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;
|
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);
|
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' });
|
||||||
@@ -180,4 +277,6 @@ function share(req, res) {
|
|||||||
res.json({ ok: true, sent });
|
res.json({ ok: true, sent });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { list, create, update, remove, createCollection, updateCollection, deleteCollection, share };
|
module.exports = { list, getOne, create, update, remove, createCollection, updateCollection, deleteCollection, share,
|
||||||
|
// exported for tests / reuse
|
||||||
|
safeUrl, measureBytes, releaseFileForUrl };
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 073: Storage accounting for «Мои материалы»
|
||||||
|
--
|
||||||
|
-- Per-material byte size, used to enforce a per-user storage quota and to free
|
||||||
|
-- orphaned files. Populated on create/update:
|
||||||
|
-- image|board → size of the file on disk (server-measured)
|
||||||
|
-- note → text length
|
||||||
|
-- link → 0
|
||||||
|
-- Existing rows default to 0 (the next edit recomputes them; quota is a soft cap).
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE student_materials ADD COLUMN bytes INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -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;
|
||||||
@@ -19,6 +19,8 @@ router.patch('/collections/:id', c.updateCollection);
|
|||||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
router.delete('/collections/:id', c.deleteCollection);
|
router.delete('/collections/:id', c.deleteCollection);
|
||||||
|
|
||||||
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
|
router.get('/:id', c.getOne);
|
||||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
router.patch('/:id', c.update);
|
router.patch('/:id', c.update);
|
||||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Integration tests: /api/materials — «Мои материалы» (v2 hardening).
|
||||||
|
* Covers: auth, CRUD happy-path, ownership (чужой PATCH/DELETE → 403, 404),
|
||||||
|
* collections (create / move / delete keeps material), share-копия (роль + owner
|
||||||
|
* + привязка ученика), URL-allowlist (javascript: → 400), лимит числа материалов,
|
||||||
|
* и ссылочно-подсчётную чистку файла (releaseFileForUrl на временном файле).
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||||
|
|
||||||
|
// setup.js не монтирует /api/materials — монтируем на общий тест-app.
|
||||||
|
app.use('/api/materials', require('../src/routes/materials'));
|
||||||
|
const ctrl = require('../src/controllers/studentMaterialsController');
|
||||||
|
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
describe('/api/materials', () => {
|
||||||
|
let studentToken, studentId, otherToken, teacherToken, teacherId;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const s = await getToken('student'); studentToken = s.token; studentId = s.userId;
|
||||||
|
otherToken = (await getToken('student')).token;
|
||||||
|
const t = await getToken('teacher'); teacherToken = t.token; teacherId = t.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET requires auth (401 without token)', async () => {
|
||||||
|
const res = await inject('GET', '/api/materials', null, null);
|
||||||
|
assert.equal(res.status, 401, `got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
let noteId;
|
||||||
|
it('student can create a note (201) and list it back', async () => {
|
||||||
|
const c = await inject('POST', '/api/materials', { kind: 'note', title: 'Закон Ома', body: 'U=IR' }, studentToken);
|
||||||
|
assert.equal(c.status, 201, JSON.stringify(c.body));
|
||||||
|
noteId = c.body.id;
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.equal(l.status, 200);
|
||||||
|
assert.ok(l.body.materials.some(m => m.id === noteId && m.title === 'Закон Ома'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts http(s) and app-relative link urls', async () => {
|
||||||
|
const a = await inject('POST', '/api/materials', { kind: 'link', url: 'https://example.com/x' }, studentToken);
|
||||||
|
assert.equal(a.status, 201, JSON.stringify(a.body));
|
||||||
|
const b = await inject('POST', '/api/materials', { kind: 'link', url: '/textbook/phys7#sec-1' }, studentToken);
|
||||||
|
assert.equal(b.status, 201, JSON.stringify(b.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a link with javascript: scheme (400) — stored-XSS guard', async () => {
|
||||||
|
const res = await inject('POST', '/api/materials', { kind: 'link', url: 'javascript:alert(1)' }, studentToken);
|
||||||
|
assert.equal(res.status, 400, JSON.stringify(res.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a protocol-relative url (400)', async () => {
|
||||||
|
const res = await inject('POST', '/api/materials', { kind: 'link', url: '//evil.example.com' }, studentToken);
|
||||||
|
assert.equal(res.status, 400, JSON.stringify(res.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH cannot smuggle a javascript: url (400)', async () => {
|
||||||
|
const res = await inject('PATCH', `/api/materials/${noteId}`, { url: 'javascript:alert(1)' }, studentToken);
|
||||||
|
assert.equal(res.status, 400, JSON.stringify(res.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner can rename; others get 403; missing → 404', async () => {
|
||||||
|
const ok = await inject('PATCH', `/api/materials/${noteId}`, { title: 'Ом' }, studentToken);
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
const forbidden = await inject('PATCH', `/api/materials/${noteId}`, { title: 'hack' }, otherToken);
|
||||||
|
assert.equal(forbidden.status, 403);
|
||||||
|
const missing = await inject('PATCH', '/api/materials/999999', { title: 'x' }, studentToken);
|
||||||
|
assert.equal(missing.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list returns a 1000-char body preview; GET /:id returns the full body (owner only)', async () => {
|
||||||
|
const big = 'x'.repeat(1500);
|
||||||
|
const c = await inject('POST', '/api/materials', { kind: 'note', body: big }, studentToken);
|
||||||
|
const id = c.body.id;
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
const row = l.body.materials.find(m => m.id === id);
|
||||||
|
assert.equal(row.body.length, 1000, 'preview trimmed');
|
||||||
|
assert.equal(row.body_trunc, 1, 'truncation flagged');
|
||||||
|
const one = await inject('GET', `/api/materials/${id}`, null, studentToken);
|
||||||
|
assert.equal(one.status, 200);
|
||||||
|
assert.equal(one.body.body.length, 1500, 'full body returned');
|
||||||
|
assert.equal(one.body.user_id, undefined, 'user_id not leaked');
|
||||||
|
const forbidden = await inject('GET', `/api/materials/${id}`, null, otherToken);
|
||||||
|
assert.equal(forbidden.status, 403);
|
||||||
|
const missing = await inject('GET', '/api/materials/999999', null, studentToken);
|
||||||
|
assert.equal(missing.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collections: create, move material in, delete keeps material (uncategorised)', async () => {
|
||||||
|
const col = await inject('POST', '/api/materials/collections', { name: 'Физика' }, studentToken);
|
||||||
|
assert.equal(col.status, 201, JSON.stringify(col.body));
|
||||||
|
const cid = col.body.id;
|
||||||
|
const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: cid }, studentToken);
|
||||||
|
assert.equal(mv.status, 200);
|
||||||
|
let l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, cid);
|
||||||
|
assert.equal(l.body.collections.find(c => c.id === cid).count, 1);
|
||||||
|
const del = await inject('DELETE', `/api/materials/collections/${cid}`, null, studentToken);
|
||||||
|
assert.equal(del.status, 200);
|
||||||
|
l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null, 'material survives folder delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moving into another user\'s collection is ignored (collection_id stays null)', async () => {
|
||||||
|
const col = await inject('POST', '/api/materials/collections', { name: 'Чужая' }, otherToken);
|
||||||
|
const foreignCid = col.body.id;
|
||||||
|
const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: foreignCid }, studentToken);
|
||||||
|
assert.equal(mv.status, 200);
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('share: student is role-gated (403)', async () => {
|
||||||
|
const res = await inject('POST', `/api/materials/${noteId}/share`, { userId: studentId }, studentToken);
|
||||||
|
assert.equal(res.status, 403, JSON.stringify(res.body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('share: teacher → linked student copies the material; unlinked → 403', async () => {
|
||||||
|
const tNote = await inject('POST', '/api/materials', { kind: 'note', title: 'Раздатка', body: 'привет' }, teacherToken);
|
||||||
|
const tId = tNote.body.id;
|
||||||
|
// not linked yet
|
||||||
|
const denied = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken);
|
||||||
|
assert.equal(denied.status, 403, JSON.stringify(denied.body));
|
||||||
|
// link teacher → student, then share
|
||||||
|
db.prepare('INSERT INTO teacher_students (teacher_id, student_id) VALUES (?, ?)').run(teacherId, studentId);
|
||||||
|
const ok = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken);
|
||||||
|
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||||
|
assert.equal(ok.body.sent, 1);
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.ok(l.body.materials.some(m => m.title === 'Раздатка' && /Раздатка:/.test(m.source_title || '')), 'student received a copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the per-user item cap (413)', async () => {
|
||||||
|
const q = await getToken('student');
|
||||||
|
const prev = process.env.MATERIALS_MAX_ITEMS;
|
||||||
|
process.env.MATERIALS_MAX_ITEMS = '3';
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const r = await inject('POST', '/api/materials', { kind: 'note', body: 'n' + i }, q.token);
|
||||||
|
assert.equal(r.status, 201, `create #${i}: ${JSON.stringify(r.body)}`);
|
||||||
|
}
|
||||||
|
const over = await inject('POST', '/api/materials', { kind: 'note', body: 'overflow' }, q.token);
|
||||||
|
assert.equal(over.status, 413, JSON.stringify(over.body));
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.MATERIALS_MAX_ITEMS; else process.env.MATERIALS_MAX_ITEMS = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delete removes the row; owner only', async () => {
|
||||||
|
const m = await inject('POST', '/api/materials', { kind: 'note', body: 'temp' }, studentToken);
|
||||||
|
const id = m.body.id;
|
||||||
|
const forbidden = await inject('DELETE', `/api/materials/${id}`, null, otherToken);
|
||||||
|
assert.equal(forbidden.status, 403);
|
||||||
|
const ok = await inject('DELETE', `/api/materials/${id}`, null, studentToken);
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||||
|
assert.ok(!l.body.materials.some(x => x.id === id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releaseFileForUrl: unlinks the file only when no material references it', () => {
|
||||||
|
const dir = path.join(__dirname, '..', 'uploads', 'materials');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const fname = 'test_' + Date.now() + '.png';
|
||||||
|
const fpath = path.join(dir, fname);
|
||||||
|
const url = '/uploads/materials/' + fname;
|
||||||
|
fs.writeFileSync(fpath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||||
|
const ins = db.prepare('INSERT INTO student_materials (user_id, kind, url) VALUES (?, ?, ?)');
|
||||||
|
const r1 = ins.run(studentId, 'image', url).lastInsertRowid;
|
||||||
|
const r2 = ins.run(studentId, 'image', url).lastInsertRowid; // aliasing copy (как при share)
|
||||||
|
|
||||||
|
ctrl.releaseFileForUrl(url);
|
||||||
|
assert.ok(fs.existsSync(fpath), 'file kept while two rows reference it');
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM student_materials WHERE id = ?').run(r1);
|
||||||
|
ctrl.releaseFileForUrl(url);
|
||||||
|
assert.ok(fs.existsSync(fpath), 'file kept while one row still references it');
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM student_materials WHERE id = ?').run(r2);
|
||||||
|
ctrl.releaseFileForUrl(url);
|
||||||
|
assert.ok(!fs.existsSync(fpath), 'file unlinked once orphaned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('safeUrl / measureBytes behave as documented', () => {
|
||||||
|
assert.equal(ctrl.safeUrl('https://a.b/c'), 'https://a.b/c');
|
||||||
|
assert.equal(ctrl.safeUrl('/textbook/x'), '/textbook/x');
|
||||||
|
assert.equal(ctrl.safeUrl(''), '');
|
||||||
|
assert.equal(ctrl.safeUrl('javascript:x'), undefined);
|
||||||
|
assert.equal(ctrl.safeUrl('//host'), undefined);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
+227
-32
@@ -70,6 +70,27 @@
|
|||||||
.mm-viewer-note { white-space: pre-wrap; word-break: break-word; line-height: 1.6; font-size: 0.9rem; color: var(--text-2); }
|
.mm-viewer-note { white-space: pre-wrap; word-break: break-word; line-height: 1.6; font-size: 0.9rem; color: var(--text-2); }
|
||||||
.mm-empty { padding: 60px 20px; text-align: center; color: var(--text-3); }
|
.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; }
|
.mm-empty svg { width: 38px; height: 38px; opacity: 0.4; margin-bottom: 12px; }
|
||||||
|
.mm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||||
|
.mm-tag { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 99px; background: rgba(6,182,212,0.12); color: #0891b2; cursor: pointer; transition: background .12s; }
|
||||||
|
.mm-tag:hover { background: rgba(6,182,212,0.24); }
|
||||||
|
.mm-src { color: var(--text-3); text-decoration: none; border-bottom: 1px dotted var(--text-3); }
|
||||||
|
.mm-src:hover { color: var(--violet); border-bottom-color: var(--violet); }
|
||||||
|
.mm-tagpill { display: inline-flex; align-items: center; gap: 4px; font-size: .76rem; font-weight: 600; padding: 6px 10px; border-radius: 9px; background: rgba(155,93,229,0.12); color: var(--violet); }
|
||||||
|
.mm-tagpill-x { display: inline-flex; cursor: pointer; }
|
||||||
|
.mm-tagpill-x svg { width: 13px; height: 13px; }
|
||||||
|
.mm-check { position: absolute; top: 10px; left: 10px; z-index: 3; width: 18px; height: 18px; cursor: pointer; accent-color: var(--violet); opacity: 0; transition: opacity .12s; }
|
||||||
|
.mm-card:hover .mm-check, .mm-check:checked { opacity: 1; }
|
||||||
|
.mm-card.mm-selected { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.35); }
|
||||||
|
.mm-bulk { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; padding: 10px 12px; border: 1px solid var(--violet); border-radius: 10px; background: rgba(155,93,229,0.06); }
|
||||||
|
.mm-bulk-count { font-weight: 700; font-size: .84rem; color: var(--violet); margin-right: auto; }
|
||||||
|
.mm-swatches { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
|
||||||
|
.mm-swatch { width: 26px; height: 26px; border-radius: 8px; cursor: pointer; border: 2px solid transparent; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); }
|
||||||
|
.mm-swatch.on { border-color: var(--text); }
|
||||||
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.mm-body { flex-direction: column; }
|
.mm-body { flex-direction: column; }
|
||||||
.mm-rail { width: auto; position: static; flex-direction: row; overflow-x: auto; gap: 6px; padding-bottom: 4px; }
|
.mm-rail { width: auto; position: static; flex-direction: row; overflow-x: auto; gap: 6px; padding-bottom: 4px; }
|
||||||
@@ -109,7 +130,15 @@
|
|||||||
<option value="note">Заметки</option>
|
<option value="note">Заметки</option>
|
||||||
<option value="link">Ссылки</option>
|
<option value="link">Ссылки</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select class="mm-kind" id="mm-sort" onchange="onSort(this.value)" title="Сортировка">
|
||||||
|
<option value="new">Сначала новые</option>
|
||||||
|
<option value="old">Сначала старые</option>
|
||||||
|
<option value="title">По названию</option>
|
||||||
|
<option value="kind">По типу</option>
|
||||||
|
</select>
|
||||||
|
<span id="mm-tagfilter"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mm-bulk" class="mm-bulk" style="display:none"></div>
|
||||||
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
|
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +178,9 @@
|
|||||||
}
|
}
|
||||||
return tmp.innerHTML;
|
return tmp.innerHTML;
|
||||||
}
|
}
|
||||||
|
/* Live formula preview for the note editor (renders $…$ as you type). */
|
||||||
|
function mmPreview(ta, prevId) { const p = document.getElementById(prevId); if (p) p.innerHTML = mathHtml(ta.value); }
|
||||||
|
window.mmPreview = mmPreview;
|
||||||
function fmtDate(s) {
|
function fmtDate(s) {
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
try { const d = new Date(s.replace(' ', 'T') + (s.includes('Z') ? '' : 'Z'));
|
try { const d = new Date(s.replace(' ', 'T') + (s.includes('Z') ? '' : 'Z'));
|
||||||
@@ -170,9 +202,41 @@
|
|||||||
if (u.startsWith('/lab')) return 'Лаборатория';
|
if (u.startsWith('/lab')) return 'Лаборатория';
|
||||||
return 'Ссылка';
|
return 'Ссылка';
|
||||||
}
|
}
|
||||||
|
function parseTags(s) { return String(s || '').split(',').map(t => t.trim()).filter(Boolean); }
|
||||||
|
/* Only trust folder colors that look like a hex value (guards inline-style injection). */
|
||||||
|
function safeColor(c) { return /^#[0-9a-fA-F]{3,8}$/.test(String(c || '')) ? c : ''; }
|
||||||
|
|
||||||
|
/* Meta line: source title links back to the originating lesson when known. */
|
||||||
|
function metaHtml(m) {
|
||||||
|
const date = fmtDate(m.created_at);
|
||||||
|
let src = '';
|
||||||
|
if (m.source_title) {
|
||||||
|
src = m.source_session_id
|
||||||
|
? `<a class="mm-src" href="/my-lessons?session=${Number(m.source_session_id)}" title="Открыть исходный урок">${esc(m.source_title)}</a>`
|
||||||
|
: esc(m.source_title);
|
||||||
|
src += ' · ';
|
||||||
|
}
|
||||||
|
return src + esc(date);
|
||||||
|
}
|
||||||
|
/* Tag chips (click → filter). data-t carries the raw value, dodging JS-string injection. */
|
||||||
|
function tagsHtml(m) {
|
||||||
|
const tg = parseTags(m.tags);
|
||||||
|
if (!tg.length) return '';
|
||||||
|
return `<div class="mm-tags">${tg.map(t => `<span class="mm-tag" data-t="${esc(t)}" onclick="filterTag(this.dataset.t)">${esc(t)}</span>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
/* Lazy-load the full note body — the list endpoint returns only a 1000-char preview. */
|
||||||
|
async function ensureFullBody(m) {
|
||||||
|
if (!m || !m.body_trunc) return m;
|
||||||
|
try { const full = await LS.getMaterial(m.id); if (full && typeof full.body === 'string') { m.body = full.body; m.body_trunc = 0; } } catch (e) {}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
let _mats = [];
|
let _mats = [];
|
||||||
let _cols = [];
|
let _cols = [];
|
||||||
const _filter = { col: 'all', kind: 'all', q: '' };
|
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 ── */
|
/* ── Move-to-collection select ── */
|
||||||
function moveSelect(m) {
|
function moveSelect(m) {
|
||||||
@@ -183,7 +247,10 @@
|
|||||||
|
|
||||||
function card(m) {
|
function card(m) {
|
||||||
const kind = KIND_LABEL[m.kind] || m.kind;
|
const kind = KIND_LABEL[m.kind] || m.kind;
|
||||||
const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`;
|
const meta = metaHtml(m);
|
||||||
|
const tags = tagsHtml(m);
|
||||||
|
const selCls = _sel.has(m.id) ? ' mm-selected' : '';
|
||||||
|
const cb = `<input type="checkbox" class="mm-check" ${_sel.has(m.id) ? 'checked' : ''} onclick="toggleSel(event,${m.id})" title="Выбрать" />`;
|
||||||
const chip = `<span class="mm-kind-chip"><i data-lucide="${KIND_ICON[m.kind] || 'tag'}"></i>${kind}</span>`;
|
const chip = `<span class="mm-kind-chip"><i data-lucide="${KIND_ICON[m.kind] || 'tag'}"></i>${kind}</span>`;
|
||||||
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})" title="Удалить"><i data-lucide="trash-2"></i></button>`;
|
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})" title="Удалить"><i data-lucide="trash-2"></i></button>`;
|
||||||
const edit = `<button class="mm-btn" onclick="editMaterial(${m.id})" title="Изменить"><i data-lucide="pencil"></i></button>`;
|
const edit = `<button class="mm-btn" onclick="editMaterial(${m.id})" title="Изменить"><i data-lucide="pencil"></i></button>`;
|
||||||
@@ -196,12 +263,12 @@
|
|||||||
const mv = moveSelect(m);
|
const mv = moveSelect(m);
|
||||||
|
|
||||||
if (m.kind === 'board' || m.kind === 'image') {
|
if (m.kind === 'board' || m.kind === 'image') {
|
||||||
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
|
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>
|
||||||
<div class="mm-card-meta">${meta}</div>
|
<div class="mm-card-meta">${meta}</div>${tags}
|
||||||
<div class="mm-card-actions">
|
<div class="mm-card-actions">
|
||||||
${mv}
|
${mv}
|
||||||
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
|
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
|
||||||
@@ -213,7 +280,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (m.kind === 'link') {
|
if (m.kind === 'link') {
|
||||||
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
|
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
|
||||||
<a class="mm-card-link" href="${esc(m.url)}" target="_blank" rel="noopener" title="${esc(m.url)}">
|
<a class="mm-card-link" href="${esc(m.url)}" target="_blank" rel="noopener" title="${esc(m.url)}">
|
||||||
<span class="mm-card-link-ic"><i data-lucide="link"></i></span>
|
<span class="mm-card-link-ic"><i data-lucide="link"></i></span>
|
||||||
<span class="mm-card-link-meta">
|
<span class="mm-card-link-meta">
|
||||||
@@ -224,7 +291,7 @@
|
|||||||
<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>
|
||||||
<div class="mm-card-meta">${meta}</div>
|
<div class="mm-card-meta">${meta}</div>${tags}
|
||||||
<div class="mm-card-actions">
|
<div class="mm-card-actions">
|
||||||
${mv}
|
${mv}
|
||||||
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
|
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
|
||||||
@@ -235,29 +302,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// note
|
// note
|
||||||
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
|
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
|
||||||
<div class="mm-card-note">${mathHtml(m.body || '')}</div>
|
<div class="mm-card-note">${mathHtml(m.body || '')}</div>
|
||||||
<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>
|
||||||
<div class="mm-card-meta">${meta}</div>
|
<div class="mm-card-meta">${meta}</div>${tags}
|
||||||
<div class="mm-card-actions">${mv}${fc}${sh}${edit}${del}</div>
|
<div class="mm-card-actions">${mv}${fc}${sh}${edit}${del}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Folder rail (вертикальный список папок слева) ── */
|
/* ── Folder rail (вертикальный список папок слева) ── */
|
||||||
function railItem(key, label, count, editId, droppable) {
|
function railItem(key, label, count, editId, droppable, color) {
|
||||||
const active = _filter.col === key ? ' active' : '';
|
const active = _filter.col === key ? ' active' : '';
|
||||||
const ed = editId
|
const ed = editId
|
||||||
? `<span class="mm-rail-edit" onclick="event.stopPropagation();editCollection(${editId})" title="Изменить папку">${PENCIL}</span>`
|
? `<span class="mm-rail-edit" onclick="event.stopPropagation();editCollection(${editId})" title="Изменить папку">${PENCIL}</span>`
|
||||||
: '';
|
: '';
|
||||||
const ic = key === 'all' ? 'inbox' : (key === 'none' ? 'folder-minus' : 'folder');
|
const ic = key === 'all' ? 'inbox' : (key === 'none' ? 'folder-minus' : 'folder');
|
||||||
|
const tint = safeColor(color);
|
||||||
|
const icStyle = (tint && !active) ? ` style="color:${tint}"` : '';
|
||||||
const drop = droppable
|
const drop = droppable
|
||||||
? ` ondragover="mmDragOver(event,this)" ondragleave="mmDragLeave(this)" ondrop="mmDrop(event,'${key}')"`
|
? ` ondragover="mmDragOver(event,this)" ondragleave="mmDragLeave(this)" ondrop="mmDrop(event,'${key}')"`
|
||||||
: '';
|
: '';
|
||||||
return `<div class="mm-rail-item${active}" onclick="setCol('${key}')"${drop}>
|
return `<div class="mm-rail-item${active}" onclick="setCol('${key}')"${drop}>
|
||||||
<i data-lucide="${ic}"></i>
|
<i data-lucide="${ic}"${icStyle}></i>
|
||||||
<span class="mm-rail-label">${esc(label)}</span>
|
<span class="mm-rail-label">${esc(label)}</span>
|
||||||
<span class="mm-rail-count">${count}</span>${ed}
|
<span class="mm-rail-count">${count}</span>${ed}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -266,7 +335,7 @@
|
|||||||
const bar = document.getElementById('mm-cols');
|
const bar = document.getElementById('mm-cols');
|
||||||
const noneCount = _mats.filter(m => !m.collection_id).length;
|
const noneCount = _mats.filter(m => !m.collection_id).length;
|
||||||
let html = railItem('all', 'Все', _mats.length, null, false);
|
let html = railItem('all', 'Все', _mats.length, null, false);
|
||||||
_cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true); });
|
_cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true, c.color); });
|
||||||
html += railItem('none', 'Без папки', noneCount, null, true);
|
html += railItem('none', 'Без папки', noneCount, null, true);
|
||||||
bar.innerHTML = html;
|
bar.innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -299,17 +368,28 @@
|
|||||||
window.mmDragStart = mmDragStart; window.mmDragEnd = mmDragEnd;
|
window.mmDragStart = mmDragStart; window.mmDragEnd = mmDragEnd;
|
||||||
window.mmDragOver = mmDragOver; window.mmDragLeave = mmDragLeave; window.mmDrop = mmDrop;
|
window.mmDragOver = mmDragOver; window.mmDragLeave = mmDragLeave; window.mmDrop = mmDrop;
|
||||||
|
|
||||||
|
function sortRows(rows) {
|
||||||
|
const s = _filter.sort || 'new';
|
||||||
|
if (s === 'new') return rows; // server already returns newest-first
|
||||||
|
const a = rows.slice();
|
||||||
|
if (s === 'old') a.reverse();
|
||||||
|
else if (s === 'title') a.sort((x, y) => (x.title || x.body || '').localeCompare(y.title || y.body || '', 'ru'));
|
||||||
|
else if (s === 'kind') a.sort((x, y) => (x.kind || '').localeCompare(y.kind || ''));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
function filtered() {
|
function filtered() {
|
||||||
return _mats.filter(m => {
|
const rows = _mats.filter(m => {
|
||||||
if (_filter.col === 'none' && m.collection_id) return false;
|
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.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.kind !== 'all' && m.kind !== _filter.kind) return false;
|
||||||
|
if (_filter.tag && !parseTags(m.tags).includes(_filter.tag)) return false;
|
||||||
if (_filter.q) {
|
if (_filter.q) {
|
||||||
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
|
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
|
||||||
if (!hay.includes(_filter.q)) return false;
|
if (!hay.includes(_filter.q)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
return sortRows(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGrid() {
|
function renderGrid() {
|
||||||
@@ -323,11 +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;
|
||||||
lucide.createIcons();
|
|
||||||
}
|
}
|
||||||
|
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();
|
||||||
|
renderBulk();
|
||||||
|
}
|
||||||
|
function showMore() { _shown += PAGE_SIZE; renderGrid(); }
|
||||||
|
window.showMore = showMore;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -336,16 +425,73 @@
|
|||||||
_cols = data.collections || [];
|
_cols = data.collections || [];
|
||||||
renderCols();
|
renderCols();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
|
renderTagFilter();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('mm-grid').innerHTML = `<div class="mm-empty" style="grid-column:1/-1">Ошибка загрузки</div>`;
|
document.getElementById('mm-grid').innerHTML = `<div class="mm-empty" style="grid-column:1/-1">Ошибка загрузки</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 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; _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;
|
||||||
|
el.innerHTML = _filter.tag
|
||||||
|
? `<span class="mm-tagpill">#${esc(_filter.tag)} <span class="mm-tagpill-x" onclick="clearTag()" title="Сбросить фильтр по тегу"><i data-lucide="x"></i></span></span>`
|
||||||
|
: '';
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
|
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
|
||||||
|
window.onSort = onSort; window.filterTag = filterTag; window.clearTag = clearTag;
|
||||||
|
|
||||||
|
/* ── Multi-select + bulk actions (reuse per-item endpoints) ── */
|
||||||
|
function renderBulk() {
|
||||||
|
const bar = document.getElementById('mm-bulk');
|
||||||
|
if (!bar) return;
|
||||||
|
const n = _sel.size;
|
||||||
|
if (!n) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
|
||||||
|
const opts = ['<option value="__none">Без папки</option>']
|
||||||
|
.concat(_cols.map(c => `<option value="${c.id}">${esc(c.name)}</option>`)).join('');
|
||||||
|
bar.style.display = 'flex';
|
||||||
|
bar.innerHTML = `<span class="mm-bulk-count">Выбрано: ${n}</span>
|
||||||
|
<select class="mm-move" onchange="bulkMove(this.value)" title="Переместить выбранные"><option value="">Переместить в…</option>${opts}</select>
|
||||||
|
<button class="mm-btn danger" onclick="bulkDelete()"><i data-lucide="trash-2"></i> Удалить</button>
|
||||||
|
<button class="mm-btn" onclick="clearSel()"><i data-lucide="x"></i> Снять</button>`;
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
function toggleSel(e, id) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const cb = e.target;
|
||||||
|
if (cb.checked) _sel.add(id); else _sel.delete(id);
|
||||||
|
const cardEl = cb.closest('.mm-card');
|
||||||
|
if (cardEl) cardEl.classList.toggle('mm-selected', cb.checked);
|
||||||
|
renderBulk();
|
||||||
|
}
|
||||||
|
function clearSel() { _sel.clear(); renderGrid(); }
|
||||||
|
async function bulkMove(v) {
|
||||||
|
if (v === '') return;
|
||||||
|
const cid = v === '__none' ? null : Number(v);
|
||||||
|
const ids = [..._sel];
|
||||||
|
try {
|
||||||
|
for (const id of ids) await LS.updateMaterial(id, { collection_id: cid });
|
||||||
|
_sel.clear(); load(); LS.toast('Перемещено: ' + ids.length, 'success');
|
||||||
|
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
|
}
|
||||||
|
async function bulkDelete() {
|
||||||
|
const ids = [..._sel];
|
||||||
|
if (!ids.length) return;
|
||||||
|
if (!await LS.confirm(`Будет удалено материалов: ${ids.length}. Действие необратимо.`, { title: 'Удалить выбранные?', confirmText: 'Удалить' })) return;
|
||||||
|
try {
|
||||||
|
for (const id of ids) await LS.deleteMaterial(id);
|
||||||
|
_sel.clear(); load(); LS.toast('Удалено: ' + ids.length, 'success');
|
||||||
|
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
|
}
|
||||||
|
window.toggleSel = toggleSel; window.clearSel = clearSel; window.bulkMove = bulkMove; window.bulkDelete = bulkDelete;
|
||||||
|
|
||||||
/* ── Material actions ── */
|
/* ── Material actions ── */
|
||||||
async function moveMaterial(id, cid) {
|
async function moveMaterial(id, cid) {
|
||||||
@@ -365,46 +511,56 @@
|
|||||||
function createNote() {
|
function createNote() {
|
||||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
|
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
|
||||||
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
|
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки… (поддерживается $формула$)" style="${FLD};resize:vertical" oninput="mmPreview(this,'mm-nt-prev')"></textarea>
|
||||||
|
<div id="mm-nt-prev" class="mm-preview"></div>
|
||||||
|
<input id="mm-nt-tags" placeholder="Теги через запятую (необязательно)" style="${FLD}" />
|
||||||
</div>`;
|
</div>`;
|
||||||
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
|
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
|
||||||
{ label: 'Отмена', onClick: () => m.close() },
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
{ label: 'Создать', primary: true, onClick: async () => {
|
{ label: 'Создать', primary: true, onClick: async () => {
|
||||||
const title = m.body.querySelector('#mm-nt-title').value.trim();
|
const title = m.body.querySelector('#mm-nt-title').value.trim();
|
||||||
const text = m.body.querySelector('#mm-nt-body').value.trim();
|
const text = m.body.querySelector('#mm-nt-body').value.trim();
|
||||||
|
const tags = m.body.querySelector('#mm-nt-tags').value.trim() || null;
|
||||||
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
|
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
|
||||||
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
|
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(); }
|
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col, tags }); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
] });
|
] });
|
||||||
}
|
}
|
||||||
window.createNote = createNote;
|
window.createNote = createNote;
|
||||||
|
|
||||||
function editMaterial(id) {
|
async function editMaterial(id) {
|
||||||
const mt = _mats.find(x => x.id === id);
|
const mt = _mats.find(x => x.id === id);
|
||||||
if (!mt) return;
|
if (!mt) return;
|
||||||
const isNote = mt.kind === 'note';
|
const isNote = mt.kind === 'note';
|
||||||
|
if (isNote) await ensureFullBody(mt);
|
||||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
|
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
|
||||||
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''}
|
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical" oninput="mmPreview(this,'mm-ed-prev')">${esc(mt.body || '')}</textarea><div id="mm-ed-prev" class="mm-preview"></div>` : ''}
|
||||||
|
<input id="mm-ed-tags" value="${esc(mt.tags || '')}" placeholder="Теги через запятую" style="${FLD}" />
|
||||||
</div>`;
|
</div>`;
|
||||||
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
|
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
|
||||||
{ label: 'Отмена', onClick: () => m.close() },
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
{ label: 'Сохранить', primary: true, onClick: async () => {
|
{ label: 'Сохранить', primary: true, onClick: async () => {
|
||||||
const data = { title: m.body.querySelector('#mm-ed-title').value.trim() };
|
const data = {
|
||||||
|
title: m.body.querySelector('#mm-ed-title').value.trim(),
|
||||||
|
tags: m.body.querySelector('#mm-ed-tags').value.trim() || null,
|
||||||
|
};
|
||||||
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
|
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
|
||||||
try { await LS.updateMaterial(id, data); m.close(); load(); }
|
try { await LS.updateMaterial(id, data); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
] });
|
] });
|
||||||
|
if (isNote) { const ta = m.body.querySelector('#mm-ed-body'); if (ta) mmPreview(ta, 'mm-ed-prev'); }
|
||||||
}
|
}
|
||||||
window.editMaterial = editMaterial;
|
window.editMaterial = editMaterial;
|
||||||
|
|
||||||
/* ── Просмотр материала в модалке (лайтбокс) ── */
|
/* ── Просмотр материала в модалке (лайтбокс) ── */
|
||||||
function openViewer(id) {
|
async function openViewer(id) {
|
||||||
const mt = _mats.find(x => x.id === id);
|
const mt = _mats.find(x => x.id === id);
|
||||||
if (!mt) return false;
|
if (!mt) return false;
|
||||||
|
if (mt.kind === 'note') await ensureFullBody(mt);
|
||||||
const kind = KIND_LABEL[mt.kind] || mt.kind;
|
const kind = KIND_LABEL[mt.kind] || mt.kind;
|
||||||
let body;
|
let body;
|
||||||
if (mt.kind === 'image' || mt.kind === 'board') {
|
if (mt.kind === 'image' || mt.kind === 'board') {
|
||||||
@@ -431,24 +587,61 @@
|
|||||||
window.openViewer = openViewer;
|
window.openViewer = openViewer;
|
||||||
|
|
||||||
/* ── Collection CRUD ── */
|
/* ── Collection CRUD ── */
|
||||||
|
const COL_PALETTE = ['#9b5de5', '#06b6d4', '#f97316', '#10b981', '#ef4444', '#eab308', '#3b82f6', '#ec4899'];
|
||||||
|
function colorPalette(sel) {
|
||||||
|
sel = safeColor(sel);
|
||||||
|
return `<div style="font-size:.78rem;color:var(--text-3)">Цвет</div>
|
||||||
|
<div class="mm-swatches">
|
||||||
|
<span class="mm-swatch mm-swatch-none${!sel ? ' on' : ''}" data-c="" onclick="pickSwatch(this)" title="Без цвета"></span>
|
||||||
|
${COL_PALETTE.map(c => `<span class="mm-swatch${sel === c ? ' on' : ''}" data-c="${c}" style="background:${c}" onclick="pickSwatch(this)"></span>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function pickSwatch(el) { el.parentNode.querySelectorAll('.mm-swatch').forEach(s => s.classList.remove('on')); el.classList.add('on'); }
|
||||||
|
function pickedColor(body) { const on = body.querySelector('.mm-swatch.on'); return on ? (on.dataset.c || null) : null; }
|
||||||
|
window.pickSwatch = pickSwatch;
|
||||||
|
|
||||||
function createCollection() {
|
function createCollection() {
|
||||||
const content = `<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />`;
|
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />
|
||||||
|
${colorPalette(null)}
|
||||||
|
</div>`;
|
||||||
const m = LS.modal({ title: 'Новая папка', content, size: 'sm', actions: [
|
const m = LS.modal({ title: 'Новая папка', content, size: 'sm', actions: [
|
||||||
{ label: 'Отмена', onClick: () => m.close() },
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
{ label: 'Создать', primary: true, onClick: async () => {
|
{ label: 'Создать', primary: true, onClick: async () => {
|
||||||
const name = m.body.querySelector('#mm-col-name').value.trim();
|
const name = m.body.querySelector('#mm-col-name').value.trim();
|
||||||
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
||||||
try { await LS.createMaterialCollection({ name }); m.close(); load(); }
|
try { await LS.createMaterialCollection({ name, color: pickedColor(m.body) }); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
] });
|
] });
|
||||||
}
|
}
|
||||||
window.createCollection = createCollection;
|
window.createCollection = createCollection;
|
||||||
|
|
||||||
|
/* Reorder a folder up/down by normalizing sort_order to the new index order. */
|
||||||
|
async function moveCollection(id, dir) {
|
||||||
|
const arr = _cols.slice();
|
||||||
|
const i = arr.findIndex(c => c.id === id);
|
||||||
|
const j = i + dir;
|
||||||
|
if (i < 0 || j < 0 || j >= arr.length) return;
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
try {
|
||||||
|
await Promise.all(arr.map((c, k) => c.sort_order !== k ? LS.updateMaterialCollection(c.id, { sortOrder: k }) : null).filter(Boolean));
|
||||||
|
load();
|
||||||
|
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
|
}
|
||||||
|
window.moveCollection = moveCollection;
|
||||||
|
|
||||||
function editCollection(id) {
|
function editCollection(id) {
|
||||||
const col = _cols.find(c => c.id === id);
|
const col = _cols.find(c => c.id === id);
|
||||||
if (!col) return;
|
if (!col) return;
|
||||||
const content = `<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />`;
|
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />
|
||||||
|
${colorPalette(col.color)}
|
||||||
|
<div style="display:flex;gap:8px;margin-top:2px">
|
||||||
|
<button class="mm-btn" onclick="moveCollection(${id},-1)"><i data-lucide="arrow-up"></i> Выше</button>
|
||||||
|
<button class="mm-btn" onclick="moveCollection(${id},1)"><i data-lucide="arrow-down"></i> Ниже</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
const m = LS.modal({ title: 'Папка', content, size: 'sm', actions: [
|
const m = LS.modal({ title: 'Папка', content, size: 'sm', actions: [
|
||||||
{ label: 'Удалить', onClick: async () => {
|
{ label: 'Удалить', onClick: async () => {
|
||||||
if (!await LS.confirm('Материалы из неё останутся и станут «Без папки».', { title: 'Удалить папку?', confirmText: 'Удалить' })) return;
|
if (!await LS.confirm('Материалы из неё останутся и станут «Без папки».', { title: 'Удалить папку?', confirmText: 'Удалить' })) return;
|
||||||
@@ -459,10 +652,11 @@
|
|||||||
{ label: 'Сохранить', primary: true, onClick: async () => {
|
{ label: 'Сохранить', primary: true, onClick: async () => {
|
||||||
const name = m.body.querySelector('#mm-col-name').value.trim();
|
const name = m.body.querySelector('#mm-col-name').value.trim();
|
||||||
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
||||||
try { await LS.updateMaterialCollection(id, { name }); m.close(); load(); }
|
try { await LS.updateMaterialCollection(id, { name, color: pickedColor(m.body) }); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
] });
|
] });
|
||||||
|
if (window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
window.editCollection = editCollection;
|
window.editCollection = editCollection;
|
||||||
|
|
||||||
@@ -502,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; }
|
||||||
@@ -525,6 +719,7 @@
|
|||||||
async function toFlashcard(id) {
|
async function toFlashcard(id) {
|
||||||
const mt = _mats.find(x => x.id === id);
|
const mt = _mats.find(x => x.id === id);
|
||||||
if (!mt) return;
|
if (!mt) return;
|
||||||
|
await ensureFullBody(mt);
|
||||||
let decks = [];
|
let decks = [];
|
||||||
try { const d = await LS.fcListDecks(); decks = d.decks || []; } catch (e) {}
|
try { const d = await LS.fcListDecks(); decks = d.decks || []; } catch (e) {}
|
||||||
const opts = ['<option value="__new">+ Новая колода «Из материалов»</option>']
|
const opts = ['<option value="__new">+ Новая колода «Из материалов»</option>']
|
||||||
|
|||||||
@@ -1037,7 +1037,7 @@ window.LS = {
|
|||||||
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
|
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
|
||||||
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
||||||
crAdminGetAllHistory, crAdminGetTeachersList,
|
crAdminGetAllHistory, crAdminGetTeachersList,
|
||||||
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
listMaterials, getMaterial, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
||||||
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
||||||
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
||||||
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
||||||
@@ -1253,6 +1253,7 @@ async function uploadMaterialFile(formData) {
|
|||||||
}
|
}
|
||||||
function downloadFileUrl(id) { return `${API}/files/${id}/download`; }
|
function downloadFileUrl(id) { return `${API}/files/${id}/download`; }
|
||||||
async function listMaterials() { return req('GET', '/materials'); }
|
async function listMaterials() { return req('GET', '/materials'); }
|
||||||
|
async function getMaterial(id) { return req('GET', `/materials/${id}`); }
|
||||||
async function saveMaterial(data) { return req('POST', '/materials', data); }
|
async function saveMaterial(data) { return req('POST', '/materials', data); }
|
||||||
async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); }
|
async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); }
|
||||||
async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); }
|
async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); }
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# «Мои материалы» — v2: харднинг и доводка
|
||||||
|
|
||||||
|
> Составлен Opus 2026-06-13. Базовый план (PLAN.md, Фазы 1–6) **полностью реализован**.
|
||||||
|
> Его раздел «Сквозные риски» отложил ровно то, что закрывает этот план: учёт/лимиты/чистку
|
||||||
|
> хранилища и `materials.test.js`. Источник истины по текущему состоянию — код
|
||||||
|
> (`studentMaterialsController.js`, `materials.js`, `my-materials.html`, `board-clip.js`,
|
||||||
|
> `material-save.js`) и [[reference_student_materials]].
|
||||||
|
|
||||||
|
Готчи проекта: новый `:id`-роут → `// @public-by-design` + проверка владельца; большие HTML — только Edit;
|
||||||
|
без эмодзи (inline SVG `.ic`); коммит поимённо + push; перезапуск сервера при правке backend; ветка
|
||||||
|
`feature/sim-builder` в рабочем дереве — НЕ коммитить чужие правки, только свои файлы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 1 — Целостность и безопасность (backend, фундамент) ✅ цель этого захода
|
||||||
|
|
||||||
|
1. **Ссылочно-подсчётная чистка файлов.** `DELETE /:id` и смена `url` (аннотация) сейчас оставляют
|
||||||
|
файл в `uploads/materials/` сиротой. `share` копирует `url` дословно → несколько строк ссылаются на
|
||||||
|
ОДИН файл, поэтому `unlink` только когда на `url` не ссылается ни одна строка. Хелпер
|
||||||
|
`releaseFileForUrl(url)` вызывается ПОСЛЕ delete/update.
|
||||||
|
2. **Allowlist схемы URL.** `create`/`update` принимали любой `url` → `link` со схемой `javascript:`
|
||||||
|
рендерится как рабочий `<a href>` (раздача делает это вектором учитель→ученики). Хелпер `safeUrl`:
|
||||||
|
только `http(s)://` или app-relative `/…` (не `//host`); иначе 400.
|
||||||
|
3. **Квота на пользователя.** Колонка `bytes` (мигр. 073), счёт `SUM(bytes)`/`COUNT(*)`. Лимит по числу
|
||||||
|
материалов — в `create()`; лимит по байтам — в `uploadPersonalFile` (до приёма файла). Конфигурируемо
|
||||||
|
через `MATERIALS_MAX_ITEMS` / `MATERIALS_MAX_BYTES` (для тестов — низкий потолок).
|
||||||
|
4. **`backend/tests/materials.test.js`** — CRUD, владелец (403/404), коллекции, share-копия + роль/owner,
|
||||||
|
валидация URL, лимит числа, ссылочная чистка (прямой вызов хелпера на временном файле).
|
||||||
|
|
||||||
|
## Фаза 2 — Производительность ✅
|
||||||
|
- ✅ `GET /api/materials` отдаёт **обрезанный** `body` (первые 1000 симв.) + флаг `body_trunc`; полный текст —
|
||||||
|
ленивый `GET /api/materials/:id` (`getOne`, owner-only). Клиент `ensureFullBody()` подгружает перед
|
||||||
|
просмотром/правкой/флешкартой (иначе правка сохранила бы усечённый текст).
|
||||||
|
- ✅ Пагинация рендера: клиент держит весь список (поиск/фильтр/сортировка в памяти), но в DOM рисует
|
||||||
|
`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 — Доводка заложенных фич ✅
|
||||||
|
- ✅ UI тегов: ввод в модалках создания/правки + чипы на карточке (клик → фильтр) + пилюля активного фильтра.
|
||||||
|
- ✅ Ссылка «открыть исходный урок» на карточке (`/my-lessons?session=<id>`, есть `source_session_id`).
|
||||||
|
- ✅ Цвет папки (палитра 8 пресетов, тинт иконки в рейле) + сортировка папок «Выше/Ниже» в модалке правки
|
||||||
|
(нормализует `sort_order` к индексам). `safeColor` гейтит inline-style инъекцию (только hex).
|
||||||
|
|
||||||
|
## Фаза 4 — UX ✅
|
||||||
|
- ✅ Варианты сортировки (новые/старые/имя/тип) — селект в тулбаре.
|
||||||
|
- ✅ Множественный выбор (чекбокс на карточке) + панель массовых действий (переместить/удалить, reuse per-item API).
|
||||||
|
- ✅ Живое превью KaTeX в редакторе заметки (oninput → `mmPreview` → `mathHtml`).
|
||||||
|
|
||||||
|
### Статус — ПЛАН 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/тинт папки + `<img>` на thumb_url + пагинация «Показать ещё»). sharp-пайплайн и
|
||||||
|
client-сейверы (board-clip/material-save/textbook-clip) проверены. Открытого из плана не осталось.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок
|
||||||
|
**Ф1 (этот заход) → Ф2 → Ф3 → Ф4.** Ф1 — серверный фундамент (риск-возврат, без него фронт-фичи множат
|
||||||
|
мусор). Дальше преимущественно фронтенд `my-materials.html` + точечные ручки API.
|
||||||
Reference in New Issue
Block a user