omni: менеджер источников в панели (338 источников, вкл/выкл, добавление, health-check, фоновые джобы)
This commit is contained in:
503
web/index.html
503
web/index.html
@@ -5,246 +5,321 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>omni · радар источников</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0b0d10; --bg2:#11151a; --card:#141a20; --card2:#171e26;
|
||||
--line:#232c35; --txt:#e7edf3; --dim:#8b98a5; --dim2:#5f6b78;
|
||||
--acc:#c6f24e; --acc2:#4ee0c6; --warn:#ffb454;
|
||||
--mono:ui-monospace,"JetBrains Mono","SF Mono",Menlo,monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%}
|
||||
body{
|
||||
background:
|
||||
radial-gradient(1200px 700px at 15% -10%, #1a2430 0%, transparent 60%),
|
||||
radial-gradient(900px 600px at 100% 0%, #14251f 0%, transparent 55%),
|
||||
var(--bg);
|
||||
color:var(--txt);
|
||||
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{display:grid;grid-template-columns:236px 1fr;min-height:100vh}
|
||||
/* ---------- sidebar ---------- */
|
||||
aside{
|
||||
border-right:1px solid var(--line);padding:22px 16px;position:sticky;top:0;height:100vh;
|
||||
display:flex;flex-direction:column;gap:20px;background:rgba(10,13,16,.6);backdrop-filter:blur(8px);
|
||||
}
|
||||
.brand{display:flex;align-items:center;gap:10px}
|
||||
.mark{width:30px;height:30px;border-radius:9px;background:linear-gradient(140deg,var(--acc),var(--acc2));
|
||||
display:grid;place-items:center;color:#0b0d10;font-weight:800;font-size:15px;font-family:var(--mono)}
|
||||
.brand b{font-size:16px;letter-spacing:.2px}
|
||||
.brand span{display:block;font-size:11px;color:var(--dim2);font-family:var(--mono)}
|
||||
nav{display:flex;flex-direction:column;gap:3px}
|
||||
nav button{
|
||||
all:unset;cursor:pointer;display:flex;justify-content:space-between;align-items:center;
|
||||
padding:8px 10px;border-radius:8px;color:var(--dim);font-size:13.5px;
|
||||
}
|
||||
nav button:hover{background:var(--card);color:var(--txt)}
|
||||
nav button.on{background:var(--card2);color:var(--txt);box-shadow:inset 0 0 0 1px var(--line)}
|
||||
nav button i{font-family:var(--mono);font-size:11px;color:var(--dim2);font-style:normal}
|
||||
nav button.on i{color:var(--acc)}
|
||||
.side-foot{margin-top:auto;font-family:var(--mono);font-size:11px;color:var(--dim2);line-height:1.7}
|
||||
/* ---------- main ---------- */
|
||||
main{padding:26px 32px 60px;max-width:1180px}
|
||||
header{display:flex;gap:14px;align-items:flex-end;justify-content:space-between;flex-wrap:wrap}
|
||||
h1{margin:0;font-size:24px;letter-spacing:-.4px}
|
||||
h1 em{font-style:normal;color:var(--acc)}
|
||||
.sub{color:var(--dim);font-size:13px;margin-top:4px}
|
||||
.tools{display:flex;gap:8px;align-items:center}
|
||||
input[type=search]{
|
||||
background:var(--card);border:1px solid var(--line);border-radius:10px;color:var(--txt);
|
||||
padding:10px 13px;width:270px;font-size:14px;outline:none;
|
||||
}
|
||||
input[type=search]:focus{border-color:#3a4a56;box-shadow:0 0 0 3px rgba(198,242,78,.08)}
|
||||
.btn{
|
||||
cursor:pointer;border:1px solid var(--line);background:var(--card);color:var(--txt);
|
||||
padding:10px 16px;border-radius:10px;font-size:13.5px;font-weight:600;
|
||||
}
|
||||
.btn:hover{border-color:#3a4a56}
|
||||
.btn.primary{background:var(--acc);color:#0b0d10;border-color:transparent}
|
||||
.btn.primary:disabled{opacity:.5;cursor:wait}
|
||||
/* ---------- stats ---------- */
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:22px 0 26px}
|
||||
.stat{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px}
|
||||
.stat b{display:block;font-family:var(--mono);font-size:24px;letter-spacing:-.5px}
|
||||
.stat span{font-size:11.5px;color:var(--dim);text-transform:uppercase;letter-spacing:.6px}
|
||||
.stat.a b{color:var(--acc)} .stat.b b{color:var(--acc2)}
|
||||
/* ---------- feed ---------- */
|
||||
.bar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||||
.chip{
|
||||
font-family:var(--mono);font-size:11px;color:var(--dim);border:1px solid var(--line);
|
||||
border-radius:999px;padding:4px 10px;background:var(--card)
|
||||
}
|
||||
.chip.hot{color:var(--acc);border-color:#2b3a20}
|
||||
.list{display:flex;flex-direction:column;gap:10px}
|
||||
.row{
|
||||
display:grid;grid-template-columns:56px 1fr auto;gap:14px;align-items:start;
|
||||
background:linear-gradient(180deg,var(--card2),var(--card));
|
||||
border:1px solid var(--line);border-radius:14px;padding:14px 16px;transition:border-color .15s,transform .15s;
|
||||
}
|
||||
.row:hover{border-color:#33414d;transform:translateY(-1px)}
|
||||
.score{font-family:var(--mono);font-size:15px;color:var(--acc);text-align:right;padding-top:1px}
|
||||
.score small{display:block;font-size:9.5px;color:var(--dim2);letter-spacing:.5px}
|
||||
.t{font-size:15px;font-weight:600;line-height:1.35;margin:0 0 6px;letter-spacing:-.2px}
|
||||
.t a{color:inherit;text-decoration:none}
|
||||
.t a:hover{color:var(--acc)}
|
||||
.meta{display:flex;gap:8px;flex-wrap:wrap;align-items:center;font-family:var(--mono);font-size:11px;color:var(--dim)}
|
||||
.src{color:var(--acc2)}
|
||||
.txt{color:var(--dim);font-size:13px;margin-top:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.ext{font-family:var(--mono);font-size:11px;color:var(--dim2);align-self:center;white-space:nowrap}
|
||||
.empty{color:var(--dim);padding:40px 0;text-align:center;font-family:var(--mono);font-size:13px}
|
||||
.toast{
|
||||
position:fixed;bottom:22px;right:22px;background:var(--card2);border:1px solid var(--line);
|
||||
border-radius:12px;padding:12px 16px;font-size:13px;color:var(--txt);opacity:0;pointer-events:none;
|
||||
transition:opacity .25s;max-width:340px;font-family:var(--mono)
|
||||
}
|
||||
.toast.on{opacity:1}
|
||||
@media(max-width:820px){
|
||||
.wrap{grid-template-columns:1fr} aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;gap:12px;padding:14px}
|
||||
nav{flex-direction:row;flex-wrap:wrap} main{padding:18px 16px 50px} .tools{width:100%} input[type=search]{flex:1;width:auto}
|
||||
.row{grid-template-columns:44px 1fr}
|
||||
}
|
||||
:root{
|
||||
--bg:#0b0d10; --bg2:#11151a; --bg3:#161b22; --line:#232a33;
|
||||
--tx:#e6edf3; --dim:#8b949e; --dim2:#5b6673;
|
||||
--acc:#4ee39b; --acc2:#7aa2ff; --warn:#f0b429; --bad:#f85149;
|
||||
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--tx);
|
||||
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
|
||||
a{color:var(--acc2);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
button{font:inherit;cursor:pointer}
|
||||
.wrap{max-width:1240px;margin:0 auto;padding:22px 18px 60px}
|
||||
|
||||
header{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
|
||||
.logo{font:700 20px/1 var(--mono);letter-spacing:.5px}
|
||||
.logo b{color:var(--acc)}
|
||||
.sub{color:var(--dim);font-size:12.5px}
|
||||
.tabs{display:flex;gap:6px;margin:16px 0 18px;border-bottom:1px solid var(--line)}
|
||||
.tab{padding:9px 14px;border:0;background:none;color:var(--dim);border-bottom:2px solid transparent;
|
||||
font-size:13.5px;letter-spacing:.2px}
|
||||
.tab.on{color:var(--tx);border-bottom-color:var(--acc)}
|
||||
.tab:hover{color:var(--tx)}
|
||||
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(126px,1fr));gap:8px;margin-bottom:18px}
|
||||
.stat{background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:11px 13px}
|
||||
.stat .v{font:700 19px/1.2 var(--mono)}
|
||||
.stat .k{color:var(--dim);font-size:11.5px;text-transform:uppercase;letter-spacing:.6px;margin-top:3px}
|
||||
.stat.ok .v{color:var(--acc)} .stat.bad .v{color:var(--bad)} .stat.warn .v{color:var(--warn)}
|
||||
|
||||
.bar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
|
||||
input[type=text],select{background:var(--bg3);border:1px solid var(--line);color:var(--tx);
|
||||
border-radius:8px;padding:8px 11px;font:13px var(--mono);outline:none;min-width:0}
|
||||
input[type=text]:focus,select:focus{border-color:var(--acc2)}
|
||||
input[type=text]{flex:1 1 220px}
|
||||
.btn{background:var(--bg3);border:1px solid var(--line);color:var(--tx);border-radius:8px;
|
||||
padding:8px 13px;font-size:13px;white-space:nowrap}
|
||||
.btn:hover{border-color:var(--acc2)}
|
||||
.btn.pri{background:rgba(78,227,155,.12);border-color:rgba(78,227,155,.45);color:var(--acc)}
|
||||
.btn.gh{background:none;color:var(--dim)}
|
||||
.btn:disabled{opacity:.4;cursor:default}
|
||||
|
||||
.add{background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:13px;margin-bottom:16px}
|
||||
.add .ttl{font:600 12px/1 var(--mono);color:var(--dim);text-transform:uppercase;letter-spacing:.8px;margin-bottom:10px}
|
||||
.add .row{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.add .row input[type=text]{flex:1 1 180px}
|
||||
|
||||
.listhead{display:flex;justify-content:space-between;align-items:center;color:var(--dim);
|
||||
font:11.5px var(--mono);text-transform:uppercase;letter-spacing:.7px;padding:0 4px 8px}
|
||||
.list{border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--bg2)}
|
||||
.src{display:grid;grid-template-columns:38px 1fr 132px 104px 62px 34px;gap:10px;align-items:center;
|
||||
padding:9px 12px;border-bottom:1px solid var(--line)}
|
||||
.src:last-child{border-bottom:0}
|
||||
.src:hover{background:rgba(255,255,255,.022)}
|
||||
.src.off{opacity:.45}
|
||||
.nm{font-size:13.5px;display:flex;align-items:center;gap:7px;flex-wrap:wrap}
|
||||
.u{color:var(--dim2);font:11.5px var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.cat{font:11px var(--mono);color:var(--dim);text-transform:uppercase;letter-spacing:.5px}
|
||||
.cat.k{color:var(--acc2)}
|
||||
.pill{font:11.5px var(--mono);padding:2px 8px;border-radius:20px;border:1px solid var(--line);
|
||||
color:var(--dim);white-space:nowrap}
|
||||
.pill.ok{color:var(--acc);border-color:rgba(78,227,155,.35);background:rgba(78,227,155,.08)}
|
||||
.pill.bad{color:var(--bad);border-color:rgba(248,81,73,.35);background:rgba(248,81,73,.07)}
|
||||
.pill.wait{color:var(--warn);border-color:rgba(240,180,41,.3)}
|
||||
.cnt{font:12px var(--mono);color:var(--dim);text-align:right}
|
||||
.del{background:none;border:0;color:var(--dim2);font-size:15px;line-height:1;padding:4px}
|
||||
.del:hover{color:var(--bad)}
|
||||
.sw{position:relative;width:34px;height:19px;display:inline-block}
|
||||
.sw input{opacity:0;width:0;height:0;position:absolute}
|
||||
.sw span{position:absolute;inset:0;background:var(--bg3);border:1px solid var(--line);
|
||||
border-radius:20px;transition:.15s}
|
||||
.sw span:before{content:"";position:absolute;width:13px;height:13px;left:2px;top:2px;border-radius:50%;
|
||||
background:var(--dim);transition:.15s}
|
||||
.sw input:checked + span{background:rgba(78,227,155,.18);border-color:rgba(78,227,155,.5)}
|
||||
.sw input:checked + span:before{transform:translateX(15px);background:var(--acc)}
|
||||
|
||||
.card{background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:13px 15px;margin-bottom:10px}
|
||||
.card h3{margin:0 0 6px;font:600 14.5px/1.35 inherit}
|
||||
.card .m{color:var(--dim);font:11.5px var(--mono);display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px}
|
||||
.card p{margin:0;color:#b9c2cc;font-size:13px;line-height:1.45}
|
||||
.card .body{margin-top:7px;color:#8b949e;font-size:12.5px;max-height:4.4em;overflow:hidden}
|
||||
.empty{color:var(--dim);text-align:center;padding:36px 10px;font:12.5px var(--mono)}
|
||||
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%) translateY(20px);
|
||||
background:var(--bg3);border:1px solid var(--line);padding:9px 15px;border-radius:9px;
|
||||
font:12.5px var(--mono);opacity:0;pointer-events:none;transition:.2s;z-index:9}
|
||||
#toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
.prog{font:12px var(--mono);color:var(--warn);margin-bottom:10px}
|
||||
.hide{display:none!important}
|
||||
@media(max-width:760px){
|
||||
.src{grid-template-columns:34px 1fr 34px;grid-template-areas:"sw nm del" ". st ." ". cat .";gap:6px 10px}
|
||||
.src .sw{grid-area:sw}.src .meta{grid-area:nm}.src .del{grid-area:del}
|
||||
.src .catcell{grid-area:cat}.src .stcell{grid-area:st}.src .cnt{display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<aside>
|
||||
<div class="brand">
|
||||
<div class="mark">o</div>
|
||||
<div><b>omni</b><span>радар источников</span></div>
|
||||
</div>
|
||||
<nav id="nav"></nav>
|
||||
<div class="side-foot" id="foot">загрузка…</div>
|
||||
</aside>
|
||||
<header>
|
||||
<div class="logo">omni<b>·</b>radar</div>
|
||||
<div class="sub" id="sub">загрузка…</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<h1>что прилетело <em>без запроса</em></h1>
|
||||
<div class="sub" id="sub">ленты, API и форумы в одной базе</div>
|
||||
</div>
|
||||
<div class="tools">
|
||||
<input id="q" type="search" placeholder="поиск по базе…" autocomplete="off">
|
||||
<button class="btn" id="refresh">обновить вид</button>
|
||||
<button class="btn primary" id="run">собрать заново</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="tabs">
|
||||
<button class="tab on" data-view="feed">лента</button>
|
||||
<button class="tab" data-view="src">источники</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ FEED ============ -->
|
||||
<section id="view-feed">
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<div class="bar">
|
||||
<span class="chip" id="chipCount">—</span>
|
||||
<span class="chip" id="chipCat">все категории</span>
|
||||
<span class="chip hot" id="chipApi">api /api/items</span>
|
||||
<input type="text" id="q" placeholder="поиск по заголовку, описанию, url…">
|
||||
<select id="cat"><option value="">все категории</option></select>
|
||||
<button class="btn pri" id="feed">собрать ленту</button>
|
||||
</div>
|
||||
<div class="prog hide" id="fprog"></div>
|
||||
<div id="items"></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ SOURCES ============ -->
|
||||
<section id="view-src" class="hide">
|
||||
<div class="stats" id="sstats"></div>
|
||||
<div class="bar">
|
||||
<input type="text" id="sq" placeholder="фильтр по имени или url…">
|
||||
<select id="scat"><option value="all">все категории</option></select>
|
||||
<button class="btn" id="checkAll">проверить всё</button>
|
||||
<button class="btn" id="checkCat">проверить категорию</button>
|
||||
<button class="btn gh" id="onAll">вкл все</button>
|
||||
<button class="btn gh" id="offAll">выкл все</button>
|
||||
</div>
|
||||
<div class="prog hide" id="sprog"></div>
|
||||
|
||||
<div class="add">
|
||||
<div class="ttl">подключить источник</div>
|
||||
<div class="row">
|
||||
<input type="text" id="aname" placeholder="имя (латиницей, без пробелов)" style="flex:0 1 190px">
|
||||
<input type="text" id="aurl" placeholder="https://example.com/feed.xml | …/latest.json | github.com/org">
|
||||
<select id="acat"></select>
|
||||
<button class="btn pri" id="add">подключить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list" id="list"></div>
|
||||
<div class="empty" id="empty" style="display:none">пусто — нажми «собрать заново»</div>
|
||||
</main>
|
||||
<div class="listhead"><span id="scount">—</span><span>статус · записей</span></div>
|
||||
<div class="list" id="slist"></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const state = {cat:"all", q:"", items:[], cats:[]};
|
||||
const fmt = n => n==null?"—":(n>=1000?(n/1000).toFixed(1)+"k":String(n));
|
||||
const el = (t, c, h) => { const d = document.createElement(t); if (c) d.className = c; if (h != null) d.innerHTML = h; return d; };
|
||||
const esc = s => String(s == null ? "" : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
let CATS = {}, STATE = {view: "feed", scat: "all", sq: "", cat: ""};
|
||||
|
||||
function toast(msg, ms=4000){
|
||||
const t=$("#toast"); t.textContent=msg; t.classList.add("on");
|
||||
clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove("on"), ms);
|
||||
}
|
||||
|
||||
async function getJSON(url){
|
||||
const r = await fetch(url, {headers:{"Accept":"application/json"}});
|
||||
if(!r.ok) throw new Error(r.status);
|
||||
function toast(m){ const t = $("#toast"); t.textContent = m; t.classList.add("on"); clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove("on"), 2200); }
|
||||
async function api(u, b){
|
||||
const r = await fetch(u, b ? {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(b)} : undefined);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function loadStats(){
|
||||
const s = await getJSON("/api/stats");
|
||||
const cards = [
|
||||
{cls:"a", v:fmt(s.items), l:"записей в базе"},
|
||||
{cls:"", v:fmt(s.sources), l:"источников"},
|
||||
{cls:"b", v:fmt(s.runs), l:"прогонов"},
|
||||
{cls:"", v:fmt(s.by_provider.length), l:"активных коллекторов"},
|
||||
{cls:"", v:fmt(s.priced), l:"с ценником"},
|
||||
];
|
||||
$("#stats").innerHTML = cards.map(c=>`<div class="stat ${c.cls}"><b>${c.v}</b><span>${c.l}</span></div>`).join("");
|
||||
const cats = s.categories||{};
|
||||
state.cats = Object.entries(cats).sort((a,b)=>b[1]-a[1]);
|
||||
renderNav(s.items);
|
||||
$("#foot").innerHTML = `источников: ${s.sources}<br>категорий: ${state.cats.length}<br>прогонов ok: ${s.runs_ok}/${s.runs}`;
|
||||
}
|
||||
/* ---------- вкладки ---------- */
|
||||
document.querySelectorAll(".tab").forEach(t => t.onclick = () => {
|
||||
document.querySelectorAll(".tab").forEach(x => x.classList.toggle("on", x === t));
|
||||
STATE.view = t.dataset.view;
|
||||
$("#view-feed").classList.toggle("hide", STATE.view !== "feed");
|
||||
$("#view-src").classList.toggle("hide", STATE.view !== "src");
|
||||
if (STATE.view === "src") loadSources();
|
||||
});
|
||||
|
||||
function renderNav(total){
|
||||
const rows = [["all","всё", total], ...state.cats];
|
||||
$("#nav").innerHTML = rows.map(([k,label,n])=>
|
||||
`<button data-cat="${k}" class="${state.cat===k?'on':''}"><span>${label}</span><i>${fmt(n)}</i></button>`).join("");
|
||||
$("#nav").querySelectorAll("button").forEach(b=>b.onclick=()=>{
|
||||
state.cat=b.dataset.cat; renderNav(total); loadItems();
|
||||
});
|
||||
/* ---------- лента ---------- */
|
||||
async function loadStats(){
|
||||
const s = await api("/api/stats");
|
||||
CATS = s.categories || {};
|
||||
$("#sub").textContent = `${s.sources} источников · ${s.sources_enabled} включено · ${s.items} записей · ${s.priced} с ценой`;
|
||||
const box = $("#stats"); box.innerHTML = "";
|
||||
[["записей", s.items, ""], ["с ценой", s.priced, ""], ["прогонов", s.runs, ""],
|
||||
["источников", s.sources, "ok"], ["включено", s.sources_enabled, "ok"]]
|
||||
.forEach(([k, v, c]) => { const d = el("div", "stat " + c, `<div class="v">${v ?? 0}</div><div class="k">${k}</div>`); box.appendChild(d); });
|
||||
const sel = $("#cat"), cur = sel.value;
|
||||
sel.innerHTML = '<option value="">все категории</option>' +
|
||||
Object.entries(CATS).map(([k, v]) => `<option value="${k}">${k} · ${v}</option>`).join("");
|
||||
sel.value = cur;
|
||||
const scat = $("#scat"), curs = scat.value || "all";
|
||||
scat.innerHTML = '<option value="all">все категории</option>' +
|
||||
Object.entries(CATS).map(([k, v]) => `<option value="${k}">${k} · ${v}</option>`).join("");
|
||||
scat.value = curs;
|
||||
const acat = $("#acat");
|
||||
acat.innerHTML = Object.keys(CATS).map(k => `<option value="${k}">${k}</option>`).join("") + '<option value="custom">custom</option>';
|
||||
}
|
||||
|
||||
async function loadItems(){
|
||||
$("#chipCat").textContent = state.cat==="all" ? "все категории" : state.cat;
|
||||
const url = `/api/items?category=${encodeURIComponent(state.cat)}&q=${encodeURIComponent(state.q)}&limit=80`;
|
||||
const d = await getJSON(url);
|
||||
state.items = d.items||[];
|
||||
$("#chipCount").textContent = `${d.count} записей`;
|
||||
$("#empty").style.display = state.items.length ? "none" : "block";
|
||||
$("#list").innerHTML = state.items.map(it=>{
|
||||
const score = (it.score||0).toFixed(2);
|
||||
const date = (it.published||"").toString().slice(0,16).replace("T"," ");
|
||||
const host = (()=>{try{return new URL(it.url).hostname.replace("www.","")}catch(e){return ""}})();
|
||||
const ext = it.price ? `${it.price} ${it.currency||""}` : (it.kind||"");
|
||||
return `<article class="row">
|
||||
<div class="score">${score}<small>score</small></div>
|
||||
<div>
|
||||
<p class="t"><a href="${it.url||"#"}" target="_blank" rel="noopener">${esc(it.title||"(без заголовка)")}</a></p>
|
||||
<div class="meta">
|
||||
<span class="src">${esc(it.source||it.provider)}</span>
|
||||
<span>${esc(it.provider)}</span>
|
||||
${date?`<span>${esc(date)}</span>`:""}
|
||||
${it.author?`<span>@${esc(it.author)}</span>`:""}
|
||||
</div>
|
||||
${it.text?`<div class="txt">${esc(it.text.slice(0,240))}</div>`:""}
|
||||
</div>
|
||||
<div class="ext">${esc(ext)}${host?`<br>${esc(host)}`:""}</div>
|
||||
</article>`;
|
||||
}).join("");
|
||||
const q = $("#q").value.trim(), cat = $("#cat").value;
|
||||
const d = await api(`/api/items?q=${encodeURIComponent(q)}&category=${encodeURIComponent(cat)}&limit=80`);
|
||||
const box = $("#items"); box.innerHTML = "";
|
||||
if (!d.items.length) { box.appendChild(el("div", "empty", "пусто — нажми «собрать ленту»")); return; }
|
||||
d.items.forEach(it => {
|
||||
const c = el("div", "card");
|
||||
const price = it.price ? ` · $${it.price}${it.currency ? " " + it.currency : ""}` : "";
|
||||
c.innerHTML = `<h3><a href="${esc(it.url)}" target="_blank" rel="noopener">${esc(it.title)}</a></h3>
|
||||
<div class="m"><span>${esc(it.provider || "—")}</span><span>${esc(it.kind || "")}</span>
|
||||
<span>${esc(it.author || "")}</span><span>★ ${it.score ?? 0}</span>${price ? `<span>${esc(price)}</span>` : ""}</div>
|
||||
${it.body ? `<div class="body">${esc(it.body)}</div>` : ""}`;
|
||||
box.appendChild(c);
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s){return String(s==null?"":s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))}
|
||||
|
||||
let tmr;
|
||||
$("#q").addEventListener("input", e=>{
|
||||
clearTimeout(tmr);
|
||||
tmr = setTimeout(()=>{ state.q = e.target.value.trim(); loadItems(); }, 280);
|
||||
});
|
||||
$("#refresh").onclick = ()=>{ loadStats(); loadItems(); toast("вид обновлён"); };
|
||||
|
||||
$("#run").onclick = async ()=>{
|
||||
const b = $("#run"); b.disabled=true; b.textContent="собираю…";
|
||||
try{
|
||||
const cat = state.cat==="all" ? null : state.cat;
|
||||
const r = await fetch("/api/feed", {method:"POST", headers:{"Content-Type":"application/json"},
|
||||
body: JSON.stringify({category:cat, limit:12})});
|
||||
const {job} = await r.json();
|
||||
let tries=0;
|
||||
while(tries++ < 120){
|
||||
await new Promise(x=>setTimeout(x,1500));
|
||||
const j = await getJSON("/api/job/"+job);
|
||||
if(j.state==="done"){
|
||||
toast(`собрано ${j.fetched}, новых ${j.new}, дублей ${j.dup}, пустых источников ${j.empty}`);
|
||||
await loadStats(); await loadItems(); break;
|
||||
}
|
||||
if(j.state==="error"){ toast("ошибка: "+j.error); break; }
|
||||
if(tries%4===0) toast("ещё собираю… "+tries*1.5+"с", 1600);
|
||||
}
|
||||
}catch(e){ toast("не вышло: "+e.message); }
|
||||
finally{ b.disabled=false; b.textContent="собрать заново"; }
|
||||
$("#q").oninput = (() => { let h; return () => { clearTimeout(h); h = setTimeout(loadItems, 260); }; })();
|
||||
$("#cat").onchange = loadItems;
|
||||
$("#feed").onclick = async () => {
|
||||
const b = $("#feed"); b.disabled = true;
|
||||
const cat = $("#cat").value;
|
||||
const {job} = await api("/api/feed", {category: cat || null, limit: 10});
|
||||
$("#fprog").classList.remove("hide");
|
||||
const tick = setInterval(async () => {
|
||||
const j = await api("/api/job/" + job);
|
||||
if (j.state === "running") { $("#fprog").textContent = "собираю…"; return; }
|
||||
clearInterval(tick); b.disabled = false;
|
||||
$("#fprog").textContent = j.state === "done"
|
||||
? `источников ${j.sources} · собрано ${j.fetched} · новых ${j.new} · дублей ${j.dup} · пустых ${j.empty}`
|
||||
: "ошибка: " + (j.error || "?");
|
||||
setTimeout(() => $("#fprog").classList.add("hide"), 6000);
|
||||
await loadStats(); await loadItems();
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
loadStats().then(loadItems).catch(e=>toast("api недоступен: "+e.message));
|
||||
/* ---------- источники ---------- */
|
||||
async function loadSources(){
|
||||
const cat = STATE.scat, q = STATE.sq;
|
||||
const d = await api(`/api/sources?category=${encodeURIComponent(cat === "all" ? "" : cat)}&q=${encodeURIComponent(q)}`);
|
||||
const sb = $("#sstats"); sb.innerHTML = "";
|
||||
[["всего", d.total, ""], ["в списке", d.shown, ""], ["включено", d.enabled, "ok"],
|
||||
["живых", d.alive, "ok"], ["мёртвых", d.dead, "bad"], ["не проверено", d.untested, "warn"]]
|
||||
.forEach(([k, v, c]) => sb.appendChild(el("div", "stat " + c, `<div class="v">${v ?? 0}</div><div class="k">${k}</div>`)));
|
||||
$("#scount").textContent = `${d.shown} из ${d.total}`;
|
||||
|
||||
const box = $("#slist"); box.innerHTML = "";
|
||||
if (!d.items.length) { box.appendChild(el("div", "empty", "ничего не найдено")); return; }
|
||||
d.items.forEach(s => {
|
||||
const row = el("div", "src" + (s.enabled ? "" : " off"));
|
||||
const st = s.items > 0
|
||||
? `<span class="pill ok">${s.items} зап.</span>`
|
||||
: (s.checked ? `<span class="pill bad">${esc(s.note || "мёртв")}</span>` : `<span class="pill">не проверен</span>`);
|
||||
row.innerHTML = `
|
||||
<label class="sw"><input type="checkbox" ${s.enabled ? "checked" : ""}><span></span></label>
|
||||
<div class="meta"><div class="nm">${esc(s.name)}${s.custom ? ' <span class="pill wait">свой</span>' : ""}</div>
|
||||
<div class="u" title="${esc(s.url)}">${esc(s.url)}</div></div>
|
||||
<div class="catcell"><span class="cat">${esc(s.category)}</span> <span class="cat k">${esc(s.kind)}</span></div>
|
||||
<div class="stcell">${st}</div>
|
||||
<div class="cnt">${s.items || ""}</div>
|
||||
<button class="del" title="удалить">${s.custom ? "✕" : ""}</button>`;
|
||||
row.querySelector("input").onchange = async e => {
|
||||
const on = e.target.checked;
|
||||
row.classList.toggle("off", !on);
|
||||
await api("/api/sources/toggle", {name: s.name, enabled: on});
|
||||
toast(`${s.name}: ${on ? "включён" : "выключен"}`);
|
||||
loadStats();
|
||||
};
|
||||
const del = row.querySelector(".del");
|
||||
if (s.custom) del.onclick = async () => {
|
||||
if (!confirm(`удалить источник ${s.name}?`)) return;
|
||||
await api("/api/sources/remove", {name: s.name});
|
||||
toast("удалён"); loadSources(); loadStats();
|
||||
};
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
$("#sq").oninput = (() => { let h; return () => { clearTimeout(h); h = setTimeout(() => { STATE.sq = $("#sq").value.trim(); loadSources(); }, 240); }; })();
|
||||
$("#scat").onchange = () => { STATE.scat = $("#scat").value; loadSources(); };
|
||||
|
||||
async function runCheck(body, label){
|
||||
const {job} = await api("/api/sources/check", body);
|
||||
const p = $("#sprog"); p.classList.remove("hide");
|
||||
const tick = setInterval(async () => {
|
||||
const j = await api("/api/job/" + job);
|
||||
if (j.state === "running") {
|
||||
p.textContent = `${label}: ${j.done || 0}/${j.total || "?"} · живых ${j.alive || 0} · мёртвых ${j.dead || 0} · ${j.current || ""}`;
|
||||
return;
|
||||
}
|
||||
clearInterval(tick);
|
||||
p.textContent = `готово: живых ${j.alive || 0}, мёртвых ${j.dead || 0}`;
|
||||
setTimeout(() => p.classList.add("hide"), 6000);
|
||||
loadSources(); loadStats();
|
||||
}, 1300);
|
||||
}
|
||||
$("#checkAll").onclick = () => runCheck({all: true}, "проверка всех");
|
||||
$("#checkCat").onclick = () => {
|
||||
const c = STATE.scat === "all" ? "" : STATE.scat;
|
||||
if (!c) return toast("выбери категорию в селекте");
|
||||
runCheck({category: c}, "проверка " + c);
|
||||
};
|
||||
$("#onAll").onclick = async () => {
|
||||
const d = await api(`/api/sources?category=${encodeURIComponent(STATE.scat === "all" ? "" : STATE.scat)}&q=${encodeURIComponent(STATE.sq)}`);
|
||||
await Promise.all(d.items.map(s => api("/api/sources/toggle", {name: s.name, enabled: true})));
|
||||
toast(`включено: ${d.items.length}`); loadSources(); loadStats();
|
||||
};
|
||||
$("#offAll").onclick = async () => {
|
||||
const d = await api(`/api/sources?category=${encodeURIComponent(STATE.scat === "all" ? "" : STATE.scat)}&q=${encodeURIComponent(STATE.sq)}`);
|
||||
await Promise.all(d.items.map(s => api("/api/sources/toggle", {name: s.name, enabled: false})));
|
||||
toast(`выключено: ${d.items.length}`); loadSources(); loadStats();
|
||||
};
|
||||
$("#add").onclick = async () => {
|
||||
const name = $("#aname").value.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
const url = $("#aurl").value.trim();
|
||||
const category = $("#acat").value;
|
||||
if (!name || !url) return toast("нужны имя и url");
|
||||
const r = await api("/api/sources/add", {name, url, category});
|
||||
if (r.error) return toast(r.error);
|
||||
toast(`подключён: ${r.name} (${r.adapter})`);
|
||||
$("#aname").value = ""; $("#aurl").value = "";
|
||||
loadSources(); loadStats();
|
||||
};
|
||||
|
||||
/* ---------- старт ---------- */
|
||||
loadStats().then(loadItems);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user