feat: типизация записей (14 типов, до 3 на запись), чипы и бейджи типов в панели, CSV с типами, /api/types, doctor --only по категориям, волны источников 5-6 (635 всего)

This commit is contained in:
Vlad Gothbreach
2026-10-26 19:01:45 +03:00
parent 4196d66b4e
commit 7b3d708402
10 changed files with 652 additions and 24 deletions

View File

@@ -269,6 +269,11 @@ select:focus,input:focus{border-color:rgba(124,108,244,.6)}
.runs-time{font:11.5px var(--mono);color:var(--dim2)}
.chip b{font:10.5px var(--mono);color:var(--dim2);font-weight:500;margin-left:3px}
.chip.on b{color:#cbc4ff}
.tps{display:flex;flex-wrap:wrap;gap:6px;margin:7px 0 3px}
.tp{font-size:11px;line-height:1;padding:4px 8px;border-radius:999px;background:rgba(120,180,255,.12);
color:#9dc4ff;border:1px solid rgba(120,180,255,.24);letter-spacing:.02em}
#type-tools{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:-2px 0 14px}
</style>
</head>
<body>
@@ -324,6 +329,7 @@ select:focus,input:focus{border-color:rgba(124,108,244,.6)}
<section id="view-feed">
<div class="kpis" id="kpis"></div>
<div class="toolbar" id="feed-tools"></div>
<div class="filters" id="type-tools"></div>
<div class="cards" id="cards"></div>
<div class="empty" id="cards-more" style="display:none"><button class="btn" id="btn-more">Показать ещё</button></div>
</section>
@@ -445,7 +451,11 @@ const api = async (p, opt) => {
};
const post = (p, b) => api(p, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(b || {})});
const S = {view: 'feed', cat: 'all', q: '', state: '', srcQ: '', offset: 0, limit: 36, sort: 'score', stats: null, sources: [], job: null};
const TYPE_LABEL = {news: 'новость', tool: 'софт', release: 'релиз', cve: 'уязвимость', model: 'модель',
question: 'вопрос', price: 'цена', guide: 'гайд', dataset: 'датасет', leak: 'слив', trial: 'триал',
promo: 'промо', resale: 'перепродажа', job: 'вакансия'};
const S = {view: 'feed', cat: 'all', q: '', state: '', srcQ: '', offset: 0, limit: 36, sort: 'score', priced: false, type: 'all', types: [], stats: null, sources: [], job: null};
function toast(msg, cls) {
const d = el('div', cls || '', esc(msg));
@@ -476,6 +486,7 @@ function syncChips() {
const k = b.dataset.k || 'cat';
if (k === 'cat') b.classList.toggle('on', b.dataset.cat === S.cat);
if (k === 'sort') b.classList.toggle('on', b.dataset.sort === S.sort);
if (k === 'priced') b.classList.toggle('on', !!S.priced);
});
}
@@ -494,6 +505,25 @@ function setCat(name) {
}
/* ---------- stats / KPI ---------- */
function buildTypes(list) {
const box = $('#type-tools');
if (!box) return;
S.types = list || S.types;
const arr = S.types || [];
box.innerHTML = '<span class="kv" style="margin-right:6px">тип:</span>' +
`<button class="chip ${S.type === 'all' ? 'on' : ''}" data-t="all">все</button>` +
arr.filter(x => x.n > 0).map(x =>
`<button class="chip ${S.type === x.key ? 'on' : ''}" data-t="${esc(x.key)}">${esc(x.label)} <b>${fmt(x.n)}</b></button>`).join('');
box.querySelectorAll('.chip').forEach(b => {
b.onclick = () => { S.type = b.dataset.t; S.offset = 0; buildTypes(S.types); loadItems(true); };
});
}
async function loadTypes() {
const r = await api('/api/types');
if (r.ok) buildTypes(r.types);
}
async function loadStats() {
const s = await api('/api/stats');
if (s.error) return toast(s.error, 'err');
@@ -508,6 +538,7 @@ async function loadStats() {
`<b>${fmt(s.runs)}</b> прогонов · <b>${fmt(s.priced)}</b> с ценой`;
renderCats(s.categories);
buildChips(s.categories);
loadTypes();
const ks = [
['записей', s.items, 'в sqlite + fts5', ''],
['источников', s.sources, `${s.sources_enabled} включено`, ''],
@@ -533,6 +564,7 @@ function card(it) {
<span class="score">${it.score != null ? (+it.score).toFixed(1) : ''}</span></div>
<h3>${esc(it.title || '(без заголовка)')}</h3>
${text ? `<p>${esc(text)}</p>` : ''}
${it.types ? `<div class="tps">${String(it.types).split(',').filter(Boolean).map(t => `<span class="tp">${esc(TYPE_LABEL[t] || t)}</span>`).join('')}</div>` : ''}
<div class="meta">${it.author ? `<span>${esc(String(it.author).slice(0, 22))}</span>` : ''}
${pub ? `<span>${esc(pub)}</span>` : ''}${price}</div>`;
if (it.url) c.onclick = () => window.open(it.url, '_blank', 'noopener');
@@ -542,7 +574,7 @@ function card(it) {
async function loadItems(reset) {
if (reset) { S.offset = 0; $('#cards').innerHTML = '<div class="skel"></div><div class="skel"></div><div class="skel"></div>'; }
const u = `/api/items?category=${encodeURIComponent(S.cat)}&q=${encodeURIComponent(S.q)}&limit=${S.limit}&offset=${S.offset}&sort=${S.sort}`;
const u = `/api/items?category=${encodeURIComponent(S.cat)}&q=${encodeURIComponent(S.q)}&limit=${S.limit}&offset=${S.offset}&sort=${S.sort}&priced=${S.priced ? 1 : 0}&type=${encodeURIComponent(S.type)}`;
const r = await api(u);
if (r.error) return toast(r.error, 'err');
if (reset) $('#cards').innerHTML = '';
@@ -792,6 +824,7 @@ function setView(v) {
u.searchParams.set('view', v);
S.cat && S.cat !== 'all' ? u.searchParams.set('cat', S.cat) : u.searchParams.delete('cat');
S.q ? u.searchParams.set('q', S.q) : u.searchParams.delete('q');
S.priced ? u.searchParams.set('priced', '1') : u.searchParams.delete('priced');
history.replaceState(null, '', u.toString());
} catch (e) {}
if (v === 'feed') loadItems(true);
@@ -866,17 +899,27 @@ function buildChips(cats) {
const list = cats || (S.stats && S.stats.categories) || [];
const tot = {}; list.forEach(c => { tot[c.name] = c.total; });
box.innerHTML =
`<button class="chip" data-k="cat" data-cat="all">всё <b>${fmt(S.stats ? S.stats.sources : 0)}</b></button>` +
list.map(c => `<button class="chip" data-k="cat" data-cat="${esc(c.name)}">${esc(c.name)} <b>${fmt(c.total)}</b></button>`).join('') +
'<span style="flex:1"></span>' +
'<button class="chip" data-k="sort" data-sort="score">по score</button>' +
'<button class="chip" data-k="sort" data-sort="fresh">по свежести</button>';
box.querySelectorAll('.chip').forEach(b => {
b.onclick = () => {
if (b.dataset.k === 'sort') { S.sort = b.dataset.sort; syncChips(); loadItems(true); }
else setCat(b.dataset.cat);
};
});
`<button class="chip" data-k="cat" data-cat="all">всё <b>${fmt(S.stats ? S.stats.sources : 0)}</b></button>` +
list.map(c => `<button class="chip" data-k="cat" data-cat="${esc(c.name)}">${esc(c.name)} <b>${fmt(c.total)}</b></button>`).join('') +
(S.q ? `<button class="chip on" data-k="clear">поиск: ${esc(S.q.slice(0, 18))} ✕</button>` : '') +
'<span style="flex:1"></span>' +
'<button class="chip" data-k="priced">только с ценой</button>' +
'<button class="chip" data-k="sort" data-sort="score">по score</button>' +
'<button class="chip" data-k="sort" data-sort="fresh">по свежести</button>' +
'<button class="chip" data-k="sort" data-sort="price">по цене</button>' +
'<button class="chip" data-k="csv">CSV</button>';
box.querySelectorAll('.chip').forEach(b => {
b.onclick = () => {
if (b.dataset.k === 'sort') { S.sort = b.dataset.sort; syncChips(); loadItems(true); }
else if (b.dataset.k === 'priced') { S.priced = !S.priced; syncChips(); loadItems(true); }
else if (b.dataset.k === 'clear') { S.q = ''; $('#q').value = ''; syncChips(); loadItems(true); }
else if (b.dataset.k === 'csv') {
const u = `/api/export.csv?category=${encodeURIComponent(S.cat)}&q=${encodeURIComponent(S.q)}&sort=${S.sort}&priced=${S.priced ? 1 : 0}&type=${encodeURIComponent(S.type)}`;
window.open(u, '_blank'); toast('CSV выгружается', 'ok');
}
else setCat(b.dataset.cat);
};
});
syncChips();
}
@@ -954,6 +997,7 @@ try { if (localStorage.getItem('omni.cats') === '0') { $('#cats').classList.add(
const p = new URLSearchParams(location.search);
if (p.get('cat')) S.cat = p.get('cat');
if (p.get('q')) { S.q = p.get('q'); $('#q').value = S.q; }
if (p.get('priced') === '1') S.priced = true;
await loadStats();
const v = p.get('view');
if (v === 'abuse') { setView('abuse'); setTimeout(() => loadAbuse().catch(e => console.error('abuse', e)), 300); }