diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..77a76e7 --- /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/rankings/ + only: + - main + environment: + name: production + url: https://rankings.gateway-gamers.net diff --git a/index.html b/index.html index bfbbe0c..d59bf74 100644 --- a/index.html +++ b/index.html @@ -74,7 +74,7 @@ Player ITC Points W–L–T - League Rank + ITC Rank diff --git a/public/header.html b/public/header.html index f0313d9..14e4fb9 100644 --- a/public/header.html +++ b/public/header.html @@ -1,14 +1,37 @@
-
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/rankings.gateway-gamers.net.conf b/rankings.gateway-gamers.net.conf new file mode 100644 index 0000000..38a8015 --- /dev/null +++ b/rankings.gateway-gamers.net.conf @@ -0,0 +1,36 @@ +server { + listen 80; + listen [::]:80; + server_name rankings.gateway-gamers.net; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name rankings.gateway-gamers.net; + + root /var/www/domains/gateway-gamers.net/rankings; + index index.html; + + # SSL — managed by Certbot + # ssl_certificate /etc/letsencrypt/live/rankings.gateway-gamers.net/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/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/src/main.js b/src/main.js index eb2f929..f7a9229 100644 --- a/src/main.js +++ b/src/main.js @@ -18,7 +18,22 @@ function highlightActiveNav() { }); } -loadHTML('/header.html', 'header', highlightActiveNav); +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'); /* ============================================================ @@ -29,15 +44,8 @@ const CONFIG = { 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], + // Fallback roster if the team endpoint requires auth. + memberIds: [], // Flip to true to preview the layout with placeholder rows (no network). useSample: false, @@ -45,7 +53,7 @@ const CONFIG = { 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 CACHE_KEY = "gg_board_v3"; const $ = (id) => document.getElementById(id); const fmtPts = (n) => Number(n || 0).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 }); @@ -55,7 +63,7 @@ const fullName = (u = {}) => [u.firstName, u.lastName].filter(Boolean).join(" ") async function getMemberIds(force) { try { const r = await fetch(`${API}/teams/${CONFIG.teamId}?expand[]=owner`, - { headers: HEADERS, cache: force ? "reload" : "default" }); + { 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(); @@ -65,58 +73,63 @@ async function getMemberIds(force) { 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 }; + if (ids.size) return { ids, live: true }; throw new Error("empty"); } catch (e) { - if (CONFIG.memberIds.length) return { ids: new Set(CONFIG.memberIds), name: "Gateway Gamers", live: false }; + if (CONFIG.memberIds.length) return { ids: new Set(CONFIG.memberIds), live: false }; throw e; } } -/* ---- Standings (largest accepted limit, one call) ---- */ -async function getPlacings(force) { +/* ---- Standings (cursor-paginated, circular-cursor safe) ---- */ +async function* streamPlacings() { 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; + `®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 rows = Array.isArray(p) ? p : (p.data || p.placings || []); - return { rows, limit: lim, truncated: rows.length >= lim }; - } - throw new Error("placings/limit-all-rejected"); + 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 = [], matched = new Set(); + const kept = []; 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); } + 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, matchedCount: matched.size }; + return kept; } /* ---- Render ---- */ function render(state) { - const { kept, rosterSize, updated, truncated, limit, live, fromCache } = 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 list)"); + $("meta").textContent = `${kept.length} of ${rosterSize} members placed` + (live ? "" : " · roster (cached)"); - // Banner - const b = $("banner"); - b.className = "alert mb-4 hidden"; - if (truncated) { - b.className = "alert alert-warning mb-4"; - b.innerHTML = `Heads up: the league returned the full limit=${limit} rows, ` + - `so some lower-ranked players may be cut off. Tell the developer so the paging cursor can be added.`; - } + $("banner").className = "alert mb-4 hidden"; const champ = $("champion"), strip = $("strip"), empty = $("empty"), tb = $("rows"); tb.innerHTML = ""; @@ -134,7 +147,7 @@ function render(state) { 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-sub").textContent = `${top.wins || 0}–${top.losses || 0}–${top.ties || 0} · ITC #${top.placing ?? "—"}`; $("champ-pts").textContent = fmtPts(top.ITCPoints); // Stat strip @@ -178,8 +191,7 @@ function paintCache() { 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 + kept: state.kept, rosterSize: state.rosterSize, updated: state.updated, live: state.live })); } catch (e) {} } @@ -193,10 +205,19 @@ async function load(force) { 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 }; + + 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) { @@ -230,17 +251,9 @@ function failure(err) { 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 " + - `CONFIG.memberIds 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: " + - `${esc(msg)}`; - } + 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) ---- */ @@ -252,7 +265,7 @@ const SAMPLE = (() => { 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 }; + return { kept, rosterSize: 8, updated: Date.now(), live: true }; })(); /* ---- Go ---- */