commit e39cb70c0479437f48401a35b7a870753b663962 Author: mandalore Date: Fri Jun 26 18:31:33 2026 -0500 scaffold AOS rankings site (Vite + Tailwind v4 + DaisyUI v5) Clone of the 40K rankings project adapted for Age of Sigmar: leagueId RtgcexBzqjCM, AOS-branded headings, separate cache key, and CI/CD deploying to /var/www/domains/gateway-gamers.net/aos-rankings/. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dafa699 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +.env.local diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..2638b85 --- /dev/null +++ b/.gitlab-ci.yml @@ -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 diff --git a/aos-rankings.gateway-gamers.net.conf b/aos-rankings.gateway-gamers.net.conf new file mode 100644 index 0000000..f52e308 --- /dev/null +++ b/aos-rankings.gateway-gamers.net.conf @@ -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; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..785d2cb --- /dev/null +++ b/index.html @@ -0,0 +1,98 @@ + + + + + + Gateway Gamers — AOS Rankings + + + + + + +
+ + +
+
+

AOS Rankings

+

Age of Sigmar · ITC Standings

+
+
+ + + Loading… + + +
+
+ + + + + + + + + + + +
+ + + + + + + + + + + +
#PlayerITC Points
+
+ + + +
+ +

+ Data from Best Coast Pairings · live on refresh +

+ +
+ + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..15852fd --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..1d5a3b2 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {} + } +} diff --git a/public/footer.html b/public/footer.html new file mode 100644 index 0000000..35977fb --- /dev/null +++ b/public/footer.html @@ -0,0 +1,3 @@ +
+

© 2026 Gateway Gamers. All rights reserved.

+
diff --git a/public/header.html b/public/header.html new file mode 100644 index 0000000..a1a15f1 --- /dev/null +++ b/public/header.html @@ -0,0 +1,57 @@ +
+ + +
+ + + + + +
+ + +
+ + Gateway Gamers + +
+ +
+ + + + +
diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..0ea6f7e Binary files /dev/null and b/public/images/logo.png differ diff --git a/public/images/photo1.jpg b/public/images/photo1.jpg new file mode 100644 index 0000000..1bc8791 Binary files /dev/null and b/public/images/photo1.jpg differ diff --git a/public/images/photo2.jpg b/public/images/photo2.jpg new file mode 100644 index 0000000..6484be1 Binary files /dev/null and b/public/images/photo2.jpg differ diff --git a/public/images/photo3.jpg b/public/images/photo3.jpg new file mode 100644 index 0000000..152560f Binary files /dev/null and b/public/images/photo3.jpg differ diff --git a/public/images/photo4.jpg b/public/images/photo4.jpg new file mode 100644 index 0000000..3bf4a4f Binary files /dev/null and b/public/images/photo4.jpg differ diff --git a/public/images/photo5.jpg b/public/images/photo5.jpg new file mode 100644 index 0000000..5c4807a Binary files /dev/null and b/public/images/photo5.jpg differ diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..6c8be19 --- /dev/null +++ b/src/main.js @@ -0,0 +1,274 @@ +/* ---- 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 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, 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) { + 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 = `${top.wins || 0}–${top.losses || 0}–${top.ties || 0} · 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 = `${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 = + `${i === 0 ? `1` : (i + 1)}` + + `${esc(name)}` + + `${u.nickname && fullName(u) ? `"${esc(u.nickname)}"` : ""}` + + `${fmtPts(r.ITCPoints)}` + + `` + + `${r.wins || 0}${r.losses || 0}–${r.ties || 0}` + + `#${r.placing ?? "—"}`; + 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 = + `
` + + `
` + + `
` + + `
` + + `
`; + 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: ${esc(msg)}`; +} + +/* ---- 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)); +paintCache(); +load(false); diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..4c1b0c2 --- /dev/null +++ b/src/style.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@plugin "daisyui"; diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..87b601b --- /dev/null +++ b/vite.config.js @@ -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'), + } + } + } +});