Import aos-rankings.gateway-gamers.net into monorepo
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
stages:
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- npm ci
|
||||||
|
- npm run build
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- dist/
|
||||||
|
expire_in: 1 hour
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
stage: deploy
|
||||||
|
script:
|
||||||
|
- rsync -avz --delete dist/ /var/www/domains/gateway-gamers.net/aos-rankings/
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
url: https://aos-rankings.gateway-gamers.net
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name aos-rankings.gateway-gamers.net;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name aos-rankings.gateway-gamers.net;
|
||||||
|
|
||||||
|
root /var/www/domains/gateway-gamers.net/aos-rankings;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SSL — managed by Certbot
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/aos-rankings.gateway-gamers.net/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/aos-rankings.gateway-gamers.net/privkey.pem;
|
||||||
|
# include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# Hide nginx version
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' https://newprod-api.bestcoastpairings.com; img-src 'self' data:;" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!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 — AOS Rankings</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">AOS Rankings</h1>
|
||||||
|
<p class="text-sm opacity-50 uppercase tracking-widest mt-1">Age of Sigmar · 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">Team Win %</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 class="cursor-pointer select-none" data-sort="name">Player</th>
|
||||||
|
<th class="text-right cursor-pointer select-none" data-sort="pts">ITC Points</th>
|
||||||
|
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="wlt">W–L–T</th>
|
||||||
|
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="wpct">Win %</th>
|
||||||
|
<th class="text-right hidden sm:table-cell cursor-pointer select-none" data-sort="rank">ITC 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>
|
||||||
Generated
+1847
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "aos-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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<footer class="footer footer-center bg-base-300 text-base-content p-4">
|
||||||
|
<p>© 2026 Gateway Gamers. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<header class="bg-base-300 shadow-lg">
|
||||||
|
|
||||||
|
<!-- Hero: carousel background + centered logo -->
|
||||||
|
<div class="relative overflow-hidden min-h-64 py-10 flex items-center justify-center">
|
||||||
|
|
||||||
|
<!-- Carousel images -->
|
||||||
|
<div id="hero-carousel" class="absolute inset-0">
|
||||||
|
<img src="/images/photo1.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
|
||||||
|
<img src="/images/photo2.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
|
||||||
|
<img src="/images/photo3.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
|
||||||
|
<img src="/images/photo4.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
|
||||||
|
<img src="/images/photo5.jpg" class="carousel-slide absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-1000" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlay so logo stays readable over busy images -->
|
||||||
|
<div class="absolute inset-0 bg-black/40"></div>
|
||||||
|
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="relative z-10">
|
||||||
|
<a href="/index.html">
|
||||||
|
<img src="/images/logo.png" alt="Gateway Gamers" class="h-48 w-auto drop-shadow-2xl" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nav row -->
|
||||||
|
<div class="navbar bg-base-200 px-4 min-h-0 py-2">
|
||||||
|
<div class="navbar-center hidden lg:flex flex-1 justify-center">
|
||||||
|
<ul class="menu menu-horizontal px-1 gap-1">
|
||||||
|
<li><a href="https://40k-rankings.gateway-gamers.net">40K Rankings</a></li>
|
||||||
|
<li><a href="/index.html">AOS Rankings</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-end gap-2 ml-auto">
|
||||||
|
<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="https://40k-rankings.gateway-gamers.net">40K Rankings</a></li>
|
||||||
|
<li><a href="/index.html">AOS Rankings</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</header>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
@@ -0,0 +1,387 @@
|
|||||||
|
/* ---- 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCarousel() {
|
||||||
|
const slides = document.querySelectorAll('#hero-carousel .carousel-slide');
|
||||||
|
if (!slides.length) return;
|
||||||
|
let current = 0;
|
||||||
|
slides[current].style.opacity = '1';
|
||||||
|
setInterval(() => {
|
||||||
|
slides[current].style.opacity = '0';
|
||||||
|
current = (current + 1) % slides.length;
|
||||||
|
slides[current].style.opacity = '1';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHTML('/header.html', 'header', () => {
|
||||||
|
highlightActiveNav();
|
||||||
|
startCarousel();
|
||||||
|
});
|
||||||
|
loadHTML('/footer.html', 'footer');
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
CONFIG — the only block you edit
|
||||||
|
============================================================ */
|
||||||
|
const CONFIG = {
|
||||||
|
teamId: "2iGDVMgX0a",
|
||||||
|
leagueId: "RtgcexBzqjCM",
|
||||||
|
regionId: "VgQKgqmTPU",
|
||||||
|
|
||||||
|
// Fallback roster if the team endpoint requires auth.
|
||||||
|
memberIds: [],
|
||||||
|
|
||||||
|
// 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_aos_board_v1";
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const fmtPts = (n) => Number(n || 0).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||||
|
const fmtWpct = (w, l, t) => { const g = (+w||0)+(+l||0)+(+t||0); return g ? ((+w||0)/g*100).toFixed(1)+'%' : '—'; };
|
||||||
|
const fullName = (u = {}) => [u.firstName, u.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
|
||||||
|
/* ---- Career stats ---- */
|
||||||
|
const careerData = {}; // userId → { w, l, t } | null (in-flight)
|
||||||
|
|
||||||
|
async function fetchCareer(userId) {
|
||||||
|
const r = await fetch(
|
||||||
|
`${API}/placings?placingsType=player&userId=${userId}&limit=1000`,
|
||||||
|
{ headers: HEADERS, credentials: 'omit' }
|
||||||
|
);
|
||||||
|
if (!r.ok) return { w: 0, l: 0, t: 0 };
|
||||||
|
const p = await r.json();
|
||||||
|
const items = Array.isArray(p) ? p : (p.data || []);
|
||||||
|
let w = 0, l = 0, t = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const v = item.value || item;
|
||||||
|
if (v.wins != null || v.losses != null) {
|
||||||
|
w += +(v.wins || 0);
|
||||||
|
l += +(v.losses || 0);
|
||||||
|
t += +(v.ties || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { w, l, t };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCareerStats(kept) {
|
||||||
|
const toFetch = kept.filter(r => {
|
||||||
|
const uid = r.userId || r.user?.id;
|
||||||
|
return uid && !(uid in careerData);
|
||||||
|
});
|
||||||
|
if (!toFetch.length) return;
|
||||||
|
toFetch.forEach(r => { careerData[r.userId || r.user?.id] = null; });
|
||||||
|
await Promise.allSettled(toFetch.map(async r => {
|
||||||
|
const uid = r.userId || r.user?.id;
|
||||||
|
try { careerData[uid] = await fetchCareer(uid); }
|
||||||
|
catch { careerData[uid] = { w: 0, l: 0, t: 0 }; }
|
||||||
|
}));
|
||||||
|
if (lastState) render(lastState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sort state ---- */
|
||||||
|
let sortState = { col: 'pts', dir: 'desc' };
|
||||||
|
let lastState = null;
|
||||||
|
|
||||||
|
const sortCols = {
|
||||||
|
pts: { key: r => +(r.ITCPoints || 0), defaultDir: 'desc' },
|
||||||
|
name: { key: r => fullName(r.user || {}).toLowerCase() || '', defaultDir: 'asc' },
|
||||||
|
wlt: { key: r => (+r.wins||0) * 10000 - (+r.losses||0) * 100 - (+r.ties||0), defaultDir: 'desc' },
|
||||||
|
wpct: { key: r => { const g=(+r.wins||0)+(+r.losses||0)+(+r.ties||0); return g?(+r.wins||0)/g:-1; }, defaultDir: 'desc' },
|
||||||
|
rank: { key: r => +(r.placing || Infinity), defaultDir: 'asc' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function applySort(rows) {
|
||||||
|
const { col, dir } = sortState;
|
||||||
|
const sc = sortCols[col] || sortCols.pts;
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const av = sc.key(a), bv = sc.key(b);
|
||||||
|
if (typeof av === 'string') return dir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||||
|
return dir === 'asc' ? av - bv : bv - av;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortHeaders() {
|
||||||
|
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||||||
|
const arrow = th.querySelector('.sort-arrow');
|
||||||
|
if (arrow) arrow.textContent = th.dataset.sort === sortState.col ? (sortState.dir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSortHeaders() {
|
||||||
|
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||||||
|
const arrow = document.createElement('span');
|
||||||
|
arrow.className = 'sort-arrow text-xs opacity-60';
|
||||||
|
th.appendChild(arrow);
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
const col = th.dataset.sort;
|
||||||
|
if (sortState.col === col) {
|
||||||
|
sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
sortState.col = col;
|
||||||
|
sortState.dir = (sortCols[col] || sortCols.pts).defaultDir;
|
||||||
|
}
|
||||||
|
updateSortHeaders();
|
||||||
|
if (lastState) render(lastState);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
updateSortHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Roster ---- */
|
||||||
|
async function getMemberIds(force) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/teams/${CONFIG.teamId}?expand[]=owner`,
|
||||||
|
{ headers: HEADERS, credentials: "omit" });
|
||||||
|
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, live: true };
|
||||||
|
throw new Error("empty");
|
||||||
|
} catch (e) {
|
||||||
|
if (CONFIG.memberIds.length) return { ids: new Set(CONFIG.memberIds), live: false };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Standings (cursor-paginated, circular-cursor safe) ---- */
|
||||||
|
async function* streamPlacings() {
|
||||||
|
const base = `${API}/placings?placingsType=player&leagueId=${CONFIG.leagueId}` +
|
||||||
|
`®ionId=${CONFIG.regionId}&sortAscending=false&limit=1000`;
|
||||||
|
|
||||||
|
let nextKey = null;
|
||||||
|
let prevNextKey = undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const url = nextKey ? `${base}&nextKey=${encodeURIComponent(nextKey)}` : base;
|
||||||
|
const r = await fetch(url, { headers: HEADERS, credentials: "omit" });
|
||||||
|
if (!r.ok) throw new Error("placings/" + r.status);
|
||||||
|
const p = await r.json();
|
||||||
|
const page = Array.isArray(p) ? p : (p.data || p.placings || []);
|
||||||
|
|
||||||
|
yield page;
|
||||||
|
|
||||||
|
const rawKey = p.nextKey;
|
||||||
|
nextKey = !rawKey ? null
|
||||||
|
: typeof rawKey === "string" ? rawKey
|
||||||
|
: btoa(JSON.stringify(rawKey));
|
||||||
|
|
||||||
|
// BCP returns the same cursor when there are no more results — stop when it loops
|
||||||
|
if (nextKey === prevNextKey) break;
|
||||||
|
prevNextKey = nextKey;
|
||||||
|
} while (nextKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Build leaderboard ---- */
|
||||||
|
function build(rows, ids) {
|
||||||
|
const kept = [];
|
||||||
|
for (const rec of rows) {
|
||||||
|
const uid = rec.userId || (rec.user && rec.user.id);
|
||||||
|
if (uid && ids.has(uid)) kept.push(rec);
|
||||||
|
}
|
||||||
|
kept.sort((a, b) => Number(b.ITCPoints || 0) - Number(a.ITCPoints || 0));
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Render ---- */
|
||||||
|
function render(state) {
|
||||||
|
lastState = state;
|
||||||
|
const { kept, rosterSize, updated, 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)");
|
||||||
|
|
||||||
|
$("banner").className = "alert mb-4 hidden";
|
||||||
|
|
||||||
|
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 = `${fmtWpct(top.wins, top.losses, top.ties)} Win Rate · ITC #${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 = fmtWpct(W, L, T);
|
||||||
|
$("s-best").textContent = isFinite(best) ? ("#" + best) : "—";
|
||||||
|
|
||||||
|
// Kick off career stat fetches for any players not yet loaded (non-blocking)
|
||||||
|
loadCareerStats(kept);
|
||||||
|
|
||||||
|
// Rows
|
||||||
|
applySort(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>` : ""}` +
|
||||||
|
`<span class="block text-xs opacity-40 sm:hidden"><span class="text-success">${r.wins || 0}</span>–<span class="text-error">${r.losses || 0}</span>–${r.ties || 0} · ITC #${r.placing ?? "—"}</span>` +
|
||||||
|
`</td>` +
|
||||||
|
(() => {
|
||||||
|
const uid = r.userId || r.user?.id;
|
||||||
|
const d = uid && careerData[uid];
|
||||||
|
const careerSub = d == null
|
||||||
|
? `<span class="block text-xs opacity-20">…</span>`
|
||||||
|
: (d.w + d.l + d.t) === 0 ? ''
|
||||||
|
: `<span class="block text-xs opacity-40">${d.w}–${d.l}–${d.t} career</span>`;
|
||||||
|
const careerPct = d == null
|
||||||
|
? `<span class="block text-xs opacity-20">…</span>`
|
||||||
|
: (d.w + d.l + d.t) === 0 ? ''
|
||||||
|
: `<span class="block text-xs opacity-40">${fmtWpct(d.w, d.l, d.t)} career</span>`;
|
||||||
|
const mobilePct = `<span class="block text-xs opacity-40 sm:hidden">${fmtWpct(r.wins, r.losses, r.ties)} win rate</span>`;
|
||||||
|
return `<td class="text-right font-mono font-bold">${fmtPts(r.ITCPoints)}` +
|
||||||
|
mobilePct + `</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}` +
|
||||||
|
careerSub + `</td>` +
|
||||||
|
`<td class="text-right font-mono hidden sm:table-cell">${fmtWpct(r.wins, r.losses, r.ties)}` +
|
||||||
|
careerPct + `</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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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, 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, live } = await getMemberIds(force);
|
||||||
|
const allRows = [];
|
||||||
|
|
||||||
|
for await (const page of streamPlacings()) {
|
||||||
|
allRows.push(...page);
|
||||||
|
// Render incrementally so results appear as pages arrive
|
||||||
|
const kept = build(allRows, ids);
|
||||||
|
if (kept.length) render({ kept, rosterSize: ids.size, updated: Date.now(), live, fromCache: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const kept = build(allRows, ids);
|
||||||
|
const state = { kept, rosterSize: ids.size, updated: Date.now(), 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><div class="skeleton h-3 w-2/5 mt-1 sm:hidden"></div></td>` +
|
||||||
|
`<td><div class="skeleton h-4 w-12 ml-auto"></div><div class="skeleton h-3 w-10 mt-1 ml-auto sm:hidden"></div></td>` +
|
||||||
|
`<td class="hidden sm:table-cell"><div class="skeleton h-4 w-14 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";
|
||||||
|
b.innerHTML = msg.includes("placings")
|
||||||
|
? "Couldn't reach the BCP standings feed. Try refreshing, or open the page directly in a browser."
|
||||||
|
: `Something went wrong: <code class="font-mono text-sm ml-1">${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(), live: true };
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---- Go ---- */
|
||||||
|
$("refresh").addEventListener("click", () => load(true));
|
||||||
|
initSortHeaders();
|
||||||
|
paintCache();
|
||||||
|
load(false);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui";
|
||||||
@@ -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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user