feat(materials): Фаза 2 — коллекции (папки), поиск и фильтры
- Миграция 061: material_collections + student_materials.collection_id (ON DELETE SET NULL) + tags.
- API: CRUD коллекций (/api/materials/collections), GET /materials отдаёт {materials, collections}
со счётчиками; PATCH /materials/:id принимает collection_id/tags. Хелперы в js/api.js.
- /my-materials: бар папок (Все/папки/Без папки/+папка) с фильтром, поиск по тексту, фильтр по типу,
перенос материала в папку (select на карточке), создание/переименование/удаление папок.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,15 +6,32 @@ const db = require('../db/db');
|
|||||||
|
|
||||||
const KINDS = ['board', 'note', 'link', 'image'];
|
const KINDS = ['board', 'note', 'link', 'image'];
|
||||||
|
|
||||||
/* GET /api/materials — list the current user's saved materials */
|
/* GET /api/materials — list the current user's saved materials + their collections */
|
||||||
function list(req, res) {
|
function list(req, res) {
|
||||||
const rows = db.prepare(`
|
const uid = req.user.id;
|
||||||
SELECT id, kind, title, body, url, source_session_id, source_title, created_at
|
const materials = db.prepare(`
|
||||||
|
SELECT id, kind, title, body, 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
|
||||||
`).all(req.user.id);
|
`).all(uid);
|
||||||
res.json({ materials: rows });
|
const collections = db.prepare(`
|
||||||
|
SELECT c.id, c.name, c.color, c.sort_order,
|
||||||
|
(SELECT COUNT(*) FROM student_materials m WHERE m.collection_id = c.id) AS count
|
||||||
|
FROM material_collections c
|
||||||
|
WHERE c.user_id = ?
|
||||||
|
ORDER BY c.sort_order, c.id
|
||||||
|
`).all(uid);
|
||||||
|
res.json({ materials, collections });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Validate that a collection id belongs to the user; returns null if unset/invalid. */
|
||||||
|
function ownCollectionId(raw, uid) {
|
||||||
|
if (raw === null || raw === '' || raw === undefined) return null;
|
||||||
|
const cid = Number(raw);
|
||||||
|
if (!Number.isFinite(cid)) return null;
|
||||||
|
const own = db.prepare('SELECT 1 FROM material_collections WHERE id = ? AND user_id = ?').get(cid, uid);
|
||||||
|
return own ? cid : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* POST /api/materials — save a new item to the current user's collection.
|
/* POST /api/materials — save a new item to the current user's collection.
|
||||||
@@ -37,11 +54,13 @@ function create(req, res) {
|
|||||||
if (!Number.isFinite(sourceSessionId)) sourceSessionId = null;
|
if (!Number.isFinite(sourceSessionId)) sourceSessionId = null;
|
||||||
else if (!db.prepare('SELECT 1 FROM classroom_sessions WHERE id = ?').get(sourceSessionId)) sourceSessionId = null;
|
else if (!db.prepare('SELECT 1 FROM classroom_sessions WHERE id = ?').get(sourceSessionId)) sourceSessionId = null;
|
||||||
const sourceTitle = b.sourceTitle != null ? String(b.sourceTitle).slice(0, 300) : null;
|
const sourceTitle = b.sourceTitle != null ? String(b.sourceTitle).slice(0, 300) : null;
|
||||||
|
const collectionId = ownCollectionId(b.collection_id, req.user.id);
|
||||||
|
const tags = b.tags != null ? String(b.tags).slice(0, 500) : null;
|
||||||
|
|
||||||
const r = db.prepare(`
|
const r = db.prepare(`
|
||||||
INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title)
|
INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle);
|
`).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle, collectionId, tags);
|
||||||
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +74,8 @@ function update(req, res) {
|
|||||||
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.collection_id !== undefined) { fields.push('collection_id = ?'); args.push(ownCollectionId(b.collection_id, req.user.id)); }
|
||||||
|
if (b.tags !== undefined) { fields.push('tags = ?'); args.push(b.tags != null ? String(b.tags).slice(0, 500) : null); }
|
||||||
if (!fields.length) return res.json({ ok: true });
|
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);
|
||||||
@@ -70,4 +91,42 @@ function remove(req, res) {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { list, create, update, remove };
|
/* ── Collections (folders) ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* POST /api/materials/collections — create a folder */
|
||||||
|
function createCollection(req, res) {
|
||||||
|
const name = String((req.body && req.body.name) || '').trim().slice(0, 120);
|
||||||
|
if (!name) return res.status(400).json({ error: 'name required' });
|
||||||
|
const color = req.body && req.body.color ? String(req.body.color).slice(0, 20) : null;
|
||||||
|
const r = db.prepare(
|
||||||
|
'INSERT INTO material_collections (user_id, name, color, sort_order) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(req.user.id, name, color, Number(req.body && req.body.sortOrder) || 0);
|
||||||
|
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PATCH /api/materials/collections/:id — rename / recolor / reorder */
|
||||||
|
function updateCollection(req, res) {
|
||||||
|
const row = db.prepare('SELECT user_id FROM material_collections WHERE id = ?').get(req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
||||||
|
const b = req.body || {};
|
||||||
|
const fields = [], args = [];
|
||||||
|
if (b.name !== undefined) { fields.push('name = ?'); args.push(String(b.name || '').trim().slice(0, 120)); }
|
||||||
|
if (b.color !== undefined) { fields.push('color = ?'); args.push(b.color ? String(b.color).slice(0, 20) : null); }
|
||||||
|
if (b.sortOrder !== undefined) { fields.push('sort_order = ?'); args.push(Number(b.sortOrder) || 0); }
|
||||||
|
if (!fields.length) return res.json({ ok: true });
|
||||||
|
args.push(req.params.id);
|
||||||
|
db.prepare(`UPDATE material_collections SET ${fields.join(', ')} WHERE id = ?`).run(...args);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DELETE /api/materials/collections/:id — delete folder (materials kept, uncategorised) */
|
||||||
|
function deleteCollection(req, res) {
|
||||||
|
const row = db.prepare('SELECT user_id FROM material_collections WHERE id = ?').get(req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
||||||
|
db.prepare('DELETE FROM material_collections WHERE id = ?').run(req.params.id); // ON DELETE SET NULL
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { list, create, update, remove, createCollection, updateCollection, deleteCollection };
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 061: Collections (folders) + tags for «Мои материалы»
|
||||||
|
--
|
||||||
|
-- A user groups saved materials into named collections (folders). A material
|
||||||
|
-- belongs to at most one collection (collection_id, SET NULL when the folder
|
||||||
|
-- is deleted — the material stays, just becomes "uncategorised"). Optional
|
||||||
|
-- free-text tags for quick filtering.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE material_collections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_material_collections_user ON material_collections(user_id, sort_order, id);
|
||||||
|
|
||||||
|
ALTER TABLE student_materials ADD COLUMN collection_id INTEGER REFERENCES material_collections(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE student_materials ADD COLUMN tags TEXT;
|
||||||
@@ -8,6 +8,14 @@ router.use(authMiddleware);
|
|||||||
|
|
||||||
router.get('/', c.list);
|
router.get('/', c.list);
|
||||||
router.post('/', c.create);
|
router.post('/', c.create);
|
||||||
|
|
||||||
|
// Collections (folders) — literal '/collections' prefix before '/:id'
|
||||||
|
router.post('/collections', c.createCollection);
|
||||||
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
|
router.patch('/collections/:id', c.updateCollection);
|
||||||
|
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||||
|
router.delete('/collections/:id', c.deleteCollection);
|
||||||
|
|
||||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
// @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
|
||||||
|
|||||||
+150
-26
@@ -12,7 +12,19 @@
|
|||||||
.mm-main { padding: 28px 24px; max-width: 1100px; margin: 0 auto; width: 100%; }
|
.mm-main { padding: 28px 24px; max-width: 1100px; margin: 0 auto; width: 100%; }
|
||||||
.mm-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
.mm-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||||
.mm-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.5rem; color: var(--text); }
|
.mm-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.5rem; color: var(--text); }
|
||||||
.mm-sub { color: var(--text-3); font-size: 0.9rem; margin-bottom: 22px; }
|
.mm-sub { color: var(--text-3); font-size: 0.9rem; margin-bottom: 16px; }
|
||||||
|
.mm-toolbar { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
|
||||||
|
.mm-search { flex: 1; min-width: 180px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 9px; font: inherit; background: var(--surface); color: var(--text); }
|
||||||
|
.mm-kind { padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; font: inherit; background: var(--surface); color: var(--text); }
|
||||||
|
.mm-cols { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||||
|
.mm-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1px solid var(--border); border-radius: 99px; background: var(--surface); cursor: pointer; font-size: .8rem; font-weight: 600; color: var(--text-2); }
|
||||||
|
.mm-chip:hover { border-color: var(--violet); color: var(--violet); }
|
||||||
|
.mm-chip.active { background: var(--violet); border-color: var(--violet); color: #fff; }
|
||||||
|
.mm-chip-count { font-size: .7rem; opacity: .7; }
|
||||||
|
.mm-chip-edit { display: inline-flex; opacity: .65; margin-left: 2px; }
|
||||||
|
.mm-chip-edit svg { width: 12px; height: 12px; }
|
||||||
|
.mm-chip-edit:hover { opacity: 1; }
|
||||||
|
.mm-chip-add { border-style: dashed; }
|
||||||
.mm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
|
.mm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
|
||||||
.mm-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; display: flex; flex-direction: column; position: relative; }
|
.mm-card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; display: flex; flex-direction: column; position: relative; }
|
||||||
.mm-card-media { background: #f1f5f9; aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
.mm-card-media { background: #f1f5f9; aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||||
@@ -21,12 +33,13 @@
|
|||||||
.mm-card-body { padding: 12px 14px; border-top: 1px solid var(--border); }
|
.mm-card-body { padding: 12px 14px; border-top: 1px solid var(--border); }
|
||||||
.mm-card-title { font-weight: 700; font-size: 0.86rem; color: var(--text); margin-bottom: 3px; }
|
.mm-card-title { font-weight: 700; font-size: 0.86rem; color: var(--text); margin-bottom: 3px; }
|
||||||
.mm-card-meta { font-size: 0.74rem; color: var(--text-3); }
|
.mm-card-meta { font-size: 0.74rem; color: var(--text-3); }
|
||||||
.mm-card-actions { display: flex; gap: 8px; margin-top: 10px; }
|
.mm-card-actions { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; align-items: center; }
|
||||||
.mm-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; font-size: 0.76rem; font-weight: 600; color: var(--text-2); text-decoration: none; transition: border-color .12s, color .12s; }
|
.mm-btn { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); cursor: pointer; font-size: 0.76rem; font-weight: 600; color: var(--text-2); text-decoration: none; transition: border-color .12s, color .12s; }
|
||||||
.mm-btn:hover { border-color: var(--violet); color: var(--violet); }
|
.mm-btn:hover { border-color: var(--violet); color: var(--violet); }
|
||||||
.mm-btn.danger:hover { border-color: #ef4444; color: #ef4444; }
|
.mm-btn.danger:hover { border-color: #ef4444; color: #ef4444; }
|
||||||
.mm-btn svg { width: 13px; height: 13px; }
|
.mm-btn svg { width: 13px; height: 13px; }
|
||||||
.mm-kind { position: absolute; top: 8px; left: 8px; font-size: 0.68rem; font-weight: 700; padding: 3px 8px; border-radius: 99px; background: rgba(155,93,229,0.12); color: var(--violet); }
|
.mm-move { padding: 4px 6px; border: 1px solid var(--border); border-radius: 8px; font-size: .74rem; background: var(--surface); color: var(--text-2); max-width: 130px; margin-right: auto; }
|
||||||
|
.mm-kind-badge { position: absolute; top: 8px; left: 8px; font-size: 0.68rem; font-weight: 700; padding: 3px 8px; border-radius: 99px; background: rgba(155,93,229,0.12); color: var(--violet); }
|
||||||
.mm-empty { padding: 60px 20px; text-align: center; color: var(--text-3); }
|
.mm-empty { 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; }
|
||||||
@media (max-width: 640px) { .mm-grid { grid-template-columns: 1fr 1fr; } .mm-main { padding: 18px 14px; } }
|
@media (max-width: 640px) { .mm-grid { grid-template-columns: 1fr 1fr; } .mm-main { padding: 18px 14px; } }
|
||||||
@@ -42,6 +55,17 @@
|
|||||||
<button class="mm-btn" style="margin-left:auto" onclick="createNote()"><i data-lucide="plus"></i> Заметка</button>
|
<button class="mm-btn" style="margin-left:auto" onclick="createNote()"><i data-lucide="plus"></i> Заметка</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mm-sub">Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.</div>
|
<div class="mm-sub">Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.</div>
|
||||||
|
<div class="mm-toolbar">
|
||||||
|
<input class="mm-search" id="mm-search" placeholder="Поиск по материалам…" oninput="onSearch(this.value)" />
|
||||||
|
<select class="mm-kind" id="mm-kind" onchange="onKind(this.value)">
|
||||||
|
<option value="all">Все типы</option>
|
||||||
|
<option value="board">Доска</option>
|
||||||
|
<option value="image">Изображения</option>
|
||||||
|
<option value="note">Заметки</option>
|
||||||
|
<option value="link">Ссылки</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mm-cols" id="mm-cols"></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>
|
||||||
</main>
|
</main>
|
||||||
@@ -62,23 +86,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const KIND_LABEL = { board: 'Доска', note: 'Заметка', link: 'Ссылка', image: 'Изображение' };
|
const KIND_LABEL = { board: 'Доска', note: 'Заметка', link: 'Ссылка', image: 'Изображение' };
|
||||||
|
const PENCIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>';
|
||||||
let _mats = [];
|
let _mats = [];
|
||||||
|
let _cols = [];
|
||||||
|
const _filter = { col: 'all', kind: 'all', q: '' };
|
||||||
|
|
||||||
|
/* ── Move-to-collection select ── */
|
||||||
|
function moveSelect(m) {
|
||||||
|
const opts = ['<option value=""' + (!m.collection_id ? ' selected' : '') + '>Без папки</option>']
|
||||||
|
.concat(_cols.map(c => `<option value="${c.id}"${m.collection_id === c.id ? ' selected' : ''}>${esc(c.name)}</option>`));
|
||||||
|
return `<select class="mm-move" title="Папка" onchange="moveMaterial(${m.id}, this.value)">${opts.join('')}</select>`;
|
||||||
|
}
|
||||||
|
|
||||||
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 = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`;
|
||||||
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})"><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>`;
|
||||||
|
const mv = moveSelect(m);
|
||||||
if (m.kind === 'board' || m.kind === 'image') {
|
if (m.kind === 'board' || m.kind === 'image') {
|
||||||
return `<div class="mm-card">
|
return `<div class="mm-card">
|
||||||
<span class="mm-kind">${kind}</span>
|
<span class="mm-kind-badge">${kind}</span>
|
||||||
<a class="mm-card-media" href="${esc(m.url)}" target="_blank" rel="noopener"><img src="${esc(m.url)}" alt="" loading="lazy"/></a>
|
<a class="mm-card-media" href="${esc(m.url)}" target="_blank" rel="noopener"><img src="${esc(m.url)}" alt="" loading="lazy"/></a>
|
||||||
<div class="mm-card-body">
|
<div class="mm-card-body">
|
||||||
<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>
|
||||||
<div class="mm-card-actions">
|
<div class="mm-card-actions">
|
||||||
<a class="mm-btn" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
|
${mv}
|
||||||
<a class="mm-btn" href="${esc(m.url)}" download><i data-lucide="download"></i></a>
|
<a class="mm-btn" href="${esc(m.url)}" target="_blank" rel="noopener" title="Открыть"><i data-lucide="external-link"></i></a>
|
||||||
|
<a class="mm-btn" href="${esc(m.url)}" download title="Скачать"><i data-lucide="download"></i></a>
|
||||||
${edit}${del}
|
${edit}${del}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,47 +122,98 @@
|
|||||||
}
|
}
|
||||||
if (m.kind === 'link') {
|
if (m.kind === 'link') {
|
||||||
return `<div class="mm-card">
|
return `<div class="mm-card">
|
||||||
<span class="mm-kind">${kind}</span>
|
<span class="mm-kind-badge">${kind}</span>
|
||||||
<div class="mm-card-note"><a href="${esc(m.url)}" target="_blank" rel="noopener" style="color:var(--violet)">${esc(m.url)}</a></div>
|
<div class="mm-card-note"><a href="${esc(m.url)}" target="_blank" rel="noopener" style="color:var(--violet)">${esc(m.url)}</a></div>
|
||||||
<div class="mm-card-body">
|
<div class="mm-card-body">
|
||||||
<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>
|
||||||
<div class="mm-card-actions">${edit}${del}</div>
|
<div class="mm-card-actions">${mv}${edit}${del}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
// note
|
// note
|
||||||
return `<div class="mm-card">
|
return `<div class="mm-card">
|
||||||
<span class="mm-kind">${kind}</span>
|
<span class="mm-kind-badge">${kind}</span>
|
||||||
<div class="mm-card-note">${esc(m.body || '')}</div>
|
<div class="mm-card-note">${esc(m.body || '')}</div>
|
||||||
<div class="mm-card-body">
|
<div class="mm-card-body">
|
||||||
<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>
|
||||||
<div class="mm-card-actions">${del}</div>
|
<div class="mm-card-actions">${mv}${edit}${del}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
/* ── Collections bar ── */
|
||||||
const grid = document.getElementById('mm-grid');
|
function chip(key, label, count, editId) {
|
||||||
try {
|
const active = _filter.col === key ? ' active' : '';
|
||||||
const { materials } = await LS.listMaterials();
|
const ed = editId ? `<span class="mm-chip-edit" onclick="event.stopPropagation();editCollection(${editId})" title="Изменить папку">${PENCIL}</span>` : '';
|
||||||
_mats = materials || [];
|
return `<button class="mm-chip${active}" onclick="setCol('${key}')">${esc(label)} <span class="mm-chip-count">${count}</span>${ed}</button>`;
|
||||||
if (!materials || !materials.length) {
|
}
|
||||||
grid.innerHTML = `<div class="mm-empty" style="grid-column:1/-1">
|
function renderCols() {
|
||||||
<i data-lucide="folder-open"></i>
|
const bar = document.getElementById('mm-cols');
|
||||||
<p>Пока пусто. На странице «Мои уроки» откройте прошлый урок и нажмите «К себе» на странице доски или заметке.</p>
|
const noneCount = _mats.filter(m => !m.collection_id).length;
|
||||||
</div>`;
|
let html = chip('all', 'Все', _mats.length);
|
||||||
lucide.createIcons();
|
_cols.forEach(c => { html += chip(String(c.id), c.name, c.count, c.id); });
|
||||||
return;
|
if (noneCount) html += chip('none', 'Без папки', noneCount);
|
||||||
|
html += `<button class="mm-chip mm-chip-add" onclick="createCollection()">+ папка</button>`;
|
||||||
|
bar.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtered() {
|
||||||
|
return _mats.filter(m => {
|
||||||
|
if (_filter.col === 'none' && m.collection_id) return false;
|
||||||
|
if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false;
|
||||||
|
if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false;
|
||||||
|
if (_filter.q) {
|
||||||
|
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
|
||||||
|
if (!hay.includes(_filter.q)) return false;
|
||||||
}
|
}
|
||||||
grid.innerHTML = materials.map(card).join('');
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
const grid = document.getElementById('mm-grid');
|
||||||
|
if (!_mats.length) {
|
||||||
|
grid.innerHTML = `<div class="mm-empty" style="grid-column:1/-1">
|
||||||
|
<i data-lucide="folder-open"></i>
|
||||||
|
<p>Пока пусто. На уроке или в «Мои уроки» нажмите «К себе»/«Область», или создайте заметку.</p>
|
||||||
|
</div>`;
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = filtered();
|
||||||
|
grid.innerHTML = rows.length
|
||||||
|
? rows.map(card).join('')
|
||||||
|
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await LS.listMaterials();
|
||||||
|
_mats = data.materials || [];
|
||||||
|
_cols = data.collections || [];
|
||||||
|
renderCols();
|
||||||
|
renderGrid();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
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 ── */
|
||||||
|
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
|
||||||
|
function onKind(v) { _filter.kind = v; renderGrid(); }
|
||||||
|
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); }
|
||||||
|
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
|
||||||
|
|
||||||
|
/* ── Material actions ── */
|
||||||
|
async function moveMaterial(id, cid) {
|
||||||
|
try { await LS.updateMaterial(id, { collection_id: cid || null }); await load(); }
|
||||||
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
|
}
|
||||||
|
window.moveMaterial = moveMaterial;
|
||||||
|
|
||||||
async function delMaterial(id) {
|
async function delMaterial(id) {
|
||||||
if (!confirm('Удалить этот материал?')) return;
|
if (!confirm('Удалить этот материал?')) return;
|
||||||
try { await LS.deleteMaterial(id); load(); }
|
try { await LS.deleteMaterial(id); load(); }
|
||||||
@@ -146,7 +233,8 @@
|
|||||||
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();
|
||||||
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
|
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
|
||||||
try { await LS.saveMaterial({ kind: 'note', title, body: text }); m.close(); load(); }
|
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
|
||||||
|
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col }); m.close(); load(); }
|
||||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
} },
|
} },
|
||||||
] });
|
] });
|
||||||
@@ -173,6 +261,42 @@
|
|||||||
}
|
}
|
||||||
window.editMaterial = editMaterial;
|
window.editMaterial = editMaterial;
|
||||||
|
|
||||||
|
/* ── Collection CRUD ── */
|
||||||
|
function createCollection() {
|
||||||
|
const content = `<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />`;
|
||||||
|
const m = LS.modal({ title: 'Новая папка', content, size: 'sm', actions: [
|
||||||
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
|
{ label: 'Создать', primary: true, onClick: async () => {
|
||||||
|
const name = m.body.querySelector('#mm-col-name').value.trim();
|
||||||
|
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
||||||
|
try { await LS.createMaterialCollection({ name }); m.close(); load(); }
|
||||||
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
|
} },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
window.createCollection = createCollection;
|
||||||
|
|
||||||
|
function editCollection(id) {
|
||||||
|
const col = _cols.find(c => c.id === id);
|
||||||
|
if (!col) return;
|
||||||
|
const content = `<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />`;
|
||||||
|
const m = LS.modal({ title: 'Папка', content, size: 'sm', actions: [
|
||||||
|
{ label: 'Удалить', onClick: async () => {
|
||||||
|
if (!confirm('Удалить папку? Материалы останутся (станут «Без папки»).')) return;
|
||||||
|
try { await LS.deleteMaterialCollection(id); m.close(); if (_filter.col === String(id)) _filter.col = 'all'; load(); }
|
||||||
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
|
} },
|
||||||
|
{ label: 'Отмена', onClick: () => m.close() },
|
||||||
|
{ label: 'Сохранить', primary: true, onClick: async () => {
|
||||||
|
const name = m.body.querySelector('#mm-col-name').value.trim();
|
||||||
|
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
||||||
|
try { await LS.updateMaterialCollection(id, { name }); m.close(); load(); }
|
||||||
|
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||||
|
} },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
window.editCollection = editCollection;
|
||||||
|
|
||||||
load();
|
load();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1049,6 +1049,7 @@ window.LS = {
|
|||||||
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
||||||
crAdminGetAllHistory, crAdminGetTeachersList,
|
crAdminGetAllHistory, crAdminGetTeachersList,
|
||||||
listMaterials, saveMaterial, updateMaterial, deleteMaterial,
|
listMaterials, saveMaterial, updateMaterial, deleteMaterial,
|
||||||
|
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
||||||
escapeHtml, esc,
|
escapeHtml, esc,
|
||||||
parseDate, fmtRelTime, safeHref,
|
parseDate, fmtRelTime, safeHref,
|
||||||
initPage,
|
initPage,
|
||||||
@@ -1247,6 +1248,9 @@ async function listMaterials() { return req('GET', '/materials'); }
|
|||||||
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}`); }
|
||||||
|
async function createMaterialCollection(d) { return req('POST', '/materials/collections', d); }
|
||||||
|
async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); }
|
||||||
|
async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${id}`); }
|
||||||
async function deleteFile(id) { return req('DELETE', `/files/${id}`); }
|
async function deleteFile(id) { return req('DELETE', `/files/${id}`); }
|
||||||
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
|
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
|
||||||
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
|
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
|
||||||
|
|||||||
Reference in New Issue
Block a user