This commit is contained in:
starified
2026-04-21 22:03:19 -04:00
parent 36e2d11f2e
commit 08bf320b57
4681 changed files with 566542 additions and 0 deletions

BIN
public/assets/discord.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
public/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

3
public/credits.html Normal file
View File

@@ -0,0 +1,3 @@
starified
crafty.gg
bailey

96
public/index.html Normal file
View File

@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EaglerTiers - Rankings</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="site-bg"></div>
<header class="topbar glass-panel">
<div class="logo-wrap">
<img src="assets/logo.png" alt="Eagler Tiers" class="logo" />
</div>
<div class="search-shell" id="searchShell">
<input id="searchInput" type="text" placeholder="Search players..." autocomplete="off" />
<div id="searchDropdown" class="search-dropdown hidden"></div>
</div>
<div class="actions">
<a href="https://discord.gg/tMeKxeD3Va" class="btn btn-discord" target="_blank" rel="noopener noreferrer">
<img src="assets/discord.png" alt="Discord" />
Get Tier Tested
</a>
</div>
</header>
<nav class="tabs glass-panel" id="tabs"></nav>
<main class="content">
<div class="list-head">
<h1 id="viewTitle">Overall Rankings</h1>
<div id="statusLabel" class="status-label"></div>
</div>
<div id="loadingState" class="loading-state hidden">
<div class="spinner"></div>
<span>Loading rankings...</span>
</div>
<div id="errorState" class="error-state hidden"></div>
<section id="rankingList" class="ranking-list"></section>
<div id="pagination" class="pagination hidden">
<button id="prevPage" class="page-btn">Previous</button>
<span id="pageInfo"></span>
<button id="nextPage" class="page-btn">Next</button>
</div>
</main>
<div id="profileOverlay" class="profile-overlay hidden" aria-hidden="true">
<section id="profileModal" class="profile-modal glass-panel" role="dialog" aria-modal="true" aria-labelledby="profileUsername">
<button id="profileCloseBtn" class="close-btn" aria-label="Close profile">x</button>
<div id="profileLoading" class="loading-state">
<div class="spinner"></div>
<span>Loading profile...</span>
</div>
<div id="profileError" class="error-state hidden"></div>
<div id="profileContent" class="hidden">
<div class="profile-header">
<div class="profile-avatar-circle">
<img id="profileAvatar" src="" alt="Player avatar" />
</div>
<h1 id="profileUsername"></h1>
<p id="profileRegion" class="profile-region"></p>
<p id="profileTopPoints" class="profile-top-points"></p>
</div>
<h3 class="profile-section-title">Position</h3>
<div class="position-panel compact">
<span id="profilePosition" class="position-rank">#-</span>
</div>
<h3 class="profile-section-title">Tiers</h3>
<div id="profileTiersPanel" class="profile-tiers-panel">
<div id="profileTiers" class="profile-tier-circles"></div>
</div>
</div>
</section>
</div>
<script src="script.js"></script>
</body>
</html>

57
public/player.html Normal file
View File

@@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EaglerTiers - Player Profile</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
</head>
<body class="profile-page">
<div class="site-bg"></div>
<header class="topbar glass-panel small">
<a href="index.html" class="logo-wrap">
<img src="assets/logo.png" alt="Eagler Tiers" class="logo" />
</a>
<a href="index.html" class="btn btn-login">Back to Rankings</a>
</header>
<main class="profile-main">
<section id="profileCard" class="profile-modal glass-panel hidden">
<button id="closeBtn" class="close-btn" aria-label="Close">x</button>
<div class="profile-header">
<img id="profileAvatar" src="" alt="Player avatar" />
<h1 id="profileUsername"></h1>
</div>
<div class="position-panel">
<span>Position</span>
<strong id="profilePosition">#-</strong>
</div>
<div class="totals-panel">
<span>Total Points</span>
<strong id="profilePoints">0</strong>
</div>
<div id="profileTiers" class="tiers-grid"></div>
</section>
<div id="profileLoading" class="loading-state">
<div class="spinner"></div>
<span>Loading profile...</span>
</div>
<div id="profileError" class="error-state hidden"></div>
</main>
<script src="player.js"></script>
</body>
</html>

106
public/player.js Normal file
View File

@@ -0,0 +1,106 @@
const MODE_LABELS = {
vanilla: "Vanilla",
mace: "Mace",
axe: "Axe",
sword: "Sword",
smp: "SMP",
diamondsmp: "Diamond SMP",
uhc: "UHC",
pot: "Pot",
nethop: "Neth OP",
cart: "Cart",
};
const MODE_ICON_PATH = {
vanilla: "assets/gamemodes/smaller-vanilla-pvp.png",
mace: "assets/gamemodes/mace-pvp.png",
axe: "assets/gamemodes/axe-pvp.png",
sword: "assets/gamemodes/sword-pvp.png",
smp: "assets/gamemodes/smp.png",
diamondsmp: "assets/gamemodes/diamond-smp.png",
uhc: "assets/gamemodes/uhc.png",
pot: "assets/gamemodes/pot-pvp.png",
nethop: "assets/gamemodes/neth-op.png",
cart: "assets/gamemodes/cart-pvp.png",
};
function getAvatarUrl(username) {
return `https://render.crafty.gg/3d/bust/${encodeURIComponent(username)}`;
}
const profileCardEl = document.getElementById("profileCard");
const profileLoadingEl = document.getElementById("profileLoading");
const profileErrorEl = document.getElementById("profileError");
const profileAvatarEl = document.getElementById("profileAvatar");
const profileUsernameEl = document.getElementById("profileUsername");
const profilePositionEl = document.getElementById("profilePosition");
const profilePointsEl = document.getElementById("profilePoints");
const profileTiersEl = document.getElementById("profileTiers");
const closeBtnEl = document.getElementById("closeBtn");
function getUserFromQuery() {
const params = new URLSearchParams(window.location.search);
return params.get("user");
}
function showError(message) {
profileErrorEl.classList.remove("hidden");
profileErrorEl.textContent = message;
}
function renderProfile(player) {
profileAvatarEl.src = getAvatarUrl(player.username);
profileAvatarEl.alt = `${player.username} avatar`;
profileUsernameEl.textContent = player.username;
profilePositionEl.textContent = `#${player.position}`;
profilePointsEl.textContent = String(player.totalPoints);
const tiers = Object.entries(player.tiers || {})
.map(
([mode, tier]) => `
<article class="profile-tier">
<img src="${MODE_ICON_PATH[mode]}" alt="${MODE_LABELS[mode] || mode}" />
<h3>${MODE_LABELS[mode] || mode}</h3>
<p>${tier || "-"}</p>
</article>
`
)
.join("");
profileTiersEl.innerHTML = tiers;
profileCardEl.classList.remove("hidden");
}
async function loadProfile() {
const username = getUserFromQuery();
if (!username) {
profileLoadingEl.classList.add("hidden");
showError("Missing username. Open from the ranking page.");
return;
}
try {
const response = await fetch(`/api/players/${encodeURIComponent(username)}`, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed (${response.status})`);
}
const player = await response.json();
renderProfile(player);
} catch (_error) {
showError("Failed to load profile.");
} finally {
profileLoadingEl.classList.add("hidden");
}
}
closeBtnEl.addEventListener("click", () => {
window.location.href = "index.html";
});
loadProfile();

630
public/script.js Normal file
View File

@@ -0,0 +1,630 @@
const GAMEMODES = [
{ id: "overall", label: "Overall", icon: "assets/gamemodes/overall.png" },
{ id: "vanilla", label: "Vanilla", icon: "assets/gamemodes/smaller-vanilla-pvp.png" },
{ id: "mace", label: "Mace", icon: "assets/gamemodes/mace-pvp.png" },
{ id: "axe", label: "Axe", icon: "assets/gamemodes/axe-pvp.png" },
{ id: "sword", label: "Sword", icon: "assets/gamemodes/sword-pvp.png" },
{ id: "smp", label: "SMP", icon: "assets/gamemodes/smp.png" },
{ id: "diamondsmp", label: "Diamond SMP", icon: "assets/gamemodes/diamond-smp.png" },
{ id: "uhc", label: "UHC", icon: "assets/gamemodes/uhc.png" },
{ id: "pot", label: "Pot", icon: "assets/gamemodes/pot-pvp.png" },
{ id: "nethop", label: "Neth OP", icon: "assets/gamemodes/neth-op.png" },
{ id: "cart", label: "Cart", icon: "assets/gamemodes/cart-pvp.png" },
];
const MODE_ICON_PATH = {
vanilla: "assets/gamemodes/smaller-vanilla-pvp.png",
mace: "assets/gamemodes/mace-pvp.png",
axe: "assets/gamemodes/axe-pvp.png",
sword: "assets/gamemodes/sword-pvp.png",
smp: "assets/gamemodes/smp.png",
diamondsmp: "assets/gamemodes/diamond-smp.png",
uhc: "assets/gamemodes/uhc.png",
pot: "assets/gamemodes/pot-pvp.png",
nethop: "assets/gamemodes/neth-op.png",
cart: "assets/gamemodes/cart-pvp.png",
};
const MODE_LABELS = {
vanilla: "Vanilla",
mace: "Mace",
axe: "Axe",
sword: "Sword",
smp: "SMP",
diamondsmp: "Diamond SMP",
uhc: "UHC",
pot: "Pot",
nethop: "Neth OP",
cart: "Cart",
};
const TIER_POINTS = {
HT1: 60,
LT1: 45,
HT2: 30,
LT2: 20,
HT3: 10,
LT3: 6,
HT4: 4,
LT4: 3,
HT5: 2,
LT5: 1,
};
function getAvatarUrl(username) {
return `https://render.crafty.gg/3d/bust/${encodeURIComponent(username)}`;
}
const state = {
gamemode: "overall",
page: 1,
totalPages: 1,
totalItems: 0,
players: [],
};
const profileCache = new Map();
const searchCache = new Map();
let activeSearchController = null;
const tabsEl = document.getElementById("tabs");
const rankingListEl = document.getElementById("rankingList");
const listHeadEl = document.querySelector(".list-head");
const viewTitleEl = document.getElementById("viewTitle");
const statusLabelEl = document.getElementById("statusLabel");
const loadingStateEl = document.getElementById("loadingState");
const errorStateEl = document.getElementById("errorState");
const paginationEl = document.getElementById("pagination");
const pageInfoEl = document.getElementById("pageInfo");
const prevPageEl = document.getElementById("prevPage");
const nextPageEl = document.getElementById("nextPage");
const searchInputEl = document.getElementById("searchInput");
const searchDropdownEl = document.getElementById("searchDropdown");
const profileOverlayEl = document.getElementById("profileOverlay");
const profileModalEl = document.getElementById("profileModal");
const profileCloseBtnEl = document.getElementById("profileCloseBtn");
const profileLoadingEl = document.getElementById("profileLoading");
const profileErrorEl = document.getElementById("profileError");
const profileContentEl = document.getElementById("profileContent");
const profileAvatarEl = document.getElementById("profileAvatar");
const profileUsernameEl = document.getElementById("profileUsername");
const profileRegionEl = document.getElementById("profileRegion");
const profileTopPointsEl = document.getElementById("profileTopPoints");
const profilePositionEl = document.getElementById("profilePosition");
const profileTiersEl = document.getElementById("profileTiers");
function debounce(fn, delay = 250) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function tierColorClass(tier) {
if (!tier) {
return "";
}
return tier.toLowerCase();
}
function buildTabs() {
tabsEl.innerHTML = GAMEMODES.map(
(mode) => `
<button
class="tab-btn ${mode.id === state.gamemode ? "active" : ""}"
data-mode="${mode.id}"
title="${mode.label}"
aria-label="${mode.label}"
>
<img src="${mode.icon}" alt="${mode.label}" class="tab-icon" />
<span class="sr-only">${mode.label}</span>
</button>
`
).join("");
tabsEl.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const mode = btn.dataset.mode;
if (mode === state.gamemode) {
return;
}
state.gamemode = mode;
state.page = 1;
buildTabs();
loadRankings();
});
});
}
function closeProfileModal() {
profileOverlayEl.classList.add("hidden");
profileOverlayEl.setAttribute("aria-hidden", "true");
document.body.classList.remove("modal-open");
}
function renderProfileModal(player) {
profileAvatarEl.src = getAvatarUrl(player.username);
profileAvatarEl.alt = `${player.username} avatar`;
profileUsernameEl.textContent = player.username;
profilePositionEl.textContent = `#${player.position}`;
const profileTopClass =
player.position === 1 ? "position-top-1" : player.position === 2 ? "position-top-2" : player.position === 3 ? "position-top-3" : "";
profilePositionEl.className = `position-rank ${profileTopClass}`.trim();
const region = String(player.region || "NA").toUpperCase();
profileRegionEl.textContent = region;
profileRegionEl.className = `profile-region region-${region.toLowerCase()}`;
profileTopPointsEl.textContent = `${player.totalPoints} points`;
const sortedProfileTiers = Object.entries(player.tiers || {})
.filter(([, tier]) => tier)
.map(([mode, tier]) => ({
mode,
tier,
points: TIER_POINTS[tier] || 0,
}))
.sort((a, b) => {
if (b.points !== a.points) {
return b.points - a.points;
}
return (MODE_LABELS[a.mode] || a.mode).localeCompare(MODE_LABELS[b.mode] || b.mode);
});
const maxTierSlots = 10;
const filledSlots = sortedProfileTiers.slice(0, maxTierSlots);
const emptySlots = Math.max(maxTierSlots - filledSlots.length, 0);
profileTiersEl.innerHTML = [
...filledSlots.map(
({ mode, tier, points }) => `
<div class="profile-tier-bubble filled">
<span class="profile-tier-icon-wrap ${tierColorClass(tier)}" data-tier="${tier}" data-points="${points}">
<img src="${MODE_ICON_PATH[mode]}" alt="${MODE_LABELS[mode] || mode}" />
<span class="tier-hover-pop" role="tooltip">
<strong>${tier}</strong>
<span>${points} points</span>
</span>
</span>
<span class="profile-tier-tag ${tierColorClass(tier)}">${tier}</span>
</div>
`
),
...Array.from({ length: emptySlots }).map(
() => `
<div class="profile-tier-bubble empty">
<span class="profile-tier-icon-wrap"></span>
<span class="profile-tier-tag muted">-</span>
</div>
`
),
].join("");
if (!profileTiersEl.innerHTML) {
profileTiersEl.innerHTML = `<p class="profile-tier-empty">No gamemode tiers assigned yet</p>`;
}
}
async function openProfileModal(username) {
profileOverlayEl.classList.remove("hidden");
profileOverlayEl.setAttribute("aria-hidden", "false");
document.body.classList.add("modal-open");
profileLoadingEl.classList.remove("hidden");
profileErrorEl.classList.add("hidden");
profileErrorEl.textContent = "";
profileContentEl.classList.add("hidden");
try {
if (profileCache.has(username)) {
renderProfileModal(profileCache.get(username));
profileContentEl.classList.remove("hidden");
return;
}
const response = await fetch(`/api/players/${encodeURIComponent(username)}`, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Request failed (${response.status})`);
}
const player = await response.json();
profileCache.set(username, player);
renderProfileModal(player);
profileContentEl.classList.remove("hidden");
} catch (_error) {
profileErrorEl.classList.remove("hidden");
profileErrorEl.textContent = "Failed to load player profile.";
} finally {
profileLoadingEl.classList.add("hidden");
}
}
function setLoading(loading) {
loadingStateEl.classList.toggle("hidden", !loading);
}
function setError(message = "") {
errorStateEl.classList.toggle("hidden", !message);
errorStateEl.textContent = message;
}
function renderPagination() {
if (state.gamemode !== "overall" || state.totalItems === 0) {
paginationEl.classList.add("hidden");
return;
}
paginationEl.classList.remove("hidden");
pageInfoEl.textContent = `Page ${state.page} / ${state.totalPages} | ${state.totalItems} players`;
prevPageEl.disabled = state.page <= 1;
nextPageEl.disabled = state.page >= state.totalPages;
}
function visibleTiers(player) {
return Object.entries(player.tiers || {}).filter(([, tier]) => tier);
}
function getTierNumber(tierValue) {
const value = String(tierValue || "");
const maybeNumber = Number(value.slice(-1));
return Number.isInteger(maybeNumber) ? maybeNumber : null;
}
function getTierBand(tierValue) {
return String(tierValue || "").toUpperCase().startsWith("HT") ? "HT" : "LT";
}
function tierHeaderIconTemplate(tierNum) {
if (tierNum > 3) {
return "";
}
return `
<span class="tier-head-icon trophy-${tierNum}" aria-hidden="true">
<img src="assets/gamemodes/overall.png" alt="" />
</span>
`;
}
function playerCardTemplate(player, index) {
const globalIndex = (state.page - 1) * 20 + index + 1;
let topClass = "";
if (globalIndex === 1) {
topClass = "top-1";
} else if (globalIndex === 2) {
topClass = "top-2";
} else if (globalIndex === 3) {
topClass = "top-3";
}
const tierEntries = visibleTiers(player)
.map(([mode, tier]) => ({
mode,
tier,
points: TIER_POINTS[tier] || 0,
}))
.sort((a, b) => {
if (b.points !== a.points) {
return b.points - a.points;
}
return (MODE_LABELS[a.mode] || a.mode).localeCompare(MODE_LABELS[b.mode] || b.mode);
});
const maxTierSlots = 10;
const filledSlots = tierEntries.slice(0, maxTierSlots);
const emptySlots = Math.max(maxTierSlots - filledSlots.length, 0);
const tierSlotsHtml = [
...filledSlots.map(
(entry) => `
<div class="tier-slot filled">
<span class="tier-bubble ${tierColorClass(entry.tier)}" data-tier="${entry.tier}" data-points="${entry.points}">
<img src="${MODE_ICON_PATH[entry.mode]}" alt="${MODE_LABELS[entry.mode] || entry.mode}" />
<span class="tier-hover-pop" role="tooltip">
<strong>${entry.tier}</strong>
<span>${entry.points} points</span>
</span>
</span>
<span class="tier-label ${tierColorClass(entry.tier)}">${entry.tier}</span>
</div>
`
),
...Array.from({ length: emptySlots }).map(
() => `
<div class="tier-slot empty">
<span class="tier-bubble"></span>
<span class="tier-label muted">-</span>
</div>
`
),
].join("");
const points = state.gamemode === "overall" ? player.totalPoints : player.gamemodePoints || 0;
const region = String(player.region || "NA").toUpperCase();
const regionClass = `region-${region.toLowerCase()}`;
return `
<article class="rank-card ${topClass}" data-username="${player.username}">
<div class="rank-number">#${globalIndex}</div>
<div class="rank-main">
<div class="player-head">
<img class="avatar" src="${getAvatarUrl(player.username)}" alt="${player.username}" loading="lazy" decoding="async" />
<strong>${player.username}</strong>
</div>
<div class="rank-detail-band">
<div class="region-spot ${regionClass}">
<span>${region}</span>
</div>
<div class="tier-slot-list">${tierSlotsHtml}</div>
</div>
</div>
<div class="points-col">
<strong>${points}</strong>
<span>POINTS</span>
</div>
</article>
`;
}
function renderRankings() {
viewTitleEl.textContent =
state.gamemode === "overall"
? "Overall Rankings"
: `${GAMEMODES.find((m) => m.id === state.gamemode)?.label || state.gamemode} Rankings`;
statusLabelEl.textContent = `${state.totalItems} players ranked`;
listHeadEl.classList.add("centered");
if (state.gamemode !== "overall") {
const grouped = {
1: [],
2: [],
3: [],
4: [],
5: [],
};
for (const player of state.players) {
const tierValue = player.tiers?.[state.gamemode];
if (!tierValue) {
continue;
}
const tierNumber = getTierNumber(tierValue);
if (!tierNumber || !grouped[tierNumber]) {
continue;
}
grouped[tierNumber].push({
username: player.username,
tierValue,
band: getTierBand(tierValue),
});
}
for (const key of Object.keys(grouped)) {
grouped[key].sort((a, b) => {
if (a.band !== b.band) {
return a.band === "HT" ? -1 : 1;
}
return a.username.localeCompare(b.username);
});
}
rankingListEl.innerHTML = `
<section class="tier-columns">
${[1, 2, 3, 4, 5]
.map((tierNum) => {
const rows = grouped[tierNum];
return `
<article class="tier-column glass-panel tier-column-${tierNum}">
<header class="tier-column-head">
<div class="tier-column-title-row">
${tierHeaderIconTemplate(tierNum)}
<span class="tier-column-title">Tier ${tierNum}</span>
</div>
</header>
<div class="tier-column-body">
${
rows.length === 0
? `<p class="tier-empty">No players currently have this rank yet</p>`
: rows
.map(
(row) => `
<button class="tier-player-row" data-username="${row.username}">
<img class="tier-player-avatar" src="${getAvatarUrl(row.username)}" alt="${row.username}" loading="lazy" decoding="async" />
<span class="tier-player-name">${row.username}</span>
<span class="tier-player-arrow ${row.band === "HT" ? "up" : "down"}">${row.band === "HT" ? "↑" : "↓"}</span>
</button>
`
)
.join("")
}
</div>
</article>
`;
})
.join("")}
</section>
`;
rankingListEl.querySelectorAll(".tier-player-row").forEach((row) => {
row.addEventListener("click", () => {
openProfileModal(row.dataset.username);
});
});
renderPagination();
return;
}
if (state.players.length === 0) {
rankingListEl.innerHTML = "";
renderPagination();
return;
}
rankingListEl.innerHTML = state.players.map(playerCardTemplate).join("");
rankingListEl.querySelectorAll(".rank-card").forEach((card) => {
card.addEventListener("click", () => {
const username = card.dataset.username;
openProfileModal(username);
});
});
renderPagination();
}
async function loadRankings() {
setError("");
setLoading(true);
rankingListEl.innerHTML = "";
const endpoint =
state.gamemode === "overall"
? `/api/players?page=${state.page}`
: `/api/rankings/${state.gamemode}?page=1&perPage=500`;
try {
const response = await fetch(endpoint, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Request failed (${response.status})`);
}
const payload = await response.json();
state.players = payload.data || [];
state.totalPages = payload.pagination?.totalPages || 1;
state.totalItems = payload.pagination?.total || state.players.length;
renderRankings();
} catch (_error) {
setError("Failed to load rankings. Check API/database connection.");
} finally {
setLoading(false);
}
}
async function runSearch(query) {
if (!query) {
searchDropdownEl.classList.add("hidden");
searchDropdownEl.innerHTML = "";
return;
}
if (searchCache.has(query)) {
const cached = searchCache.get(query);
renderSearchResults(cached);
return;
}
if (activeSearchController) {
activeSearchController.abort();
}
activeSearchController = new AbortController();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
headers: { Accept: "application/json" },
signal: activeSearchController.signal,
});
if (!response.ok) {
throw new Error("Search request failed");
}
const payload = await response.json();
const results = payload.data || [];
searchCache.set(query, results);
renderSearchResults(results);
} catch (_error) {
if (_error && _error.name === "AbortError") {
return;
}
searchDropdownEl.classList.remove("hidden");
searchDropdownEl.innerHTML = `<div class="search-item"><div></div><div><strong>Search failed</strong></div></div>`;
} finally {
activeSearchController = null;
}
}
function renderSearchResults(results) {
if (results.length === 0) {
searchDropdownEl.classList.remove("hidden");
searchDropdownEl.innerHTML = `<div class="search-item"><div></div><div><strong>No players found</strong></div></div>`;
return;
}
searchDropdownEl.classList.remove("hidden");
searchDropdownEl.innerHTML = results
.map(
(player) => `
<div class="search-item" data-user="${player.username}">
<img src="${getAvatarUrl(player.username)}" alt="${player.username}" loading="lazy" decoding="async" />
<div>
<strong>${player.username}</strong>
</div>
<div class="search-points">
<strong>${player.totalPoints || 0}</strong>
<span>POINTS</span>
</div>
</div>
`
)
.join("");
searchDropdownEl.querySelectorAll(".search-item[data-user]").forEach((item) => {
item.addEventListener("click", () => {
const username = item.dataset.user;
searchDropdownEl.classList.add("hidden");
openProfileModal(username);
});
});
}
const debouncedSearch = debounce((value) => runSearch(value), 220);
searchInputEl.addEventListener("input", (event) => {
debouncedSearch(event.target.value.trim());
});
document.addEventListener("click", (event) => {
if (!event.target.closest("#searchShell")) {
searchDropdownEl.classList.add("hidden");
}
});
profileCloseBtnEl.addEventListener("click", closeProfileModal);
profileOverlayEl.addEventListener("click", (event) => {
if (!event.target.closest("#profileModal")) {
closeProfileModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !profileOverlayEl.classList.contains("hidden")) {
closeProfileModal();
}
});
prevPageEl.addEventListener("click", () => {
if (state.page > 1) {
state.page -= 1;
loadRankings();
}
});
nextPageEl.addEventListener("click", () => {
if (state.page < state.totalPages) {
state.page += 1;
loadRankings();
}
});
buildTabs();
loadRankings();

1395
public/styles.css Normal file

File diff suppressed because it is too large Load Diff