Files
bucketeer/internal/webui/index.html
T

463 lines
24 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bucketeer · CT resource monitor</title>
<link rel="icon" href="/assets/favicon.png">
<script>/* set theme before first paint to avoid a flash */
try{var t=localStorage.getItem('bucketeer-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}</script>
<style>
/* Tokens from DESIGN.md. Neutral-first, no shadows, system fonts, light only. */
:root {
--primary:#262626; --on-primary:#fff; --primary-hover:#404040; --primary-active:#171717;
--text-primary:#000; --text-secondary:#525252; --text-muted:#737373;
--canvas:#fff; --surface-subtle:#fafafa; --surface-control:#f2f2f2; --surface-hover:#ededed;
--border:#e5e5e5; --border-strong:#d4d4d4; --focus:#2563eb;
--cap-bg:#eef2ff; --cap-text:#4338ca; --size-bg:#e0f2fe; --size-text:#075985;
--err-bg:#fef2f2; --err-text:#b91c1c; --ok-bg:#f0fdf4; --ok-text:#166534;
--font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--page-gutter:16px;
color-scheme: light dark;
}
/* Dark palette (DESIGN.md specifies light only; this is an explicit product
decision). Neutral-first, same hierarchy, contrast-preserving. */
[data-theme=dark]{
--primary:#e7e9ee; --on-primary:#14161a; --primary-hover:#d3d7de; --primary-active:#ffffff;
--text-primary:#f4f5f7; --text-secondary:#b3b9c4; --text-muted:#858d9a;
--canvas:#0f1115; --surface-subtle:#171a20; --surface-control:#1c2027; --surface-hover:#252b35;
--border:#2a2f39; --border-strong:#3a414e; --focus:#69a8ff;
--cap-bg:#1e2440; --cap-text:#aab5ff; --size-bg:#0e2a3a; --size-text:#7cc4ff;
--err-bg:#3a1616; --err-text:#ffb4b4; --ok-bg:#0f2a1a; --ok-text:#7ee2a8;
}
*,*::before,*::after{box-sizing:border-box}
body{margin:0;background:var(--canvas);color:var(--text-primary);font:400 16px/24px var(--font);-webkit-font-smoothing:antialiased}
h1{margin:0;font:500 24px/32px var(--font);letter-spacing:-.01em}
a{color:inherit}
.site-shell,.search-layout{width:calc(100% - 2*var(--page-gutter));margin-inline:auto}
.site-shell{max-width:1280px}
.search-layout{max-width:768px}
@media (min-width:640px){:root{--page-gutter:24px}}
@media (min-width:1024px){:root{--page-gutter:32px}}
header{border-bottom:1px solid var(--border)}
.header{height:72px;display:flex;align-items:center;gap:24px}
.brand{display:flex;align-items:center;gap:8px;font:500 18px/28px var(--font);text-decoration:none}
.brand-logo{width:33px;height:30px;display:block;
background:url('/assets/logo-light.webp') center/contain no-repeat}
[data-theme=dark] .brand-logo{background-image:url('/assets/logo-dark.webp')}
.theme-btn{width:40px;height:40px;display:grid;place-items:center;background:transparent;border:1px solid var(--border);
border-radius:9999px;color:var(--text-secondary);cursor:pointer;padding:0}
.theme-btn:hover{background:var(--surface-hover);color:var(--text-primary)}
.theme-btn svg{width:18px;height:18px;display:block}
nav{display:flex;gap:16px;font:400 18px/28px var(--font)}
nav a{text-decoration:none;color:var(--text-secondary)}
nav a[aria-current=page]{color:var(--text-primary);text-decoration:underline;text-underline-offset:6px}
.header-right{margin-left:auto;display:flex;align-items:center;gap:12px}
.live{display:inline-flex;align-items:center;gap:6px;font:400 14px/20px var(--font);color:var(--text-muted)}
.dot{width:8px;height:8px;border-radius:9999px;background:var(--ok-text)}
.dot.paused{background:var(--text-muted)}
main{padding:48px 0 80px}
.query{position:relative;display:flex;align-items:center}
.query input{width:100%;height:48px;border:1px solid transparent;border-radius:9999px;background:var(--surface-control);
color:var(--text-primary);font:400 16px/24px var(--font);padding:0 48px 0 20px}
.query input::placeholder{color:var(--text-secondary)}
.query input:focus-visible{outline:2px solid var(--focus);outline-offset:3px;background:var(--canvas)}
.query .clear{position:absolute;right:4px;border:0;background:transparent;color:var(--text-secondary);
width:40px;height:40px;border-radius:9999px;cursor:pointer;font-size:18px;display:none}
.query .clear.show{display:block}
.query .clear:hover{background:var(--surface-hover);color:var(--text-primary)}
.controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:16px}
.filters{display:flex;gap:8px;flex-wrap:wrap}
.chip{height:32px;padding:0 12px;border:1px solid var(--border-strong);border-radius:9999px;
background:var(--surface-control);color:var(--text-secondary);font:400 14px/20px var(--font);
cursor:pointer;display:inline-flex;align-items:center;gap:6px}
.chip:hover{background:var(--surface-hover);color:var(--text-primary)}
.chip[aria-pressed=true]{background:var(--primary);border-color:var(--primary);color:var(--on-primary)}
.sort{margin-left:auto;display:flex;align-items:center;gap:8px;color:var(--text-secondary);font:400 14px/20px var(--font)}
select{height:40px;border:1px solid var(--border-strong);border-radius:9999px;background:var(--canvas);
color:var(--text-primary);font:400 14px/20px var(--font);padding:0 12px}
.scan{display:flex;gap:8px;margin-top:16px}
.scan input{flex:1;height:40px;border:1px solid var(--border-strong);border-radius:9999px;background:var(--surface-control);
color:var(--text-primary);font:400 14px/20px var(--font);padding:0 16px}
.scan input:focus-visible{outline:2px solid var(--focus);outline-offset:3px;background:var(--canvas)}
.scan button{min-height:40px;padding:0 20px;border-radius:9999px;border:1px solid var(--primary);
background:var(--primary);color:var(--on-primary);font:500 14px/20px var(--font);cursor:pointer}
.notice{margin-top:16px;padding:12px;border-radius:8px;background:var(--surface-subtle);border:1px solid var(--border)}
.notice-ok{background:var(--ok-bg);border-color:transparent}
.samples{margin-top:8px;border-top:1px solid var(--border);padding-top:8px}
.sample{color:var(--text-secondary);font-size:13px;overflow-wrap:anywhere}
.summary{display:flex;gap:16px;flex-wrap:wrap;margin-top:24px;color:var(--text-muted);font:400 14px/20px var(--font)}
.summary strong{color:var(--text-primary);font-weight:500}
ul.result-list{list-style:none;margin:0;padding:0}
/* Native virtualization: off-screen rows are not laid out or painted. */
.result-row{padding-block:24px;border-bottom:1px solid var(--border);
content-visibility:auto;contain-intrinsic-size:auto 128px}
.result-head{display:flex;align-items:flex-start;gap:12px;min-width:0}
.result-title{font:500 20px/28px var(--font);color:var(--text-primary);text-decoration:none;overflow-wrap:anywhere}
.result-title:hover{text-decoration:underline}
.result-description{margin:4px 0 0;color:var(--text-secondary);font:400 16px/24px var(--font);overflow-wrap:anywhere}
.badge-group{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.result-meta{display:flex;gap:8px 16px;flex-wrap:wrap;margin-top:12px;color:var(--text-muted);font:400 14px/20px var(--font)}
.badge{font:500 12px/16px var(--font);padding:2px 6px;border-radius:4px;background:var(--surface-control);color:var(--text-secondary);white-space:nowrap}
.badge-cap{background:var(--cap-bg);color:var(--cap-text)}
.badge-size{background:var(--size-bg);color:var(--size-text)}
.badge-ok{background:var(--ok-bg);color:var(--ok-text)}
.badge-err{background:var(--err-bg);color:var(--err-text)}
.mono{font-family:var(--mono);font-size:14px}
.empty{padding:32px 0;color:var(--text-secondary)}
.error{padding:12px;border-radius:8px;background:var(--err-bg);color:var(--err-text);font:400 14px/20px var(--font)}
.skeleton{height:16px;border-radius:6px;background:var(--surface-control);margin:12px 0}
.skeleton.w60{width:60%}.skeleton.w40{width:40%}
.more{display:flex;justify-content:center;margin-top:24px}
.more button{min-height:40px;padding:10px 20px;border-radius:9999px;border:1px solid var(--border-strong);
background:var(--canvas);color:var(--text-primary);font:500 14px/20px var(--font);cursor:pointer}
.more button:hover{background:var(--surface-hover)}
.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
footer{color:var(--text-muted);font:400 14px/20px var(--font);padding:0 0 64px}
footer p{max-width:768px;margin-inline:auto}
:where(a,button,input,select,[tabindex]):focus-visible{outline:2px solid var(--focus);outline-offset:3px}
@media (pointer:coarse){.chip,.clear,.more button{min-height:44px}.chip{min-width:44px}}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
</head>
<body>
<header>
<div class="site-shell header">
<a class="brand" href="/"><span class="brand-logo" role="img" aria-label="Bucketeer"></span>Bucketeer</a>
<nav aria-label="Primary">
<a href="?view=assets" id="nav-assets" aria-current="page">Assets</a>
<a href="?view=contents" id="nav-contents">Contents</a>
<a href="/metrics">Metrics</a>
<a href="/healthz">Health</a>
</nav>
<div class="header-right">
<span class="live" id="live"><span class="dot" id="livedot"></span><span id="livetext">live · 15s</span></span>
<button class="theme-btn" id="themebtn" type="button" aria-label="Switch to dark mode"></button>
</div>
</div>
</header>
<main>
<div class="search-layout">
<h1 class="visually-hidden">Cloud resources discovered in Certificate Transparency</h1>
<div class="query">
<label class="visually-hidden" for="q">Search</label>
<input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="Search hostname, bucket, database…">
<button class="clear" id="clear" type="button" aria-label="Clear search">×</button>
</div>
<form class="scan" id="scanform">
<label class="visually-hidden" for="scan">Scan a host or URL</label>
<input id="scan" type="text" autocomplete="off" spellcheck="false"
placeholder="Scan a host or URL (bucket, Azure container path, custom domain)…">
<button type="submit">Scan</button>
</form>
<div id="scanout" hidden></div>
<div class="controls" id="asset-controls">
<div class="filters" id="filters" role="group" aria-label="Filter by exposure">
<button class="chip" data-exposure="" aria-pressed="true">All</button>
<button class="chip" data-exposure="public_read" aria-pressed="false">Public read</button>
<button class="chip" data-exposure="reachable" aria-pressed="false">Reachable</button>
<button class="chip" data-exposure="auth_required" aria-pressed="false">Auth required</button>
<button class="chip" data-exposure="not_found" aria-pressed="false">Not found</button>
<button class="chip" data-exposure="private" aria-pressed="false">Private</button>
</div>
<div class="sort">
<label for="sort">Sort</label>
<select id="sort">
<option value="exposure">Exposure</option>
<option value="last_seen">Recently seen</option>
<option value="first_seen">Newest</option>
<option value="provider">Provider</option>
<option value="hostname">Hostname</option>
</select>
</div>
</div>
<div class="summary" id="summary" aria-live="polite"></div>
<div id="state"></div>
<ul class="result-list" id="results"></ul>
<div class="more" id="more" hidden><button id="morebtn" type="button">Load more</button></div>
</div>
</main>
<footer>
<p>
A detected endpoint is not automatically accessible. <span class="badge badge-ok">public_read</span>
means an anonymous read succeeded at probe time and readable resources are indexed below (keys and
sizes only, never contents). Databases are only reported <span class="badge badge-size">reachable</span>
unless an unauthenticated protocol read was explicitly enabled; no credentials are ever sent.
</p>
</footer>
<script>
const PAGE = 100, REFRESH_MS = 15000;
const $ = (id) => document.getElementById(id);
const params = new URLSearchParams(location.search);
const state = {
view: params.get('view') === 'contents' ? 'contents' : 'assets',
q: params.get('q') || '',
exposure: params.get('exposure') || '',
sort: params.get('sort') || 'exposure',
assets: [], assetsTotal: 0, assetsOffset: 0,
entries: [], entriesTotal: 0, entriesOffset: 0,
loading: false, abort: null, timer: null, countdown: REFRESH_MS / 1000, ok: true,
};
$('q').value = state.q;
$('sort').value = state.sort;
$('clear').classList.toggle('show', state.q.length > 0);
document.querySelectorAll('#filters .chip').forEach((c) =>
c.setAttribute('aria-pressed', String(c.dataset.exposure === state.exposure)));
const SUN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/></svg>';
const MOON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
const currentTheme = () => document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
function setTheme(t, persist) {
document.documentElement.setAttribute('data-theme', t);
if (persist) { try { localStorage.setItem('bucketeer-theme', t); } catch (e) {} }
$('themebtn').innerHTML = t === 'dark' ? SUN : MOON;
$('themebtn').setAttribute('aria-label', t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
}
setTheme(currentTheme(), false);
$('themebtn').onclick = () => setTheme(currentTheme() === 'dark' ? 'light' : 'dark', true);
if (window.matchMedia) matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
try { if (!localStorage.getItem('bucketeer-theme')) setTheme(e.matches ? 'dark' : 'light', false); } catch (_) {}
});
const esc = (s) => (s ?? '').toString().replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const key = (a) => a.provider + '|' + a.service + '|' + a.provider_hostname;
const badge = (v, cls) => `<span class="badge ${cls || ''}">${esc(v)}</span>`;
function exposureBadge(v) {
const cls = v === 'public_read' ? 'badge-ok' : v === 'reachable' ? 'badge-size'
: (v === 'not_found' || v === 'private') ? 'badge-err' : '';
return badge(v || 'unknown', cls);
}
function ago(ts) {
if (!ts) return null;
const s = Math.max(0, (Date.now() - new Date(ts)) / 1000);
if (s < 60) return Math.floor(s) + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function bytes(n) {
if (!n) return '';
const u = ['B','KB','MB','GB','TB']; let i = 0, v = n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
return (i === 0 ? v : v.toFixed(1)) + ' ' + u[i];
}
function timeEl(ts, label) {
const a = ago(ts);
return a ? `<span>${label} <time datetime="${esc(ts)}">${a}</time></span>` : '';
}
function assetRow(a) {
const meta = [];
if (a.region) meta.push(`<span>${esc(a.region)}</span>`);
if (a.hostname && a.hostname !== a.provider_hostname) meta.push(`<span>CT host <span class="mono">${esc(a.hostname)}</span></span>`);
meta.push(timeEl(a.last_seen, 'seen'));
meta.push(timeEl(a.probed_at, 'probed'));
return `<li class="result-row">
<div class="result-head">
<a class="result-title" href="https://${esc(a.provider_hostname)}/" target="_blank" rel="noopener noreferrer nofollow">${esc(a.provider_hostname)}</a>
${exposureBadge(a.exposure_state)}
</div>
${a.exposure_detail ? `<p class="result-description">${esc(a.exposure_detail)}</p>` : ''}
<div class="badge-group">
${badge(a.provider, 'badge-cap')}${badge(a.service, 'badge-size')}
${a.listable ? badge('listable', 'badge-ok') : (a.object_readable ? badge('object readable', 'badge-ok') : '')}
${a.resource_identifier ? badge(a.resource_identifier) : ''}
${a.public_semantic && a.public_semantic !== 'none' ? badge(a.public_semantic) : ''}
</div>
<div class="result-meta">${meta.filter(Boolean).join('')}</div>
</li>`;
}
function entryRow(e) {
const meta = [timeEl(e.indexed_at, 'indexed')];
if (e.size) meta.push(`<span>${bytes(e.size)}</span>`);
if (e.content_type) meta.push(`<span>${esc(e.content_type)}</span>`);
return `<li class="result-row">
<div class="result-head">
<span class="result-title mono">${esc(e.name)}</span>
${badge(e.kind)}
</div>
<div class="badge-group">
${badge(e.provider, 'badge-cap')}${badge(e.service, 'badge-size')}
<span class="badge mono">${esc(e.provider_hostname)}</span>
</div>
<div class="result-meta">${meta.join('')}</div>
</li>`;
}
function render() {
const ul = $('results');
if (state.view === 'assets') {
ul.innerHTML = state.assets.map(assetRow).join('');
if (!state.assets.length) showEmpty('No assets match.');
} else {
ul.innerHTML = state.entries.map(entryRow).join('');
if (!state.entries.length) showEmpty('No indexed contents match. Only resources confirmed publicly readable are indexed.');
}
const loaded = state.view === 'assets' ? state.assets.length : state.entries.length;
const total = state.view === 'assets' ? state.assetsTotal : state.entriesTotal;
$('more').hidden = loaded >= total;
$('morebtn').textContent = `Load more (${loaded} of ${total})`;
}
function showEmpty(msg) {
$('state').innerHTML = `<div class="empty">${esc(msg)}${state.q ? '' : ''}</div>`;
}
async function fetchJSON(url, signal) {
const r = await fetch(url, { signal });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
async function loadAssets(reset) {
if (reset) { state.assets = []; state.assetsOffset = 0; }
const p = new URLSearchParams({ q: state.q, exposure: state.exposure, sort: state.sort,
limit: String(PAGE), offset: String(state.assetsOffset) });
const data = await fetchJSON('api/assets?' + p);
mergeAssets(data.assets || []);
state.assetsTotal = data.total || 0;
state.assetsOffset = state.assets.length;
}
function mergeAssets(rows) {
const seen = new Set(state.assets.map(key));
for (const a of rows) { const k = key(a); if (!seen.has(k)) { seen.add(k); state.assets.push(a); } }
if (state.sort === 'hostname') state.assets.sort((a, b) => a.provider_hostname.localeCompare(b.provider_hostname));
}
async function loadEntries(reset) {
if (reset) { state.entries = []; state.entriesOffset = 0; }
const p = new URLSearchParams({ q: state.q, limit: String(PAGE), offset: String(state.entriesOffset) });
const data = await fetchJSON('api/entries?' + p);
const seen = new Set(state.entries.map((e) => e.id));
for (const e of (data.entries || [])) if (!seen.has(e.id)) state.entries.push(e);
state.entriesTotal = data.total || 0;
state.entriesOffset = state.entries.length;
}
async function reload(showSkeleton) {
if (state.abort) state.abort.abort();
const ac = new AbortController();
state.abort = ac;
if (showSkeleton) $('state').innerHTML = '<div class="skeleton w60"></div><div class="skeleton w40"></div><div class="skeleton w60"></div>';
try {
if (state.view === 'assets') await loadAssets(true); else await loadEntries(true);
$('state').innerHTML = '';
render();
await loadStats();
setLive(true);
} catch (e) {
if (e.name === 'AbortError') return;
$('state').innerHTML = `<div class="error">Could not load data (${esc(e.message)}). Retrying in 15s.</div>`;
setLive(false);
}
}
async function loadStats() {
const s = await fetchJSON('api/stats');
const readable = (s.by_exposure || []).find((x) => x.label === 'public_read');
$('summary').innerHTML =
`<span><strong>${s.total ?? 0}</strong> assets</span>` +
`<span><strong>${readable ? readable.count : 0}</strong> publicly readable</span>` +
`<span><strong>${s.reachable ?? 0}</strong> reachable endpoints</span>` +
`<span><strong>${s.entries ?? 0}</strong> indexed items</span>`;
}
function setLive(ok) {
state.ok = ok;
$('livedot').classList.toggle('paused', !ok);
$('livetext').textContent = (ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
}
function syncURL() {
const p = new URLSearchParams();
p.set('view', state.view);
if (state.q) p.set('q', state.q);
if (state.view === 'assets' && state.exposure) p.set('exposure', state.exposure);
if (state.view === 'assets' && state.sort !== 'exposure') p.set('sort', state.sort);
history.replaceState(null, '', '?' + p);
$('nav-assets').setAttribute('aria-current', state.view === 'assets' ? 'page' : 'false');
$('nav-contents').setAttribute('aria-current', state.view === 'contents' ? 'page' : 'false');
$('asset-controls').hidden = state.view !== 'assets';
$('q').placeholder = state.view === 'assets' ? 'Search hostname, bucket, database…' : 'Search indexed contents…';
}
function scanResult(r) {
if (r.error) return `<div class="error">${esc(r.host || r.target)}: ${esc(r.error)}</div>`;
const samples = (r.sample || []).map((s) => `<div class="mono sample">${esc(s)}</div>`).join('');
const chain = r.cname_chain && r.cname_chain.length > 1
? `<div class="result-meta"><span>chain: ${r.cname_chain.map(esc).join(' → ')}</span></div>` : '';
return `<div class="notice ${r.exposure === 'public_read' ? 'notice-ok' : ''}">
<div class="result-head">
<span class="result-title mono">${esc(r.host)}</span>${exposureBadge(r.exposure)}
</div>
${r.detail ? `<p class="result-description">${esc(r.detail)}</p>` : ''}
<div class="badge-group">${badge(r.provider, 'badge-cap')}${badge(r.service, 'badge-size')}${badge((r.entries || 0) + ' entries')}</div>
${chain}${samples ? `<div class="samples">${samples}</div>` : ''}
</div>`;
}
$('scanform').addEventListener('submit', async (e) => {
e.preventDefault();
const target = $('scan').value.trim();
if (!target) return;
const out = $('scanout');
out.hidden = false;
out.innerHTML = '<div class="notice"><div class="skeleton w40"></div></div>';
try {
const resp = await fetch('api/scan', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target }),
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const rows = await resp.json() || [];
out.innerHTML = rows.map(scanResult).join('');
if (rows.some((r) => r.entries > 0 || r.exposure === 'public_read')) reload(false);
} catch (err) {
out.innerHTML = `<div class="error">Scan failed: ${esc(err.message)}</div>`;
}
});
function setView(v) { state.view = v; syncURL(); reload(true); }
$('nav-assets').onclick = (e) => { e.preventDefault(); setView('assets'); };
$('nav-contents').onclick = (e) => { e.preventDefault(); setView('contents'); };
$('morebtn').onclick = async () => {
if (state.loading) return;
state.loading = true; $('morebtn').disabled = true;
try { if (state.view === 'assets') await loadAssets(false); else await loadEntries(false); render(); }
finally { state.loading = false; $('morebtn').disabled = false; }
};
let debounce;
$('q').addEventListener('input', () => {
state.q = $('q').value.trim();
$('clear').classList.toggle('show', state.q.length > 0);
syncURL();
clearTimeout(debounce);
debounce = setTimeout(() => reload(false), 250);
});
$('clear').onclick = () => { $('q').value = ''; state.q = ''; $('clear').classList.remove('show'); syncURL(); reload(true); $('q').focus(); };
document.querySelectorAll('#filters .chip').forEach((c) => c.onclick = () => {
state.exposure = c.dataset.exposure;
document.querySelectorAll('#filters .chip').forEach((x) => x.setAttribute('aria-pressed', String(x.dataset.exposure === state.exposure)));
syncURL(); reload(true);
});
$('sort').onchange = () => { state.sort = $('sort').value; syncURL(); reload(true); };
document.addEventListener('visibilitychange', () => { if (!document.hidden) reload(false); });
function schedule() {
clearInterval(state.timer);
state.timer = setInterval(() => {
if (document.hidden) return;
state.countdown--;
if (state.countdown <= 0) { state.countdown = REFRESH_MS / 1000; reload(false); }
$('livetext').textContent = (state.ok ? 'live' : 'offline') + ' · ' + state.countdown + 's';
}, 1000);
}
syncURL();
reload(true);
schedule();
</script>
</body>
</html>