omni: абузы (31 живой источник + классификатор), авто-добавление фидов, Telegram-бот, вкладка абузов; 589 источников

This commit is contained in:
reform
2026-10-26 12:25:24 +03:00
parent 81f3e556c4
commit ec61ec21f0
12 changed files with 1362 additions and 35 deletions

View File

@@ -229,37 +229,93 @@ select:focus,input:focus{border-color:rgba(124,108,244,.6)}
.thead,.row{grid-template-columns:38px minmax(140px,1fr) 84px 80px 64px}
.thead span:nth-child(4),.row .catpill{display:none}
}
/* ---- меню v2 ---- */
.brand .ver{margin-left:auto;font:10.5px var(--mono);color:var(--dim2);background:#151a24;border:1px solid var(--line);border-radius:20px;padding:2px 8px}
.menu-btn{display:flex;align-items:center;gap:9px;margin:0 0 12px;padding:9px 11px;width:100%;border:1px solid var(--line);
background:#11151d;color:var(--dim);border-radius:11px;font:12.5px inherit;cursor:pointer;transition:.15s}
.menu-btn:hover{border-color:var(--line2);color:var(--txt);background:#161c26}
.menu-btn i{font-style:normal;color:var(--acc2)}
.menu-btn .kbd{margin-left:auto}
.kbd{font:10px var(--mono);color:var(--dim2);border:1px solid var(--line2);border-radius:5px;padding:1px 5px;background:#0d1117}
.nav button .kbd{margin-left:auto;margin-right:6px;opacity:.55}
.nav button:hover .kbd{opacity:1}
.sect{display:flex;align-items:center;justify-content:space-between;gap:8px}
.sect .mini{border:0;background:transparent;color:var(--dim2);cursor:pointer;font-size:11px;padding:0 2px}
.sect .mini:hover{color:var(--txt)}
.cats.collapsed{display:none}
.quick{display:grid;gap:6px;margin:2px 0 10px}
.quick button{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;border:1px solid var(--line);
background:#0f131a;color:var(--dim);font:12.5px inherit;cursor:pointer;text-align:left;transition:.14s}
.quick button:hover{border-color:var(--line2);color:var(--txt);background:#151b25;transform:translateX(2px)}
.quick button i{font-style:normal;color:var(--acc);width:12px;text-align:center}
.ttlwrap{min-width:0}
.crumbs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:5px}
.crumbs span{font:10.5px var(--mono);color:var(--dim2);border:1px solid var(--line);border-radius:20px;padding:2px 8px;background:#0d1117}
.crumbs span.on{color:#cfd6ec;border-color:#3b3660;background:#181633}
.palette{position:fixed;inset:0;background:rgba(4,5,8,.72);backdrop-filter:blur(6px);display:none;z-index:80;padding-top:14vh;justify-content:center}
.palette.on{display:flex}
.pal-box{width:min(620px,92vw);background:#0f131b;border:1px solid var(--line2);border-radius:16px;
box-shadow:0 30px 80px rgba(0,0,0,.6);overflow:hidden;align-self:flex-start}
.pal-box input{width:100%;border:0;border-bottom:1px solid var(--line);background:transparent;color:var(--txt);
padding:15px 18px;font:14px inherit;outline:none}
.pal-list{max-height:52vh;overflow:auto;padding:6px}
.pal-item{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:9px;cursor:pointer;color:var(--dim);font-size:13px}
.pal-item .tag{font:10px var(--mono);color:var(--dim2);border:1px solid var(--line);border-radius:5px;padding:1px 6px}
.pal-item .cnt{margin-left:auto;font:11px var(--mono);color:var(--dim2)}
.pal-item.sel,.pal-item:hover{background:#171d29;color:var(--txt)}
.pal-foot{display:flex;gap:6px;align-items:center;padding:9px 14px;border-top:1px solid var(--line);font:10.5px var(--mono);color:var(--dim2)}
.thead.runs,.row.runs{grid-template-columns:132px minmax(120px,1fr) minmax(120px,1.2fr) 90px 100px 110px}
.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}
</style>
</head>
<body>
<div class="app">
<aside>
<aside id="side">
<div class="brand">
<div class="mark"></div>
<div><b>omni</b><span>source radar</span></div>
<span class="ver" id="brand-n">—</span>
</div>
<button class="menu-btn" id="btn-palette"><i>⌘</i>Меню<span class="kbd">Ctrl K</span></button>
<div class="nav">
<button id="nav-feed" class="on"><i>▤</i>Лента<b id="nav-feed-n">—</b></button>
<button id="nav-src"><i>◈</i>Источники<b id="nav-src-n">—</b></button>
<button id="nav-feed" class="on"><i>▤</i>Лента<span class="kbd">1</span><b id="nav-feed-n">—</b></button>
<button id="nav-src"><i>◈</i>Источники<span class="kbd">2</span><b id="nav-src-n">—</b></button>
<button id="nav-runs"><i>≡</i>Прогоны<span class="kbd">3</span><b id="nav-runs-n">—</b></button>
<button id="nav-abuse"><i>◭</i>Абузы<span class="kbd">4</span><b id="nav-abuse-n">—</b></button>
</div>
<div class="sect">категории</div>
<div class="sect"><span>категории</span><button class="mini" id="cats-toggle" title="свернуть">▾</button></div>
<div class="cats" id="cats"></div>
<div class="legend"><span><i class="lg-a"></i>живые</span><span><i class="lg-d"></i>мёртвые</span><span><i class="lg-u"></i>не проверены</span></div>
<div class="sect"><span>действия</span></div>
<div class="quick">
<button id="q-feed"><i>↻</i>Собрать ленту</button>
<button id="q-check"><i>✓</i>Проверить всё</button>
<button id="q-add"><i>+</i>Источник</button>
</div>
<div class="aside-foot" id="aside-foot">загрузка…</div>
</aside>
<main>
<div class="top">
<h1 id="ttl">Лента<small>свежие записи со всех источников</small></h1>
<div class="ttlwrap">
<div class="crumbs" id="crumbs"></div>
<h1 id="ttl">Лента<small>свежие записи со всех источников</small></h1>
</div>
<div class="spacer"></div>
<div class="search">
<span style="color:#5d6779">⌕</span>
<span class="sicon">⌕</span>
<input id="q" placeholder="поиск по базе">
<kbd>/</kbd>
</div>
<button class="btn" id="btn-palette2" title="меню (Ctrl K)">⌘ Меню</button>
<button class="btn primary" id="btn-feed">Собрать ленту</button>
</div>
@@ -273,7 +329,17 @@ select:focus,input:focus{border-color:rgba(124,108,244,.6)}
</section>
<!-- ИСТОЧНИКИ -->
<section id="view-abuse" style="display:none">
<div class="strip" id="abuse-strip"></div>
<div class="filters" id="abuse-tags"></div>
<div class="cards" id="abuse-cards"></div>
</section>
<section id="view-src" style="display:none">
<div class="filters" id="src-tools">
<button class="btn" id="btn-discover" title="искать фиды на хостах и подключать">авто-добавление</button>
<span class="kv" id="discover-note"></span>
</div>
<div class="strip" id="src-strip"></div>
<div class="filters">
<select id="f-cat"><option value="all">все категории</option></select>
@@ -300,10 +366,30 @@ select:focus,input:focus{border-color:rgba(124,108,244,.6)}
<div id="rows"></div>
</div>
</section>
<!-- ПРОГОНЫ -->
<section id="view-runs" style="display:none">
<div class="strip" id="runs-strip"></div>
<div class="table">
<div class="thead runs">
<span>время</span><span>источник</span><span>запрос</span>
<span class="num">получено</span><span class="num">сохранено</span><span>статус</span>
</div>
<div id="runs-rows"></div>
</div>
</section>
</div>
</main>
</div>
<div class="palette" id="palette">
<div class="pal-box">
<input id="pal-q" placeholder="куда идём: категория, действие, источник…">
<div class="pal-list" id="pal-list"></div>
<div class="pal-foot"><kbd>↑</kbd><kbd>↓</kbd> выбор <kbd>↵</kbd> открыть <kbd>esc</kbd> закрыть</div>
</div>
</div>
<div class="prog" id="prog">
<span id="prog-txt">работаю…</span>
<div class="track"><i id="prog-bar"></i></div>
@@ -392,6 +478,12 @@ function setCat(name) {
S.cat = name; S.offset = 0;
renderCats(S.stats ? S.stats.categories : []);
syncChips();
crumb();
try {
const u = new URL(location.href);
name && name !== 'all' ? u.searchParams.set('cat', name) : u.searchParams.delete('cat');
history.replaceState(null, '', u.toString());
} catch (e) {}
if (S.view === 'feed') loadItems(true); else loadSources();
$('#f-cat').value = name;
}
@@ -403,11 +495,14 @@ async function loadStats() {
S.stats = s;
$('#nav-feed-n').textContent = fmt(s.items);
$('#nav-src-n').textContent = fmt(s.sources);
$('#nav-runs-n').textContent = fmt(s.runs);
$('#brand-n').textContent = fmt(s.alive) + '/' + fmt(s.sources);
$('#aside-foot').innerHTML =
`<b>${fmt(s.sources)}</b> источников · <b>${fmt(s.sources_enabled)}</b> вкл<br>` +
`<b>${fmt(s.alive)}</b> живых · <b>${fmt(s.tested)}</b> проверено<br>` +
`<b>${fmt(s.runs)}</b> прогонов · <b>${fmt(s.priced)}</b> с ценой`;
renderCats(s.categories);
buildChips(s.categories);
const ks = [
['записей', s.items, 'в sqlite + fts5', ''],
['источников', s.sources, `${s.sources_enabled} включено`, ''],
@@ -555,21 +650,144 @@ async function check(category, name) {
}
}
/* ---------- прогоны ---------- */
async function loadRuns() {
const rows = $('#runs-rows');
rows.innerHTML = '<div class="skel"></div><div class="skel"></div><div class="skel"></div>';
const r = await api('/api/runs?limit=100');
if (r.error) { rows.innerHTML = ''; return toast(r.error, 'err'); }
const items = r.items || [];
const ok = items.filter(x => String(x.status || '').toLowerCase().startsWith('ok')).length;
const got = items.reduce((a, x) => a + (x.got || 0), 0);
const kept = items.reduce((a, x) => a + (x.kept || 0), 0);
$('#runs-strip').innerHTML =
`<div>всего прогонов<b>${fmt(r.total || items.length)}</b></div>` +
`<div class="g">успешных<b>${fmt(ok)}</b></div>` +
`<div class="r">пустых<b>${fmt(items.length - ok)}</b></div>` +
`<div>получено<b>${fmt(got)}</b></div>` +
`<div>сохранено<b>${fmt(kept)}</b></div>` +
`<div>конверсия<b>${got ? Math.round(kept / got * 100) : 0}%</b></div>`;
rows.innerHTML = '';
if (!items.length) { rows.innerHTML = '<div class="empty">прогонов нет. нажми «Собрать ленту».</div>'; return; }
items.forEach(x => {
const d = el('div', 'row runs');
const st = String(x.status || '').toLowerCase();
d.innerHTML =
`<span class="runs-time">${esc(x.when || '')}</span>` +
`<span>${esc(x.source || x.provider || '—')}</span>` +
`<span class="mono">${esc(String(x.query || '—').slice(0, 62))}</span>` +
`<span class="num">${fmt(x.got || 0)}</span>` +
`<span class="num">${fmt(x.kept || 0)}</span>` +
`<span class="st ${st.startsWith('ok') ? 'ok' : 'bad'}">${esc(x.status || '—')}</span>`;
rows.appendChild(d);
});
}
function crumb() {
const c = $('#crumbs');
if (!c) return;
const v = {feed: 'лента', src: 'источники', runs: 'прогоны'}[S.view] || S.view;
let h = `<span class="on">${esc(v)}</span>`;
if (S.cat && S.cat !== 'all') h += `<span>${esc(S.cat)}</span>`;
if (S.q) h += `<span>поиск: ${esc(S.q.slice(0, 22))}</span>`;
if (S.view === 'feed') h += `<span>${S.sort === 'score' ? 'по score' : 'по свежести'}</span>`;
if (S.view === 'src' && S.state) h += `<span>${esc(S.state)}</span>`;
c.innerHTML = h;
}
const VIEWMETA = {
feed: ['Лента', 'свежие записи со всех источников'],
src: ['Источники', 'подключение, здоровье, включение'],
runs: ['Прогоны', 'история запусков парсера'],
abuse: ['Абузы', 'промо, триалы, ключи, сливы, софт']
};
/* ---------- абузы ---------- */
const ABUSE_LABELS = {keys: 'ключи', promo: 'промо', trial: 'триалы', leak: 'сливы', tool: 'софт', resale: 'продажа', news: 'новости'};
S.abuseTag = null; S.abuse = null;
async function loadAbuse(tag) {
if (tag !== undefined) S.abuseTag = tag;
const q = new URLSearchParams({limit: 60});
if (S.abuseTag) q.set('tag', S.abuseTag);
const [d, st] = await Promise.all([api('/api/abuse?' + q), S.stats ? Promise.resolve(S.stats) : api('/api/stats')]);
S.abuse = d;
const tags = Object.entries(d.tags || {}).sort((a, b) => b[1] - a[1]);
const strip = $('#abuse-strip');
if (strip) strip.innerHTML =
`<span class="kv"><b>${d.total}</b> находок</span>` +
`<span class="kv"><b>${tags.length}</b> тегов</span>` +
`<span class="kv">в базе <b>${fmt(st.items)}</b></span>` +
`<span class="kv">источников <b>${fmt(st.sources)}</b></span>`;
const box = $('#abuse-tags');
if (box) {
box.innerHTML = `<button class="chip${!S.abuseTag ? ' on' : ''}" data-t="all">всё</button>` +
tags.map(([k, v]) => `<button class="chip${S.abuseTag === k ? ' on' : ''}" data-t="${esc(k)}">${esc(ABUSE_LABELS[k] || k)} <b>${v}</b></button>`).join('');
box.querySelectorAll('.chip').forEach(b => b.onclick = () => loadAbuse(b.dataset.t === 'all' ? null : b.dataset.t));
}
const cards = $('#abuse-cards');
if (!cards) return;
if (!d.items.length) { cards.innerHTML = '<div class="empty">пусто — запусти прогон категории abuse</div>'; return; }
cards.innerHTML = d.items.map(it => `
<div class="card">
<div class="card-h"><span class="src">${esc(it.source)}</span><span class="dots"></span>
<span class="score">${it.abuse_score}</span></div>
<a class="card-t" href="${esc(it.url)}" target="_blank" rel="noreferrer">${esc(it.title)}</a>
<div class="card-m"><span class="tags">${it.tags.map(t => `<i>${esc(ABUSE_LABELS[t] || t)}</i>`).join('')}</span>
<span class="date">${esc((it.published || '').slice(0, 10))}</span></div>
</div>`).join('');
}
$('#btn-discover') && ($('#btn-discover').onclick = async (e) => {
const b = e.currentTarget; const t0 = b.textContent;
b.disabled = true; b.textContent = 'ищу фиды…';
try {
const r = await api('/api/discover?hosts=40&add=25', {method: 'POST'});
b.textContent = `+${(r.added || []).length} подключено`;
const nt = $('#discover-note'); if (nt) nt.textContent = `кандидатов ${r.found || 0} · живых ${r.alive || 0}`;
setTimeout(() => { b.disabled = false; b.textContent = t0; loadStats(); }, 4000);
} catch (err) { b.textContent = 'ошибка: ' + err.message; b.disabled = false; setTimeout(() => b.textContent = t0, 4000); }
});
/* ---------- view switching ---------- */
function setView(v) {
if (!VIEWMETA[v]) v = 'feed';
S.view = v;
$('#nav-feed').classList.toggle('on', v === 'feed');
$('#nav-src').classList.toggle('on', v === 'src');
$('#view-feed').style.display = v === 'feed' ? '' : 'none';
$('#view-src').style.display = v === 'src' ? '' : 'none';
['feed', 'src', 'runs', 'abuse'].forEach(k => {
const b = $('#nav-' + k); if (b) b.classList.toggle('on', k === v);
const sec = $('#view-' + k); if (sec) sec.style.display = k === v ? '' : 'none';
});
$('#btn-feed').style.display = v === 'feed' ? '' : 'none';
$('#feed-tools').style.display = v === 'feed' ? '' : 'none';
if (v === 'feed') loadItems(true); else loadSources();
const ft = $('#feed-tools'); if (ft) ft.style.display = v === 'feed' ? '' : 'none';
if (v === 'abuse') loadAbuse().catch(e => console.error('abuse', e));
const m = VIEWMETA[v];
$('#ttl').innerHTML = `${m[0]}<small>${m[1]}</small>`;
crumb();
try {
const u = new URL(location.href);
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');
history.replaceState(null, '', u.toString());
} catch (e) {}
if (v === 'feed') loadItems(true);
else if (v === 'src') loadSources();
else loadRuns();
}
/* ---------- wire ---------- */
$('#nav-feed').onclick = () => setView('feed');
$('#nav-src').onclick = () => setView('src');
$('#nav-runs').onclick = () => setView('runs');
$('#nav-abuse').onclick = () => { setView('abuse'); loadAbuse(); };
$('#q-feed').onclick = runFeed;
$('#q-check').onclick = () => check(null, null);
$('#q-add').onclick = () => $('#modal').classList.add('on');
$('#cats-toggle').onclick = e => {
const c = $('#cats'); const hid = c.classList.toggle('collapsed');
e.target.textContent = hid ? '▸' : '▾';
try { localStorage.setItem('omni.cats', hid ? '0' : '1'); } catch (er) {}
};
$('#btn-feed').onclick = runFeed;
$('#btn-more').onclick = () => loadItems(false);
$('#btn-check-all').onclick = () => check(null, null);
@@ -588,8 +806,17 @@ $('#q').oninput = e => {
}, 320);
};
document.addEventListener('keydown', e => {
if (e.key === '/' && document.activeElement !== $('#q')) { e.preventDefault(); $('#q').focus(); }
if (e.key === 'Escape') $('#modal').classList.remove('on');
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName);
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openPalette(); return; }
if (e.key === '/' && !typing) { e.preventDefault(); $('#q').focus(); return; }
if (e.key === 'Escape') { $('#modal').classList.remove('on'); $('#palette').classList.remove('on'); return; }
if (!typing && !e.ctrlKey && !e.metaKey) {
if (e.key === '1') setView('feed');
if (e.key === '2') setView('src');
if (e.key === '3') setView('runs');
if (e.key === '4') { setView('abuse'); loadAbuse(); }
if (e.key === 'r' && !e.shiftKey) runFeed();
}
});
$('#btn-add').onclick = () => $('#modal').classList.add('on');
@@ -608,27 +835,107 @@ $('#a-save').onclick = async () => {
loadSources(); loadStats();
};
/* feed toolbar */
$('#feed-tools').innerHTML =
['all', 'community', 'papers', 'security', 'company', 'forum', 'models', 'benchmark']
.map(c => `<button class="chip" data-k="cat" data-cat="${c}">${c === 'all' ? 'всё' : c}</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>';
document.querySelectorAll('#feed-tools .chip').forEach(b => {
b.onclick = () => {
if (b.dataset.k === 'sort') { S.sort = b.dataset.sort; syncChips(); loadItems(true); }
else setCat(b.dataset.cat);
};
});
syncChips();
/* feed toolbar: чипы строятся из живого реестра категорий */
function buildChips(cats) {
const box = $('#feed-tools');
if (!box) return;
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);
};
});
syncChips();
}
/* ---------- палитра ---------- */
let PAL = [], palSel = 0;
function palItems() {
const out = [
{tag: 'вид', label: 'Лента', hint: 'свежие записи', run: () => setView('feed')},
{tag: 'вид', label: 'Источники', hint: 'менеджер источников', run: () => setView('src')},
{tag: 'вид', label: 'Прогоны', hint: 'история запусков', run: () => setView('runs')},
{tag: 'вид', label: 'Абузы', hint: 'промо/триалы/ключи', run: () => { setView('abuse'); loadAbuse(); }},
{tag: 'действие', label: 'Собрать ленту', hint: 'опрашивает включённые источники', run: runFeed},
{tag: 'действие', label: 'Проверить все источники', hint: 'health-check', run: () => check(null, null)},
{tag: 'действие', label: 'Добавить источник', hint: 'своя лента или API', run: () => $('#modal').classList.add('on')},
{tag: 'действие', label: 'Включить всё в выборке', hint: 'bulk enable', run: async () => { await post('/api/sources/toggle_all', {enabled: true, category: S.cat}); toast('все включены', 'ok'); loadSources(); loadStats(); }},
{tag: 'действие', label: 'Выключить всё в выборке', hint: 'bulk disable', run: async () => { await post('/api/sources/toggle_all', {enabled: false, category: S.cat}); toast('все выключены', 'ok'); loadSources(); loadStats(); }}
];
((S.stats && S.stats.categories) || []).forEach(c => out.push({
tag: 'категория', label: c.name, hint: `${c.alive}/${c.total} живых`, q: c.name,
run: () => { setView('feed'); setCat(c.name); }
}));
(S.sources || []).forEach(x => out.push({
tag: 'источник', label: x.name,
hint: `${x.category} · ${x.stored || 0} записей` + (x.note ? ' · ' + x.note : ''),
hay: `${x.name} ${x.category} ${(x.tags || []).join(' ')}`,
run: () => { S.srcQ = x.name; setView('src'); $('#q').value = x.name; S.q = x.name; loadSources(); }
}));
return out;
}
function renderPal(q) {
const box = $('#pal-list');
PAL = palItems();
const needle = (q || '').toLowerCase().trim();
const list = needle ? PAL.filter(x => (x.label + ' ' + x.tag + ' ' + (x.hint || '') + ' ' + (x.hay || '')).toLowerCase().includes(needle)) : PAL;
PAL = list.slice(0, 60);
if (palSel >= PAL.length) palSel = 0;
box.innerHTML = PAL.length ? '' : '<div class="empty">ничего не нашлось</div>';
PAL.forEach((x, i) => {
const d = el('div', 'pal-item' + (i === palSel ? ' sel' : ''));
d.innerHTML = `<span class="tag">${esc(x.tag)}</span><span>${esc(x.label)}</span><span class="cnt">${esc(x.hint || '')}</span>`;
d.onclick = () => { $('#palette').classList.remove('on'); x.run(); };
box.appendChild(d);
});
}
async function openPalette() {
$('#palette').classList.add('on');
palSel = 0;
$('#pal-q').value = '';
renderPal('');
setTimeout(() => $('#pal-q').focus(), 30);
if (!(S.sources || []).length) {
try {
const r = await api('/api/sources?category=all');
if (r && r.items) { S.sources = r.items; renderPal($('#pal-q').value); }
} catch (e) {}
}
}
$('#btn-palette').onclick = openPalette;
$('#btn-palette2').onclick = openPalette;
$('#palette').onclick = e => { if (e.target.id === 'palette') $('#palette').classList.remove('on'); };
$('#pal-q').oninput = e => { palSel = 0; renderPal(e.target.value); };
$('#pal-q').onkeydown = e => {
if (e.key === 'ArrowDown') { e.preventDefault(); palSel = Math.min(palSel + 1, PAL.length - 1); renderPal($('#pal-q').value); }
if (e.key === 'ArrowUp') { e.preventDefault(); palSel = Math.max(palSel - 1, 0); renderPal($('#pal-q').value); }
if (e.key === 'Enter' && PAL[palSel]) { $('#palette').classList.remove('on'); PAL[palSel].run(); }
};
try { if (localStorage.getItem('omni.cats') === '0') { $('#cats').classList.add('collapsed'); $('#cats-toggle').textContent = '▸'; } } catch (e) {}
(async () => {
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; }
await loadStats();
if (p.get('view') === 'src') { setView('src'); } else { await loadItems(true); }
const v = p.get('view');
if (v === 'abuse') { setView('abuse'); setTimeout(() => loadAbuse().catch(e => console.error('abuse', e)), 300); }
else if (v === 'src' || v === 'runs') setView(v);
else { setView('feed'); }
if (p.get('pal')) setTimeout(openPalette, 500);
})();
</script>
</body>