feat(materials): «Мои материалы» v2 — харднинг безопасности и доводка UX

Безопасность/целостность: allowlist схемы URL (safeUrl) против stored-XSS через javascript:-ссылку; ссылочно-подсчётная чистка файлов при delete/смене url (releaseFileForUrl, учёт share-алиасов); квота на пользователя — число материалов + байты (колонка bytes, миграция 073).

Производительность: список отдаёт превью body (1000 симв.) + body_trunc; полный текст — ленивый GET /api/materials/:id (getOne, owner-only).

Фичи/UX (my-materials.html): теги-UI (ввод + чипы-фильтр + пилюля), ссылка на исходный урок, сортировка, множественный выбор + массовые действия, цвет/порядок папок, live-KaTeX в редакторе заметки.

Тесты: backend/tests/materials.test.js (16 тестов) — ранее их не было.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-13 14:21:30 +03:00
parent 222005c0ba
commit abe84b9f90
8 changed files with 578 additions and 34 deletions
+206 -22
View File
@@ -70,6 +70,26 @@
.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 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); }
@media (max-width: 768px) { .mm-check { opacity: .85; } }
@media (max-width: 768px) {
.mm-body { flex-direction: column; }
.mm-rail { width: auto; position: static; flex-direction: row; overflow-x: auto; gap: 6px; padding-bottom: 4px; }
@@ -109,7 +129,15 @@
<option value="note">Заметки</option>
<option value="link">Ссылки</option>
</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 id="mm-bulk" class="mm-bulk" style="display:none"></div>
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
</div>
</div>
@@ -149,6 +177,9 @@
}
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) {
if (!s) return '';
try { const d = new Date(s.replace(' ', 'T') + (s.includes('Z') ? '' : 'Z'));
@@ -170,9 +201,39 @@
if (u.startsWith('/lab')) 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 _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
/* ── Move-to-collection select ── */
function moveSelect(m) {
@@ -183,7 +244,10 @@
function card(m) {
const kind = KIND_LABEL[m.kind] || m.kind;
const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`;
const 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 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>`;
@@ -196,12 +260,12 @@
const mv = moveSelect(m);
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>
<div class="mm-card-body">
${chip}
<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}
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
@@ -213,7 +277,7 @@
}
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)}">
<span class="mm-card-link-ic"><i data-lucide="link"></i></span>
<span class="mm-card-link-meta">
@@ -224,7 +288,7 @@
<div class="mm-card-body">
${chip}
<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}
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
@@ -235,29 +299,31 @@
}
// 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-body">
${chip}
<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>
</div>`;
}
/* ── Folder rail (вертикальный список папок слева) ── */
function railItem(key, label, count, editId, droppable) {
function railItem(key, label, count, editId, droppable, color) {
const active = _filter.col === key ? ' active' : '';
const ed = editId
? `<span class="mm-rail-edit" onclick="event.stopPropagation();editCollection(${editId})" title="Изменить папку">${PENCIL}</span>`
: '';
const ic = key === 'all' ? 'inbox' : (key === 'none' ? 'folder-minus' : 'folder');
const tint = safeColor(color);
const icStyle = (tint && !active) ? ` style="color:${tint}"` : '';
const drop = droppable
? ` ondragover="mmDragOver(event,this)" ondragleave="mmDragLeave(this)" ondrop="mmDrop(event,'${key}')"`
: '';
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-count">${count}</span>${ed}
</div>`;
@@ -266,7 +332,7 @@
const bar = document.getElementById('mm-cols');
const noneCount = _mats.filter(m => !m.collection_id).length;
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);
bar.innerHTML = html;
}
@@ -299,17 +365,28 @@
window.mmDragStart = mmDragStart; window.mmDragEnd = mmDragEnd;
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() {
return _mats.filter(m => {
const rows = _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.tag && !parseTags(m.tags).includes(_filter.tag)) 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;
}
return true;
});
return sortRows(rows);
}
function renderGrid() {
@@ -327,6 +404,7 @@
? rows.map(card).join('')
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
lucide.createIcons();
renderBulk();
}
async function load() {
@@ -336,6 +414,7 @@
_cols = data.collections || [];
renderCols();
renderGrid();
renderTagFilter();
} catch (e) {
document.getElementById('mm-grid').innerHTML = `<div class="mm-empty" style="grid-column:1/-1">Ошибка загрузки</div>`;
}
@@ -345,7 +424,63 @@
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
function onKind(v) { _filter.kind = v; renderGrid(); }
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); }
function onSort(v) { _filter.sort = v; renderGrid(); }
function filterTag(t) { _filter.tag = String(t || ''); renderTagFilter(); renderGrid(); }
function clearTag() { _filter.tag = ''; renderTagFilter(); renderGrid(); }
function 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.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 ── */
async function moveMaterial(id, cid) {
@@ -365,46 +500,56 @@
function createNote() {
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<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>`;
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Создать', primary: true, onClick: async () => {
const title = m.body.querySelector('#mm-nt-title').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; }
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'); }
} },
] });
}
window.createNote = createNote;
function editMaterial(id) {
async function editMaterial(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return;
const isNote = mt.kind === 'note';
if (isNote) await ensureFullBody(mt);
const content = `<div style="display:flex;flex-direction:column;gap:10px">
<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>`;
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ 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;
try { await LS.updateMaterial(id, data); m.close(); load(); }
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;
/* ── Просмотр материала в модалке (лайтбокс) ── */
function openViewer(id) {
async function openViewer(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return false;
if (mt.kind === 'note') await ensureFullBody(mt);
const kind = KIND_LABEL[mt.kind] || mt.kind;
let body;
if (mt.kind === 'image' || mt.kind === 'board') {
@@ -431,24 +576,61 @@
window.openViewer = openViewer;
/* ── 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() {
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: [
{ 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(); }
try { await LS.createMaterialCollection({ name, color: pickedColor(m.body) }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
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) {
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 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: [
{ label: 'Удалить', onClick: async () => {
if (!await LS.confirm('Материалы из неё останутся и станут «Без папки».', { title: 'Удалить папку?', confirmText: 'Удалить' })) return;
@@ -459,10 +641,11 @@
{ 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(); }
try { await LS.updateMaterialCollection(id, { name, color: pickedColor(m.body) }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
if (window.lucide) lucide.createIcons();
}
window.editCollection = editCollection;
@@ -525,6 +708,7 @@
async function toFlashcard(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return;
await ensureFullBody(mt);
let decks = [];
try { const d = await LS.fcListDecks(); decks = d.decks || []; } catch (e) {}
const opts = ['<option value="__new">+ Новая колода «Из материалов»</option>']