Converts the standalone leaderboard HTML into a proper build project matching the kingmaker.gateway-gamers.net pattern: shared header/footer partials in public/, leaderboard logic in src/main.js, and vite build outputting to dist/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
405 lines
17 KiB
HTML
405 lines
17 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Gateway Gamers — 40K ITC Standings</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@500;600;700&family=Inter:wght@400;500;600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* ---- Palette: "command ledger" — gunmetal base, signal amber ---- */
|
||
:root{
|
||
--ink:#0d1014; --panel:#151b22; --panel-2:#1b232c; --edge:#28323d;
|
||
--steel:#8a97a6; --steel-dim:#5d6873; --bone:#eceef1;
|
||
--signal:#e0a73e; --signal-dim:#7a5e25;
|
||
--win:#5fa88c; --loss:#bf5a52;
|
||
--r:10px;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0}
|
||
body{
|
||
background:
|
||
radial-gradient(1200px 600px at 80% -10%, #1a222c 0%, transparent 60%),
|
||
var(--ink);
|
||
color:var(--bone);
|
||
font-family:Inter,system-ui,sans-serif;
|
||
-webkit-font-smoothing:antialiased;
|
||
line-height:1.45;
|
||
min-height:100vh;
|
||
}
|
||
.wrap{max-width:920px;margin:0 auto;padding:28px 20px 64px}
|
||
|
||
/* ---- Masthead ---- */
|
||
.masthead{display:flex;align-items:center;gap:16px;padding-bottom:20px;
|
||
border-bottom:1px solid var(--edge);flex-wrap:wrap}
|
||
.crest{width:54px;height:54px;flex:0 0 auto;display:grid;place-items:center;
|
||
border:1.5px solid var(--signal);color:var(--signal);border-radius:50%;
|
||
font-family:"Saira Condensed",sans-serif;font-weight:700;font-size:22px;
|
||
letter-spacing:.5px;background:rgba(224,167,62,.06)}
|
||
.titles{flex:1 1 240px;min-width:0}
|
||
h1{font-family:"Saira Condensed",sans-serif;font-weight:700;letter-spacing:.5px;
|
||
text-transform:uppercase;margin:0;font-size:clamp(28px,5vw,42px);line-height:.95}
|
||
.kicker{margin:4px 0 0;color:var(--steel);font-size:13px;letter-spacing:.18em;
|
||
text-transform:uppercase}
|
||
.status{display:flex;align-items:center;gap:12px;margin-left:auto}
|
||
.live{display:flex;align-items:center;gap:7px;color:var(--steel);font-size:12.5px}
|
||
.dot{width:8px;height:8px;border-radius:50%;background:var(--steel-dim)}
|
||
.dot.on{background:var(--win);box-shadow:0 0 0 0 rgba(95,168,140,.6);
|
||
animation:pulse 2.4s infinite}
|
||
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(95,168,140,.5)}
|
||
70%{box-shadow:0 0 0 7px rgba(95,168,140,0)}100%{box-shadow:0 0 0 0 transparent}}
|
||
button#refresh{font-family:"Saira Condensed",sans-serif;text-transform:uppercase;
|
||
letter-spacing:.1em;font-weight:600;font-size:13px;color:var(--ink);
|
||
background:var(--signal);border:0;border-radius:6px;padding:8px 14px;cursor:pointer}
|
||
button#refresh:hover{filter:brightness(1.08)}
|
||
button#refresh:disabled{opacity:.5;cursor:wait}
|
||
button#refresh:focus-visible{outline:2px solid var(--bone);outline-offset:2px}
|
||
|
||
/* ---- Team stat strip ---- */
|
||
.strip{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;margin:22px 0;
|
||
background:var(--edge);border:1px solid var(--edge);border-radius:var(--r);overflow:hidden}
|
||
.stat{background:var(--panel);padding:14px 16px}
|
||
.stat .n{font-family:"Space Mono",monospace;font-size:22px;font-weight:700}
|
||
.stat .l{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--steel);margin-top:2px}
|
||
@media(max-width:560px){.strip{grid-template-columns:repeat(2,1fr)}}
|
||
|
||
/* ---- Banner (warnings/errors) ---- */
|
||
.banner{border-radius:var(--r);padding:12px 16px;margin:16px 0;font-size:14px;
|
||
border:1px solid var(--edge);background:var(--panel);display:none}
|
||
.banner.show{display:block}
|
||
.banner.warn{border-color:var(--signal-dim);background:rgba(224,167,62,.08)}
|
||
.banner.err{border-color:#5a2f2c;background:rgba(191,90,82,.1)}
|
||
.banner b{color:var(--signal)}
|
||
.banner code{font-family:"Space Mono",monospace;font-size:12.5px;color:var(--bone)}
|
||
|
||
/* ---- Champion ---- */
|
||
.champion{display:none;align-items:center;gap:18px;margin:18px 0;
|
||
background:linear-gradient(100deg,var(--panel-2),var(--panel));
|
||
border:1px solid var(--signal-dim);border-radius:var(--r);padding:18px 20px;position:relative}
|
||
.champion.show{display:flex}
|
||
.champion .rk{font-family:"Saira Condensed",sans-serif;font-weight:700;font-size:42px;
|
||
color:var(--signal);line-height:1;width:46px;text-align:center}
|
||
.champion .who{flex:1 1 auto;min-width:0}
|
||
.champion .name{font-family:"Saira Condensed",sans-serif;font-weight:600;
|
||
font-size:26px;text-transform:uppercase;letter-spacing:.5px}
|
||
.champion .sub{color:var(--steel);font-size:13px}
|
||
.champion .pts{font-family:"Space Mono",monospace;font-weight:700;font-size:30px;color:var(--signal)}
|
||
.champion .pts span{display:block;font-family:Inter;font-weight:500;font-size:11px;
|
||
letter-spacing:.14em;text-transform:uppercase;color:var(--steel);text-align:right}
|
||
.laurel{position:absolute;top:10px;right:14px;font-size:11px;letter-spacing:.18em;
|
||
text-transform:uppercase;color:var(--signal)}
|
||
|
||
/* ---- Board ---- */
|
||
table{width:100%;border-collapse:collapse;margin-top:8px}
|
||
thead th{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--steel-dim);
|
||
text-align:left;padding:0 12px 10px;font-weight:600}
|
||
thead th.num{text-align:right}
|
||
tbody tr{border-top:1px solid var(--edge);opacity:0;transform:translateY(6px);
|
||
animation:rise .4s ease forwards}
|
||
@keyframes rise{to{opacity:1;transform:none}}
|
||
tbody td{padding:13px 12px;vertical-align:middle}
|
||
.c-rank{font-family:"Space Mono",monospace;color:var(--steel);width:48px}
|
||
.c-rank b{color:var(--bone)}
|
||
.c-name{font-weight:600}
|
||
.c-name .nick{display:block;font-weight:400;font-size:12px;color:var(--steel-dim)}
|
||
.c-pts{font-family:"Space Mono",monospace;font-weight:700;text-align:right;font-size:16px}
|
||
.c-rec{font-family:"Space Mono",monospace;text-align:right;color:var(--steel);font-size:13px;white-space:nowrap}
|
||
.c-rec .w{color:var(--win)}.c-rec .l{color:var(--loss)}
|
||
.c-lg{font-family:"Space Mono",monospace;text-align:right;color:var(--steel-dim);font-size:13px}
|
||
@media(max-width:560px){.hide-sm{display:none}.c-pts{font-size:15px}}
|
||
|
||
/* ---- States ---- */
|
||
.empty{display:none;text-align:center;color:var(--steel);padding:48px 20px}
|
||
.empty.show{display:block}
|
||
.skeleton td{padding:16px 12px}
|
||
.sk{height:14px;border-radius:4px;background:linear-gradient(90deg,var(--panel),var(--panel-2),var(--panel));
|
||
background-size:200% 100%;animation:shimmer 1.3s infinite}
|
||
@keyframes shimmer{to{background-position:-200% 0}}
|
||
|
||
footer{margin-top:28px;padding-top:16px;border-top:1px solid var(--edge);
|
||
color:var(--steel-dim);font-size:12px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
||
footer a{color:var(--steel)}
|
||
|
||
@media(prefers-reduced-motion:reduce){
|
||
*{animation:none!important}
|
||
tbody tr{opacity:1;transform:none}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
|
||
<header class="masthead">
|
||
<div class="crest">GG</div>
|
||
<div class="titles">
|
||
<h1>Gateway Gamers</h1>
|
||
<p class="kicker">Warhammer 40,000 · ITC Standings</p>
|
||
</div>
|
||
<div class="status">
|
||
<span class="live"><span class="dot" id="dot"></span><span id="updated">Loading…</span></span>
|
||
<button id="refresh">Refresh</button>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="strip" id="strip" aria-label="Team summary" hidden>
|
||
<div class="stat"><div class="n" id="s-placed">—</div><div class="l">Players placed</div></div>
|
||
<div class="stat"><div class="n" id="s-top">—</div><div class="l">Top ITC points</div></div>
|
||
<div class="stat"><div class="n" id="s-rec">—</div><div class="l">Combined W–L–T</div></div>
|
||
<div class="stat"><div class="n" id="s-best">—</div><div class="l">Best league rank</div></div>
|
||
</section>
|
||
|
||
<div class="banner" id="banner"></div>
|
||
|
||
<section class="champion" id="champion" aria-label="Top player">
|
||
<div class="rk">1</div>
|
||
<div class="who">
|
||
<div class="name" id="champ-name"></div>
|
||
<div class="sub" id="champ-sub"></div>
|
||
</div>
|
||
<div class="pts" id="champ-pts"></div>
|
||
<div class="laurel">Team Leader</div>
|
||
</section>
|
||
|
||
<table id="board">
|
||
<thead>
|
||
<tr>
|
||
<th class="c-rank">#</th>
|
||
<th>Player</th>
|
||
<th class="num">ITC Points</th>
|
||
<th class="num hide-sm">W–L–T</th>
|
||
<th class="num hide-sm">League Rank</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="rows"></tbody>
|
||
</table>
|
||
|
||
<div class="empty" id="empty"></div>
|
||
|
||
<footer>
|
||
<span>Data from Best Coast Pairings · live on refresh</span>
|
||
<span id="meta"></span>
|
||
</footer>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
/* ============================================================
|
||
CONFIG — the only block you edit
|
||
============================================================ */
|
||
const CONFIG = {
|
||
teamId: "2iGDVMgX0a",
|
||
leagueId: "BYaaUfKum7z0",
|
||
regionId: "VgQKgqmTPU",
|
||
|
||
// Fallback roster, used only if the live team fetch needs auth.
|
||
// Paste the member user IDs your Python --debug run assembled, e.g.
|
||
// ["yHkqr1DICY", "PO4GKS85dz", ...]
|
||
memberIds: [
|
||
// "yHkqr1DICY",
|
||
],
|
||
|
||
// Single-call sizes to try (BCP ignores offset; rejects oversize with 409).
|
||
limitLadder: [14000, 12000, 10000, 8000, 6000, 4000, 3000],
|
||
|
||
// Flip to true to preview the layout with placeholder rows (no network).
|
||
useSample: false,
|
||
};
|
||
|
||
const API = "https://newprod-api.bestcoastpairings.com/v1";
|
||
const HEADERS = { "client-id": "web-app", "env": "bcp", "accept": "application/json" };
|
||
const CACHE_KEY = "gg_board_v1";
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const fmtPts = (n) => Number(n||0).toLocaleString(undefined,{minimumFractionDigits:1,maximumFractionDigits:1});
|
||
const fullName = (u={}) => [u.firstName,u.lastName].filter(Boolean).join(" ").trim();
|
||
|
||
/* ---- Roster ---- */
|
||
async function getMemberIds(force){
|
||
try{
|
||
const r = await fetch(`${API}/teams/${CONFIG.teamId}?expand[]=owner`,
|
||
{headers:HEADERS, cache: force?"reload":"default"});
|
||
if(!r.ok) throw new Error("auth/"+r.status);
|
||
let t = await r.json(); if(Array.isArray(t)) t = t[0]||{};
|
||
const ids = new Set();
|
||
if(Array.isArray(t.memberIds)) t.memberIds.forEach(x=>typeof x==="string"&&ids.add(x));
|
||
if(typeof t.ownerId==="string") ids.add(t.ownerId);
|
||
[t.users,t.members,t.teamMembers].forEach(list=>{
|
||
if(Array.isArray(list)) list.forEach(o=>o&&o.id&&ids.add(o.id));
|
||
});
|
||
if(t.owner&&t.owner.id) ids.add(t.owner.id);
|
||
if(ids.size) return {ids, name:t.name, live:true};
|
||
throw new Error("empty");
|
||
}catch(e){
|
||
if(CONFIG.memberIds.length) return {ids:new Set(CONFIG.memberIds), name:"Gateway Gamers", live:false};
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
/* ---- Standings (largest accepted limit, one call) ---- */
|
||
async function getPlacings(force){
|
||
const base = `${API}/placings?placingsType=player&leagueId=${CONFIG.leagueId}`+
|
||
`®ionId=${CONFIG.regionId}&sortAscending=false`;
|
||
for(const lim of CONFIG.limitLadder){
|
||
const r = await fetch(`${base}&limit=${lim}`, {headers:HEADERS, cache: force?"reload":"default"});
|
||
if(r.status===409) continue;
|
||
if(!r.ok) throw new Error("placings/"+r.status);
|
||
const p = await r.json();
|
||
const rows = Array.isArray(p) ? p : (p.data||p.placings||[]);
|
||
return {rows, limit:lim, truncated: rows.length>=lim};
|
||
}
|
||
throw new Error("placings/limit-all-rejected");
|
||
}
|
||
|
||
/* ---- Build leaderboard ---- */
|
||
function build(rows, ids){
|
||
const kept=[], matched=new Set();
|
||
for(const rec of rows){
|
||
const u = rec.user||{};
|
||
const uid = rec.userId||u.id;
|
||
if(uid && ids.has(uid)){ kept.push(rec); matched.add(uid); }
|
||
}
|
||
kept.sort((a,b)=> Number(b.ITCPoints||0)-Number(a.ITCPoints||0));
|
||
return {kept, matchedCount:matched.size};
|
||
}
|
||
|
||
/* ---- Render ---- */
|
||
function render(state){
|
||
const {kept, rosterSize, updated, truncated, limit, live, fromCache} = state;
|
||
$("dot").className = "dot on";
|
||
$("updated").textContent = (fromCache?"Cached ":"Updated ") +
|
||
new Date(updated).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
|
||
$("meta").textContent = `${kept.length} of ${rosterSize} members placed`+(live?"":" · roster (cached list)");
|
||
|
||
// Banner
|
||
const b=$("banner"); b.className="banner";
|
||
if(truncated){
|
||
b.classList.add("show","warn");
|
||
b.innerHTML = `<b>Heads up:</b> the league returned the full <code>limit=${limit}</code> rows, `+
|
||
`so some lower-ranked players may be cut off. Tell the developer so the paging cursor can be added.`;
|
||
}
|
||
|
||
const champ=$("champion"), strip=$("strip"), empty=$("empty"), tb=$("rows");
|
||
tb.innerHTML="";
|
||
|
||
if(!kept.length){
|
||
champ.classList.remove("show"); strip.hidden=true;
|
||
empty.classList.add("show");
|
||
empty.innerHTML = "No Gateway Gamers players found in this league’s standings yet. "+
|
||
"Once members log games here, they’ll appear ranked by ITC points.";
|
||
return;
|
||
}
|
||
empty.classList.remove("show"); strip.hidden=false;
|
||
|
||
// Champion card
|
||
const top=kept[0], tu=top.user||{};
|
||
champ.classList.add("show");
|
||
$("champ-name").textContent = fullName(tu)||tu.nickname||"—";
|
||
$("champ-sub").textContent = `${top.wins||0}–${top.losses||0}–${top.ties||0} · league #${top.placing??"—"}`;
|
||
$("champ-pts").innerHTML = `${fmtPts(top.ITCPoints)}<span>ITC points</span>`;
|
||
|
||
// Stat strip
|
||
const W=kept.reduce((s,r)=>s+(+r.wins||0),0),
|
||
L=kept.reduce((s,r)=>s+(+r.losses||0),0),
|
||
T=kept.reduce((s,r)=>s+(+r.ties||0),0),
|
||
best=Math.min(...kept.map(r=>+r.placing||Infinity));
|
||
$("s-placed").textContent=kept.length;
|
||
$("s-top").textContent=fmtPts(top.ITCPoints);
|
||
$("s-rec").textContent=`${W}–${L}–${T}`;
|
||
$("s-best").textContent=isFinite(best)?("#"+best):"—";
|
||
|
||
// Rows
|
||
kept.forEach((r,i)=>{
|
||
const u=r.user||{}, name=fullName(u)||u.nickname||"Unknown";
|
||
const tr=document.createElement("tr");
|
||
tr.style.animationDelay=(Math.min(i,20)*22)+"ms";
|
||
tr.innerHTML =
|
||
`<td class="c-rank">${i===0?"<b>1</b>":(i+1)}</td>`+
|
||
`<td class="c-name">${esc(name)}${u.nickname&&fullName(u)?`<span class="nick">"${esc(u.nickname)}"</span>`:""}</td>`+
|
||
`<td class="c-pts">${fmtPts(r.ITCPoints)}</td>`+
|
||
`<td class="c-rec hide-sm"><span class="w">${r.wins||0}</span>–<span class="l">${r.losses||0}</span>–${r.ties||0}</td>`+
|
||
`<td class="c-lg hide-sm">#${r.placing??"—"}</td>`;
|
||
tb.appendChild(tr);
|
||
});
|
||
}
|
||
function esc(s){return String(s).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));}
|
||
|
||
/* ---- Orchestration ---- */
|
||
function paintCache(){
|
||
try{
|
||
const c=JSON.parse(localStorage.getItem(CACHE_KEY)||"null");
|
||
if(c&&c.kept) render({...c, fromCache:true});
|
||
}catch(e){}
|
||
}
|
||
function saveCache(state){
|
||
try{ localStorage.setItem(CACHE_KEY, JSON.stringify({
|
||
kept:state.kept, rosterSize:state.rosterSize, updated:state.updated,
|
||
truncated:state.truncated, limit:state.limit, live:state.live
|
||
})); }catch(e){}
|
||
}
|
||
|
||
async function load(force){
|
||
const btn=$("refresh"); btn.disabled=true; $("dot").className="dot";
|
||
$("updated").textContent="Fetching…";
|
||
showSkeleton();
|
||
try{
|
||
if(CONFIG.useSample){ return render({...SAMPLE, fromCache:false}); }
|
||
const {ids,name,live} = await getMemberIds(force);
|
||
const {rows,limit,truncated} = await getPlacings(force);
|
||
const {kept} = build(rows, ids);
|
||
const state={kept, rosterSize:ids.size, updated:Date.now(), truncated, limit, live};
|
||
render({...state, fromCache:false});
|
||
saveCache(state);
|
||
}catch(err){
|
||
failure(err);
|
||
}finally{
|
||
btn.disabled=false;
|
||
}
|
||
}
|
||
|
||
function showSkeleton(){
|
||
const tb=$("rows"); tb.innerHTML="";
|
||
for(let i=0;i<6;i++){
|
||
const tr=document.createElement("tr"); tr.className="skeleton";
|
||
tr.innerHTML=`<td><div class="sk" style="width:20px"></div></td>
|
||
<td><div class="sk" style="width:60%"></div></td>
|
||
<td><div class="sk" style="width:50px;margin-left:auto"></div></td>
|
||
<td class="hide-sm"><div class="sk" style="width:50px;margin-left:auto"></div></td>
|
||
<td class="hide-sm"><div class="sk" style="width:40px;margin-left:auto"></div></td>`;
|
||
tb.appendChild(tr);
|
||
}
|
||
}
|
||
|
||
function failure(err){
|
||
$("dot").className="dot";
|
||
$("updated").textContent="Couldn’t update";
|
||
$("rows").innerHTML=""; $("champion").classList.remove("show"); $("strip").hidden=true;
|
||
const b=$("banner"); b.className="banner show err";
|
||
const msg=String(err.message||err);
|
||
if(msg.startsWith("auth")){
|
||
b.innerHTML="The team roster needs a login. Add the member IDs to "+
|
||
"<code>CONFIG.memberIds</code> in this file so the page can work without one.";
|
||
}else if(msg.includes("placings")){
|
||
b.innerHTML="Couldn’t reach the BCP standings feed. If you opened this file inside a "+
|
||
"preview that blocks outside requests, host it (GitHub Pages, Netlify, your store site) "+
|
||
"or open the file directly in a browser.";
|
||
}else{
|
||
b.innerHTML="Something went wrong reaching Best Coast Pairings: <code>"+esc(msg)+"</code>";
|
||
}
|
||
}
|
||
|
||
/* ---- Sample (preview only; CONFIG.useSample=true) ---- */
|
||
const SAMPLE = (()=>{ const mk=(f,l,p,w,ls,t,pl)=>({user:{firstName:f,lastName:l},ITCPoints:p,wins:w,losses:ls,ties:t,placing:pl});
|
||
const kept=[mk("Eric","Darais",920.7,19,4,0,453),mk("Jordan","Vance",812.4,16,6,1,712),
|
||
mk("Mara","Singh",770.1,15,7,0,889),mk("Devon","Cole",655.9,12,8,2,1340)];
|
||
return {kept, rosterSize:8, updated:Date.now(), truncated:false, limit:14000, live:true};
|
||
})();
|
||
|
||
/* ---- Go ---- */
|
||
$("refresh").addEventListener("click",()=>load(true));
|
||
paintCache();
|
||
load(false);
|
||
</script>
|
||
</body>
|
||
</html>
|