feat: типизация записей (14 типов, до 3 на запись), чипы и бейджи типов в панели, CSV с типами, /api/types, doctor --only по категориям, волны источников 5-6 (635 всего)
This commit is contained in:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user