scaffold Vite + Tailwind v4 + DaisyUI v5 project structure

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>
This commit is contained in:
2026-06-26 12:28:36 -05:00
co-authored by Claude Sonnet 4.6
parent 1841f242ed
commit 2304764d5d
11 changed files with 2689 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
.env.local
+404
View File
@@ -0,0 +1,404 @@
<!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 WLT</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">WLT</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}`+
`&regionId=${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 leagues standings yet. "+
"Once members log games here, theyll 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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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="Couldnt 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="Couldnt 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>
+98
View File
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gateway Gamers — 40K ITC Standings</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body class="min-h-screen flex flex-col">
<div id="header"></div>
<main class="flex-1 max-w-5xl w-full mx-auto px-6 py-10">
<!-- Page heading + live status + refresh -->
<div class="flex items-start justify-between mb-6 flex-wrap gap-4">
<div>
<h1 class="text-4xl font-bold uppercase tracking-wide">ITC Standings</h1>
<p class="text-sm opacity-50 uppercase tracking-widest mt-1">Warhammer 40,000 · ITC Standings</p>
</div>
<div class="flex items-center gap-3 pt-1">
<span class="flex items-center gap-2 text-sm opacity-60">
<span class="w-2 h-2 rounded-full bg-base-content/30 transition-colors" id="dot"></span>
<span id="updated">Loading…</span>
</span>
<button id="refresh" class="btn btn-warning btn-sm uppercase tracking-wide">Refresh</button>
</div>
</div>
<!-- Stats strip -->
<div class="stats shadow w-full mb-6 bg-base-200" id="strip" hidden>
<div class="stat">
<div class="stat-title">Players Placed</div>
<div class="stat-value text-2xl" id="s-placed"></div>
</div>
<div class="stat">
<div class="stat-title">Top ITC Points</div>
<div class="stat-value text-2xl text-warning" id="s-top"></div>
</div>
<div class="stat">
<div class="stat-title">Combined WLT</div>
<div class="stat-value text-2xl" id="s-rec"></div>
</div>
<div class="stat">
<div class="stat-title">Best League Rank</div>
<div class="stat-value text-2xl" id="s-best"></div>
</div>
</div>
<!-- Banner (warnings / errors) -->
<div class="alert mb-4 hidden" id="banner"></div>
<!-- Champion card -->
<div class="card card-border border-warning bg-base-200 mb-6 hidden" id="champion">
<div class="card-body flex-row items-center gap-6 py-4">
<span class="text-warning font-mono font-bold text-5xl w-12 text-center shrink-0">1</span>
<div class="flex-1 min-w-0">
<div class="font-bold text-2xl uppercase tracking-wide" id="champ-name"></div>
<div class="text-sm opacity-60 mt-1" id="champ-sub"></div>
</div>
<div class="text-right shrink-0">
<div class="text-warning font-mono font-bold text-3xl" id="champ-pts"></div>
<div class="text-xs opacity-50 uppercase tracking-widest mt-1">Team Leader</div>
</div>
</div>
</div>
<!-- Leaderboard table -->
<div class="overflow-x-auto">
<table class="table table-zebra w-full" id="board">
<thead>
<tr>
<th class="w-12">#</th>
<th>Player</th>
<th class="text-right">ITC Points</th>
<th class="text-right hidden sm:table-cell">WLT</th>
<th class="text-right hidden sm:table-cell">League Rank</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="text-center text-base-content/50 py-12 hidden" id="empty"></div>
<div class="divider mt-8"></div>
<p class="text-xs text-base-content/40 text-center" id="meta">
Data from Best Coast Pairings · live on refresh
</p>
</main>
<div id="footer"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1847
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "rankings-gateway-gamers",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"daisyui": "^5.0.0",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {}
}
}
+3
View File
@@ -0,0 +1,3 @@
<footer class="footer footer-center bg-base-300 text-base-content p-4">
<p>&copy; 2026 Gateway Gamers. All rights reserved.</p>
</footer>
+33
View File
@@ -0,0 +1,33 @@
<header class="bg-base-300 shadow-lg">
<div class="navbar bg-base-200 px-4 min-h-0 py-2">
<div class="navbar-start">
<a href="/index.html" class="text-xl font-bold tracking-wide uppercase">Gateway Gamers</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1 gap-1">
<li><a href="/index.html">ITC Standings</a></li>
</ul>
</div>
<div class="navbar-end gap-2">
<label class="swap swap-rotate btn btn-ghost btn-circle" title="Toggle light/dark">
<input type="checkbox" class="theme-controller" value="light" />
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M21.64 13a1 1 0 0 0-1.27-.66 8.5 8.5 0 0 1-10.71-10.71 1 1 0 0 0-1.32-1.27A10 10 0 1 0 22 14.29a1 1 0 0 0-.36-1.29z"/>
</svg>
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5.64 17l-.71.71a1 1 0 0 0 1.41 1.41l.71-.71A1 1 0 0 0 5.64 17zM5 12a1 1 0 0 0-1-1H3a1 1 0 0 0 0 2h1a1 1 0 0 0 1-1zm7-7a1 1 0 0 0 1-1V3a1 1 0 0 0-2 0v1a1 1 0 0 0 1 1zM5.64 7.05a1 1 0 0 0 .7.29 1 1 0 0 0 .71-.29 1 1 0 0 0 0-1.41l-.71-.71a1 1 0 0 0-1.41 1.41zm12 .29a1 1 0 0 0 .7-.29l.71-.71a1 1 0 0 0-1.41-1.41l-.71.71a1 1 0 0 0 0 1.41 1 1 0 0 0 .71.29zM21 11h-1a1 1 0 0 0 0 2h1a1 1 0 0 0 0-2zm-9 8a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1a1 1 0 0 0-1-1zm6.36-2a1 1 0 0 0-1.41 1.41l.71.71a1 1 0 0 0 1.41-1.41zM12 6.5a5.5 5.5 0 1 0 5.5 5.5A5.51 5.51 0 0 0 12 6.5z"/>
</svg>
</label>
<div class="dropdown dropdown-end lg:hidden">
<label tabindex="0" class="btn btn-ghost btn-circle">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</label>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-200 rounded-box w-52">
<li><a href="/index.html">ITC Standings</a></li>
</ul>
</div>
</div>
</div>
</header>
+261
View File
@@ -0,0 +1,261 @@
/* ---- Shared: header / footer ---- */
function loadHTML(file, elementId, callback) {
fetch(file)
.then(r => r.text())
.then(html => {
document.getElementById(elementId).innerHTML = html;
if (callback) callback();
})
.catch(err => console.error('Error loading:', file, err));
}
function highlightActiveNav() {
const current = location.pathname.split('/').pop() || 'index.html';
document.querySelectorAll('#header a[href]').forEach(link => {
if (link.getAttribute('href') === `/${current}`) {
link.classList.add('active');
}
});
}
loadHTML('/header.html', 'header', highlightActiveNav);
loadHTML('/footer.html', 'footer');
/* ============================================================
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}` +
`&regionId=${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 = "w-2 h-2 rounded-full bg-success animate-pulse transition-colors";
$("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 = "alert mb-4 hidden";
if (truncated) {
b.className = "alert alert-warning mb-4";
b.innerHTML = `<strong>Heads up:</strong> the league returned the full <code class="font-mono text-sm">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.add("hidden"); strip.hidden = true;
empty.classList.remove("hidden");
empty.textContent = "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.add("hidden"); strip.hidden = false;
// Champion card
const top = kept[0], tu = top.user || {};
champ.classList.remove("hidden");
$("champ-name").textContent = fullName(tu) || tu.nickname || "—";
$("champ-sub").textContent = `${top.wins || 0}${top.losses || 0}${top.ties || 0} · league #${top.placing ?? "—"}`;
$("champ-pts").textContent = fmtPts(top.ITCPoints);
// 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.innerHTML =
`<td class="font-mono text-base-content/60">${i === 0 ? `<strong class="text-base-content">1</strong>` : (i + 1)}</td>` +
`<td><span class="font-semibold">${esc(name)}</span>` +
`${u.nickname && fullName(u) ? `<span class="block text-xs text-base-content/40">"${esc(u.nickname)}"</span>` : ""}</td>` +
`<td class="text-right font-mono font-bold">${fmtPts(r.ITCPoints)}</td>` +
`<td class="text-right font-mono hidden sm:table-cell">` +
`<span class="text-success">${r.wins || 0}</span><span class="text-error">${r.losses || 0}</span>${r.ties || 0}</td>` +
`<td class="text-right font-mono text-base-content/40 hidden sm:table-cell">#${r.placing ?? "—"}</td>`;
tb.appendChild(tr);
});
}
function esc(s) {
return String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
/* ---- Cache ---- */
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) {}
}
/* ---- Orchestration ---- */
async function load(force) {
const btn = $("refresh");
btn.disabled = true;
$("dot").className = "w-2 h-2 rounded-full bg-base-content/30 transition-colors";
$("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.innerHTML =
`<td><div class="skeleton h-4 w-5"></div></td>` +
`<td><div class="skeleton h-4 w-3/5"></div></td>` +
`<td><div class="skeleton h-4 w-12 ml-auto"></div></td>` +
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-12 ml-auto"></div></td>` +
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-10 ml-auto"></div></td>`;
tb.appendChild(tr);
}
}
function failure(err) {
$("dot").className = "w-2 h-2 rounded-full bg-base-content/30 transition-colors";
$("updated").textContent = "Couldn't update";
$("rows").innerHTML = "";
$("champion").classList.add("hidden");
$("strip").hidden = true;
const b = $("banner");
const msg = String(err.message || err);
b.className = "alert alert-error mb-4";
if (msg.startsWith("auth")) {
b.innerHTML = "The team roster needs a login. Add the member IDs to " +
`<code class="font-mono text-sm">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 class="font-mono text-sm">${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);
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html'),
}
}
}
});