add kingmaker-style header, full pagination, and CI/CD deployment
- Hero carousel header with logo and photos matching kingmaker site - Renamed "League Rank" to "ITC Rank" - Full cursor-paginated standings scan with circular-cursor detection to handle BCP API's pagination bug at the end of results; incremental rendering shows results as each page arrives - credentials:omit on all BCP fetches to prevent session cookie 500s - GitLab CI pipeline: npm ci + vite build, rsync dist/ to /var/www/domains/gateway-gamers.net/rankings/ on push to main - nginx config for rankings.gateway-gamers.net with CSP allowing connect-src to newprod-api.bestcoastpairings.com Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
+1
-1
@@ -74,7 +74,7 @@
|
|||||||
<th>Player</th>
|
<th>Player</th>
|
||||||
<th class="text-right">ITC Points</th>
|
<th class="text-right">ITC Points</th>
|
||||||
<th class="text-right hidden sm:table-cell">W–L–T</th>
|
<th class="text-right hidden sm:table-cell">W–L–T</th>
|
||||||
<th class="text-right hidden sm:table-cell">League Rank</th>
|
<th class="text-right hidden sm:table-cell">ITC Rank</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="rows"></tbody>
|
<tbody id="rows"></tbody>
|
||||||
|
|||||||
+29
-5
@@ -1,14 +1,37 @@
|
|||||||
<header class="bg-base-300 shadow-lg">
|
<header class="bg-base-300 shadow-lg">
|
||||||
<div class="navbar bg-base-200 px-4 min-h-0 py-2">
|
|
||||||
<div class="navbar-start">
|
<!-- Hero: carousel background + centered logo -->
|
||||||
<a href="/index.html" class="text-xl font-bold tracking-wide uppercase">Gateway Gamers</a>
|
<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>
|
</div>
|
||||||
<div class="navbar-center hidden lg:flex">
|
|
||||||
|
<!-- 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">
|
<ul class="menu menu-horizontal px-1 gap-1">
|
||||||
<li><a href="/index.html">ITC Standings</a></li>
|
<li><a href="/index.html">ITC Standings</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-end gap-2">
|
<div class="navbar-end gap-2 ml-auto">
|
||||||
<label class="swap swap-rotate btn btn-ghost btn-circle" title="Toggle light/dark">
|
<label class="swap swap-rotate btn btn-ghost btn-circle" title="Toggle light/dark">
|
||||||
<input type="checkbox" class="theme-controller" value="light" />
|
<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">
|
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
@@ -30,4 +53,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</header>
|
</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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-58
@@ -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');
|
loadHTML('/footer.html', 'footer');
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -29,15 +44,8 @@ const CONFIG = {
|
|||||||
leagueId: "BYaaUfKum7z0",
|
leagueId: "BYaaUfKum7z0",
|
||||||
regionId: "VgQKgqmTPU",
|
regionId: "VgQKgqmTPU",
|
||||||
|
|
||||||
// Fallback roster, used only if the live team fetch needs auth.
|
// Fallback roster if the team endpoint requires auth.
|
||||||
// Paste the member user IDs your Python --debug run assembled, e.g.
|
memberIds: [],
|
||||||
// ["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).
|
// Flip to true to preview the layout with placeholder rows (no network).
|
||||||
useSample: false,
|
useSample: false,
|
||||||
@@ -45,7 +53,7 @@ const CONFIG = {
|
|||||||
|
|
||||||
const API = "https://newprod-api.bestcoastpairings.com/v1";
|
const API = "https://newprod-api.bestcoastpairings.com/v1";
|
||||||
const HEADERS = { "client-id": "web-app", "env": "bcp", "accept": "application/json" };
|
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 $ = (id) => document.getElementById(id);
|
||||||
const fmtPts = (n) => Number(n || 0).toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
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) {
|
async function getMemberIds(force) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${API}/teams/${CONFIG.teamId}?expand[]=owner`,
|
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);
|
if (!r.ok) throw new Error("auth/" + r.status);
|
||||||
let t = await r.json(); if (Array.isArray(t)) t = t[0] || {};
|
let t = await r.json(); if (Array.isArray(t)) t = t[0] || {};
|
||||||
const ids = new Set();
|
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 (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 (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");
|
throw new Error("empty");
|
||||||
} catch (e) {
|
} 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;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Standings (largest accepted limit, one call) ---- */
|
/* ---- Standings (cursor-paginated, circular-cursor safe) ---- */
|
||||||
async function getPlacings(force) {
|
async function* streamPlacings() {
|
||||||
const base = `${API}/placings?placingsType=player&leagueId=${CONFIG.leagueId}` +
|
const base = `${API}/placings?placingsType=player&leagueId=${CONFIG.leagueId}` +
|
||||||
`®ionId=${CONFIG.regionId}&sortAscending=false`;
|
`®ionId=${CONFIG.regionId}&sortAscending=false&limit=1000`;
|
||||||
for (const lim of CONFIG.limitLadder) {
|
|
||||||
const r = await fetch(`${base}&limit=${lim}`, { headers: HEADERS, cache: force ? "reload" : "default" });
|
let nextKey = null;
|
||||||
if (r.status === 409) continue;
|
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);
|
if (!r.ok) throw new Error("placings/" + r.status);
|
||||||
const p = await r.json();
|
const p = await r.json();
|
||||||
const rows = Array.isArray(p) ? p : (p.data || p.placings || []);
|
const page = Array.isArray(p) ? p : (p.data || p.placings || []);
|
||||||
return { rows, limit: lim, truncated: rows.length >= lim };
|
|
||||||
}
|
yield page;
|
||||||
throw new Error("placings/limit-all-rejected");
|
|
||||||
|
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 ---- */
|
/* ---- Build leaderboard ---- */
|
||||||
function build(rows, ids) {
|
function build(rows, ids) {
|
||||||
const kept = [], matched = new Set();
|
const kept = [];
|
||||||
for (const rec of rows) {
|
for (const rec of rows) {
|
||||||
const u = rec.user || {};
|
const uid = rec.userId || (rec.user && rec.user.id);
|
||||||
const uid = rec.userId || u.id;
|
if (uid && ids.has(uid)) kept.push(rec);
|
||||||
if (uid && ids.has(uid)) { kept.push(rec); matched.add(uid); }
|
|
||||||
}
|
}
|
||||||
kept.sort((a, b) => Number(b.ITCPoints || 0) - Number(a.ITCPoints || 0));
|
kept.sort((a, b) => Number(b.ITCPoints || 0) - Number(a.ITCPoints || 0));
|
||||||
return { kept, matchedCount: matched.size };
|
return kept;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Render ---- */
|
/* ---- Render ---- */
|
||||||
function render(state) {
|
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";
|
$("dot").className = "w-2 h-2 rounded-full bg-success animate-pulse transition-colors";
|
||||||
$("updated").textContent = (fromCache ? "Cached " : "Updated ") +
|
$("updated").textContent = (fromCache ? "Cached " : "Updated ") +
|
||||||
new Date(updated).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
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
|
$("banner").className = "alert mb-4 hidden";
|
||||||
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");
|
const champ = $("champion"), strip = $("strip"), empty = $("empty"), tb = $("rows");
|
||||||
tb.innerHTML = "";
|
tb.innerHTML = "";
|
||||||
@@ -134,7 +147,7 @@ function render(state) {
|
|||||||
const top = kept[0], tu = top.user || {};
|
const top = kept[0], tu = top.user || {};
|
||||||
champ.classList.remove("hidden");
|
champ.classList.remove("hidden");
|
||||||
$("champ-name").textContent = fullName(tu) || tu.nickname || "—";
|
$("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);
|
$("champ-pts").textContent = fmtPts(top.ITCPoints);
|
||||||
|
|
||||||
// Stat strip
|
// Stat strip
|
||||||
@@ -178,8 +191,7 @@ function paintCache() {
|
|||||||
function saveCache(state) {
|
function saveCache(state) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(CACHE_KEY, JSON.stringify({
|
localStorage.setItem(CACHE_KEY, JSON.stringify({
|
||||||
kept: state.kept, rosterSize: state.rosterSize, updated: state.updated,
|
kept: state.kept, rosterSize: state.rosterSize, updated: state.updated, live: state.live
|
||||||
truncated: state.truncated, limit: state.limit, live: state.live
|
|
||||||
}));
|
}));
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -193,10 +205,19 @@ async function load(force) {
|
|||||||
showSkeleton();
|
showSkeleton();
|
||||||
try {
|
try {
|
||||||
if (CONFIG.useSample) { return render({ ...SAMPLE, fromCache: false }); }
|
if (CONFIG.useSample) { return render({ ...SAMPLE, fromCache: false }); }
|
||||||
const { ids, name, live } = await getMemberIds(force);
|
|
||||||
const { rows, limit, truncated } = await getPlacings(force);
|
const { ids, live } = await getMemberIds(force);
|
||||||
const { kept } = build(rows, ids);
|
const allRows = [];
|
||||||
const state = { kept, rosterSize: ids.size, updated: Date.now(), truncated, limit, live };
|
|
||||||
|
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 });
|
render({ ...state, fromCache: false });
|
||||||
saveCache(state);
|
saveCache(state);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -230,17 +251,9 @@ function failure(err) {
|
|||||||
const b = $("banner");
|
const b = $("banner");
|
||||||
const msg = String(err.message || err);
|
const msg = String(err.message || err);
|
||||||
b.className = "alert alert-error mb-4";
|
b.className = "alert alert-error mb-4";
|
||||||
if (msg.startsWith("auth")) {
|
b.innerHTML = msg.includes("placings")
|
||||||
b.innerHTML = "The team roster needs a login. Add the member IDs to " +
|
? "Couldn't reach the BCP standings feed. Try refreshing, or open the page directly in a browser."
|
||||||
`<code class="font-mono text-sm">CONFIG.memberIds</code> in this file so the page can work without one.`;
|
: `Something went wrong: <code class="font-mono text-sm ml-1">${esc(msg)}</code>`;
|
||||||
} 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) ---- */
|
/* ---- Sample (preview only; CONFIG.useSample = true) ---- */
|
||||||
@@ -252,7 +265,7 @@ const SAMPLE = (() => {
|
|||||||
mk("Mara", "Singh", 770.1, 15, 7, 0, 889),
|
mk("Mara", "Singh", 770.1, 15, 7, 0, 889),
|
||||||
mk("Devon", "Cole", 655.9, 12, 8, 2, 1340),
|
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 ---- */
|
/* ---- Go ---- */
|
||||||
|
|||||||
Reference in New Issue
Block a user