Files
omni-parse/web/index.html
Vlad Gothbreach 16512bf4a1
Some checks failed
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled
абузы: free-панели new-api на вкладке (роут /api/panels + грид), CSS вынесен из JS, панельный классификатор даёт freeapi-вес, тесты
2026-10-27 02:06:32 +03:00

1024 lines
72 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>omni · комбайн источников</title>
<style>
:root{
--bg:#0a0e13; --bg2:#0c1219; --panel:#111823; --panel2:#0e151e; --line:#1c2735; --line2:#27364a;
--text:#e9eff7; --muted:#93a3b8; --dim:#7b8ba1;
--acc:#37d67a; --acc2:#5aa9ff; --warn:#f5b544; --bad:#ef5b5b; --violet:#a78bfa;
--r:10px; --r2:14px;
--mono:"Cascadia Mono",Consolas,"SF Mono",monospace;
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--text);font:13.5px/1.5 var(--sans);-webkit-font-smoothing:antialiased}
a{color:var(--acc2);text-decoration:none}
a:hover{text-decoration:underline}
button,input,select,textarea{font:inherit;color:inherit}
::selection{background:#2b4a6f}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:#243244;border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
/* shell */
.app{display:grid;grid-template-columns:236px 1fr;height:100vh;overflow:hidden}
.side{background:var(--bg2);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
.brand{display:flex;align-items:center;gap:9px;padding:16px 16px 12px}
.logo{width:26px;height:26px;border-radius:8px;background:linear-gradient(135deg,#37d67a,#5aa9ff);display:grid;place-items:center;color:#07120c;font-weight:800;font-size:14px}
.brand h1{font-size:15px;margin:0;letter-spacing:.4px}
.brand .ver{font:10px var(--mono);color:var(--dim);border:1px solid var(--line2);border-radius:999px;padding:1px 6px}
nav{display:flex;flex-direction:column;gap:1px;padding:6px 8px;overflow:auto}
.nav-i{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;color:var(--muted);cursor:pointer;border:0;background:none;text-align:left;width:100%}
.nav-i:hover{background:#131c27;color:var(--text)}
.nav-i.on{background:#16222f;color:var(--text);box-shadow:inset 2px 0 0 var(--acc)}
.nav-i svg{flex:0 0 16px;opacity:.85}
.nav-i .k{margin-left:auto;font:10px var(--mono);color:var(--dim);border:1px solid var(--line);border-radius:4px;padding:0 4px}
.nav-i .badge{margin-left:auto;font:10px var(--mono);background:#1b2a1f;color:var(--acc);border-radius:999px;padding:1px 6px}
.side-foot{margin-top:auto;padding:10px 12px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px}
.health{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--muted)}
.dot{width:7px;height:7px;border-radius:50%;background:var(--acc);box-shadow:0 0 0 3px #37d67a22}
.dot.bad{background:var(--bad);box-shadow:0 0 0 3px #ef5b5b22}
.main{display:flex;flex-direction:column;min-width:0;min-height:0}
.top{display:flex;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#0e151e,#0b1119)}
.crumb{font-size:13px;color:var(--muted);white-space:nowrap}
.crumb b{color:var(--text);font-weight:600}
.search{position:relative;flex:1;max-width:560px}
.search input{width:100%;background:#0b1219;border:1px solid var(--line2);border-radius:9px;padding:8px 34px 8px 32px;outline:none}
.search input:focus{border-color:#35597f;box-shadow:0 0 0 3px #5aa9ff1f}
.search .ic{position:absolute;left:10px;top:8px;color:var(--dim)}
.search .kbd{position:absolute;right:8px;top:7px}
.sug{position:absolute;z-index:40;top:40px;left:0;right:0;background:#0f1822;border:1px solid var(--line2);border-radius:10px;box-shadow:0 18px 40px #0009;overflow:hidden;display:none}
.sug.on{display:block}
.sug .grp{font:10px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--dim);padding:7px 11px 3px}
.sug .it{padding:6px 11px;cursor:pointer;display:flex;gap:8px;align-items:center}
.sug .it:hover,.sug .it.sel{background:#17222f}
.sug .it .s{color:var(--dim);font-size:11px;margin-left:auto;white-space:nowrap}
.actions{display:flex;align-items:center;gap:7px;margin-left:auto}
.btn{display:inline-flex;align-items:center;gap:6px;background:#16202c;border:1px solid var(--line2);color:var(--text);border-radius:8px;padding:7px 11px;cursor:pointer;white-space:nowrap}
.btn:hover{background:#1b2836;border-color:#35506e}
.btn.primary{background:#12351f;border-color:#1f5c33;color:#b9f5cd}
.btn.primary:hover{background:#16452a}
.btn.ghost{background:transparent}
.btn.sm{padding:4px 8px;font-size:12px;border-radius:7px}
.btn.icon{padding:7px 8px}
.btn:disabled{opacity:.45;cursor:default}
.kbd{font:10.5px var(--mono);color:var(--dim);border:1px solid var(--line2);border-radius:5px;padding:1px 5px;background:#0b1119}
.content{overflow:auto;padding:18px;min-height:0}
.page-h{display:flex;align-items:flex-end;gap:12px;margin-bottom:14px;flex-wrap:wrap}
.page-h h2{margin:0;font-size:19px;letter-spacing:.2px}
.page-h p{margin:2px 0 0;color:var(--muted);font-size:12.5px;max-width:70ch}
.page-h .right{margin-left:auto;display:flex;gap:7px;align-items:center}
/* cards */
.grid{display:grid;gap:14px}
.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
.g21{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r2);padding:14px}
.card.hov:hover{border-color:#2b3d52}
.card h3{margin:0 0 10px;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:600;display:flex;align-items:center;gap:8px}
.card h3 .r{margin-left:auto;font:11px var(--mono);color:var(--dim);text-transform:none;letter-spacing:0}
.kpi{font-size:26px;font-weight:700;letter-spacing:-.5px;line-height:1.1}
.kpi small{font-size:12px;color:var(--muted);font-weight:500;margin-left:6px;letter-spacing:0}
.sub{color:var(--muted);font-size:12px;margin-top:3px}
.up{color:var(--acc)} .down{color:var(--bad)}
.num{font-family:var(--mono);font-variant-numeric:tabular-nums}
/* chart */
.chart{display:flex;align-items:flex-end;gap:2px;height:96px;margin-top:8px}
.chart .b{flex:1;background:linear-gradient(180deg,#2f7d52,#1d4433);border-radius:2px 2px 0 0;min-height:2px;position:relative}
.chart .b:hover{background:linear-gradient(180deg,#4ade80,#25603f)}
.chart .b .tip{display:none;position:absolute;bottom:100%;left:50%;transform:translateX(-50%);background:#0b1119;border:1px solid var(--line2);border-radius:6px;padding:2px 6px;font:10px var(--mono);white-space:nowrap;z-index:5}
.chart .b:hover .tip{display:block}
.chart-x{display:flex;justify-content:space-between;color:var(--dim);font:10px var(--mono);margin-top:5px}
/* chips + tables */
.chip{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line2);border-radius:999px;padding:2px 8px;font-size:11px;color:var(--muted);background:#101925}
.chip.t{background:#141f2e;color:#a8c4e0}
.chip.cve{background:#2a1620;color:#f2a6b6;border-color:#4a2331}
.chip.leak{background:#2a1f12;color:#f3c07a;border-color:#4a3620}
.chip.tool{background:#132028;color:#8fd8e8;border-color:#234450}
.chip.model{background:#1b1630;color:#c2b2f7;border-color:#332a52}
.chip.promo{background:#12251a;color:#9fe6b4;border-color:#245035}
.chip.news{background:#151f2b;color:#a9bdd2;border-color:#26374b}
.chip.trial{background:#221a33;color:#cbb3f2;border-color:#3a2c58}
.chip.resale{background:#2a1626;color:#f0a8cf;border-color:#4a2340}
.chip.release{background:#12233a;color:#9dc4f5;border-color:#21405f}
.chip.guide{background:#12282a;color:#96dede;border-color:#1f4a4c}
.chip.dataset{background:#1a2438;color:#a9bdf0;border-color:#2c3c5c}
.chip.job{background:#2a2413;color:#ecd08a;border-color:#4a3f1f}
.chip.price{background:#12251a;color:#9fe6b4;border-color:#245035}
.chip.question{background:#1b2029;color:#b6c2d4;border-color:#2c3746}
.chip.on{background:#12351f;border-color:#1f5c33;color:#b9f5cd}
.chip.off{background:#211517;border-color:#4a2429;color:#f0a9a9}
.chip.click{cursor:pointer}
.chip.click:hover{border-color:#3c5a7c}
.chip .n{font-family:var(--mono);font-size:10px;color:inherit;opacity:.8}
table{width:100%;border-collapse:collapse}
th{position:sticky;top:0;background:#0e151e;text-align:left;font:11px var(--mono);letter-spacing:.05em;text-transform:uppercase;color:var(--dim);padding:8px 10px;border-bottom:1px solid var(--line);z-index:2}
td{padding:8px 10px;border-bottom:1px solid #151f2b;vertical-align:middle}
tbody tr:hover td{background:#0f1822}
.tbl-wrap{max-height:calc(100vh - 260px);overflow:auto;border:1px solid var(--line);border-radius:var(--r)}
td .nm{font-weight:500}
td .u{color:var(--dim);font-size:11px;display:block;max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.right{text-align:right}
.nowrap{white-space:nowrap}
.pill{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:1px}
.pill.ok{background:var(--acc)} .pill.no{background:var(--bad)} .pill.unk{background:#46566b}
.bar{height:5px;border-radius:3px;background:#1a2532;overflow:hidden;min-width:60px}
.bar>i{display:block;height:100%;background:var(--acc)}
/* feed */
.item{border-bottom:1px solid #151f2b;padding:11px 13px;cursor:pointer;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px}
.item:hover{background:#0f1822}
.item .t{font-size:14.5px;font-weight:600;letter-spacing:.1px;line-height:1.35;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.item .m{display:flex;gap:8px;align-items:center;flex-wrap:wrap;color:var(--dim);font-size:11.5px}
.item .src{color:#9db7d4}
.item .x{color:var(--muted);font-size:12.5px;margin-top:5px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.item .side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;justify-content:center}
.sc{font:11px var(--mono);color:var(--muted);border:1px solid var(--line2);border-radius:6px;padding:1px 5px}
.sc.hi{color:#b9f5cd;border-color:#1f5c33;background:#12251a}
.sc.mid{color:#f3c07a;border-color:#4a3620;background:#221a10}
.ico{width:26px;height:26px;display:grid;place-items:center;border-radius:7px;border:1px solid transparent;color:var(--dim);cursor:pointer;background:none}
.ico:hover{background:#17222f;color:var(--text);border-color:var(--line2)}
/* drawer */
.drawer{position:fixed;top:0;right:0;bottom:0;width:min(640px,92vw);background:#0d141d;border-left:1px solid var(--line2);box-shadow:-30px 0 60px #0008;transform:translateX(102%);transition:transform .18s ease;z-index:60;display:flex;flex-direction:column}
.drawer.on{transform:none}
.drawer .dh{display:flex;gap:10px;align-items:flex-start;padding:14px 16px;border-bottom:1px solid var(--line)}
.drawer .dh h3{margin:0;font-size:15px;line-height:1.4;text-transform:none;letter-spacing:0;color:var(--text);font-weight:600}
.drawer .db{overflow:auto;padding:14px 16px;display:flex;flex-direction:column;gap:14px}
.drawer .txt{white-space:pre-wrap;font-size:13px;color:#cbd8e6;line-height:1.55}
.drawer .sec-h{font:11px var(--mono);text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin-bottom:6px}
.rel{display:flex;flex-direction:column;gap:6px}
.rel a{display:block;color:#b9cbe0;font-size:12.5px;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:#0f1822}
.rel a:hover{background:#131f2c;text-decoration:none;border-color:#2b3d52}
.backdrop{position:fixed;inset:0;background:#05090dbb;z-index:55;display:none}
.backdrop.on{display:block}
/* misc */
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.spacer{flex:1}
.mut{color:var(--muted)} .dim{color:var(--dim)} .mono{font-family:var(--mono)}
.empty{padding:34px 16px;text-align:center;color:var(--muted)}
.empty b{display:block;color:var(--text);margin-bottom:4px;font-size:14px}
.skel{background:linear-gradient(90deg,#131d28,#1a2634,#131d28);background-size:200% 100%;animation:sk 1.2s infinite;border-radius:6px;height:12px}
@keyframes sk{0%{background-position:0 0}100%{background-position:-200% 0}}
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%) translateY(20px);background:#12202b;border:1px solid #2b4762;color:var(--text);padding:9px 14px;border-radius:9px;box-shadow:0 12px 30px #000a;opacity:0;transition:.18s;z-index:90;font-size:12.5px}
.toast.on{opacity:1;transform:translateX(-50%)}
.toast.bad{border-color:#7a2a2a;background:#2a1416}
.modal{position:fixed;inset:0;z-index:80;display:none;place-items:center;background:#05090dd0}
.modal.on{display:grid}
.modal .box{width:min(720px,94vw);max-height:86vh;overflow:auto;background:#0d141d;border:1px solid var(--line2);border-radius:16px;padding:18px;box-shadow:0 30px 80px #000b}
.modal h3{margin:0 0 12px;font-size:16px}
.kv{display:grid;grid-template-columns:170px minmax(0,1fr);gap:6px 12px;font-size:12.5px}
.kv .k{color:var(--dim)}
.hl{background:#0f1822;border:1px solid var(--line);border-radius:10px;padding:10px 12px}
.hl .n{font-size:20px;font-weight:700;font-family:var(--mono)}
.hl .l{font-size:11.5px;color:var(--muted)}
.prog{height:6px;border-radius:4px;background:#16212d;overflow:hidden}
.prog>i{display:block;height:100%;background:linear-gradient(90deg,#37d67a,#5aa9ff);transition:width .2s}
.field{display:flex;flex-direction:column;gap:5px}
.field label{font-size:11.5px;color:var(--muted)}
input[type=text],input[type=number],select,textarea{background:#0b1219;border:1px solid var(--line2);border-radius:8px;padding:7px 9px;outline:none}
input:focus,select:focus,textarea:focus{border-color:#35597f;box-shadow:0 0 0 3px #5aa9ff1a}
textarea{min-height:72px;resize:vertical;font-family:var(--mono);font-size:12px}
label.chk{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;cursor:pointer}
.md{font-size:13.5px;line-height:1.6}
.md h1{font-size:18px;margin:0 0 10px}
.md h2{font-size:14px;margin:18px 0 8px;color:#cfe0f0;border-bottom:1px solid var(--line);padding-bottom:4px}
.md ul{margin:0;padding-left:18px}
.md li{margin:3px 0;color:#cbd8e6}
.md code{font-family:var(--mono);font-size:11.5px;background:#132029;padding:1px 5px;border-radius:5px;color:#9fd7c8}
.md a{color:#8fc2ff}
.pager{display:flex;gap:8px;justify-content:center;padding:14px}
.legend{font-size:11px;color:var(--dim);display:flex;gap:14px;align-items:center;flex-wrap:wrap}
.hint{font-size:11.5px;color:var(--dim)}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (max-width:1100px){.g4{grid-template-columns:repeat(2,1fr)}.g21{grid-template-columns:1fr}}
@media (max-width:820px){
.app{grid-template-columns:64px 1fr}
.brand h1,.brand .ver,.nav-i span.lbl,.nav-i .k,.side-foot .health span{display:none}
.nav-i{justify-content:center}
.g4,.g3,.g2{grid-template-columns:1fr}
.content{padding:12px}
.top{padding:8px 10px}
.crumb{display:none}
}
.pn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px;margin-top:8px}
.pn{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;text-decoration:none;color:inherit;font-size:12px}
.pn:hover{border-color:var(--acc)}
.pn b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pn .muted{opacity:.6}
</style>
</head>
<body>
<div class="app">
<aside class="side">
<div class="brand"><div class="logo">o</div><h1>omni</h1><span class="ver">v2</span></div>
<nav id="nav"></nav>
<div class="side-foot">
<div class="health" id="health"><span class="dot"></span><span>подключение…</span></div>
<button class="btn ghost sm" data-act="help"><span class="kbd">?</span> горячие клавиши</button>
</div>
</aside>
<div class="main">
<header class="top">
<div class="crumb" id="crumb"><b>Обзор</b></div>
<div class="search">
<svg class="ic" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="q" type="text" placeholder="Поиск по 41 000 записям: CVE, ключи, сливы, модели…" autocomplete="off" spellcheck="false">
<span class="kbd" style="position:absolute;right:8px;top:7px">/</span>
<div class="sug" id="sug"></div>
</div>
<div class="actions">
<select id="cat" title="Категория источников"></select>
<button class="btn primary" data-act="run" title="Собрать свежее по категории">▶ Собрать</button>
<button class="btn icon" data-act="refresh" title="Обновить (r)">⟳</button>
</div>
</header>
<main class="content" id="view"></main>
</div>
</div>
<div class="backdrop" id="backdrop"></div>
<aside class="drawer" id="drawer">
<div class="dh"><h3 id="d-title">…</h3><button class="btn sm ghost" data-act="close-drawer" style="margin-left:auto">✕</button></div>
<div class="db" id="d-body"></div>
</aside>
<div class="toast" id="toast"></div>
<div class="modal" id="modal"><div class="box" id="modal-box"></div></div>
<script>
"use strict";
/* ============================= helpers ============================= */
const $=(s,r=document)=>r.querySelector(s), $$=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const num=n=>(n==null||isNaN(n))?0:Number(n).toLocaleString("ru-RU");
const k=n=>n>=1e6?(n/1e6).toFixed(1)+"M":n>=1e4?(n/1e3).toFixed(1)+"k":num(n);
const dt=s=>{if(!s)return"";s=String(s);const m=s.match(/^(\d{4})-(\d{2})-(\d{2})/);return m?`${m[3]}.${m[2]}.${m[1]}`:s.slice(0,10)};
const ago=s=>{if(!s)return"";const t=Date.parse(!isNaN(s)?+s:s);if(!t)return dt(s);
const d=(Date.now()-t)/1000;if(d<3600)return Math.max(1,Math.round(d/60))+" мин";if(d<86400)return Math.round(d/3600)+" ч";return Math.round(d/86400)+" дн"};
const debounce=(f,ms=220)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>f(...a),ms)}};
/* статус прогона: "ok*" — успех; "пусто"/"empty" — пустой ответ (не ошибка); "" — нет статуса */
const isOkSt=s=>String(s==null?"":s).trim().toLowerCase().startsWith("ok");
const isEmpSt=s=>["пусто","empty"].includes(String(s==null?"":s).trim().toLowerCase());
const stKey=s=>{const v=String(s==null?"":s).trim();return !v?"none":(isOkSt(v)?"ok":(isEmpSt(v)?"empty":"err"))};
const stPill=s=>{const k=stKey(s);return k==="ok"?"ok":(k==="err"?"no":"unk")};
let TOAST_T;
function toast(msg,bad=false){const el=$("#toast");el.textContent=msg;el.classList.toggle("bad",!!bad);el.classList.add("on");clearTimeout(TOAST_T);TOAST_T=setTimeout(()=>el.classList.remove("on"),2600);}
async function api(path,opt={}){
const r=await fetch(path,{headers:{"Content-Type":"application/json"},...opt});
if(!r.ok) throw new Error("HTTP "+r.status);
return r.json();
}
const post=(p,b)=>api(p,{method:"POST",body:JSON.stringify(b||{})});
function copy(txt){navigator.clipboard?.writeText(txt).then(()=>toast("скопировано"),()=>toast("не удалось скопировать",true));}
/* mini markdown → html */
function md(src){
const lines=String(src||"").split("\n");let out=[],inUl=false;
const inline=t=>esc(t).replace(/`([^`]+)`/g,"<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g,"<b>$1</b>")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(_,a,b)=>`<a href="${b}" target="_blank" rel="noopener">${a}</a>`);
for(const ln of lines){
if(/^\s*-\s+/.test(ln)){if(!inUl){out.push("<ul>");inUl=true}out.push("<li>"+inline(ln.replace(/^\s*-\s+/,""))+"</li>");continue}
if(inUl){out.push("</ul>");inUl=false}
if(/^###\s/.test(ln))out.push("<h2>"+inline(ln.slice(4))+"</h2>");
else if(/^##\s/.test(ln))out.push("<h2>"+inline(ln.slice(3))+"</h2>");
else if(/^#\s/.test(ln))out.push("<h1>"+inline(ln.slice(2))+"</h1>");
else if(ln.trim())out.push("<p>"+inline(ln)+"</p>");
}
if(inUl)out.push("</ul>");
return out.join("\n");
}
/* ============================= state / router ============================= */
const NAV=[
{id:"overview", label:"Обзор", icon:"M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z"},
{id:"feed", label:"Лента", icon:"M4 6h16M4 12h16M4 18h10"},
{id:"abuse", label:"Абузы", icon:"M12 3l8 4v6c0 5-3.5 7.5-8 8-4.5-.5-8-3-8-8V7z"},
{id:"sources", label:"Источники", icon:"M4 6h16M4 12h16M4 18h16M4 6v12"},
{id:"runs", label:"Прогоны", icon:"M12 8v5l3 2M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z"},
{id:"tg", label:"Telegram", icon:"M21 4L3 11l5 2 2 6 3-4 5 3z"},
{id:"keys", label:"Ключи", icon:"M14 7a4 4 0 1 1-3 6.9L4 21H2v-3l8.1-6.9A4 4 0 0 1 14 7z"},
{id:"digest", label:"Дайджест", icon:"M6 3h9l4 4v14H6zM15 3v4h4"},
{id:"watch", label:"Watchlist", icon:"M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"}
];
const S={view:"overview",q:"",cat:"",type:"all",sort:"score",days:0,priced:false,src:"",
offset:0,items:[],total:0,auto:localStorage.getItem("omni.auto")==="1",
runQ:"",runF:"all",types:[],cats:[]};
function route(){
const h=location.hash.replace(/^#\/?/,"");
const [v,qs]=h.split("?");
const p=new URLSearchParams(qs||"");
S.view=NAV.some(n=>n.id===v)?v:"overview";
S.q=p.get("q")||""; S.cat=p.get("cat")||""; S.type=p.get("type")||"all";
S.sort=p.get("sort")||"score"; S.days=+(p.get("days")||0); S.priced=p.get("priced")==="1";
S.src=p.get("src")||"";
S.offset=0;
$("#q").value=S.q;
$(".crumb").innerHTML="<b>"+esc(NAV.find(n=>n.id===S.view).label)+"</b>"+(S.q?` · «${esc(S.q)}»`:"");
paintNav(); render();
}
function go(view,params={},replace=false){
const p=new URLSearchParams();
const q=params.q!==undefined?params.q:S.q;
if(q)p.set("q",q);
const cat=params.cat!==undefined?params.cat:S.cat; if(cat)p.set("cat",cat);
if((params.type??S.type)!=="all")p.set("type",params.type??S.type);
const sort=params.sort??S.sort; if(sort!=="score")p.set("sort",sort);
const days=params.days??S.days; if(days)p.set("days",days);
if((params.priced??S.priced))p.set("priced","1");
const src=params.src!==undefined?params.src:S.src; if(src)p.set("src",src);
const url="#/"+view+(p.toString()?"?"+p:"");
if(replace)history.replaceState(null,"",url); else location.hash=url;
if(replace)route();
}
function paintNav(){
$("#nav").innerHTML=NAV.map((n,i)=>`<button class="nav-i ${S.view===n.id?"on":""}" data-nav="${n.id}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="${n.icon}"/></svg>
<span class="lbl">${n.label}</span><span class="k">${i+1}</span></button>`).join("");
}
/* ============================= chrome ============================= */
async function boot(){
paintNav();
try{
const [st,kv]=await Promise.all([api("/api/stats"),api("/api/types")]);
S.stats=st; S.types=kv.types||[];
S.cats=(st.categories||[]).map(c=>c.name);
$("#cat").innerHTML=`<option value="">Все категории · ${num(st.sources)}</option>`+
(st.categories||[]).map(c=>`<option value="${esc(c.name)}">${esc(c.name)} · ${c.total}</option>`).join("");
$("#cat").value=S.cat;
const ok=st.alive>0;
$("#health").innerHTML=`<span class="dot ${ok?"":"bad"}"></span><span>${num(st.items)} записей · ${num(st.alive)}/${num(st.sources)} живых</span>`;
$("#q").placeholder=`Поиск по ${num(st.items)} записям: CVE, ключи, сливы, модели…`;
}catch(e){ $("#health").innerHTML=`<span class="dot bad"></span><span>API недоступен</span>`; }
route();
setInterval(refreshHealth,60000);
}
async function refreshHealth(){
try{
const h=await api("/api/health");
$("#health").innerHTML=`<span class="dot ${h.alive>0?"":"bad"}"></span><span>${num(h.items)} записей · ${num(h.alive)}/${num(h.sources)} живых · аптайм ${Math.round(h.uptime/60)} мин</span>`;
$("#q").placeholder=`Поиск по ${num(h.items)} записям: CVE, ключи, сливы, модели…`;
}catch(e){ $("#health").innerHTML=`<span class="dot bad"></span><span>API недоступен</span>`; }
}
/* ============================= views ============================= */
const V={};
/* ---------- overview ---------- */
V.overview=async()=>{
const box=$("#view");
box.innerHTML=`<div class="grid g4">${Array(4).fill(`<div class="card"><div class="skel" style="width:40%"></div><div class="skel" style="width:70%;height:26px;margin-top:10px"></div><div class="skel" style="width:55%;margin-top:10px"></div></div>`).join("")}</div>
<div class="grid g21" style="margin-top:14px"><div class="card"><div class="skel" style="height:130px"></div></div><div class="card"><div class="skel" style="height:130px"></div></div></div>`;
const [st,tl,runs,watch,ab]=await Promise.all([
api("/api/stats"), api("/api/timeline?days=30&mode=seen"), api("/api/runs?limit=6"),
api("/api/watch").catch(()=>({rules:[]})), api("/api/abuse?limit=8").catch(()=>({items:[]}))
]);
S.stats=st;
// last24 — честное скользящее окно от бэкенда; «сегодня» неполный день, процент не считаем
const day=tl.last24??0;
const live=(st.sources?(st.alive/st.sources*100):0).toFixed(0);
const bs=runs.by_status||{};
const runAll=(bs.ok||0)+(bs.empty||0)+(bs.err||0)+(bs.none||0);
const okRate=runAll?Math.round((bs.ok||0)/runAll*100):(st.runs?Math.round(st.runs_ok/st.runs*100):0);
const kpi=(label,val,sub,accent)=>`<div class="card hov"><div class="sub">${label}</div>
<div class="kpi num" style="${accent?`color:${accent}`:""}">${val}</div><div class="sub">${sub}</div></div>`;
const hits=(watch.rules||[]).reduce((a,r)=>a+(r.count||0),0);
box.innerHTML=`
<div class="page-h"><div><h2>Обзор</h2><p>Комбайн источников: реестр, лента, абузы, ключи и Telegram-сессия в одном окне.</p></div>
<div class="right">
<button class="btn" data-act="digest-quick">Дайджест за сутки</button>
<button class="btn ${S.auto?"primary":""}" data-act="auto">${S.auto?"Автообновление вкл":"Автообновление выкл"}</button>
</div></div>
<div class="grid g4">
${kpi("Записей всего",num(st.items),`за последние 24 часа: +${num(day)}`)}
${kpi("Источников живых",`${num(st.alive)}<small>/ ${num(st.sources)}</small>`,`${live}% реестра отдают записи`)}
${kpi("Ключей готово",`${num(st.keys?.ready||0)}<small>/ ${num(st.keys?.keys||0)}</small>`,`${num(st.keys?.services||0)} сервисов · ротация и остывание`)}
${kpi("Watchlist",num((watch.rules||[]).length),`${num(hits)} совпадений по правилам`)}
</div>
<div class="grid g21" style="margin-top:14px">
<div class="card"><h3>Импорт за 30 дней <span class="r">${num(tl.total)} записей · пик ${num(tl.max)}/день</span></h3>
${chart(tl.points)}
<div class="chart-x"><span>${dt(tl.points[0]?.day)}</span><span>${dt(tl.points[Math.floor(tl.points.length/2)]?.day)}</span><span>сегодня</span></div>
<div class="legend" style="margin-top:9px"><span>столбец = записей за день импорта</span><span>наведение — дата и число CVE</span><span>переключатель «сутки» в ленте фильтрует по дате публикации</span></div>
</div>
<div class="card"><h3>Источники по категориям</h3>
${(st.categories||[]).slice(0,8).map(c=>{const r=c.total?Math.round((c.alive||0)/c.total*100):0;
return `<div style="margin:7px 0">
<div class="row" style="justify-content:space-between;font-size:12px"><span>${esc(c.name)}</span>
<span class="dim mono">${c.alive||0}/${c.total} · ${num(c.stored||0)}</span></div>
<div class="bar" style="margin-top:3px"><i style="width:${r}%"></i></div></div>`}).join("")||`<div class="empty">нет данных</div>`}
</div>
</div>
<div class="grid g2" style="margin-top:14px">
<div class="card"><h3>Свежее <span class="r"><a href="#/feed">вся лента →</a></span></h3><div id="ov-latest">${skel(4)}</div></div>
<div class="card"><h3>Последние прогоны <span class="r">ok ${num(okRate)}% · <a href="#/runs">все →</a></span></h3>
<table><tbody>${(runs.items||[]).map(r=>`<tr><td class="mut mono nowrap">${esc(r.when||"")}</td>
<td class="nowrap">${esc(r.source||"—")}</td><td class="right mono">${r.got??""}/${r.kept??""}</td>
<td class="right"><span class="pill ${stPill(r.status)}" title="${esc(r.status||"нет статуса")}"></span><span class="dim">${esc(r.status||"—")}</span></td></tr>`).join("")}</tbody></table>
</div>
</div>
<div class="card" style="margin-top:14px"><h3>Абузы сейчас <span class="r"><a href="#/abuse">разбор →</a></span></h3>
<div id="ov-abuse">${skel(3)}</div></div>`;
// Latest items (fast, 6)
api("/api/items?limit=6&sort=fresh").then(d=>{
$("#ov-latest").innerHTML=d.items.map(it=>itemRow(it,true)).join("")||`<div class="empty">пусто</div>`;
}).catch(()=>{$("#ov-latest").innerHTML=`<div class="empty">не удалось загрузить</div>`});
$("#ov-abuse").innerHTML=(ab.items||[]).map(it=>itemRow(it,true)).join("")||`<div class="empty">пусто</div>`;
};
function chart(pts){
const max=Math.max(1,...pts.map(p=>p.n));
return `<div class="chart">${pts.map(p=>`<div class="b" style="height:${Math.max(2,Math.round(p.n/max*96))}px">
<div class="tip">${dt(p.day)} · ${num(p.n)}${p.cve?` · CVE ${p.cve}`:""}</div></div>`).join("")}</div>`;
}
const skel=n=>Array(n).fill(`<div style="padding:9px 0"><div class="skel" style="width:70%"></div><div class="skel" style="width:45%;margin-top:6px"></div></div>`).join("");
/* ---------- feed ---------- */
V.feed=async()=>{
const box=$("#view");
box.innerHTML=`<div class="page-h"><div><h2>Лента</h2><p>Все записи реестра: фильтры по типу, периоду, сортировке; клик — детали и соседи.</p></div>
<div class="right">
<a class="btn sm" href="/api/export.csv?${exQS()}" download title="Экспорт текущего фильтра: период, тип, источник, цена; до 1000 записей">CSV</a>
<a class="btn sm" href="/api/export.json?${exQS()}" download title="Экспорт текущего фильтра: период, тип, источник, цена; до 1000 записей">JSON</a>
</div></div>
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
<span class="dim" style="font-size:11.5px">Типы:</span>
<span class="chip click ${S.type==="all"?"on":""}" data-type="all">все <span class="n">${num(S.stats?.items||0)}</span></span>
${(S.types||[]).slice(0,9).map(t=>`<span class="chip click ${S.type===t.key?"on":""}" data-type="${esc(t.key)}">${esc(t.label)} <span class="n">${k(t.n)}</span></span>`).join("")}
<span class="spacer"></span>
<select id="f-sort">${[["score","по релевантности"],["fresh","сначала новые"],["old","сначала старые"],["price","по цене"]].map(([v,l])=>`<option value="${v}" ${S.sort===v?"selected":""}>${l}</option>`).join("")}</select>
<select id="f-days">${[[0,"за всё время"],[1,"сутки"],[7,"неделя"],[30,"месяц"],[90,"квартал"]].map(([v,l])=>`<option value="${v}" ${S.days==v?"selected":""}>${l}</option>`).join("")}</select>
<label class="chk"><input type="checkbox" id="f-priced" ${S.priced?"checked":""}> только с ценой</label>
</div></div>
<div class="card" style="padding:0"><div id="list">${skel(6)}</div><div id="more" class="pager"></div></div>`;
await loadItems(true);
};
const exQS=()=>new URLSearchParams({q:S.q,category:S.cat,type:S.type,sort:S.sort,
...(S.days?{days:String(S.days)}:{}),...(S.priced?{priced:"1"}:{}),...(S.src?{src:S.src}:{}),
limit:"1000"}).toString();
async function loadItems(reset){
if(reset){S.offset=0;S.items=[]}
const p=new URLSearchParams({limit:"40",offset:String(S.offset),q:S.q,sort:S.sort,type:S.type,
...(S.cat?{category:S.cat}:{}),...(S.days?{days:String(S.days)}:{}),...(S.priced?{priced:"1"}:{}),
...(S.src?{src:S.src}:{})});
const d=await api("/api/items?"+p);
S.total=d.total; S.items=S.items.concat(d.items); S.offset+=d.items.length;
const list=$("#list"); if(!list)return;
list.innerHTML=S.items.map(it=>itemRow(it)).join("")||`<div class="empty"><b>Ничего не найдено</b>Сбросьте фильтры или добавьте источник</div>`;
$("#more").innerHTML=S.items.length<S.total
? `<button class="btn" data-act="more">Показать ещё · осталось ${num(S.total-S.items.length)}</button>`
: `<span class="dim">показано ${num(S.items.length)} из ${num(S.total)}</span>`;
}
function findChips(f){
if(!f) return "";
const p=[];
(f.codes||[]).slice(0,2).forEach(c=>p.push(`<span class="chip promo" title="промокод/код">code: ${esc(c)}</span>`));
if(f.amount) p.push(`<span class="chip price" title="объём/сумма">${esc(f.amount)}</span>`);
if(f.term) p.push(`<span class="chip trial" title="срок">${esc(f.term)}</span>`);
return p.join("");
}
function itemRow(it,compact){
const types=(it.types||"").split(",").filter(Boolean).slice(0,3);
const labels=typeLabels();
const score=it.abuse_score??it.score??0;
const cls=score>=70?"hi":score>=40?"mid":"";
const price=it.price?`<span class="chip">${esc(it.price)}${it.currency?" "+esc(it.currency):""}</span>`:"";
return `<div class="item" data-id="${it.id}">
<div>
<div class="t">${esc(it.title||"(без заголовка)")}</div>
<div class="m"><span class="src">${esc(it.source||"")}</span>
${types.map(t=>`<span class="chip t ${t}">${esc(labels[t]||t)}</span>`).join("")}
${price}${findChips(it.find)}<span>${dt(it.published)}</span>${it.views?`<span>👁 ${num(it.views)}</span>`:""}</div>
${compact?"":`<div class="x">${esc(it.text||"")}</div>`}
</div>
<div class="side">
<span class="sc ${cls}">${score?Math.round(score):"—"}</span>
<div class="row" style="gap:2px">
<button class="ico" data-open="${esc(it.url||"")}" title="открыть">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 4h6v6M20 4l-8 8M9 5H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4"/></svg></button>
<button class="ico" data-copy="${esc(it.url||it.title||"")}" title="копировать ссылку">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg></button>
</div>
</div></div>`;
}
const typeLabels=()=>Object.fromEntries((S.types||[]).map(t=>[t.key,t.label]));
async function openItem(id){
const dr=$("#drawer"), bd=$("#backdrop");
dr.classList.add("on"); bd.classList.add("on");
$("#d-title").textContent="Загрузка…";
$("#d-body").innerHTML=`<div class="skel" style="width:60%"></div><div class="skel" style="width:90%;margin-top:10px"></div><div class="skel" style="width:80%;margin-top:8px"></div>`;
try{
const d=await api("/api/item/"+id); const it=d.item;
$("#d-title").textContent=it.title||"(без заголовка)";
const rel=r=>r.map(x=>`<a href="#" data-id="${x.id}">${esc(x.title)} <span class="dim">· ${esc(x.source)}</span></a>`).join("");
$("#d-body").innerHTML=`
<div class="row"><span class="chip">${esc(it.source)}</span>
${(it.types||[]).map(t=>`<span class="chip t ${esc(t)}">${esc(t)}</span>`).join("")}
<span class="dim mono">${dt(it.published)}</span>
${it.price?`<span class="chip on">${esc(it.price)} ${esc(it.currency||"")}</span>`:""}</div>
<div class="row">
<a class="btn primary" href="${esc(it.url)}" target="_blank" rel="noopener">Открыть оригинал ↗</a>
<button class="btn" data-copy="${esc(it.url)}">Копировать ссылку</button>
<button class="btn" data-watch="${esc((it.title||"").slice(0,40))}">☆ В watchlist</button>
</div>
<div class="txt">${esc(it.text||"текста нет")}</div>
${it.related_same_source?.length?`<div><div class="sec-h">ещё из ${esc(it.source)}</div><div class="rel">${rel(it.related_same_source)}</div></div>`:""}
${it.related_same_host?.length?`<div><div class="sec-h">с того же домена</div><div class="rel">${rel(it.related_same_host)}</div></div>`:""}
<div class="kv"><div class="k">id</div><div class="mono">${it.id}</div>
<div class="k">провайдер / вид</div><div>${esc(it.provider)} / ${esc(it.kind)}</div>
<div class="k">автор</div><div>${esc(it.author||"—")}</div>
<div class="k">score</div><div class="mono">${it.score??"—"}</div></div>`;
}catch(e){ $("#d-body").innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
}
function closeDrawer(){$("#drawer").classList.remove("on");$("#backdrop").classList.remove("on");}
/* ---------- abuse ---------- */
V.abuse=async()=>{
const d=await api("/api/abuse?limit=120");
const tags=Object.entries(d.tags||{}).slice(0,14);
$("#view").innerHTML=`
<div class="page-h"><div><h2>Абузы</h2><p>Скам/промо/триалы/ключи в постах каналов и форумов. Вес считается по признакам: обещания, цены, «в личку», свежесть.</p></div>
<div class="right"><button class="btn" data-act="summary">ИИ-саммари</button></div></div>
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
<span class="chip click on" data-tag="">все <span class="n">${num(d.total)}</span></span>
${tags.map(([t,n])=>`<span class="chip click" data-tag="${esc(t)}">${esc(d.labels?.[t]||t)} <span class="n">${num(n)}</span></span>`).join("")}
</div></div>
<div class="card" style="padding:0"><div id="ab-list">${(d.items||[]).map(it=>itemRow(it)).join("")||`<div class="empty">пока пусто</div>`}</div></div>
<div id="pn-box"></div>
<div id="sum-box"></div>`;
S.abuse=(d.items||[]);
loadPanels();
};
async function loadPanels(){
const box=$("#pn-box"); if(!box) return;
try{
const d=await api("/api/panels");
const all=(d.panels||[]).filter(p=>(p.free_count||p.free||0)>0);
const rows=all.slice(0,40);
if(!rows.length){ box.innerHTML=""; return; }
box.innerHTML=`<div class="card" style="margin-top:14px"><h3>Free-панели new-api <span class="r">всего ${num(d.total)} · с бесплатными ${num(all.length)}${all.length>rows.length?` · показано ${num(rows.length)}`:""}</span></h3>
<div class="pn-grid">${rows.map(p=>`<a class="pn" href="https://${esc(p.host||p.url||"")}" target="_blank" rel="noopener">
<b>${esc(p.host||p.url||"?")}</b><span class="mono">${num(p.free_count||p.free||0)} free</span>
<span class="muted">${num(p.models||p.model_count||0)} моделей</span></a>`).join("")}</div></div>`;
}catch(e){ box.innerHTML=""; }
}
async function abuseByTag(tag){
const d=await api("/api/abuse?limit=60"+(tag?"&tag="+encodeURIComponent(tag):""));
const box=$("#ab-list"); if(box) box.innerHTML=(d.items||[]).map(it=>itemRow(it)).join("")||`<div class="empty">пусто</div>`;
S.abuse=d.items||[];
}
async function summary(tag){
const box=$("#sum-box"); box.innerHTML=`<div class="card" style="margin-top:14px"><div class="skel" style="width:40%"></div><div class="skel" style="width:90%;margin-top:10px"></div></div>`;
try{
const d=await api("/api/summary?limit=14"+(tag?"&tag="+encodeURIComponent(tag):""));
box.innerHTML=`<div class="card" style="margin-top:14px"><h3>ИИ-саммари <span class="r">${d.n||0} записей</span></h3>
<div class="md">${md(d.summary||"")}</div></div>`;
}catch(e){ box.innerHTML=`<div class="card" style="margin-top:14px"><div class="empty">ИИ не настроен: ${esc(e.message)}</div></div>`; }
}
/* ---------- sources ---------- */
V.sources=async()=>{
const box=$("#view");
box.innerHTML=`<div class="page-h"><div><h2>Источники</h2><p>Реестр площадок: адаптер, категория, живость, сколько записей реально лежит в базе.</p></div>
<div class="right"><button class="btn" data-act="add-src">+ Добавить</button><button class="btn" data-act="check-all">Проверить выбранные</button></div></div>
<div id="src-tiles" class="grid g4" style="margin-bottom:12px"></div>
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
<input id="s-q" type="text" placeholder="фильтр по имени или url" style="min-width:260px" value="${esc(S.srcQ||"")}">
<select id="s-cat"><option value="">все категории</option></select>
<select id="s-state">
<option value="">любое состояние</option><option value="alive">живые</option>
<option value="dead">мёртвые</option><option value="untested">не проверялись</option><option value="off">выключены</option>
</select>
<span class="spacer"></span>
<button class="btn sm" data-act="toggle-all" data-on="1">включить все</button>
<button class="btn sm" data-act="toggle-all" data-on="0">выключить все</button>
</div></div>
<div id="src-bulk" class="card" style="display:none;padding:8px 12px;margin-bottom:10px"><div class="row">
<span id="src-sel" class="dim">выбрано 0</span><span class="spacer"></span>
<button class="btn sm" data-act="bulk-on">включить</button><button class="btn sm" data-act="bulk-off">выключить</button>
<button class="btn sm" data-act="bulk-check">проверить</button></div></div>
<div class="card" style="padding:0"><div class="tbl-wrap"><table id="src-tbl"><thead><tr>
<th style="width:26px"><input type="checkbox" id="src-all"></th><th>источник</th><th>категория</th>
<th>адаптер</th><th class="right">проверка</th><th class="right">в базе</th><th>состояние</th></tr></thead>
<tbody><tr><td colspan="7">${skel(4)}</td></tr></tbody></table></div></div>`;
try{
const st=S.stats||await api("/api/stats");
$("#src-tiles").innerHTML=[
["всего",st.sources,""],["живых",st.alive,`${Math.round(st.alive/st.sources*100)}%`],
["проверено",st.tested,`${Math.round((st.tested||0)/st.sources*100)}%`],["выключено",st.sources-st.sources_enabled,""]
].map(([l,v,s])=>`<div class="card"><div class="sub">${l}</div><div class="hl"><span class="n">${num(v)}</span> <span class="l">${s||""}</span></div></div>`).join("");
$("#s-cat").innerHTML=`<option value="">все категории</option>`+(st.categories||[]).map(c=>`<option>${esc(c.name)}</option>`).join("");
}catch(e){}
await loadSources();
};
async function loadSources(){
const p=new URLSearchParams({category:S.srcCat||"",q:S.srcQ||"",state:S.srcState||""});
const d=await api("/api/sources?"+p);
const tb=$("#src-tbl tbody"); if(!tb)return;
tb.innerHTML=(d.items||[]).slice(0,900).map(s=>{
const alive=(s.items||0)>0, state=s.enabled?(s.checked?(alive?"живой":"мёртвый"):"не проверен"):"выключен";
const pc=alive?"ok":(s.checked?"no":"unk");
return `<tr data-src="${esc(s.name)}">
<td><input type="checkbox" class="src-ck" data-name="${esc(s.name)}"></td>
<td><span class="nm">${esc(s.name)}</span><span class="u">${esc(s.url)}</span></td>
<td><span class="chip">${esc(s.category)}</span></td>
<td class="mono dim">${esc(s.adapter)}</td>
<td class="right mono">${s.items??"—"}</td>
<td class="right mono">${num(s.stored||0)}</td>
<td class="nowrap"><span class="pill ${pc}"></span>${state}${s.note?` <span class="dim">· ${esc(s.note)}</span>`:""}</td>
</tr>`}).join("")||`<tr><td colspan="7"><div class="empty">ничего не найдено</div></td></tr>`;
const hidden=(d.items||[]).length>900?`<div class="hint" style="padding:8px 10px">показаны первые 900 из ${num(d.items.length)} — уточните фильтр</div>`:"";
tb.insertAdjacentHTML("beforeend",`<tr><td colspan="7" style="border:0">${hidden}</td></tr>`);
}
async function srcDetail(name){
closeDrawer(); const dr=$("#drawer"),bd=$("#backdrop"); dr.classList.add("on");bd.classList.add("on");
$("#d-title").textContent=name; $("#d-body").innerHTML=`<div class="skel" style="width:50%"></div>`;
try{
const d=await api("/api/source?name="+encodeURIComponent(name));
if(d.error) throw new Error(d.error);
const s=d.source;
$("#d-body").innerHTML=`
<div class="kv"><div class="k">категория</div><div>${esc(s.category)}</div>
<div class="k">адаптер</div><div class="mono">${esc(s.adapter)}</div>
<div class="k">url</div><div><a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.url)}</a></div>
<div class="k">теги</div><div>${(s.tags||[]).map(t=>`<span class="chip">${esc(t)}</span>`).join(" ")||"—"}</div>
<div class="k">в базе</div><div class="mono">${num(d.stored)}</div>
<div class="k">состояние</div><div>${d.state?.checked?(d.state.items>0?"живой":"мёртвый"):"не проверен"} ${d.state?.enabled===false?"· выключен":""}</div></div>
<div class="row"><button class="btn" data-toggle-src="${esc(name)}" data-on="${d.state?.enabled===false?1:0}">${d.state?.enabled===false?"Включить":"Выключить"}</button>
<button class="btn" data-check-src="${esc(name)}">Проверить</button>
<button class="btn" data-act="feed-src" data-src="${esc(name)}">Собрать этот источник</button></div>
<div><div class="sec-h">последние записи</div><div class="rel">
${(d.latest||[]).map(x=>`<a href="#" data-id="${x.id}">${esc(x.title)} <span class="dim">· ${dt(x.published)}</span></a>`).join("")||`<span class="dim">нет</span>`}</div></div>
<div><div class="sec-h">прогоны</div><table><tbody>${(d.runs||[]).map(r=>`<tr><td class="dim mono">${esc(r.when)}</td><td class="mono right">${r.got}/${r.kept}</td><td class="right">${esc(r.status||"")}</td></tr>`).join("")||`<tr><td class="dim">нет</td></tr>`}</tbody></table></div>`;
}catch(e){ $("#d-body").innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
}
/* ---------- runs ---------- */
V.runs=async()=>{
const d=await api("/api/runs?limit=200");
const bs=d.by_status||{};
const items=d.items||[];
const ok=bs.ok??items.filter(r=>isOkSt(r.status)).length; // "ok", "ok-304" — успех
const empty=bs.empty??items.filter(r=>stKey(r.status)==="empty").length;
const err=bs.err??items.filter(r=>stKey(r.status)==="err").length;
const none=bs.none??items.filter(r=>stKey(r.status)==="none").length;
const rf=(k,l,n)=>`<span class="chip click ${S.runF===k?"on":""}" data-rf="${k}">${l} <span class="n">${num(n)}</span></span>`;
$("#view").innerHTML=`
<div class="page-h"><div><h2>Прогоны</h2><p>Журнал сборов: что запросили, сколько получили и сколько нового легло в базу.</p></div>
<div class="right">
${rf("all","все",d.total)}${rf("ok","ok",ok)}${empty?rf("empty","пусто",empty):""}${rf("err","ошибки",err)}${none?rf("none","без статуса",none):""}</div></div>
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
<input id="r-q" placeholder="фильтр: источник или запрос" style="min-width:280px" value="${esc(S.runQ||"")}">
<span class="spacer"></span><span class="dim mono">всего ${num(d.total)} · показано ${num(items.length)}</span></div></div>
<div class="card" style="padding:0"><div class="tbl-wrap"><table id="r-tbl"><thead><tr>
<th>когда</th><th>источник</th><th>запрос</th><th class="right">got</th><th class="right">kept</th><th class="right">байт</th><th>статус</th></tr></thead>
<tbody>${items.map(r=>`<tr data-st="${stKey(r.status)}"><td class="mono dim nowrap">${esc(r.when||"")}</td><td>${esc(r.source||"—")}</td>
<td class="dim" style="max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.query||"")}</td>
<td class="right mono">${r.got??""}</td><td class="right mono">${r.kept??""}</td><td class="right mono dim">${r.bytes?num(r.bytes):""}</td>
<td><span class="pill ${stPill(r.status)}"></span>${esc(r.status||"—")}</td></tr>`).join("")}</tbody></table></div></div>`;
// S.runQ/S.runF не сбрасываем: автообновление перерисовывает раздел каждые 60 с
const applyRuns=()=>{const v=(S.runQ||"").toLowerCase();
$$("#r-tbl tbody tr").forEach(tr=>{
const okT=!v||tr.textContent.toLowerCase().includes(v);
const okS=S.runF==="all"||S.runF===tr.dataset.st;
tr.style.display=(okT&&okS)?"":"none";});};
$("#r-q").addEventListener("input",debounce(e=>{S.runQ=e.target.value;applyRuns()},120));
$$("#view [data-rf]").forEach(c=>c.addEventListener("click",()=>{
$$("#view [data-rf]").forEach(x=>x.classList.remove("on"));c.classList.add("on");S.runF=c.dataset.rf;applyRuns();}));
applyRuns();
};
/* ---------- telegram ---------- */
V.tg=async()=>{
const [st,recent]=await Promise.all([api("/api/tg/status"), api("/api/items?limit=12&sort=fresh")]);
$("#view").innerHTML=`
<div class="page-h"><div><h2>Telegram</h2><p>Своя сессия Telethon: глубина истории и каналы без веб-превью. Веб-превью остаётся для публичных каналов.</p></div></div>
<div class="grid g3">
<div class="card"><h3>Сессия</h3>
<div class="row"><span class="pill ${st.enabled?"ok":"no"}"></span><b>${st.enabled?"подключена":"не настроена"}</b></div>
<div class="kv" style="margin-top:10px">
<div class="k">аккаунт</div><div>${esc(st.me?.first_name||"—")} ${st.me?.username?"@"+esc(st.me.username):""}</div>
<div class="k">id</div><div class="mono">${st.me?.id||"—"}</div>
<div class="k">session</div><div class="mono" style="font-size:11px">${esc(st.session||"—")}</div>
<div class="k">глубина</div><div class="mono">${st.limit} постов</div>
</div>
${st.error?`<div class="hint" style="color:var(--bad);margin-top:8px">${esc(st.error)}</div>`:""}
</div>
<div class="card"><h3>Найти каналы</h3>
<div class="row"><input id="tg-q" placeholder="например: приватки, скам, ai" style="flex:1">
<button class="btn primary" data-act="tg-search">Искать</button></div>
<div id="tg-res" class="hint" style="margin-top:10px">Глобальный поиск по каналам и группам через сессию.</div>
</div>
<div class="card"><h3>Тянуть посты</h3>
<div class="field"><label>каналы через запятую (@name или ссылка)</label>
<textarea id="tg-hands" placeholder="durov, @alstack"></textarea></div>
<div class="row" style="margin-top:8px"><div class="field"><label>постов на канал</label>
<input id="tg-lim" type="number" value="100" min="5" max="500" style="width:90px"></div>
<button class="btn primary" data-act="tg-pull" style="align-self:flex-end">Тянуть</button></div>
<div id="tg-job" class="hint" style="margin-top:8px"></div>
</div>
</div>
<div class="card" style="margin-top:14px"><h3>Последние записи из Telegram <span class="r">${st.enabled?"сессия + веб-превью":"веб-превью"}</span></h3>
<div id="tg-recent">${(recent.items||[]).map(it=>itemRow(it,true)).join("")||`<div class="empty">нет</div>`}</div></div>`;
};
async function tgSearch(){
const q=$("#tg-q").value.trim(); if(q.length<2){toast("минимум 2 символа",true);return}
const box=$("#tg-res"); box.innerHTML="ищу…";
try{
const d=await api("/api/tg/search?q="+encodeURIComponent(q)+"&limit=30");
if(!d.ok) throw new Error(d.error||"ошибка");
box.innerHTML=`<table><tbody>${(d.channels||[]).map(c=>`<tr><td><b>${esc(c.title)}</b><span class="u">@${esc(c.username)}</span></td>
<td class="right mono dim">${k(c.participants||0)}</td>
<td class="right"><button class="btn sm" data-pull-one="${esc(c.username)}">тянуть</button></td></tr>`).join("")||`<tr><td class="dim">ничего</td></tr>`}</tbody></table>`;
}catch(e){ box.innerHTML=`<span style="color:var(--bad)">${esc(e.message)}</span>`; }
}
async function tgPull(handles){
const lim=+($("#tg-lim")?.value||100);
const box=$("#tg-job"); box.textContent="задача запущена…";
try{
const {job}=await post("/api/tg/pull",{handles,limit:lim});
const t=setInterval(async()=>{
const st=await api("/api/job/"+job);
if(st.state==="done"||st.state==="error"){
clearInterval(t);
box.innerHTML=st.state==="error"?`<span style="color:var(--bad)">${esc(st.error||"ошибка")}</span>`
:`готово: каналов ${st.channels}, постов ${st.fetched}, новых ${st.new}, дублей ${st.dup}`;
if(st.errors?.length) box.innerHTML+=`<div class="dim">ошибки: ${st.errors.map(e=>esc(e.handle+": "+e.error)).join(", ")}</div>`;
toast("Telegram: +"+ (st.new||0) +" записей");
} else box.textContent="тяну…";
},1200);
}catch(e){ box.textContent="ошибка: "+e.message; }
}
/* ---------- keys ---------- */
V.keys=async()=>{
const d=await api("/api/keys");
const rows=(d.services||[]).sort((a,b)=>b.keys-a.keys);
const max=Math.max(...rows.map(r=>r.keys),1);
$("#view").innerHTML=`
<div class="page-h"><div><h2>Ключи</h2><p>Vault вне git: пул на сервис, ротация round-robin, остывание после 401/402/403 (час) и 429 (10 минут).</p></div>
<div class="right"><span class="chip on">готово ${num(d.total_ready)}</span><span class="chip">всего ${num(d.total_keys)}</span></div></div>
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
<input id="k-q" placeholder="фильтр по сервису" style="min-width:240px"><span class="spacer"></span>
<span class="dim hint">значения не отдаются в UI — только маска</span></div></div>
<div class="card" style="padding:0"><div class="tbl-wrap"><table id="k-tbl"><thead><tr>
<th>сервис</th><th>ключей</th><th style="width:30%">распределение</th><th class="right">готово</th><th class="right">остывают</th><th>источник</th></tr></thead>
<tbody>${rows.map(r=>`<tr><td class="nm">${esc(r.service)}</td><td class="mono">${r.keys}</td>
<td><div class="bar"><i style="width:${Math.round(r.keys/max*100)}%;background:${r.ready?"var(--acc)":"var(--bad)"}"></i></div></td>
<td class="right mono">${r.ready}</td><td class="right mono ${r.cooling?"down":""}">${r.cooling||""}</td>
<td class="dim">${r.env?"env (override)":"vault.json"}</td></tr>`).join("")}</tbody></table></div></div>
<div class="hint" style="margin-top:10px">Проверка ключа = реальный вызов API из парсера; неудачный ответ отправляет ключ в остывание автоматически. <span class="mono">omni keys</span> — CLI-срез.</div>`;
$("#k-q").addEventListener("input",debounce(e=>{
const v=e.target.value.toLowerCase();
$$("#k-tbl tbody tr").forEach(tr=>tr.style.display=tr.textContent.toLowerCase().includes(v)?"":"none");
},120));
};
/* ---------- digest ---------- */
V.digest=async()=>{
$("#view").innerHTML=`
<div class="page-h"><div><h2>Дайджест</h2><p>Markdown-сводка за период: главное по score, уязвимости, сливы, абузы. Готовый файл — data/digest_latest.md.</p></div>
<div class="right"><select id="d-days"><option value="1">сутки</option><option value="3" selected>3 дня</option><option value="7">неделя</option></select>
<button class="btn primary" data-act="digest">Собрать</button>
<a class="btn" id="d-dl" href="/api/digest?days=3&download=1">Скачать .md</a></div></div>
<div class="card"><div id="d-out" class="md"><div class="empty">Нажмите «Собрать» — займёт пару секунд.</div></div></div>`;
};
async function runDigest(){
const days=$("#d-days").value; const out=$("#d-out");
const dl=$("#d-dl"); if(dl) dl.href=`/api/digest?days=${days}&download=1`;
out.innerHTML=`<div class="skel" style="width:40%"></div><div class="skel" style="width:90%;margin-top:10px"></div><div class="skel" style="width:75%;margin-top:8px"></div>`;
try{ const d=await api("/api/digest?days="+days); out.innerHTML=md(d.md);
out.insertAdjacentHTML("afterbegin",`<div class="row" style="margin-bottom:10px"><button class="btn sm" data-copy-md>Копировать markdown</button><span class="dim">${num(d.n)} записей за период</span></div>`);
out.dataset.md=d.md;
}catch(e){ out.innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
}
/* ---------- watchlist ---------- */
V.watch=async()=>{
const d=await api("/api/watch");
$("#view").innerHTML=`
<div class="page-h"><div><h2>Watchlist</h2><p>Правила по подстроке и типу: панель считает совпадения с момента добавления и показывает свежие хиты.</p></div></div>
<div class="card" style="margin-bottom:14px"><div class="row">
<input id="w-q" placeholder="например: CVE-2026, firecrawl, приватка" style="min-width:320px">
<select id="w-type"><option value="all">любой тип</option>${(S.types||[]).map(t=>`<option value="${esc(t.key)}">${esc(t.label)}</option>`).join("")}</select>
<button class="btn primary" data-act="watch-add">Добавить правило</button></div>
<div class="row" style="margin-top:10px"><span class="dim">быстро:</span>
${["CVE-2026","ключ","firecrawl","слив","промо","52pojie"].map(x=>`<span class="chip click" data-watch-q="${esc(x)}">${esc(x)}</span>`).join("")}</div></div>
<div class="grid g2">${(d.rules||[]).map(r=>`<div class="card hov">
<div class="row"><b>${esc(r.q)}</b><span class="chip">${esc(r.type)}</span><span class="spacer"></span>
<span class="chip on">${num(r.count)}</span><button class="ico" data-watch-del="${esc(r.id)}">✕</button></div>
<div class="rel" style="margin-top:8px">${(r.latest||[]).map(x=>`<a href="#" data-id="${x.id}">${esc(x.title)} <span class="dim">· ${esc(x.source||"")}</span></a>`).join("")||`<span class="dim">совпадений пока нет</span>`}</div>
</div>`).join("")||`<div class="card"><div class="empty"><b>Правил нет</b>Добавьте первое — например «CVE-2026» или «ключ»</div></div>`}</div>`;
$("#w-q").addEventListener("keydown",e=>{if(e.key==="Enter")$("[data-act=watch-add]").click()});
};
/* ============================= actions ============================= */
const ACT={
help:()=>{
$("#modal").classList.add("on");
$("#modal-box").innerHTML=`<h3>Горячие клавиши</h3><div class="kv">
<div class="k"><span class="kbd">/</span></div><div>фокус в поиск</div>
<div class="k"><span class="kbd">1…9</span></div><div>разделы: обзор, лента, абузы, источники, прогоны, telegram, ключи, дайджест, watchlist</div>
<div class="k"><span class="kbd">Enter</span></div><div>открыть запись (в ленте — первая)</div>
<div class="k"><span class="kbd">Esc</span></div><div>закрыть панель/модалку</div>
<div class="k"><span class="kbd">r</span></div><div>перезагрузить данные раздела</div>
<div class="k"><span class="kbd">?</span></div><div>эта справка</div></div>
<div class="hint" style="margin-top:12px">Фильтры живут в URL: ссылку можно кидать в чат и открывать в новой вкладке.</div>
<div class="row" style="margin-top:14px"><button class="btn" data-act="close-modal">Понятно</button></div>`;
},
"close-modal":()=>$("#modal").classList.remove("on"),
"close-drawer":closeDrawer,
refresh:async()=>{toast("обновляю");await render()},
auto:()=>{S.auto=!S.auto;localStorage.setItem("omni.auto",S.auto?"1":"0");toast(S.auto?"автообновление раз в минуту":"автообновление выключено");render()},
run:async()=>{
const cat=$("#cat").value||null;
const {job}=await post("/api/feed",{category:cat,limit:12});
toast("сбор запущен: "+(cat||"все категории"));
const t=setInterval(async()=>{
const st=await api("/api/job/"+job);
if(st.state==="done"||st.state==="error"){clearInterval(t);
toast(st.state==="error"?"сбор упал: "+st.error:`сбор: +${st.new} новых, ${st.fetched} получено`,st.state==="error");
render();}
},1500);
},
more:()=>loadItems(false),
"digest-quick":()=>{go("digest",{},true);setTimeout(()=>{const b=$("[data-act=digest]");b&&b.click()},150)},
digest:runDigest,
summary:()=>summary(),
"add-src":()=>{
$("#modal").classList.add("on");
$("#modal-box").innerHTML=`<h3>Новый источник</h3><div class="grid g2">
<div class="field"><label>имя</label><input id="a-name" placeholder="example-forum"></div>
<div class="field"><label>категория</label><input id="a-cat" value="community" list="cats-list"><datalist id="cats-list">${(S.cats||[]).map(c=>`<option>${esc(c)}</option>`).join("")}</datalist></div>
<div class="field" style="grid-column:1/-1"><label>url</label><input id="a-url" placeholder="https://example.com/rss"></div>
<div class="field"><label>адаптер</label><select id="a-adapter">${["rss","html_listing","json","tg_web","xenforo_latest","discourse","sitemap_recent"].map(a=>`<option>${a}</option>`).join("")}</select></div>
<div class="field"><label>лимит</label><input id="a-lim" type="number" value="20"></div>
<div class="field" style="grid-column:1/-1"><label>теги (через запятую)</label><input id="a-tags"></div></div>
<div class="row" style="margin-top:14px"><button class="btn primary" data-act="add-src-save">Добавить</button>
<button class="btn" data-act="close-modal">Отмена</button></div>`;
},
"add-src-save":async()=>{
const body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",
url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:+$("#a-lim").value||20,
tags:$("#a-tags").value.split(",").map(x=>x.trim()).filter(Boolean)};
if(!body.name||!body.url){toast("нужны имя и url",true);return}
try{ await post("/api/sources/add",body); toast("источник добавлен"); $("#modal").classList.remove("on"); render(); }
catch(e){ toast("ошибка: "+e.message,true) }
},
"toggle-all":async(el)=>{
const on=el.dataset.on==="1";
if(!confirm((on?"Включить":"Выключить")+" все источники"+(S.srcCat?" категории "+S.srcCat:"")))return;
const d=await post("/api/sources/toggle_all",{enabled:on,category:S.srcCat||null});
toast(`${on?"включено":"выключено"}: ${d.changed}`); render();
},
"bulk-on":()=>bulkToggle(true),"bulk-off":()=>bulkToggle(false),
"bulk-check":async()=>{
const names=selectedSrc(); if(!names.length){toast("ничего не выбрано",true);return}
const {job}=await post("/api/sources/check",{names});
toast("проверка запущена: "+names.length);
const t=setInterval(async()=>{const st=await api("/api/job/"+job);
if(st.state==="done"){clearInterval(t);toast(`проверено ${st.done}: живых ${st.alive}, мёртвых ${st.dead}`);render()}},1200);
},
"check-all":async()=>{ const {job}=await post("/api/sources/check",{category:S.srcCat||null});
toast("проверка категории запущена");
const t=setInterval(async()=>{const st=await api("/api/job/"+job);
if(st.state==="done"){clearInterval(t);toast(`проверено ${st.done}: живых ${st.alive}`);render()}},1500); },
"tg-search":tgSearch,
"tg-pull":()=>{
const raw=$("#tg-hands").value.split(/[,\s]+/).map(x=>x.trim()).filter(Boolean);
if(!raw.length){toast("укажите каналы",true);return}
tgPull(raw);
},
"feed-src":async(el)=>{ const {job}=await post("/api/feed",{category:null,limit:12,tags:el.dataset.src});
toast("сбор источника"); const t=setInterval(async()=>{const st=await api("/api/job/"+job);
if(st.state==="done"){clearInterval(t);toast(`+${st.new} новых`);render()}},1500); },
"watch-add":async()=>{
const q=$("#w-q").value.trim(),type=$("#w-type").value;
if(q.length<2){toast("минимум 2 символа",true);return}
await post("/api/watch/add",{q,type}); toast("правило добавлено"); render();
}
};
async function bulkToggle(on){
const names=selectedSrc(); if(!names.length){toast("ничего не выбрано",true);return}
for(const n of names) await post("/api/sources/toggle",{name:n,enabled:on});
toast(`${on?"включено":"выключено"}: ${names.length}`); render();
}
const selectedSrc=()=>$$(".src-ck:checked").map(c=>c.dataset.name);
/* ============================= events ============================= */
document.addEventListener("click",async e=>{
const t=e.target;
const nav=t.closest("[data-nav]"); if(nav){go(nav.dataset.nav,{},{replace:true});return}
const act=t.closest("[data-act]"); if(act){ const f=ACT[act.dataset.act]; if(f){e.preventDefault();await f(act);} return }
if(t.closest("[data-close-modal]")) return;
const open=t.closest("[data-open]"); if(open){e.preventDefault();window.open(open.dataset.open,"_blank","noopener");return}
const cp=t.closest("[data-copy]"); if(cp){e.preventDefault();copy(cp.dataset.copy);return}
const cm=t.closest("[data-copy-md]"); if(cm){e.preventDefault();copy($("#d-out").dataset.md||"");return}
const ty=t.closest("[data-type]"); if(ty){e.preventDefault();go("feed",{type:ty.dataset.type},{replace:true});return}
const tg=t.closest("[data-tag]"); if(tg){e.preventDefault();$$("[data-tag]").forEach(x=>x.classList.remove("on"));tg.classList.add("on");abuseByTag(tg.dataset.tag);return}
const it=t.closest("[data-id]"); if(it){e.preventDefault();openItem(+it.dataset.id);return}
const wd=t.closest("[data-watch-del]"); if(wd){e.preventDefault();await post("/api/watch/remove",{id:wd.dataset.watchDel});toast("правило удалено");render();return}
const wt=t.closest("[data-watch]"); if(wt){e.preventDefault();await post("/api/watch/add",{q:wt.dataset.watch,type:"all"});toast("в watchlist");return}
const wq=t.closest("[data-watch-q]"); if(wq){e.preventDefault();
post("/api/watch/add",{q:wq.dataset.watchQ,type:"all"}).then(()=>{toast("правило добавлено: "+wq.dataset.watchQ);render()});return}
const po=t.closest("[data-pull-one]"); if(po){e.preventDefault();tgPull([po.dataset.pullOne]);return}
const ts=t.closest("[data-toggle-src]"); if(ts){e.preventDefault();
await post("/api/sources/toggle",{name:ts.dataset.toggleSrc,enabled:ts.dataset.on==="1"});toast("переключено");srcDetail(ts.dataset.toggleSrc);return}
const cs=t.closest("[data-check-src]"); if(cs){e.preventDefault();
const {job}=await post("/api/sources/check",{names:[cs.dataset.checkSrc]});toast("проверяю источник");
const tm=setInterval(async()=>{const st=await api("/api/job/"+job);if(st.state==="done"){clearInterval(tm);srcDetail(cs.dataset.checkSrc)}},1000);return}
const row=t.closest("tr[data-src]"); if(row && !t.closest("input")){e.preventDefault();srcDetail(row.dataset.src);return}
if(t.closest("#backdrop")||t.id==="modal"){ closeDrawer(); $("#modal").classList.remove("on"); }
});
document.addEventListener("change",async e=>{
const t=e.target;
if(t.id==="cat"){S.cat=t.value;go(S.view,{cat:t.value},{replace:true});return}
if(t.id==="f-sort"){go("feed",{sort:t.value},{replace:true});return}
if(t.id==="f-days"){go("feed",{days:+t.value},{replace:true});return}
if(t.id==="f-priced"){go("feed",{priced:t.checked},{replace:true});return}
if(t.id==="s-cat"){S.srcCat=t.value;loadSources();return}
if(t.id==="s-state"){S.srcState=t.value;loadSources();return}
if(t.id==="src-all"){ $$(".src-ck").forEach(c=>c.checked=t.checked); srcBulkBar(); return }
if(t.classList?.contains("src-ck")){ srcBulkBar(); return }
});
function srcBulkBar(){
const n=selectedSrc().length; const bar=$("#src-bulk");
if(!bar)return; bar.style.display=n?"":"none"; const s=$("#src-sel"); if(s)s.textContent="выбрано "+n;
}
document.addEventListener("keydown",e=>{
const inField=/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName||"");
if(e.key==="Escape"){closeDrawer();$("#modal").classList.remove("on");$("#sug").classList.remove("on");return}
if(e.key==="/"&&!inField){e.preventDefault();$("#q").focus();return}
if(e.key==="?"&&!inField){e.preventDefault();ACT.help();return}
if(e.key==="r"&&!inField){e.preventDefault();render();return}
if(/^[1-9]$/.test(e.key)&&!inField){const n=NAV[+e.key-1];if(n)go(n.id,{},{replace:true});return}
if(e.key==="Enter"&&!inField&&S.view==="feed"&&S.items[0]){openItem(S.items[0].id)}
});
/* search + suggestions */
const qEl=$("#q");
qEl.addEventListener("input",debounce(async e=>{
const v=e.target.value.trim();
if(v.length<2){$("#sug").classList.remove("on");return}
try{
const d=await api("/api/suggest?q="+encodeURIComponent(v));
if(!d.titles?.length&&!d.sources?.length){$("#sug").classList.remove("on");return}
$("#sug").innerHTML=
(d.titles?.length?`<div class="grp">записи</div>`+d.titles.map(x=>`<div class="it" data-sug-id="${x.id}">${esc(x.title)}<span class="s">${esc(x.source)}</span></div>`).join(""):"")+
(d.sources?.length?`<div class="grp">источники</div>`+d.sources.map(x=>`<div class="it" data-sug-src="${esc(x.name)}">${esc(x.name)}<span class="s">${esc(x.category)}</span></div>`).join(""):"")+
`<div class="grp">поиск</div><div class="it" data-sug-q="${esc(v)}">Искать «${esc(v)}» во всех записях<span class="s">Enter</span></div>`;
$("#sug").classList.add("on");
}catch(e){}
},200));
qEl.addEventListener("keydown",e=>{
if(e.key==="Enter"){
const v=qEl.value.trim(); $("#sug").classList.remove("on");
if(S.view==="feed") go("feed",{q:v},{replace:true}); else go("feed",{q:v});
}
});
$("#sug").addEventListener("click",e=>{
const a=e.target.closest("[data-sug-id]"), s=e.target.closest("[data-sug-src]"), q=e.target.closest("[data-sug-q]");
$("#sug").classList.remove("on");
if(a) return openItem(+a.dataset.sugId);
if(s){ S.srcQ=s.dataset.sugSrc; if(S.view!=="sources") go("sources"); else render(); return }
if(q){ qEl.value=q.dataset.sugQ; go("feed",{q:q.dataset.sugQ},{replace:S.view==="feed"}) }
});
document.addEventListener("click",e=>{ if(!e.target.closest(".search")) $("#sug").classList.remove("on") });
/* ============================= render loop ============================= */
let AUTO_T;
async function render(){
const v=NAV.find(n=>n.id===S.view); if(!v)return;
paintNav();
$("#crumb").innerHTML="<b>"+esc(v.label)+"</b>"+(S.q?` · «${esc(S.q)}»`:"");
try{ await V[v.id](); }catch(e){ $("#view").innerHTML=`<div class="card"><div class="empty"><b>Ошибка раздела</b>${esc(e.message)}</div></div>`; }
if($("#cat")) $("#cat").value=S.cat;
clearInterval(AUTO_T);
if(S.auto) AUTO_T=setInterval(()=>{ if(document.visibilityState==="visible"&&!$("#drawer").classList.contains("on")) render() },60000);
}
window.addEventListener("hashchange",()=>{ const before=S.view; route(); if(before===S.view) render(); });
boot();
</script>
</body>
</html>