@@ -0,0 +1,166 @@
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0, viewport-fit=cover" / >
< meta name = "theme-color" content = "#070707" / >
< title > Lyra — Players< / title >
< style >
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;--fade:#8a8a8a;--accent:#ff7a00;}
*{box-sizing:border-box;}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
padding:env(safe-area-inset-top) 14px 0;}
.topbar{display:flex;align-items:center;gap:10px;padding:13px 0;}
.topbar h1{font-size:1.05rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.count{margin-left:auto;color:var(--fade);font-size:.8rem;}
main{max-width:640px;margin:0 auto;padding:12px 12px 44px;}
h2.sec{font-size:.74rem;text-transform:uppercase;letter-spacing:.6px;color:var(--fade);margin:20px 2px 8px;}
.queue{background:#160d05;border:1px solid var(--accent);border-radius:10px;padding:11px 12px;margin-bottom:9px;}
.queue .k{font-size:.62rem;text-transform:uppercase;letter-spacing:.5px;color:var(--accent);}
.queue .q-body{font-size:.9rem;margin:5px 0 9px;}
.queue .who{font-weight:600;}
.btns{display:flex;flex-wrap:wrap;gap:7px;}
button{font:inherit;font-size:.82rem;padding:6px 11px;border-radius:7px;border:1px solid var(--border);
background:var(--bg-line);color:var(--text);cursor:pointer;}
button.pri{border-color:var(--accent);color:var(--accent);}
button:active{background:#241400;}
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;}
.card .row{display:flex;align-items:center;gap:9px;cursor:pointer;}
.nm{font-size:.96rem;font-weight:600;}
.nm.desc{font-weight:500;font-style:italic;color:#e8d3bf;}
.meta{font-size:.74rem;color:var(--fade);}
.pill{font-size:.6rem;text-transform:uppercase;letter-spacing:.4px;border:1px solid var(--border);
border-radius:20px;padding:1px 7px;color:var(--fade);}
.pill.desc{border-color:#5a3c1e;color:#d0a56e;}
.spacer{margin-left:auto;}
.detail{margin-top:9px;padding-top:9px;border-top:1px solid var(--bg-line);font-size:.86rem;display:none;}
.detail.open{display:block;}
.detail .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.4px;margin:8px 0 3px;}
.detail ul{margin:3px 0;padding-left:18px;} .detail li{margin:2px 0;}
.detail a{color:var(--accent);text-decoration:none;}
.edit{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px;}
.edit input,.edit select{font:inherit;font-size:.82rem;padding:5px 8px;border-radius:6px;
border:1px solid var(--border);background:var(--bg);color:var(--text);}
.empty{color:var(--fade);text-align:center;padding:34px 16px;}
< / style >
< / head >
< body >
< header >
< div class = "topbar" >
< h1 > 👤 Players< / h1 >
< a class = "back" href = "/" > ← Chat< / a >
< span class = "count" id = "count" > < / span >
< / div >
< / header >
< main id = "root" > < p class = "empty" > Loading…< / p > < / main >
< script >
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
let DATA={players:[],queue:[]};
async function load(){
try{ DATA=await (await fetch('/players/data',{cache:'no-store'})).json(); }
catch(e){ document.getElementById('root').innerHTML='< p class = "empty" > Couldn\'t load players.< / p > '; return; }
render();
}
function render(){
const {players,queue}=DATA;
const named=players.filter(p=>p.named), nameless=players.filter(p=>!p.named);
document.getElementById('count').textContent=`${players.length} player${players.length===1?'':'s'}`;
let html='';
if(queue.length){
html+=`< h2 class = "sec" > ⚠ Needs your call — ${queue.length}< / h2 > `;
html+=queue.map(qCard).join('');
}
html+=`< h2 class = "sec" > Named — ${named.length} < button class = "pri" style = "float:right;padding:3px 9px" onclick = "scan()" > Scan for dupes< / button > < / h2 > `;
html+= named.length ? named.map(pCard).join('') : '< p class = "empty" > No named players yet.< / p > ';
html+=`< h2 class = "sec" > By description — ${nameless.length}< / h2 > `;
html+= nameless.length ? nameless.map(pCard).join('') : '< p class = "empty" > No nameless villains yet — they show up here as you describe players at the table.< / p > ';
document.getElementById('root').innerHTML=html;
}
function qCard(q){
const ps=q.players||[];
if(q.kind==='merge_candidate' & & ps.length===2){
const a=ps[0], b=ps[1];
return `< div class = "queue" > < div class = "k" > Possible merge${q.confidence?` · ${Math.round(q.confidence*100)}%`:''}< / div >
< div class = "q-body" > Same person? < span class = "who" > ${label(a)}< / span > vs < span class = "who" > ${label(b)}< / span > < / div >
< div class = "btns" >
< button class = "pri" onclick = "resolveTask(${q.id},'merge',{keep_id:${keepId(a,b)},dup_id:${dupId(a,b)}})" > ✓ Same — merge< / button >
< button onclick = "resolveTask(${q.id},'distinct',{a_id:${a.id},b_id:${b.id}})" > ✕ Different< / button >
< button onclick = "resolveTask(${q.id},'dismiss',{})" > Dismiss< / button >
< / div > < / div > `;
}
const who=ps[0]?label(ps[0]):'?';
return `< div class = "queue" > < div class = "k" > Needs clarification< / div >
< div class = "q-body" > You referred to < span class = "who" > “${esc(q.descriptor||'')}”< / span > ${ps[0]?` — is that ${who}?`:''}< / div >
< div class = "btns" > < button onclick = "resolveTask(${q.id},'dismiss',{})" > Got it< / button > < / div > < / div > `;
}
const label=p=>`${esc(p.name)}${p.named?'':' < span class = "pill desc" > desc< / span > '}${p.venue?` · ${esc(p.venue)}`:''}${p.obs?` · ${p.obs}h`:''}`;
const keepId=(a,b)=>a.named?a.id:(b.named?b.id:a.id);
const dupId=(a,b)=>a.named?b.id:(b.named?a.id:b.id);
function pCard(p){
const pills=[p.named?'':'< span class = "pill desc" > desc< / span > ',p.category?`< span class = "pill" > ${esc(p.category)}< / span > `:''].join('');
const meta=[p.venue,p.obs?`${p.obs} hands`:'',p.reads?`${p.reads} reads`:''].filter(Boolean).join(' · ');
return `< div class = "card" id = "p${p.id}" >
< div class = "row" onclick = "toggle(${p.id})" >
< span class = "nm ${p.named?'':'desc'}" > ${p.named?esc(p.name):'“'+esc(p.name)+'”'}< / span >
${pills}< span class = "spacer" > < / span > < span class = "meta" > ${esc(meta)}< / span >
< / div >
< div class = "detail" id = "d${p.id}" > < / div > < / div > `;
}
async function toggle(id){
const el=document.getElementById('d'+id);
if(el.classList.contains('open')){el.classList.remove('open');return;}
el.classList.add('open'); el.innerHTML='< span class = "meta" > Loading…< / span > ';
const r=await (await fetch(`/player/${id}/data`,{cache:'no-store'})).json();
el.innerHTML=detailHtml(id,r);
}
function detailHtml(id,r){
const p=r.player||{}; let h='';
const seen=[r.times_seen?`seen ${r.times_seen}× `:'', r.last_seen?`last ${String(r.last_seen).slice(0,10)}`:''].filter(Boolean).join(' · ');
if(seen) h+=`< div class = "meta" > ${esc(seen)}< / div > `;
if(r.stats) h+=`< div class = "lbl" > Stats< / div > < div > VPIP ${r.stats.vpip_pct} · PFR ${r.stats.pfr_pct} · WTSD ${r.stats.wtsd_pct} < span class = "meta" > (${r.stats.hands} hands)< / span > < / div > `;
if(r.descriptors) h+=`< div class = "lbl" > Descriptors< / div > < div > ${esc(r.descriptors)}< / div > `;
if(p.tendencies) h+=`< div class = "lbl" > Tendencies< / div > < div > ${esc(p.tendencies)}< / div > `;
if(p.adjustment) h+=`< div class = "lbl" > Exploit< / div > < div > ${esc(p.adjustment)}< / div > `;
if((r.reads||[]).length){h+='< div class = "lbl" > Reads< / div > < ul > '+r.reads.slice(0,8).map(x=>`< li > ${esc(x)}< / li > `).join('')+'< / ul > ';}
if((r.notable_hands||[]).length){h+='< div class = "lbl" > Notable hands< / div > < ul > '+r.notable_hands.map(x=>`< li > < a href = "/hand/${x.hand_id}" > hand #${x.hand_id}< / a > ${x.cards?' — '+esc(x.cards):''}${x.summary?' < span class = "meta" > '+esc(x.summary)+'< / span > ':''}< / li > `).join('')+'< / ul > ';}
h+=`< div class = "edit" >
${p.named?'':`< input id = "nm${id}" placeholder = "give a name…" size = "12" > < button onclick = "rename(${id})" > Name< / button > `}
< select id = "cat${id}" onchange = "setCat(${id})" >
${['','feeder','risky','reg','unknown'].map(c=>`< option value = "${c}" $ { p . category = ==c?'selected':''} > ${c||'category…'}< / option > `).join('')}
< / select > < / div > `;
return h;
}
async function rename(id){
const v=document.getElementById('nm'+id).value.trim(); if(!v)return;
await fetch(`/player/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:v})});
load();
}
async function setCat(id){
const v=document.getElementById('cat'+id).value;
await fetch(`/player/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({category:v})});
}
async function resolveTask(id,action,kw){
await fetch(`/identity/${id}/resolve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...kw})});
load();
}
async function scan(){
const r=await (await fetch('/players/scan',{method:'POST'})).json();
load();
}
load();
< / script >
< script src = "/nav.js" > < / script >
< / body >
< / html >