← back to Re Flyers
public/index.html
150 lines
<!doctype html>
<html lang="en">
<head>
<script src="/drill.js"></script>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RE Flyers — Unified Index (TK-10708)</title>
<style>
:root{--blue:#16357A;--navy:#0E2350;--red:#D0202E;--gold:#E8A81C;--ink:#141414;
--gray:#6B7280;--panel:#F4F6FB;--cols:4;}
*{box-sizing:border-box}
body{margin:0;font-family:Inter,Archivo,Helvetica,Arial,sans-serif;color:var(--ink);background:#eef1f6}
header{background:var(--navy);color:#fff;padding:18px 22px}
header h1{margin:0;font-size:20px;letter-spacing:.3px}
header .sub{opacity:.8;font-size:13px;margin-top:4px}
.controls{display:flex;gap:18px;align-items:center;flex-wrap:wrap;
background:#fff;padding:12px 22px;border-bottom:1px solid #dfe3ec;position:sticky;top:0;z-index:5}
.controls label{font-size:12px;color:var(--gray);text-transform:uppercase;letter-spacing:.4px;margin-right:6px}
select,input[type=range],input[type=search]{font:inherit}
select{padding:6px 8px;border:1px solid #cdd4e0;border-radius:6px;background:#fff}
input[type=search]{padding:6px 10px;border:1px solid #cdd4e0;border-radius:6px;min-width:200px}
.count{margin-left:auto;color:var(--gray);font-size:13px}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:22px}
.card{background:#fff;border:1px solid #e2e6ee;border-radius:10px;padding:14px;
display:flex;flex-direction:column;gap:8px;min-height:120px}
.card .type{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--blue);font-weight:700}
.card .title{font-weight:600;font-size:14px;line-height:1.3}
.card .meta{font-size:12px;color:var(--gray);margin-top:auto;display:flex;flex-wrap:wrap;gap:6px}
.chip{background:var(--panel);border:1px solid #dfe3ec;border-radius:999px;padding:2px 8px;font-size:11px}
.chip.t1{color:#0a7a2f}.chip.t2{color:#8a5a00}.chip.t3{color:#a11}
.card a{color:var(--blue);font-size:12px;text-decoration:none;word-break:break-all}
.card a:hover{text-decoration:underline}
.gated{opacity:.55;border-style:dashed}
.gated .title::after{content:' 🔒 GATED';color:var(--red);font-size:11px}
</style>
</head>
<body>
<header>
<h1>RE Flyers — Unified Cross-Source Index</h1>
<div class="sub">TK-10708 · normalized aggregate of broker OMs · usre deal assets · RENTV spotlight recaps · read-only</div>
</header>
<input type="hidden" id="pattern" value="">
<div class="controls">
<div><label>Sort</label>
<select id="sort">
<option value="newest">Newest</option>
<option value="source">Source build</option>
<option value="type">Asset type</option>
<option value="tier">Tier</option>
<option value="title">Title A→Z</option>
</select>
</div>
<div><label>Source</label>
<select id="src"><option value="">All</option></select>
</div>
<div><label>Density</label>
<input type="range" id="density" min="2" max="7" value="4">
</div>
<input type="search" id="q" placeholder="Search title / firm…">
<div class="count" id="count"></div>
</div>
<div class="grid" id="grid"></div>
<script>
// href-drill primitives (TK-10903) — FIELDS/FIL must be var globals so drill.js can see them
var FIELDS=[['source_build','Source'],['asset_type','Type'],['tier','Tier']];
var FIL={source_build:'',asset_type:'',tier:''};
function applyFilters(push){if(push)writeURL(true);render();}
const LS = 're-flyers-controls';
const state = Object.assign({sort:'newest',src:'',density:4,q:''}, JSON.parse(localStorage.getItem(LS)||'{}'));
let ALL = [];
const $ = s => document.querySelector(s);
$('#sort').value=state.sort; $('#density').value=state.density; $('#q').value=state.q;
document.documentElement.style.setProperty('--cols',state.density);
function save(){ localStorage.setItem(LS, JSON.stringify(state)); }
function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
function render(){
let rows = ALL.slice();
if(state.src) rows = rows.filter(r=>r.source_build===state.src);
if(state.q){ const q=state.q.toLowerCase(); rows=rows.filter(r=>((r.title||'')+' '+(r.firm||'')).toLowerCase().includes(q)); }
const cmp={
newest:(a,b)=>(b.generated_at||'').localeCompare(a.generated_at||''),
source:(a,b)=>(a.source_build||'').localeCompare(b.source_build||''),
type:(a,b)=>(a.asset_type||'').localeCompare(b.asset_type||''),
tier:(a,b)=>(a.tier||9)-(b.tier||9),
title:(a,b)=>(a.title||'').localeCompare(b.title||''),
}[state.sort];
rows.sort(cmp);
$('#count').textContent = rows.length+' / '+ALL.length+' flyers';
$('#grid').innerHTML = rows.map(r=>{
const gated=/GATED/i.test(r.rights_basis||'');
const when=r.generated_at?new Date(r.generated_at).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'—';
return `<div class="card${gated?' gated':''}">
<div class="type">${drill('asset_type',r.asset_type)}</div>
<div class="title">${esc(r.title)}</div>
<div class="meta">
${drill('source_build',r.source_build,`<span class="chip">${esc(r.source_build)}</span>`,'')}
<span class="chip t${r.tier||''}">${drill('tier',r.tier,'tier '+esc(r.tier),'')}</span>
${r.firm?`<span class="chip">${esc(r.firm)}</span>`:''}
<span class="chip">${when}</span>
</div>
${r.flyer_url&&!gated?`<a href="${esc(r.flyer_url)}" target="_blank" rel="noopener noreferrer">${esc(r.flyer_url)}</a>`:''}
</div>`;
}).join('') || '<p style="padding:22px;color:var(--gray)">No flyers match.</p>';
}
$('#sort').onchange=e=>{state.sort=e.target.value;save();render();};
$('#src').onchange=e=>{state.src=e.target.value;save();render();};
$('#density').oninput=e=>{state.density=+e.target.value;document.documentElement.style.setProperty('--cols',state.density);save();render();};
$('#q').oninput=e=>{state.q=e.target.value;save();render();};
readURL();
fetch('/api/flyers').then(r=>r.json()).then(d=>{
ALL=d.flyers||[];
const srcs=[...new Set(ALL.map(r=>r.source_build))].sort();
$('#src').innerHTML='<option value="">All</option>'+srcs.map(s=>`<option${s===state.src?' selected':''}>${esc(s)}</option>`).join('');
render();
}).catch(e=>{$('#grid').innerHTML='<p style="padding:22px;color:#a11">Failed to load /api/flyers: '+esc(e.message)+'</p>';});
</script>
</body>
</html>