← back to Abrams
public/hub.html
111 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Abrams — Info Hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/panel.css" />
<style>
.container { max-width: 1280px; margin: 0 auto; padding: 110px 24px 60px; }
.source { background: var(--bg-2); border: 1px solid var(--hairline); padding: 18px 22px; margin-bottom: 10px; display: grid; grid-template-columns: 1fr 200px; gap: 20px; align-items: start; }
.source .nm { font-family: var(--serif); font-size: 19px; font-weight: 500; }
.source .nm a { color: var(--fg); }
.source .nm a:hover { color: var(--accent); }
.source .desc { color: var(--fg-2); font-size: 13px; line-height: 1.6; margin: 6px 0 8px; }
.source .url { font-family: var(--mono); font-size: 11px; color: var(--accent); word-break: break-all; }
.source .meta { font-family: var(--mono); font-size: 10px; letter-spacing: .15em; color: var(--fg-3); text-transform: uppercase; text-align: right; }
.source .meta .row { padding: 3px 0; }
.source .meta .mfa-yes { color: var(--warn); }
.source .meta .mfa-no { color: var(--ok); }
.cat-block { margin-bottom: 36px; }
.cat-block h2 { font-family: var(--serif); font-size: 26px; font-weight: 400; margin: 0 0 4px; }
.cat-block .sub { font-family: var(--mono); font-size: 10px; letter-spacing: .25em; color: var(--accent); text-transform: uppercase; margin: 0 0 16px; }
</style>
</head>
<body>
<div class="chrome">
<a class="logo" href="/">ABRAMS<span class="dot">.</span></a>
<div class="nav-burger" role="button" aria-label="open menu">
<span class="bars"><span></span><span></span><span></span></span>
<span class="label">MENU</span>
</div>
</div>
<div class="container">
<h1 style="font-family:var(--serif);font-size:48px;font-weight:400;margin:0 0 6px;">Info Hub</h1>
<p style="color:var(--fg-2);max-width:760px;margin:0 0 16px;">Authoritative external sources every independent operator should bookmark — legal IP, email compliance, founder bookkeeping, tooling APIs, self-audit. Curated by the search-specialist subagent, refreshed on demand.</p>
<div class="controls">
<span class="lbl">Filter</span>
<input id="filter" type="search" placeholder="search name + description + url" style="background:var(--bg-2);border:1px solid var(--hairline);color:var(--fg);padding:8px 12px;font-family:var(--mono);font-size:12px;min-width:300px;" />
<span class="lbl">Cat</span>
<select id="cat-filter"><option value="">all</option></select>
<span class="lbl">Mobile</span>
<select id="mobile-filter"><option value="">any</option><option value="3">★★★ only</option></select>
</div>
<div id="hub"></div>
<p id="meta" style="color:var(--fg-3);font-family:var(--mono);font-size:11px;letter-spacing:.15em;margin-top:24px;"></p>
</div>
<footer>© 2026 Steven Abrams</footer>
<script src="/static/js/corner-nav.js"></script>
<script src="/static/js/gamify.js"></script>
<script>
function esc(s){ return String(s||'').replace(/[&<>"']/g, m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
let allSources = [];
function render(){
const f = (document.getElementById('filter').value||'').toLowerCase();
const c = document.getElementById('cat-filter').value;
const m = document.getElementById('mobile-filter').value;
let sources = allSources.filter(s => {
const blob = (s.name+' '+s.description+' '+s.url+' '+(s.subcategory||'')).toLowerCase();
if (f && !blob.includes(f)) return false;
if (c && s.category !== c) return false;
if (m && (s.mobile||0) < parseInt(m, 10)) return false;
return true;
});
const byCat = {};
for (const s of sources) { (byCat[s.category] ||= []).push(s); }
const order = Object.keys(byCat).sort();
document.getElementById('hub').innerHTML = order.map(cat => `
<div class="cat-block">
<h2>${esc(cat)}</h2>
<p class="sub">${byCat[cat].length} source${byCat[cat].length===1?'':'s'}</p>
${byCat[cat].map(s => `
<div class="source">
<div>
<div class="nm"><a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${esc(s.name)}</a></div>
<div class="desc">${esc(s.description)}</div>
<div class="url">${esc(s.url)}</div>
</div>
<div class="meta">
<div class="row">${esc(s.subcategory||'')}</div>
<div class="row mfa-${s.mfa?'yes':'no'}">${s.mfa?'MFA REQUIRED':'NO LOGIN'}</div>
<div class="row">RET: ${esc(s.retention||'unknown')}</div>
<div class="row">MOBILE ${'★'.repeat(s.mobile||1)}${'☆'.repeat(3-(s.mobile||1))}</div>
</div>
</div>
`).join('')}
</div>
`).join('') || '<div style="color:var(--fg-3);padding:48px;text-align:center;font-family:var(--mono);">no sources match filters</div>';
document.getElementById('meta').textContent = `${sources.length} of ${allSources.length} sources shown`;
}
fetch('/api/info-hub').then(r=>r.json()).then(d => {
allSources = d.sources || [];
// populate category dropdown
const cats = [...new Set(allSources.map(s => s.category))].sort();
document.getElementById('cat-filter').innerHTML = '<option value="">all categories</option>' + cats.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join('');
render();
});
['filter','cat-filter','mobile-filter'].forEach(id => document.getElementById(id).addEventListener('input', render));
document.getElementById('cat-filter').addEventListener('change', render);
document.getElementById('mobile-filter').addEventListener('change', render);
</script>
</body>
</html>