942 lines
49 KiB
HTML
942 lines
49 KiB
HTML
<!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:#08090c; --bg2:#0c0e13; --panel:#101319; --panel2:#141821; --line:#1d2230; --line2:#2a3140;
|
||
--txt:#e9edf5; --dim:#98a3b6; --dim2:#6b7688;
|
||
--acc:#7c6cf4; --acc2:#2dd4bf; --ok:#34d399; --bad:#fb7185; --warn:#fbbf24;
|
||
--mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Consolas,monospace;
|
||
--r:14px;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;height:100%}
|
||
body{
|
||
background:
|
||
radial-gradient(1100px 520px at 76% -12%, rgba(124,108,244,.16), transparent 62%),
|
||
radial-gradient(760px 420px at 6% 8%, rgba(45,212,191,.09), transparent 60%),
|
||
var(--bg);
|
||
color:var(--txt);
|
||
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif;
|
||
-webkit-font-smoothing:antialiased;
|
||
}
|
||
a{color:inherit;text-decoration:none}
|
||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||
::selection{background:rgba(124,108,244,.35)}
|
||
|
||
.app{display:grid;grid-template-columns:252px minmax(0,1fr);min-height:100vh}
|
||
|
||
/* ---------- sidebar ---------- */
|
||
aside{
|
||
position:sticky;top:0;height:100vh;display:flex;flex-direction:column;gap:18px;
|
||
padding:20px 16px;border-right:1px solid var(--line);background:linear-gradient(180deg,#0a0c11,#08090c 60%);
|
||
}
|
||
.brand{display:flex;align-items:center;gap:10px;padding:2px 6px 12px}
|
||
.mark{
|
||
width:30px;height:30px;border-radius:9px;flex:0 0 auto;
|
||
background:conic-gradient(from 200deg,var(--acc),#3b82f6 40%,var(--acc2) 72%,var(--acc));
|
||
box-shadow:0 0 22px rgba(124,108,244,.5) inset,0 4px 14px rgba(0,0,0,.5);
|
||
}
|
||
.brand b{font-size:16px;letter-spacing:.2px}
|
||
.brand span{display:block;font:11px/1.3 var(--mono);color:var(--dim2);letter-spacing:.06em;text-transform:uppercase}
|
||
|
||
.nav{display:flex;flex-direction:column;gap:4px}
|
||
.nav button{
|
||
display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:10px;
|
||
color:var(--dim);font-weight:500;transition:.15s;text-align:left;width:100%;
|
||
}
|
||
.nav button i{width:16px;height:16px;flex:0 0 auto;opacity:.75;display:grid;place-items:center;font-size:12px;line-height:1}
|
||
.nav button:hover{background:#12151d;color:var(--txt)}
|
||
.nav button.on{background:linear-gradient(90deg,rgba(124,108,244,.18),rgba(124,108,244,.05));color:#fff;box-shadow:inset 0 0 0 1px rgba(124,108,244,.3)}
|
||
.nav button b{margin-left:auto;font:11px var(--mono);color:var(--dim2)}
|
||
|
||
.sect{font:11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--dim2);padding:0 8px}
|
||
.cats{display:flex;flex-direction:column;gap:2px;overflow:auto;margin:0 -4px;padding:0 4px;flex:1}
|
||
.cat{
|
||
display:grid;grid-template-columns:1fr auto;gap:3px 8px;align-items:center;
|
||
padding:8px 9px;border-radius:9px;cursor:pointer;border:1px solid transparent;
|
||
}
|
||
.cat:hover{background:#0f1218}
|
||
.cat.on{background:#12151d;border-color:var(--line2)}
|
||
.cat .cn{font-size:13.5px;color:#c3cad8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.cat.on .cn{color:#fff}
|
||
.cat .cc{font:11.5px var(--mono);color:var(--dim2)}
|
||
.bar{grid-column:1/-1;height:3px;border-radius:3px;background:#1a1f2b;overflow:hidden;display:flex}
|
||
.bar i{display:block;height:100%}
|
||
.bar .a{background:var(--ok)}
|
||
.bar .d{background:var(--bad)}
|
||
.bar .u{background:#39414f}
|
||
|
||
.legend{display:flex;gap:10px;flex-wrap:wrap;padding:8px 8px 0;border-top:1px solid var(--line);font:10.5px var(--mono);color:var(--dim2)}
|
||
.legend span{display:flex;align-items:center;gap:5px}
|
||
.legend i{width:7px;height:7px;border-radius:2px;display:block}
|
||
.lg-a{background:var(--ok)} .lg-d{background:var(--bad)} .lg-u{background:#39414f}
|
||
.aside-foot{border-top:1px solid var(--line);padding-top:12px;font:11.5px/1.6 var(--mono);color:var(--dim2)}
|
||
.aside-foot b{color:var(--dim);font-weight:500}
|
||
|
||
/* ---------- main ---------- */
|
||
main{min-width:0;display:flex;flex-direction:column}
|
||
.top{
|
||
position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:12px;
|
||
padding:14px 26px;border-bottom:1px solid var(--line);
|
||
background:rgba(8,9,12,.72);backdrop-filter:blur(14px);
|
||
}
|
||
.top h1{font-size:15.5px;margin:0;font-weight:600;letter-spacing:.2px}
|
||
.top h1 small{display:block;font:11px var(--mono);color:var(--dim2);letter-spacing:.08em;text-transform:uppercase;font-weight:400;margin-top:2px}
|
||
.spacer{flex:1}
|
||
.search{
|
||
display:flex;align-items:center;gap:8px;background:#0e1117;border:1px solid var(--line);
|
||
border-radius:10px;padding:7px 11px;min-width:230px;transition:.15s;
|
||
}
|
||
.search:focus-within{border-color:rgba(124,108,244,.6);box-shadow:0 0 0 3px rgba(124,108,244,.12)}
|
||
.search input{background:none;border:0;outline:0;color:var(--txt);width:100%;font-size:13.5px}
|
||
.search kbd{font:10.5px var(--mono);color:var(--dim2);border:1px solid var(--line2);border-radius:5px;padding:1px 5px}
|
||
|
||
.btn{
|
||
display:inline-flex;align-items:center;gap:7px;padding:8px 13px;border-radius:10px;
|
||
border:1px solid var(--line2);background:#11141b;font-size:13.5px;font-weight:500;transition:.15s;white-space:nowrap;
|
||
}
|
||
.btn:hover{background:#161a23;border-color:#39414f}
|
||
.btn.primary{background:linear-gradient(180deg,#8b7cf6,#6d5ce0);border-color:#8b7cf6;color:#fff;box-shadow:0 6px 18px rgba(109,92,224,.3)}
|
||
.btn.primary:hover{filter:brightness(1.07)}
|
||
.btn.ghost{background:none}
|
||
.btn.sm{padding:5px 9px;font-size:12.5px;border-radius:8px}
|
||
.btn:disabled{opacity:.45;cursor:not-allowed}
|
||
|
||
.content{padding:28px 30px 74px;max-width:1280px;width:100%}
|
||
|
||
/* KPI */
|
||
.kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:20px}
|
||
.kpi{
|
||
position:relative;overflow:hidden;padding:15px 16px;border-radius:var(--r);border:1px solid var(--line);
|
||
background:linear-gradient(180deg,var(--panel2),var(--panel));
|
||
}
|
||
.kpi:after{content:"";position:absolute;inset:auto -30% -60% auto;width:150px;height:150px;border-radius:50%;background:radial-gradient(circle,rgba(124,108,244,.22),transparent 70%)}
|
||
.kpi .k{font:11px var(--mono);letter-spacing:.09em;text-transform:uppercase;color:var(--dim2)}
|
||
.kpi .v{font:600 27px/1.25 var(--mono);letter-spacing:-.5px;margin-top:6px}
|
||
.kpi .v small{font-size:13px;color:var(--dim);font-weight:400;letter-spacing:0}
|
||
.kpi .s{font-size:12px;color:var(--dim);margin-top:2px}
|
||
|
||
/* feed cards */
|
||
.toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px}
|
||
.chip{
|
||
padding:6px 11px;border-radius:999px;border:1px solid var(--line);background:#0e1117;
|
||
font-size:12.5px;color:var(--dim);transition:.15s;
|
||
}
|
||
.chip:hover{color:var(--txt);border-color:var(--line2)}
|
||
.chip.on{background:rgba(124,108,244,.16);border-color:rgba(124,108,244,.55);color:#fff}
|
||
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(342px,1fr));gap:14px}
|
||
.card{
|
||
display:flex;flex-direction:column;gap:9px;padding:15px 16px;border-radius:var(--r);
|
||
border:1px solid var(--line);background:linear-gradient(180deg,#12151c,#0e1116);
|
||
transition:.16s;position:relative;
|
||
}
|
||
.card:hover{transform:translateY(-1px);border-color:#39414f;box-shadow:0 10px 26px rgba(0,0,0,.35)}
|
||
.card .head{display:flex;align-items:baseline;gap:8px;font:11.5px var(--mono);color:var(--dim2);min-width:0}
|
||
.card .head .src{color:var(--acc2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:52%}
|
||
.card .head .score{margin-left:auto;color:var(--warn);font-weight:600;min-width:28px;text-align:right;font-variant-numeric:tabular-nums}
|
||
.card h3{margin:0;font-size:14.5px;line-height:1.4;font-weight:600;letter-spacing:.1px;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||
.card p{margin:0;font-size:13px;line-height:1.5;color:var(--dim);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||
.card .meta{display:flex;gap:10px;font:11px var(--mono);color:var(--dim2);margin-top:auto;padding-top:4px}
|
||
.tag{font:10.5px var(--mono);padding:1px 6px;border-radius:5px;background:#181d27;color:#9aa5b8}
|
||
.price{color:var(--ok);border:1px solid rgba(52,211,153,.3);background:rgba(52,211,153,.08);border-radius:6px;padding:0 6px;font:11px var(--mono)}
|
||
|
||
/* sources table */
|
||
.strip{display:flex;gap:22px;flex-wrap:wrap;padding:13px 16px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel);margin-bottom:14px}
|
||
.strip div{font:12px var(--mono);color:var(--dim2)}
|
||
.strip b{display:block;font-size:17px;color:var(--txt);font-weight:600;margin-top:2px;font-family:var(--mono)}
|
||
.cbar{display:block;height:4px;border-radius:4px;background:#1a1f2b;margin-top:6px;overflow:hidden}
|
||
.cbar i{display:block;height:100%;background:linear-gradient(90deg,var(--acc),var(--acc2))}
|
||
.strip .g b{color:var(--ok)} .strip .r b{color:var(--bad)} .strip .y b{color:var(--warn)}
|
||
|
||
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
|
||
select,input[type=text],input[type=url]{
|
||
background:#0e1117;border:1px solid var(--line);border-radius:9px;padding:7px 10px;color:var(--txt);font-size:13px;outline:0;
|
||
}
|
||
select:focus,input:focus{border-color:rgba(124,108,244,.6)}
|
||
|
||
.table{border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--panel)}
|
||
.thead,.row{display:grid;grid-template-columns:42px minmax(200px,1fr) 104px 96px 116px 68px 84px;gap:10px;align-items:center;padding:9px 12px}
|
||
.thead{
|
||
position:sticky;top:56px;z-index:5;background:#0d1016;border-bottom:1px solid var(--line);
|
||
font:10.5px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--dim2);
|
||
}
|
||
.row{border-bottom:1px solid #151a23;font-size:13px}
|
||
.row:last-child{border-bottom:0}
|
||
.row:hover{background:#10141b}
|
||
.row .nm{min-width:0}
|
||
.row .nm b{display:block;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.row .nm span{display:block;font:10.5px var(--mono);color:var(--dim2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.catpill{font:10.5px var(--mono);color:#a9b3c6;background:#151a24;border:1px solid #202634;border-radius:6px;padding:2px 6px;justify-self:start;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.st{display:inline-flex;align-items:center;gap:5px;font:11.5px var(--mono)}
|
||
.st i{width:6px;height:6px;border-radius:50%;background:#4b5563}
|
||
.st.ok{color:var(--ok)} .st.ok i{background:var(--ok);box-shadow:0 0 8px rgba(52,211,153,.8)}
|
||
.st.bad{color:var(--bad)} .st.bad i{background:var(--bad)}
|
||
.st.no{color:var(--dim2)}
|
||
.note{display:block;font:10px var(--mono);color:#5a6373;max-width:112px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.num{font:12px var(--mono);color:var(--dim);text-align:right}
|
||
.acts{display:flex;gap:6px;justify-content:flex-end}
|
||
.icon{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;border:1px solid var(--line);color:var(--dim);font-size:12px}
|
||
.icon:hover{color:#fff;border-color:var(--line2);background:#151a23}
|
||
|
||
/* switch */
|
||
.sw{position:relative;width:32px;height:18px;border-radius:999px;background:#242b38;cursor:pointer;transition:.16s;justify-self:center}
|
||
.sw:after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#7d8798;transition:.16s}
|
||
.sw.on{background:rgba(124,108,244,.55)}
|
||
.sw.on:after{left:16px;background:#fff}
|
||
|
||
/* progress */
|
||
.prog{
|
||
position:fixed;left:0;right:0;bottom:0;z-index:40;display:none;align-items:center;gap:12px;
|
||
padding:10px 22px;border-top:1px solid var(--line2);background:rgba(10,12,17,.95);backdrop-filter:blur(10px);
|
||
font:12.5px var(--mono);color:var(--dim);
|
||
}
|
||
.prog.on{display:flex}
|
||
.prog .track{flex:1;height:4px;border-radius:4px;background:#1a1f2b;overflow:hidden}
|
||
.prog .track i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--acc),var(--acc2));transition:width .3s}
|
||
|
||
/* modal */
|
||
.modal{position:fixed;inset:0;z-index:60;display:none;place-items:center;background:rgba(4,5,8,.66);backdrop-filter:blur(3px)}
|
||
.modal.on{display:grid}
|
||
.sheet{width:min(520px,92vw);border:1px solid var(--line2);border-radius:16px;background:linear-gradient(180deg,#14181f,#0e1116);padding:20px;box-shadow:0 30px 70px rgba(0,0,0,.6)}
|
||
.sheet h2{margin:0 0 4px;font-size:16px}
|
||
.sheet p.hint{margin:0 0 14px;font-size:12.5px;color:var(--dim)}
|
||
.field{display:flex;flex-direction:column;gap:5px;margin-bottom:10px}
|
||
.field label{font:11px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--dim2)}
|
||
.field input,.field select{width:100%}
|
||
.sheet .foot{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
||
|
||
.toast{position:fixed;right:20px;bottom:20px;z-index:80;display:flex;flex-direction:column;gap:8px}
|
||
.toast div{
|
||
padding:10px 14px;border-radius:10px;border:1px solid var(--line2);background:#14181f;font-size:13px;
|
||
box-shadow:0 12px 30px rgba(0,0,0,.5);animation:in .18s ease-out;
|
||
}
|
||
.toast div.ok{border-color:rgba(52,211,153,.45)}
|
||
.toast div.err{border-color:rgba(251,113,133,.45)}
|
||
@keyframes in{from{opacity:0;transform:translateY(6px)}}
|
||
.empty{padding:40px;text-align:center;color:var(--dim2);font-size:13.5px}
|
||
.skel{height:96px;border-radius:var(--r);background:linear-gradient(90deg,#11151c,#151a23,#11151c);background-size:200% 100%;animation:sh 1.2s infinite}
|
||
@keyframes sh{to{background-position:-200% 0}}
|
||
@media (max-width:1000px){
|
||
.app{grid-template-columns:1fr}
|
||
aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line)}
|
||
.cats{flex-direction:row;overflow-x:auto;flex:1 0 100%}
|
||
.cat{min-width:150px}
|
||
.kpis{grid-template-columns:repeat(2,1fr)}
|
||
.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 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>Лента<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"><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">
|
||
<div class="ttlwrap">
|
||
<div class="crumbs" id="crumbs"></div>
|
||
<h1 id="ttl">Лента<small>свежие записи со всех источников</small></h1>
|
||
</div>
|
||
<div class="spacer"></div>
|
||
<div class="search">
|
||
<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>
|
||
|
||
<div class="content">
|
||
<!-- ЛЕНТА -->
|
||
<section id="view-feed">
|
||
<div class="kpis" id="kpis"></div>
|
||
<div class="toolbar" id="feed-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>
|
||
|
||
<!-- ИСТОЧНИКИ -->
|
||
<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>
|
||
<select id="f-state">
|
||
<option value="">любое состояние</option>
|
||
<option value="alive">живые</option>
|
||
<option value="dead">мёртвые</option>
|
||
<option value="untested">не проверены</option>
|
||
<option value="off">выключены</option>
|
||
</select>
|
||
<input type="text" id="f-q" placeholder="фильтр по имени или url" style="min-width:220px">
|
||
<div class="spacer"></div>
|
||
<button class="btn sm" id="btn-check-cat">Проверить категорию</button>
|
||
<button class="btn sm" id="btn-check-all">Проверить все</button>
|
||
<button class="btn sm" id="btn-on">Включить все</button>
|
||
<button class="btn sm" id="btn-off">Выключить все</button>
|
||
<button class="btn sm primary" id="btn-add">+ Источник</button>
|
||
</div>
|
||
<div class="table">
|
||
<div class="thead">
|
||
<span>вкл</span><span>источник</span><span>категория</span><span>адаптер</span>
|
||
<span>состояние</span><span class="num">записей</span><span></span>
|
||
</div>
|
||
<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>
|
||
<span id="prog-n"></span>
|
||
</div>
|
||
|
||
<div class="modal" id="modal">
|
||
<div class="sheet">
|
||
<h2>Добавить источник</h2>
|
||
<p class="hint">любой публичный rss / json / html. ключи не нужны.</p>
|
||
<div class="field"><label>имя</label><input id="a-name" placeholder="мой_блог"></div>
|
||
<div class="field"><label>url</label><input id="a-url" placeholder="https://example.com/feed.xml"></div>
|
||
<div class="field"><label>категория</label>
|
||
<select id="a-cat">
|
||
<option>community</option><option>papers</option><option>company</option><option>security</option>
|
||
<option>forum</option><option>models</option><option>benchmark</option><option>company_cn</option><option>company_kr</option>
|
||
</select>
|
||
</div>
|
||
<div class="field"><label>адаптер</label>
|
||
<select id="a-adapter">
|
||
<option value="">определить самому</option>
|
||
<option value="rss">rss / atom</option>
|
||
<option value="json">json (auto)</option>
|
||
<option value="discourse">discourse</option>
|
||
<option value="reddit_listing">reddit</option>
|
||
<option value="html_listing">html listing</option>
|
||
<option value="telegram_web">telegram web</option>
|
||
</select>
|
||
</div>
|
||
<div class="foot">
|
||
<button class="btn ghost" id="a-cancel">Отмена</button>
|
||
<button class="btn primary" id="a-save">Добавить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const $ = s => document.querySelector(s);
|
||
const el = (t, c, h) => { const n = document.createElement(t); if (c) n.className = c; if (h != null) n.innerHTML = h; return n; };
|
||
const esc = s => (s == null ? '' : String(s)).replace(/[&<>"]/g, m => ({'&':'&','<':'<','>':'>','"':'"'}[m]));
|
||
const fmt = n => (n == null ? '—' : Number(n).toLocaleString('ru-RU'));
|
||
const api = async (p, opt) => {
|
||
const r = await fetch(p, opt);
|
||
const t = await r.text();
|
||
try { return JSON.parse(t); } catch (e) { return {error: t.slice(0, 120)}; }
|
||
};
|
||
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};
|
||
|
||
function toast(msg, cls) {
|
||
const d = el('div', cls || '', esc(msg));
|
||
$('#toast').appendChild(d);
|
||
setTimeout(() => d.remove(), 3200);
|
||
}
|
||
|
||
/* ---------- sidebar ---------- */
|
||
function renderCats(list) {
|
||
const box = $('#cats');
|
||
box.innerHTML = '';
|
||
const all = el('div', 'cat' + (S.cat === 'all' ? ' on' : ''));
|
||
all.innerHTML = `<span class="cn">все источники</span><span class="cc">${fmt(S.stats ? S.stats.sources : 0)}</span>`;
|
||
all.onclick = () => setCat('all');
|
||
box.appendChild(all);
|
||
(list || []).forEach(c => {
|
||
const d = el('div', 'cat' + (S.cat === c.name ? ' on' : ''));
|
||
const t = c.total || 1;
|
||
d.innerHTML = `<span class="cn">${esc(c.name)}</span><span class="cc">${fmt(c.total)}</span>
|
||
<span class="bar"><i class="a" style="width:${c.alive / t * 100}%"></i><i class="d" style="width:${c.dead / t * 100}%"></i><i class="u" style="width:${c.untested / t * 100}%"></i></span>`;
|
||
d.onclick = () => setCat(c.name);
|
||
box.appendChild(d);
|
||
});
|
||
}
|
||
|
||
function syncChips() {
|
||
document.querySelectorAll('#feed-tools .chip').forEach(b => {
|
||
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);
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/* ---------- stats / KPI ---------- */
|
||
async function loadStats() {
|
||
const s = await api('/api/stats');
|
||
if (s.error) return toast(s.error, 'err');
|
||
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} включено`, ''],
|
||
['живых', s.alive, `из ${s.tested} проверенных`, 'g'],
|
||
['с ценой', s.priced, 'извлечено из текста', 'y'],
|
||
];
|
||
$('#kpis').innerHTML = ks.map(([k, v, sub, cls]) =>
|
||
`<div class="kpi"><div class="k">${k}</div><div class="v">${fmt(v)}</div><div class="s">${sub}</div></div>`).join('');
|
||
const sel = $('#f-cat');
|
||
sel.innerHTML = '<option value="all">все категории</option>' +
|
||
s.categories.map(c => `<option value="${esc(c.name)}">${esc(c.name)} · ${c.total}</option>`).join('');
|
||
sel.value = S.cat;
|
||
}
|
||
|
||
/* ---------- feed ---------- */
|
||
function card(it) {
|
||
const c = el('article', 'card');
|
||
const text = (it.text || '').replace(/\s+/g, ' ').trim();
|
||
const price = it.price != null ? `<span class="price">${esc(it.price)} ${esc(it.currency || '')}</span>` : '';
|
||
const pub = it.published ? String(it.published).slice(0, 16).replace('T', ' ') : '';
|
||
c.innerHTML =
|
||
`<div class="head"><span class="src">${esc(it.source)}</span><span>${esc(it.kind || '')}</span>
|
||
<span class="score">${it.score != null ? (+it.score).toFixed(1) : ''}</span></div>
|
||
<h3>${esc(it.title || '(без заголовка)')}</h3>
|
||
${text ? `<p>${esc(text)}</p>` : ''}
|
||
<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');
|
||
c.style.cursor = it.url ? 'pointer' : 'default';
|
||
return c;
|
||
}
|
||
|
||
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 r = await api(u);
|
||
if (r.error) return toast(r.error, 'err');
|
||
if (reset) $('#cards').innerHTML = '';
|
||
if (!r.items.length && reset) {
|
||
$('#cards').innerHTML = '<div class="empty">пусто. собери ленту или добавь свои источники.</div>';
|
||
}
|
||
r.items.forEach(it => $('#cards').appendChild(card(it)));
|
||
S.offset += r.items.length;
|
||
$('#cards-more').style.display = S.offset < r.total ? 'block' : 'none';
|
||
$('#btn-more').textContent = `Показать ещё (${fmt(Math.max(0, r.total - S.offset))})`;
|
||
const t = $('#ttl');
|
||
t.innerHTML = `Лента<small>${fmt(r.total)} записей · ${S.cat === 'all' ? 'все источники' : esc(S.cat)}</small>`;
|
||
}
|
||
|
||
/* ---------- sources ---------- */
|
||
async function loadSources() {
|
||
const u = `/api/sources?category=${encodeURIComponent(S.cat)}&q=${encodeURIComponent(S.srcQ)}&state=${encodeURIComponent(S.state)}`;
|
||
const r = await api(u);
|
||
if (r.error) return toast(r.error, 'err');
|
||
S.sources = r.items;
|
||
$('#src-strip').innerHTML =
|
||
`<div>всего<b>${fmt(r.total)}</b></div><div class="g">живых<b>${fmt(r.alive)}</b></div>` +
|
||
`<div class="r">мёртвых<b>${fmt(r.dead)}</b></div><div class="y">не проверено<b>${fmt(r.untested)}</b></div>` +
|
||
`<div>включено<b>${fmt(r.enabled)}</b></div><div>в выборке<b>${fmt(r.shown)}</b></div>` +
|
||
`<div style="flex:1;min-width:180px">покрытие проверкой
|
||
<b>${Math.round((r.total - r.untested) / Math.max(1, r.total) * 100)}%</b>
|
||
<span class="cbar"><i style="width:${(r.total - r.untested) / Math.max(1, r.total) * 100}%"></i></span></div>`;
|
||
const rows = $('#rows');
|
||
rows.innerHTML = '';
|
||
if (!r.items.length) { rows.innerHTML = '<div class="empty">ничего не найдено под эти фильтры</div>'; return; }
|
||
const frag = document.createDocumentFragment();
|
||
r.items.forEach(s => {
|
||
const row = el('div', 'row');
|
||
const alive = (s.items || 0) > 0;
|
||
const stCls = alive ? 'ok' : (s.checked ? 'bad' : 'no');
|
||
const stTxt = alive ? 'живой' : (s.checked ? 'мёртвый' : 'не проверен');
|
||
row.innerHTML =
|
||
`<div class="sw ${s.enabled ? 'on' : ''}" title="вкл/выкл"></div>
|
||
<div class="nm"><b>${esc(s.name)}</b><span>${esc(s.url)}</span></div>
|
||
<div class="catpill">${esc(s.category)}</div>
|
||
<div><span class="catpill">${esc(s.adapter)}</span></div>
|
||
<div><span class="st ${stCls}"><i></i>${stTxt}</span>${!alive && s.note ? `<span class="note">${esc(String(s.note).slice(0, 22))}</span>` : ''}</div>
|
||
<div class="num">${fmt(s.stored)}</div>
|
||
<div class="acts">
|
||
<button class="icon act-check" title="проверить">↻</button>
|
||
${s.url ? `<a class="icon" href="${esc(s.url)}" target="_blank" rel="noopener" title="открыть">↗</a>` : ''}
|
||
${s.custom ? '<button class="icon act-del" title="удалить">✕</button>' : ''}
|
||
</div>`;
|
||
row.querySelector('.sw').onclick = async (e) => {
|
||
const on = !e.currentTarget.classList.contains('on');
|
||
e.currentTarget.classList.toggle('on', on);
|
||
const res = await post('/api/sources/toggle', {name: s.name, enabled: on});
|
||
if (res.error) { e.currentTarget.classList.toggle('on', !on); return toast(res.error, 'err'); }
|
||
toast(`${s.name}: ${on ? 'включён' : 'выключен'}`, 'ok');
|
||
loadStats();
|
||
};
|
||
row.querySelector('.act-check').onclick = () => check(null, s.name);
|
||
const del = row.querySelector('.act-del');
|
||
if (del) del.onclick = async () => {
|
||
const res = await post('/api/sources/remove', {name: s.name});
|
||
if (res.error) return toast(res.error, 'err');
|
||
toast(`${s.name} удалён`, 'ok'); loadSources(); loadStats();
|
||
};
|
||
frag.appendChild(row);
|
||
});
|
||
rows.appendChild(frag);
|
||
$('#ttl').innerHTML = `Источники<small>${fmt(r.total)} в реестре · ${fmt(r.shown)} в выборке</small>`;
|
||
}
|
||
|
||
/* ---------- jobs ---------- */
|
||
async function poll(job, label) {
|
||
S.job = job;
|
||
const box = $('#prog');
|
||
box.classList.add('on');
|
||
for (;;) {
|
||
const j = await api('/api/job/' + job);
|
||
if (j.state === 'unknown') { box.classList.remove('on'); return null; }
|
||
const total = j.total || 0, done = j.done || 0;
|
||
const pct = total ? Math.min(100, done / total * 100) : (j.state === 'done' ? 100 : 8);
|
||
$('#prog-bar').style.width = pct + '%';
|
||
$('#prog-txt').textContent = j.state === 'done' ? label + ': готово' : label + (j.current ? ` · ${j.current}` : '…');
|
||
$('#prog-n').textContent = total ? `${fmt(done)} / ${fmt(total)}` : '';
|
||
if (j.state === 'done' || j.state === 'error') {
|
||
box.classList.remove('on');
|
||
if (j.state === 'error') toast(j.error || 'ошибка джобы', 'err');
|
||
return j;
|
||
}
|
||
await new Promise(r => setTimeout(r, 700));
|
||
}
|
||
}
|
||
|
||
async function runFeed() {
|
||
$('#btn-feed').disabled = true;
|
||
const {job} = await post('/api/feed', {category: S.cat, limit: 12});
|
||
const j = await poll(job, 'сбор ленты');
|
||
$('#btn-feed').disabled = false;
|
||
if (j && j.state === 'done') {
|
||
toast(`собрано: источников ${fmt(j.sources)}, новых ${fmt(j.new)}, дублей ${fmt(j.dup)}`, 'ok');
|
||
await loadStats(); await loadItems(true);
|
||
}
|
||
}
|
||
|
||
async function check(category, name) {
|
||
const body = name ? {name} : {category: category || (S.cat === 'all' ? null : S.cat)};
|
||
const {job} = await post('/api/sources/check', body);
|
||
const j = await poll(job, 'проверка источников');
|
||
if (j && j.state === 'done') {
|
||
toast(`проверено ${fmt(j.done)}: живых ${fmt(j.alive)}, мёртвых ${fmt(j.dead)}`, 'ok');
|
||
await loadStats(); if (S.view === 'src') await loadSources();
|
||
}
|
||
}
|
||
|
||
/* ---------- прогоны ---------- */
|
||
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;
|
||
['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';
|
||
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);
|
||
$('#btn-check-cat').onclick = () => check(S.cat === 'all' ? null : S.cat, null);
|
||
$('#btn-on').onclick = async () => { await post('/api/sources/toggle_all', {enabled: true, category: S.cat}); toast('все включены', 'ok'); loadSources(); loadStats(); };
|
||
$('#btn-off').onclick = async () => { await post('/api/sources/toggle_all', {enabled: false, category: S.cat}); toast('все выключены', 'ok'); loadSources(); loadStats(); };
|
||
$('#f-cat').onchange = e => { S.cat = e.target.value; renderCats(S.stats.categories); S.view === 'src' ? loadSources() : loadItems(true); };
|
||
$('#f-state').onchange = e => { S.state = e.target.value; loadSources(); };
|
||
|
||
let qt = null;
|
||
$('#q').oninput = e => {
|
||
clearTimeout(qt);
|
||
qt = setTimeout(() => {
|
||
S.q = e.target.value.trim();
|
||
if (S.view === 'feed') loadItems(true); else { S.srcQ = S.q; loadSources(); }
|
||
}, 320);
|
||
};
|
||
document.addEventListener('keydown', e => {
|
||
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');
|
||
$('#a-cancel').onclick = () => $('#modal').classList.remove('on');
|
||
$('#modal').onclick = e => { if (e.target.id === 'modal') $('#modal').classList.remove('on'); };
|
||
$('#a-save').onclick = async () => {
|
||
const name = $('#a-name').value.trim(), url = $('#a-url').value.trim();
|
||
if (!name || !url) return toast('нужны имя и url', 'err');
|
||
const res = await post('/api/sources/add', {
|
||
name, url, category: $('#a-cat').value, adapter: $('#a-adapter').value || null
|
||
});
|
||
if (res.error) return toast(res.error, 'err');
|
||
$('#modal').classList.remove('on');
|
||
$('#a-name').value = ''; $('#a-url').value = '';
|
||
toast(`${name} добавлен`, 'ok');
|
||
loadSources(); loadStats();
|
||
};
|
||
|
||
/* 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();
|
||
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>
|
||
</html> |