Files
omni-parse/web/index.html

635 lines
32 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:#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}
}
</style>
</head>
<body>
<div class="app">
<aside>
<div class="brand">
<div class="mark"></div>
<div><b>omni</b><span>source radar</span></div>
</div>
<div class="nav">
<button id="nav-feed" class="on"><i>▤</i>Лента<b id="nav-feed-n">—</b></button>
<button id="nav-src"><i>◈</i>Источники<b id="nav-src-n">—</b></button>
</div>
<div class="sect">категории</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="aside-foot" id="aside-foot">загрузка…</div>
</aside>
<main>
<div class="top">
<h1 id="ttl">Лента<small>свежие записи со всех источников</small></h1>
<div class="spacer"></div>
<div class="search">
<span style="color:#5d6779">⌕</span>
<input id="q" placeholder="поиск по базе">
<kbd>/</kbd>
</div>
<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-src" style="display:none">
<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>
</div>
</main>
</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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();
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);
$('#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);
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();
}
}
/* ---------- view switching ---------- */
function setView(v) {
S.view = v;
$('#nav-feed').classList.toggle('on', v === 'feed');
$('#nav-src').classList.toggle('on', v === 'src');
$('#view-feed').style.display = v === 'feed' ? '' : 'none';
$('#view-src').style.display = v === 'src' ? '' : 'none';
$('#btn-feed').style.display = v === 'feed' ? '' : 'none';
$('#feed-tools').style.display = v === 'feed' ? '' : 'none';
if (v === 'feed') loadItems(true); else loadSources();
}
/* ---------- wire ---------- */
$('#nav-feed').onclick = () => setView('feed');
$('#nav-src').onclick = () => setView('src');
$('#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 => {
if (e.key === '/' && document.activeElement !== $('#q')) { e.preventDefault(); $('#q').focus(); }
if (e.key === 'Escape') $('#modal').classList.remove('on');
});
$('#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 */
$('#feed-tools').innerHTML =
['all', 'community', 'papers', 'security', 'company', 'forum', 'models', 'benchmark']
.map(c => `<button class="chip" data-k="cat" data-cat="${c}">${c === 'all' ? 'всё' : c}</button>`).join('') +
'<span style="flex:1"></span>' +
'<button class="chip" data-k="sort" data-sort="score">по score</button>' +
'<button class="chip" data-k="sort" data-sort="fresh">по свежести</button>';
document.querySelectorAll('#feed-tools .chip').forEach(b => {
b.onclick = () => {
if (b.dataset.k === 'sort') { S.sort = b.dataset.sort; syncChips(); loadItems(true); }
else setCat(b.dataset.cat);
};
});
syncChips();
(async () => {
const p = new URLSearchParams(location.search);
if (p.get('cat')) S.cat = p.get('cat');
if (p.get('q')) { S.q = p.get('q'); $('#q').value = S.q; }
await loadStats();
if (p.get('view') === 'src') { setView('src'); } else { await loadItems(true); }
})();
</script>
</body>
</html>