Files
omni-parse/web/index.html

325 lines
17 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:#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">
<header>
<div class="logo">omni<b>·</b>radar</div>
<div class="sub" id="sub">загрузка…</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">
<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="listhead"><span id="scount">—</span><span>статус · записей</span></div>
<div class="list" id="slist"></div>
</section>
</div>
<div id="toast"></div>
<script>
const $ = s => document.querySelector(s);
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
let CATS = {}, STATE = {view: "feed", scat: "all", sq: "", cat: ""};
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();
}
/* ---------- вкладки ---------- */
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();
});
/* ---------- лента ---------- */
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(){
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);
});
}
$("#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);
};
/* ---------- источники ---------- */
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>