← back to Approvals Viewer

public/index.html

192 lines

<!doctype html><html lang=en><head>
<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>Approvals — yes / no</title>
<style>
:root{--bg:#0e0f13;--card:#181a20;--ink:#e8e8ea;--mut:#8a8f9a;--line:#2a2d36;--ok:#1db954;--no:#e5484d;--sk:#5b6472;--gold:#f5c842}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
header{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--line);padding:10px 16px;display:flex;align-items:center;gap:10px;z-index:5;flex-wrap:wrap}
.bar{flex:1;min-width:80px;height:8px;background:#23262f;border-radius:6px;overflow:hidden}.bar>i{display:block;height:100%;background:linear-gradient(90deg,#1db954,#3ea6ff);width:0;transition:width .25s}
.count{font-variant-numeric:tabular-nums;color:var(--mut);white-space:nowrap;font-size:13px}
.tally{font-size:13px;color:var(--mut)}.tally b{color:var(--ink)}
select.sort-sel{background:#23262f;border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:5px 10px;font-size:13px;cursor:pointer}
main{max-width:860px;margin:0 auto;padding:18px 16px 130px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:22px 24px;box-shadow:0 8px 30px rgba(0,0,0,.35)}
.chip{display:inline-block;font-size:12px;padding:3px 10px;border:1px solid var(--line);border-radius:999px;color:var(--mut);margin-right:8px}
.when{font-size:12px;color:var(--mut)}
h1.title{font-size:21px;margin:14px 0 6px;line-height:1.3}
.file{font-size:12px;color:#6b7280;word-break:break-all;margin-bottom:10px}
/* star rating */
.stars{display:flex;align-items:center;gap:4px;margin-bottom:12px}
.stars .star{font-size:22px;cursor:pointer;color:#444;transition:color .1s;user-select:none}
.stars .star.on{color:var(--gold)}
.stars .star:hover{color:var(--gold)}
.rating-note{font-size:12px;color:var(--mut);margin-left:6px;font-style:italic}
.rating-badge{display:inline-flex;align-items:center;gap:3px;font-size:12px;padding:3px 8px;border-radius:999px;background:#23262f;border:1px solid var(--line);color:var(--gold);margin-left:6px;font-weight:600}
.body{border-top:1px solid var(--line);padding-top:14px;max-height:52vh;overflow:auto}
.body h1,.body h2,.body h3{font-size:16px;margin:16px 0 6px;color:#fff}.body h1{font-size:18px}
.body code{background:#23262f;padding:1px 5px;border-radius:5px;font-size:13px}
.body pre{background:#101217;border:1px solid var(--line);padding:12px;border-radius:8px;overflow:auto;font-size:12.5px}
.body table{border-collapse:collapse;margin:8px 0;font-size:13px}.body td,.body th{border:1px solid var(--line);padding:5px 9px}
.body a{color:#3ea6ff}.body ul{padding-left:22px}.body blockquote{border-left:3px solid var(--sk);margin:8px 0;padding-left:12px;color:var(--mut)}
.actions{position:fixed;left:0;right:0;bottom:0;background:linear-gradient(180deg,transparent,var(--bg) 22%);padding:16px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
button{border:0;border-radius:12px;padding:14px 22px;font-size:16px;font-weight:600;cursor:pointer;color:#fff;min-width:150px}
.b-no{background:var(--no)}.b-sk{background:var(--sk)}.b-ok{background:var(--ok)}.b-undo{background:#333;min-width:auto;padding:14px 16px;font-size:14px}
.hint{color:var(--mut);font-size:12px;text-align:center;margin-top:8px}
.done{text-align:center;padding:60px 20px}.done h2{font-size:26px}
.kbd{background:#23262f;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:12px}
/* Audit All — read-only full-queue overlay */
.b-audit{background:#23262f;border:1px solid var(--line);color:var(--ink);min-width:auto;padding:5px 10px;font-size:13px;font-weight:500;border-radius:8px}
.audit-ov{position:fixed;inset:0;background:var(--bg);z-index:20;display:none;overflow:auto}
.audit-head{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--line);padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:10px;z-index:1}
.audit-list{max-width:860px;margin:0 auto;padding:18px 16px 80px;display:flex;flex-direction:column;gap:14px}
.audit-card .body{max-height:40vh}
.audit-card summary{cursor:pointer;color:#3ea6ff;font-size:13px;margin-top:8px;list-style:none}
</style>  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head><body>
<header>
 <strong>Approvals</strong>
 <div class=bar><i id=bar></i></div>
 <span class=count id=count>—</span>
 <span class=tally id=tally></span>
 <select class=sort-sel id=sortSel title="Sort order">
   <option value=mtime>⏱ Newest</option>
   <option value=rating>⭐ Highest Rated</option>
   <option value=category>🗂 By Category</option>
   <option value=security>🔴 Security First</option>
 </select>
 <button class=b-audit onclick=openAudit() title="Audit the full queue at once (read-only)">🔍 Audit All</button>
</header>
<main id=main><div class=card><div class=body>Loading…</div></div></main>
<div class=actions id=actions style=display:flex>
 <button class=b-undo id=undo title="Undo last (U)">↶ Undo</button>
 <button class=b-no  onclick="decide('reject')">❌ Reject <span class=kbd>←</span></button>
 <button class=b-sk  onclick="decide('defer')">⏭ Defer <span class=kbd>↓</span></button>
 <button class=b-ok  onclick="decide('approve')">✅ Approve <span class=kbd>→</span></button>
</div>
<script>
let memos=[],i=0,total=0,tally={approve:0,reject:0,defer:0},lastAction=null,currentSort='mtime',auditOpen=false;
function md(s){
 s=s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
 s=s.replace(/```([\s\S]*?)```/g,(m,c)=>'<pre>'+c+'</pre>');
 s=s.replace(/(^\|.*\|$\n?)+/gm,blk=>{const rows=blk.trim().split('\n').filter(r=>!/^\|[\s:\-|]+\|$/.test(r));
   return '<table>'+rows.map((r,ri)=>{const cells=r.split('|').slice(1,-1);const tag=ri===0?'th':'td';
     return '<tr>'+cells.map(c=>'<'+tag+'>'+c.trim()+'</'+tag+'>').join('')+'</tr>';}).join('')+'</table>';});
 s=s.replace(/^### (.*)$/gm,'<h3>$1</h3>').replace(/^## (.*)$/gm,'<h2>$1</h2>').replace(/^# (.*)$/gm,'<h1>$1</h1>');
 s=s.replace(/^\s*[-*] (.*)$/gm,'<li>$1</li>').replace(/(<li>[\s\S]*?<\/li>)/g,'<ul>$1</ul>');
 s=s.replace(/^> (.*)$/gm,'<blockquote>$1</blockquote>');
 s=s.replace(/\*\*([^*]+)\*\*/g,'<b>$1</b>').replace(/`([^`]+)`/g,'<code>$1</code>');
 s=s.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g,'<a href="$2" target=_blank>$1</a>');
 s=s.replace(/\n{2,}/g,'<br><br>').replace(/\n/g,'<br>');
 return s;}
function fmtDate(iso){return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}

// Audit All — render the WHOLE loaded queue at once, read-only (no decision writes).
// memos[] is already fetched client-side, so this is a pure in-page view; toggling.
function openAudit(){
 if(auditOpen)return closeAudit();
 auditOpen=true;
 let ov=document.getElementById('auditOv');
 if(!ov){ov=document.createElement('div');ov.id='auditOv';ov.className='audit-ov';document.body.appendChild(ov);}
 const rows=memos.map(x=>{
   const rb=x.rating>0?'<span class=rating-badge>'+x.rating+'★</span>':'';
   return '<div class="card audit-card"><div><span class=chip>'+x.category.label+'</span><span class=when>🕓 '+fmtDate(x.mtime)+'</span>'+rb+'</div>'
     +'<h1 class=title>'+x.title.replace(/</g,'&lt;')+'</h1><div class=file>'+x.file+'</div>'
     +'<details><summary>▸ Show memo</summary><div class=body>'+md(x.body)+'</div></details></div>';
 }).join('');
 ov.innerHTML='<div class=audit-head><strong>🔍 Audit — full queue ('+memos.length+') · read-only</strong>'
   +'<button class=b-audit onclick=closeAudit() title="Close (Esc)">✕ Close</button></div>'
   +'<div class=audit-list>'+(memos.length?rows:'<p style="color:var(--mut);padding:24px">Queue is empty — nothing to audit.</p>')+'</div>';
 ov.scrollTop=0;ov.style.display='block';
}
function closeAudit(){auditOpen=false;const ov=document.getElementById('auditOv');if(ov)ov.style.display='none';}

function starsHtml(file,currentRating){
 let h='<div class=stars>';
 for(let s=1;s<=5;s++){
   h+='<span class="star'+(s<=currentRating?' on':'')+'" data-file="'+file+'" data-star="'+s+'" title="Rate '+s+' star'+(s>1?'s':'')+'" onclick="rateMemo(this)">★</span>';
 }
 if(currentRating>0) h+='<span class=rating-note>'+currentRating+'★ priority</span>';
 h+='</div>';
 return h;
}

async function rateMemo(el){
 const file=el.dataset.file,star=parseInt(el.dataset.star);
 const x=memos.find(m=>m.file===file);if(!x)return;
 const newRating=(x.rating===star)?0:star; // toggle off if same star clicked
 x.rating=newRating;
 try{await fetch('/api/rate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file,rating:newRating,note:''})});}catch(e){}
 // re-render stars in place
 const starsEl=document.querySelector('.stars');
 if(starsEl) starsEl.outerHTML=starsHtml(file,newRating);
}

function render(){
 const m=document.getElementById('main');
 document.getElementById('bar').style.width=(total?Math.round((i)/total*100):0)+'%';
 document.getElementById('count').textContent=(i)+' / '+total+' done';
 document.getElementById('tally').innerHTML='✅<b>'+tally.approve+'</b> ❌<b>'+tally.reject+'</b> ⏭<b>'+tally.defer+'</b>';
 document.getElementById('undo').style.visibility=lastAction?'visible':'hidden';
 if(i>=memos.length){document.getElementById('actions').style.display='none';
   m.innerHTML='<div class=done><h2>🎉 All caught up</h2><p>'+tally.approve+' approved · '+tally.reject+' rejected · '+tally.defer+' deferred.</p><p style=color:#8a8f9a>Approved memos moved to <code>_approved/</code> · rejected to <code>_rejected/</code>. Fully reversible; nothing was executed.</p></div>';return;}
 document.getElementById('actions').style.display='flex';
 const x=memos[i];
 const ratingBadge=x.rating>0?'<span class=rating-badge>'+x.rating+'★</span>':'';
 m.innerHTML='<div class=card><div><span class=chip>'+x.category.label+'</span><span class=when>🕓 '+fmtDate(x.mtime)+'</span>'+ratingBadge+'</div>'
  +'<h1 class=title>'+x.title.replace(/</g,'&lt;')+'</h1><div class=file>'+x.file+'</div>'
  +starsHtml(x.file,x.rating)
  +'<div class=body>'+md(x.body)+'</div></div>';
 m.querySelector('.body').scrollTop=0;
}
async function decide(decision){
 const x=memos[i];if(!x)return;tally[decision]++;lastAction={file:x.file,decision,idx:i};
 if(decision!=='defer'){try{await fetch('/api/decide',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:x.file,decision})});}catch(e){}}
 i++;render();
}
async function undo(){
 if(!lastAction)return;const a=lastAction;
 if(a.decision!=='defer'){try{await fetch('/api/undo',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file:a.file,decision:a.decision})});}catch(e){}}
 tally[a.decision]--;i=a.idx;lastAction=null;render();
}
document.getElementById('undo').onclick=undo;
document.getElementById('sortSel').onchange=function(){currentSort=this.value;load();};
addEventListener('keydown',e=>{
 if(auditOpen){if(e.key==='Escape')closeAudit();return;}
 if(i>=memos.length)return;
 if(e.key==='ArrowRight'||e.key==='y')decide('approve');
 else if(e.key==='ArrowLeft'||e.key==='n')decide('reject');
 else if(e.key==='ArrowDown'||e.key===' '||e.key==='s'){e.preventDefault();decide('defer');}
 else if(e.key==='u')undo();
 else if(e.key>='1'&&e.key<='5'){const x=memos[i];if(x){const star=document.querySelector('.star[data-star="'+e.key+'"]');if(star)rateMemo(star);}}
});
function load(){fetch('/api/memos?sort='+currentSort).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json();})
 .then(d=>{memos=d;total=d.length;i=0;tally={approve:0,reject:0,defer:0};render();})
 .catch(e=>{document.getElementById('actions').style.display='none';
   document.getElementById('main').innerHTML='<div class=done><h2>⚠️ Could not load memos</h2><p style=color:#8a8f9a>'+e.message+'</p><p><button onclick="load()">↻ Retry</button></p></div>';});}
load();
</script>  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body></html>