Files
omni-parse/web/index.html

250 lines
12 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; --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}
}
</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>
<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="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>
</div>
<div class="list" id="list"></div>
<div class="empty" id="empty" style="display:none">пусто — нажми «собрать заново»</div>
</main>
</div>
<div class="toast" 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));
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);
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}`;
}
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 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("");
}
function esc(s){return String(s==null?"":s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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="собрать заново"; }
};
loadStats().then(loadItems).catch(e=>toast("api недоступен: "+e.message));
</script>
</body>
</html>