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:
@@ -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, "'")}')">#${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, "'")}')">#${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'); }
|
||||
} },
|
||||
|
||||
Reference in New Issue
Block a user