feat: /players browser + identity review queue UI
Phase 4. A Players page that browses the whole villain file (named + nameless,
expandable to episodic recall — reads, notable hands, stats, descriptors) and, at
the top, the identity-resolution queue: possible-merges (same/different/dismiss)
and needs-clarification tasks. Rename a nameless villain, set category, or run a
dupe scan inline.
- poker.players_overview() for the list.
- routes: /players (page), /players/data, /player/{id}/data, POST /player/{id}
(rename/retag), POST /identity/{id}/resolve, POST /players/scan.
- nav: 👤 Players.
Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user