← back to Gated Queue Viewer
public/index.html
171 lines
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<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 name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Gated Approval Queue</title>
<style>
:root{
--bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a3441; --ink:#e6edf3; --dim:#9aa7b4;
--accent:#4493f8; --red:#e5534b; --amber:#e0a44b; --purple:#a371f7; --green:#3fb950;
--cols:3;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
header{padding:18px 24px 10px;border-bottom:1px solid var(--line);position:sticky;top:env(safe-area-inset-top,0);background:var(--bg);z-index:5}
h1{margin:0 0 2px;font-size:19px}
.sub{color:var(--dim);font-size:12.5px}
.sub code{background:var(--panel2);padding:1px 6px;border-radius:5px;color:var(--accent)}
.controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:10px 24px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:calc(env(safe-area-inset-top,0) + 56px);z-index:4}
.controls label{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);margin-right:6px}
select,input[type=range],input[type=search]{background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:13px}
input[type=range]{padding:0;width:130px}
input[type=search]{width:200px}
.stat{margin-left:auto;font-size:12.5px;color:var(--dim)}
.stat b{color:var(--ink)}
main{padding:18px 24px;display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px}
@media(max-width:900px){main{grid-template-columns:1fr}.controls{position:static}}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:8px;cursor:pointer;transition:border-color .12s}
.card:hover{border-color:var(--accent)}
.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.title{font-size:14px;font-weight:650;line-height:1.35}
.badge{font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;padding:3px 7px;border-radius:20px;white-space:nowrap}
.b-credential{background:rgba(163,113,247,.16);color:#c9a4ff}
.b-gmc-google{background:rgba(224,164,75,.16);color:#f0b866}
.b-shopify{background:rgba(63,185,80,.16);color:#6ee787}
.b-dns-domain{background:rgba(68,147,248,.16);color:#79b8ff}
.b-deploy-prod{background:rgba(229,83,75,.16);color:#ff8b83}
.b-send-to-list{background:rgba(240,136,62,.18);color:#ffab5e}
.b-scheduled-job{background:rgba(110,231,183,.14);color:#7de8c8}
.b-other{background:rgba(154,167,180,.14);color:var(--dim)}
.when{font-size:10.5px;color:var(--dim);margin-left:auto;white-space:nowrap}
.excerpt{font-size:12px;color:var(--dim);overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
.ticket{font-size:11px;color:var(--accent);font-family:ui-monospace,monospace}
.fname{font-size:10px;color:#5a6b7a;font-family:ui-monospace,monospace;word-break:break-all}
/* modal */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:20;padding:20px}
.modal-bg.open{display:flex}
.modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;max-width:900px;width:100%;max-height:85vh;display:flex;flex-direction:column}
.modal-head{padding:16px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px}
.modal-head .fname{font-size:11px}
.modal-body{padding:20px;overflow-y:auto;white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;line-height:1.6}
.close-btn{margin-left:auto;background:var(--panel2);border:1px solid var(--line);color:var(--ink);border-radius:7px;padding:6px 12px;cursor:pointer;font-size:13px}
.close-btn:hover{border-color:var(--accent)}
</style>
</head>
<body>
<header>
<h1>Gated Approval Queue <span style="color:var(--dim);font-weight:400">— read-only</span></h1>
<div class="sub">Every item drafted to <code>~/.claude/yolo-queue/pending-approval/</code> awaiting your APPROVE / REVISE / BLOCK · click a card to read the full memo</div>
</header>
<div class="controls">
<div><label>Sort</label>
<select id="sort">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="ticket">Ticket #</option>
<option value="category">Category</option>
<option value="title">Title A→Z</option>
</select></div>
<div><label>Category</label>
<select id="cat"><option value="">All</option></select></div>
<div><label>Search</label><input type="search" id="q" placeholder="ticket, title, text…"></div>
<div><label>Density</label><input type="range" id="dens" min="1" max="4" value="3"></div>
<div class="stat" id="stat"></div>
</div>
<main id="grid"></main>
<div class="modal-bg" id="modalBg">
<div class="modal">
<div class="modal-head">
<div>
<div class="title" id="modalTitle" style="font-size:15px"></div>
<div class="fname" id="modalFname"></div>
</div>
<button class="close-btn" id="closeBtn">Close ✕</button>
</div>
<div class="modal-body" id="modalBody">Loading…</div>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
let ITEMS = [];
const CATLABEL = { credential:'Credential', 'gmc-google':'GMC/Google', shopify:'Shopify', 'dns-domain':'DNS/Domain', 'deploy-prod':'Deploy/Prod', 'send-to-list':'Send-to-list', 'scheduled-job':'Scheduled job', other:'Other' };
const fmtDate = iso => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch(e){ return iso; } };
function populateCatFilter(){
const cats = [...new Set(ITEMS.map(i=>i.category))].sort();
const sel = $('#cat');
for(const c of cats){ const o=document.createElement('option'); o.value=c; o.textContent=CATLABEL[c]||c; sel.appendChild(o); }
}
function render(){
const sort = $('#sort').value, cat = $('#cat').value, q = $('#q').value.toLowerCase();
let items = ITEMS.filter(i => (!cat || i.category===cat) && (!q || (i.title+' '+i.ticket+' '+i.excerpt+' '+i.filename).toLowerCase().includes(q)));
items = [...items].sort((a,b)=>{
if(sort==='newest') return new Date(b.created_at)-new Date(a.created_at);
if(sort==='oldest') return new Date(a.created_at)-new Date(b.created_at);
if(sort==='ticket') return (a.ticket||'zz').localeCompare(b.ticket||'zz');
if(sort==='category') return a.category.localeCompare(b.category) || a.title.localeCompare(b.title);
return a.title.localeCompare(b.title);
});
$('#stat').innerHTML = `<b>${items.length}</b> shown of <b>${ITEMS.length}</b> total`;
$('#grid').innerHTML = items.map(i=>`
<div class="card" data-id="${i.id}">
<div class="row">
<span class="badge b-${i.category}">${CATLABEL[i.category]||i.category}</span>
<span class="when" title="${i.created_at}">🕓 ${fmtDate(i.created_at)}</span>
</div>
<div class="title">${i.title}</div>
${i.ticket?`<div class="ticket">${i.ticket}</div>`:''}
<div class="excerpt">${i.excerpt}</div>
<div class="fname">${i.filename}</div>
</div>`).join('') || '<p style="color:var(--dim)">No matches.</p>';
document.querySelectorAll('.card').forEach(el => el.onclick = () => openModal(el.dataset.id));
}
function openModal(id){
const item = ITEMS.find(i=>i.id===id);
$('#modalTitle').textContent = item ? item.title : id;
$('#modalFname').textContent = item ? item.filename : '';
$('#modalBody').textContent = 'Loading…';
$('#modalBg').classList.add('open');
fetch('/api/queue/'+id).then(r=>r.json()).then(d=>{ $('#modalBody').textContent = d.content || d.error || '(empty)'; });
}
$('#closeBtn').onclick = () => $('#modalBg').classList.remove('open');
$('#modalBg').onclick = e => { if(e.target.id==='modalBg') $('#modalBg').classList.remove('open'); };
$('#sort').onchange = render; $('#cat').onchange = render; $('#q').oninput = render;
$('#dens').oninput = e=>{ document.documentElement.style.setProperty('--cols', e.target.value); try{localStorage.setItem('gq_dens',e.target.value);}catch(e){} };
try{ const d=localStorage.getItem('gq_dens'); if(d){$('#dens').value=d;document.documentElement.style.setProperty('--cols',d);} }catch(e){}
fetch('/api/queue').then(r=>r.json()).then(d=>{ ITEMS=d.items||[]; populateCatFilter(); render(); }).catch(e=>{ $('#grid').innerHTML='<p style="color:var(--red)">Failed to load: '+e+'</p>'; });
</script>
</body>
</html>