feat(materials): теги и фильтр по тегам в «Мои материалы»

- теги показываются чипами на карточках (клик по тегу — фильтр)
- панель тегов над сеткой: «Все теги» + все теги пользователя, активный подсвечен
- теги редактируются в модалках «Изменить» и «Новая заметка» (через запятую,
  normTags: тримминг, дедуп без учёта регистра, лимит 12)
- фильтр по тегу + существующий текстовый поиск (поиск уже включал tags в haystack)

Только фронт: колонка tags и приём в create/update/list уже были на бэкенде.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 15:35:11 +03:00
parent c49077abbc
commit e53c107d83
+44 -3
View File
@@ -48,6 +48,14 @@
.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-meta { font-size: 0.74rem; color: var(--text-3); }
.mm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.mm-tag { font-size: 0.7rem; font-weight: 600; color: var(--violet); background: rgba(155,93,229,0.10); border: 1px solid rgba(155,93,229,0.22); border-radius: 99px; padding: 2px 9px; cursor: pointer; }
.mm-tag:hover { background: rgba(155,93,229,0.18); }
.mm-tagbar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; }
.mm-tagbar:empty { display: none; }
.mm-tagf { font-size: 0.76rem; font-weight: 600; color: var(--text-2); background: var(--surface); border: 1px solid var(--border); border-radius: 99px; padding: 4px 11px; cursor: pointer; }
.mm-tagf:hover { border-color: rgba(155,93,229,0.4); }
.mm-tagf.active { background: var(--violet); color: #fff; border-color: var(--violet); }
.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:hover { border-color: var(--violet); color: var(--violet); }
@@ -110,6 +118,7 @@
<option value="link">Ссылки</option>
</select>
</div>
<div class="mm-tagbar" id="mm-tags"></div>
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
</div>
</div>
@@ -172,7 +181,30 @@
}
let _mats = [];
let _cols = [];
const _filter = { col: 'all', kind: 'all', q: '' };
const _filter = { col: 'all', kind: 'all', q: '', tag: null };
/* ── Теги (хранятся строкой через запятую в m.tags) ── */
function tagsOf(m) { return String((m && m.tags) || '').split(',').map(t => t.trim()).filter(Boolean); }
function normTags(str) { const seen = new Set(), out = []; String(str || '').split(',').forEach(t => { t = t.trim().slice(0, 40); const k = t.toLowerCase(); if (t && !seen.has(k)) { seen.add(k); out.push(t); } }); return out.slice(0, 12).join(', '); }
function tagsHtml(m) {
const ts = tagsOf(m);
if (!ts.length) return '';
return `<div class="mm-tags">${ts.map(t => `<span class="mm-tag" onclick="event.stopPropagation();setTag('${esc(t).replace(/'/g, "&#39;")}')">#${esc(t)}</span>`).join('')}</div>`;
}
function allTags() {
const set = new Map();
_mats.forEach(m => tagsOf(m).forEach(t => set.set(t.toLowerCase(), t)));
return Array.from(set.values()).sort((a, b) => a.localeCompare(b, 'ru'));
}
function renderTags() {
const bar = document.getElementById('mm-tags');
if (!bar) return;
const tags = allTags();
if (!tags.length) { bar.innerHTML = ''; return; }
let html = `<span class="mm-tagf${_filter.tag ? '' : ' active'}" onclick="setTag(null)">Все теги</span>`;
html += tags.map(t => `<span class="mm-tagf${_filter.tag === t.toLowerCase() ? ' active' : ''}" onclick="setTag('${esc(t).replace(/'/g, "&#39;")}')">#${esc(t)}</span>`).join('');
bar.innerHTML = html;
}
/* ── Move-to-collection select ── */
function moveSelect(m) {
@@ -202,6 +234,7 @@
${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>
${tagsHtml(m)}
<div class="mm-card-actions">
${mv}
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
@@ -225,6 +258,7 @@
${chip}
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>
${tagsHtml(m)}
<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>
@@ -304,6 +338,7 @@
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 && !tagsOf(m).some(t => t.toLowerCase() === _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;
@@ -326,6 +361,7 @@
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>`;
renderTags();
lucide.createIcons();
}
@@ -345,7 +381,8 @@
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;
function setTag(t) { const lt = t ? String(t).toLowerCase() : null; _filter.tag = (_filter.tag === lt) ? null : lt; renderGrid(); }
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch; window.setTag = setTag;
/* ── Material actions ── */
async function moveMaterial(id, cid) {
@@ -366,6 +403,7 @@
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>
<input id="mm-nt-tags" placeholder="Теги через запятую (необязательно)" style="${FLD}" />
</div>`;
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
@@ -374,7 +412,8 @@
const text = m.body.querySelector('#mm-nt-body').value.trim();
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(); }
const tags = normTags(m.body.querySelector('#mm-nt-tags').value);
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col, tags }); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
@@ -388,12 +427,14 @@
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>` : ''}
<input id="mm-ed-tags" value="${esc(tagsOf(mt).join(', '))}" 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() };
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
data.tags = normTags(m.body.querySelector('#mm-ed-tags').value);
try { await LS.updateMaterial(id, data); m.close(); load(); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },