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
+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);