@@ -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 : 1 px solid var ( - - border ) ;
padding : env ( safe - area - inset - top ) 14 px 0 ; }
. topbar { display : flex ; align-items : center ; gap : 10 px ; padding : 13 px 0 ; }
. topbar h1 { font-size : 1.05 rem ; margin : 0 ; font-weight : 600 ; }
. topbar a . back { color : var ( - - accent ) ; text-decoration : none ; font-size : .92 rem ; }
. count { margin-left : auto ; color : var ( - - fade ) ; font-size : .8 rem ; }
main { max-width : 640 px ; margin : 0 auto ; padding : 12 px 12 px 44 px ; }
h2 . sec { font-size : .74 rem ; text-transform : uppercase ; letter-spacing : .6 px ; color : var ( - - fade ) ; margin : 20 px 2 px 8 px ; }
. queue { background : #160d05 ; border : 1 px solid var ( - - accent ) ; border-radius : 10 px ; padding : 11 px 12 px ; margin-bottom : 9 px ; }
. queue . k { font-size : .62 rem ; text-transform : uppercase ; letter-spacing : .5 px ; color : var ( - - accent ) ; }
. queue . q-body { font-size : .9 rem ; margin : 5 px 0 9 px ; }
. queue . who { font-weight : 600 ; }
. btns { display : flex ; flex-wrap : wrap ; gap : 7 px ; }
button { font : inherit ; font-size : .82 rem ; padding : 6 px 11 px ; border-radius : 7 px ; border : 1 px 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 : 1 px solid var ( - - border ) ; border-radius : 10 px ; padding : 10 px 12 px ; margin-bottom : 8 px ; }
. card . row { display : flex ; align-items : center ; gap : 9 px ; cursor : pointer ; }
. nm { font-size : .96 rem ; font-weight : 600 ; }
. nm . desc { font-weight : 500 ; font-style : italic ; color : #e8d3bf ; }
. meta { font-size : .74 rem ; color : var ( - - fade ) ; }
. pill { font-size : .6 rem ; text-transform : uppercase ; letter-spacing : .4 px ; border : 1 px solid var ( - - border ) ;
border-radius : 20 px ; padding : 1 px 7 px ; color : var ( - - fade ) ; }
. pill . desc { border-color : #5a3c1e ; color : #d0a56e ; }
. spacer { margin-left : auto ; }
. detail { margin-top : 9 px ; padding-top : 9 px ; border-top : 1 px solid var ( - - bg - line ) ; font-size : .86 rem ; display : none ; }
. detail . open { display : block ; }
. detail . lbl { color : var ( - - fade ) ; font-size : .72 rem ; text-transform : uppercase ; letter-spacing : .4 px ; margin : 8 px 0 3 px ; }
. detail ul { margin : 3 px 0 ; padding-left : 18 px ; } . detail li { margin : 2 px 0 ; }
. detail a { color : var ( - - accent ) ; text-decoration : none ; }
. edit { display : flex ; flex-wrap : wrap ; gap : 6 px ; margin-top : 9 px ; }
. edit input , . edit select { font : inherit ; font-size : .82 rem ; padding : 5 px 8 px ; border-radius : 6 px ;
border : 1 px solid var ( - - border ) ; background : var ( - - bg ) ; color : var ( - - text ) ; }
. empty { color : var ( - - fade ) ; text-align : center ; padding : 34 px 16 px ; }
< / 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 >