+
+
+
-
+
+
+
+
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 ---- */