HASH 3ecd8d4266b7
DATE 2025-09-16
SUBJECT web: support class/realm/region player leaderboards
FILES 11 CHANGED
HASH 3ecd8d4266b7
DATE 2025-09-16
SUBJECT web: support class/realm/region player
leaderboards
FILES 11 CHANGED
┌─ web/.astro/data-store.json ───────────────────────────────────────────────┐
│ diff --git a/web/.astro/data-store.json b/web/.astro/data-store.json │
│ index fb97c30..3aef997 100644 │
│ --- a/web/.astro/data-store.json │
│ +++ b/web/.astro/data-store.json │
│ @@ -1 +1 @@ │
│ -[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.12.0","astro-config- │
│ digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{} │
│ ,\"site\":\"https://wowsimstats.com\",\"compressHTML\":true,\"base\":\"/\",\"trail │
│ ingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\", │
│ \"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astr │
│ o\",\"serverEntry\":\"entry.mjs\",\"redirects\":false,\"inlineStylesheets\":\"auto │
│ \",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":24173,\"s │
│ treaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"r │
│ oute\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\" │
│ config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"de │
│ vToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\ │
│ ",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"th │
│ eme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPl │
│ ugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":t │
│ rue},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\ │
│ ":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false │
│ ,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\ │
│ ":false,\"csp\":false,\"rawEnvValues\":false},\"legacy\":{\"collections\":false}}" │
│ ] │
│ \ No newline at end of file │
│ +[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.12.0","astro-config- │
│ digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{} │
│ ,\"site\":\"https://wowsimstats.com\",\"compressHTML\":true,\"base\":\"/\",\"trail │
│ ingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\", │
│ \"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astr │
│ o\",\"serverEntry\":\"entry.mjs\",\"redirects\":false,\"inlineStylesheets\":\"auto │
│ \",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":30711,\"s │
│ treaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"r │
│ oute\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\" │
│ config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"de │
│ vToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\ │
│ ",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"th │
│ eme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPl │
│ ugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":t │
│ rue},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\ │
│ ":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false │
│ ,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\ │
│ ":false,\"csp\":false,\"rawEnvValues\":false},\"legacy\":{\"collections\":false}}" │
│ ] │
│ \ No newline at end of file │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ web/.astro/data-store.json ─────────┐
│ diff --git a/web/.astro/data-store.json b/we │
│ b/.astro/data-store.json │
│ index fb97c30..3aef997 100644 │
│ --- a/web/.astro/data-store.json │
│ +++ b/web/.astro/data-store.json │
│ @@ -1 +1 @@ │
│ -[["Map",1,2],"meta::meta",["Map",3,4,5,6]," │
│ astro-version","5.12.0","astro-config-digest │
│ ","{\"root\":{},\"srcDir\":{},\"publicDir\": │
│ {},\"outDir\":{},\"cacheDir\":{},\"site\":\" │
│ https://wowsimstats.com\",\"compressHTML\":t │
│ rue,\"base\":\"/\",\"trailingSlash\":\"ignor │
│ e\",\"output\":\"server\",\"scopedStyleStrat │
│ egy\":\"attribute\",\"build\":{\"format\":\" │
│ directory\",\"client\":{},\"server\":{},\"as │
│ sets\":\"_astro\",\"serverEntry\":\"entry.mj │
│ s\",\"redirects\":false,\"inlineStylesheets\ │
│ ":\"auto\",\"concurrency\":1},\"server\":{\" │
│ open\":false,\"host\":false,\"port\":24173,\ │
│ "streaming\":true,\"allowedHosts\":[]},\"red │
│ irects\":{},\"image\":{\"endpoint\":{\"route │
│ \":\"/_image\"},\"service\":{\"entrypoint\": │
│ \"astro/assets/services/sharp\",\"config\":{ │
│ }},\"domains\":[],\"remotePatterns\":[],\"re │
│ sponsiveStyles\":false},\"devToolbar\":{\"en │
│ abled\":true},\"markdown\":{\"syntaxHighligh │
│ t\":{\"type\":\"shiki\",\"excludeLangs\":[\" │
│ math\"]},\"shikiConfig\":{\"langs\":[],\"lan │
│ gAlias\":{},\"theme\":\"github-dark\",\"them │
│ es\":{},\"wrap\":false,\"transformers\":[]}, │
│ \"remarkPlugins\":[],\"rehypePlugins\":[],\" │
│ remarkRehype\":{},\"gfm\":true,\"smartypants │
│ \":true},\"security\":{\"checkOrigin\":true} │
│ ,\"env\":{\"schema\":{},\"validateSecrets\": │
│ false},\"experimental\":{\"clientPrerender\" │
│ :false,\"contentIntellisense\":false,\"headi │
│ ngIdCompat\":false,\"preserveScriptOrder\":f │
│ alse,\"liveContentCollections\":false,\"csp\ │
│ ":false,\"rawEnvValues\":false},\"legacy\":{ │
│ \"collections\":false}}"] │
│ \ No newline at end of file │
│ +[["Map",1,2],"meta::meta",["Map",3,4,5,6]," │
│ astro-version","5.12.0","astro-config-digest │
│ ","{\"root\":{},\"srcDir\":{},\"publicDir\": │
│ {},\"outDir\":{},\"cacheDir\":{},\"site\":\" │
│ https://wowsimstats.com\",\"compressHTML\":t │
│ rue,\"base\":\"/\",\"trailingSlash\":\"ignor │
│ e\",\"output\":\"server\",\"scopedStyleStrat │
│ egy\":\"attribute\",\"build\":{\"format\":\" │
│ directory\",\"client\":{},\"server\":{},\"as │
│ sets\":\"_astro\",\"serverEntry\":\"entry.mj │
│ s\",\"redirects\":false,\"inlineStylesheets\ │
│ ":\"auto\",\"concurrency\":1},\"server\":{\" │
│ open\":false,\"host\":false,\"port\":30711,\ │
│ "streaming\":true,\"allowedHosts\":[]},\"red │
│ irects\":{},\"image\":{\"endpoint\":{\"route │
│ \":\"/_image\"},\"service\":{\"entrypoint\": │
│ \"astro/assets/services/sharp\",\"config\":{ │
│ }},\"domains\":[],\"remotePatterns\":[],\"re │
│ sponsiveStyles\":false},\"devToolbar\":{\"en │
│ abled\":true},\"markdown\":{\"syntaxHighligh │
│ t\":{\"type\":\"shiki\",\"excludeLangs\":[\" │
│ math\"]},\"shikiConfig\":{\"langs\":[],\"lan │
│ gAlias\":{},\"theme\":\"github-dark\",\"them │
│ es\":{},\"wrap\":false,\"transformers\":[]}, │
│ \"remarkPlugins\":[],\"rehypePlugins\":[],\" │
│ remarkRehype\":{},\"gfm\":true,\"smartypants │
│ \":true},\"security\":{\"checkOrigin\":true} │
│ ,\"env\":{\"schema\":{},\"validateSecrets\": │
│ false},\"experimental\":{\"clientPrerender\" │
│ :false,\"contentIntellisense\":false,\"headi │
│ ngIdCompat\":false,\"preserveScriptOrder\":f │
│ alse,\"liveContentCollections\":false,\"csp\ │
│ ":false,\"rawEnvValues\":false},\"legacy\":{ │
│ \"collections\":false}}"] │
│ \ No newline at end of file │
└──────────────────────────────────────────────┘
┌─ web/.astro/types.d.ts ────────────────────────────────────────────────────┐
│ diff --git a/web/.astro/types.d.ts b/web/.astro/types.d.ts │
│ index f964fe0..03d7cc4 100644 │
│ --- a/web/.astro/types.d.ts │
│ +++ b/web/.astro/types.d.ts │
│ @@ -1 +1,2 @@ │
│ /// <reference types="astro/client" /> │
│ +/// <reference path="content.d.ts" /> │
│ \ No newline at end of file │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ web/.astro/types.d.ts ──────────────┐
│ diff --git a/web/.astro/types.d.ts b/web/.as │
│ tro/types.d.ts │
│ index f964fe0..03d7cc4 100644 │
│ --- a/web/.astro/types.d.ts │
│ +++ b/web/.astro/types.d.ts │
│ @@ -1 +1,2 @@ │
│ /// <reference types="astro/client" /> │
│ +/// <reference path="content.d.ts" /> │
│ \ No newline at end of file │
└──────────────────────────────────────────────┘
┌─ web/src/client/filter-panel.ts ───────────────────────────────────────────┐
│ diff --git a/web/src/client/filter-panel.ts b/web/src/client/filter-panel.ts │
│ index 5b15d8a..f191e28 100644 │
│ --- a/web/src/client/filter-panel.ts │
│ +++ b/web/src/client/filter-panel.ts │
│ @@ -37,13 +37,11 @@ class FilterPanel { │
│ if (initDungeon) this.dungeonSelect.value = initDungeon; │
│ │
│ this.initializeFromURL(); │
│ - // If URL didn't drive an initial load, auto-load current selection once tabl │
│ e is ready │
│ - if (!this.didInitFromURL) { │
│ + // If URL didn't drive an initial load, only auto-load if the dungeon section │
│ is active │
│ + if (!this.didInitFromURL && this.isDungeonSectionActive()) { │
│ const region = this.regionSelect.value; │
│ const dungeon = this.dungeonSelect.value; │
│ - if (region && dungeon) { │
│ - this.loadWhenReady(1); │
│ - } │
│ + if (region && dungeon) this.loadWhenReady(1); │
│ } │
│ } │
│ │
│ @@ -56,6 +54,17 @@ class FilterPanel { │
│ this.dungeonSelect.addEventListener("change", () => this.loadLeaderboard()); │
│ } │
│ │
│ + private isDungeonSectionActive(): boolean { │
│ + try { │
│ + const section = this.container.closest("#dungeon-section") as │
│ + | HTMLElement │
│ + | null; │
│ + return !!section && section.classList.contains("active"); │
│ + } catch { │
│ + return false; │
│ + } │
│ + } │
│ + │
│ private updateRealmOptions() { │
│ const region = this.regionSelect.value; │
│ const allOptions = this.realmSelect.querySelectorAll("option[data-region]"); │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ web/src/client/filter-panel.ts ─────┐
│ diff --git a/web/src/client/filter-panel.ts │
│ b/web/src/client/filter-panel.ts │
│ index 5b15d8a..f191e28 100644 │
│ --- a/web/src/client/filter-panel.ts │
│ +++ b/web/src/client/filter-panel.ts │
│ @@ -37,13 +37,11 @@ class FilterPanel { │
│ if (initDungeon) this.dungeonSelect.val │
│ ue = initDungeon; │
│ │
│ this.initializeFromURL(); │
│ - // If URL didn't drive an initial load, │
│ auto-load current selection once table is r │
│ eady │
│ - if (!this.didInitFromURL) { │
│ + // If URL didn't drive an initial load, │
│ only auto-load if the dungeon section is ac │
│ tive │
│ + if (!this.didInitFromURL && this.isDung │
│ eonSectionActive()) { │
│ const region = this.regionSelect.valu │
│ e; │
│ const dungeon = this.dungeonSelect.va │
│ lue; │
│ - if (region && dungeon) { │
│ - this.loadWhenReady(1); │
│ - } │
│ + if (region && dungeon) this.loadWhenR │
│ eady(1); │
│ } │
│ } │
│ │
│ @@ -56,6 +54,17 @@ class FilterPanel { │
│ this.dungeonSelect.addEventListener("ch │
│ ange", () => this.loadLeaderboard()); │
│ } │
│ │
│ + private isDungeonSectionActive(): boolean │
│ { │
│ + try { │
│ + const section = this.container.closes │
│ t("#dungeon-section") as │
│ + | HTMLElement │
│ + | null; │
│ + return !!section && section.classList │
│ .contains("active"); │
│ + } catch { │
│ + return false; │
│ + } │
│ + } │
│ + │
│ private updateRealmOptions() { │
│ const region = this.regionSelect.value; │
│ const allOptions = this.realmSelect.que │
│ rySelectorAll("option[data-region]"); │
└──────────────────────────────────────────────┘
┌─ web/src/client/leaderboard-table.ts ──────────────────────────────────────┐
│ diff --git a/web/src/client/leaderboard-table.ts b/web/src/client/leaderboard-tabl │
│ e.ts │
│ index adea96f..2931fd6 100644 │
│ --- a/web/src/client/leaderboard-table.ts │
│ +++ b/web/src/client/leaderboard-table.ts │
│ @@ -13,6 +13,7 @@ class LeaderboardTable { │
│ private realm: string; │
│ private dungeon: string; │
│ private currentPage = 1; │
│ + private lastRenderWasEmpty = false; │
│ │
│ constructor(container: HTMLElement) { │
│ this.container = container; │
│ @@ -131,6 +132,8 @@ class LeaderboardTable { │
│ } catch (error) { │
│ this.showError(error as Error); │
│ console.error("Leaderboard loading error:", error); │
│ + // Treat error state as empty for transition tracking │
│ + this.lastRenderWasEmpty = true; │
│ } finally { │
│ this.hideLoading(); │
│ } │
│ @@ -141,21 +144,74 @@ class LeaderboardTable { │
│ "#leaderboard-content", │
│ ) as HTMLElement | null; │
│ if (!content) return; │
│ - if (!data.leading_groups || data.leading_groups.length === 0) { │
│ + │
│ + const hasData = data.leading_groups && data.leading_groups.length > 0; │
│ + │
│ + console.log(`[LeaderboardTable] Rendering leaderboard - hasData: ${hasData}, │
│ lastRenderWasEmpty: ${this.lastRenderWasEmpty}`); │
│ + │
│ + // Always reset all UI elements first │
│ + const paginationContainer = this.container.querySelector( │
│ + ".pagination-container", │
│ + ) as HTMLElement | null; │
│ + if (paginationContainer) { │
│ + paginationContainer.style.display = "none"; │
│ + } │
│ + │
│ + const crossRealmNote = this.container.querySelector( │
│ + "#cross-realm-note", │
│ + ) as HTMLElement | null; │
│ + if (crossRealmNote) { │
│ + crossRealmNote.style.display = "none"; │
│ + } │
│ + │
│ + // Clear all loading/error states │
│ + this.hideLoading(); │
│ + const errorContainer = this.container.querySelector("#error-container") as HT │
│ MLElement | null; │
│ + if (errorContainer) { │
│ + errorContainer.style.display = "none"; │
│ + } │
│ + │
│ + // Ensure content is visible (in case it was hidden by error state) │
│ + content.style.display = ""; │
│ + │
│ + // Always clear existing content completely to ensure clean slate │
│ + content.innerHTML = ""; │
│ + │
│ + if (!hasData) { │
│ + console.log(`[LeaderboardTable] Showing empty state`); │
│ content.innerHTML = ` │
│ <div class="empty-state"> │
│ <p>No runs found for this leaderboard.</p> │
│ <p>Try selecting a different region, realm, or dungeon.</p> │
│ </div> │
│ `; │
│ + this.lastRenderWasEmpty = true; │
│ return; │
│ } │
│ │
│ + // Force DOM cleanup when transitioning from empty/error to populated │
│ + if (this.lastRenderWasEmpty) { │
│ + console.log(`[LeaderboardTable] Transitioning from empty/error to populated │
│ - forcing DOM reflow`); │
│ + // Force reflow to ensure DOM is clean │
│ + content.offsetHeight; │
│ + // Additional cleanup - remove any residual event listeners │
│ + const oldRows = this.container.querySelectorAll('.leaderboard-table-row'); │
│ + oldRows.forEach(row => row.remove()); │
│ + } │
│ + │
│ content.innerHTML = │
│ '<div id="leaderboard-rows" class="leaderboard-table"></div>'; │
│ const rowsContainer = content.querySelector( │
│ "#leaderboard-rows", │
│ ) as HTMLElement | null; │
│ + │
│ + if (!rowsContainer) { │
│ + console.error(`[LeaderboardTable] Failed to create rowsContainer`); │
│ + return; │
│ + } │
│ + │
│ + console.log(`[LeaderboardTable] About to render ${data.leading_groups.length} │
│ runs`); │
│ + this.lastRenderWasEmpty = false; │
│ data.leading_groups.forEach((run: any, index: number) => { │
│ const rank = run.ranking || (this.currentPage - 1) * 25 + index + 1; │
│ const duration = formatDurationMMSS(run.duration); │
│ @@ -278,6 +334,8 @@ class LeaderboardTable { │
│ `; │
│ rowsContainer?.appendChild(row); │
│ }); │
│ + │
│ + console.log(`[LeaderboardTable] Completed rendering ${data.leading_groups.len │
│ gth} runs`); │
│ } │
│ │
│ private updatePagination(pagination: any) { │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...c/client/leaderboard-table.ts ───┐
│ diff --git a/web/src/client/leaderboard-tabl │
│ e.ts b/web/src/client/leaderboard-table.ts │
│ index adea96f..2931fd6 100644 │
│ --- a/web/src/client/leaderboard-table.ts │
│ +++ b/web/src/client/leaderboard-table.ts │
│ @@ -13,6 +13,7 @@ class LeaderboardTable { │
│ private realm: string; │
│ private dungeon: string; │
│ private currentPage = 1; │
│ + private lastRenderWasEmpty = false; │
│ │
│ constructor(container: HTMLElement) { │
│ this.container = container; │
│ @@ -131,6 +132,8 @@ class LeaderboardTable { │
│ } catch (error) { │
│ this.showError(error as Error); │
│ console.error("Leaderboard loading er │
│ ror:", error); │
│ + // Treat error state as empty for tra │
│ nsition tracking │
│ + this.lastRenderWasEmpty = true; │
│ } finally { │
│ this.hideLoading(); │
│ } │
│ @@ -141,21 +144,74 @@ class LeaderboardTable │
│ { │
│ "#leaderboard-content", │
│ ) as HTMLElement | null; │
│ if (!content) return; │
│ - if (!data.leading_groups || data.leadin │
│ g_groups.length === 0) { │
│ + │
│ + const hasData = data.leading_groups && │
│ data.leading_groups.length > 0; │
│ + │
│ + console.log(`[LeaderboardTable] Renderi │
│ ng leaderboard - hasData: ${hasData}, lastRe │
│ nderWasEmpty: ${this.lastRenderWasEmpty}`); │
│ + │
│ + // Always reset all UI elements first │
│ + const paginationContainer = this.contai │
│ ner.querySelector( │
│ + ".pagination-container", │
│ + ) as HTMLElement | null; │
│ + if (paginationContainer) { │
│ + paginationContainer.style.display = " │
│ none"; │
│ + } │
│ + │
│ + const crossRealmNote = this.container.q │
│ uerySelector( │
│ + "#cross-realm-note", │
│ + ) as HTMLElement | null; │
│ + if (crossRealmNote) { │
│ + crossRealmNote.style.display = "none" │
│ ; │
│ + } │
│ + │
│ + // Clear all loading/error states │
│ + this.hideLoading(); │
│ + const errorContainer = this.container.q │
│ uerySelector("#error-container") as HTMLElem │
│ ent | null; │
│ + if (errorContainer) { │
│ + errorContainer.style.display = "none" │
│ ; │
│ + } │
│ + │
│ + // Ensure content is visible (in case i │
│ t was hidden by error state) │
│ + content.style.display = ""; │
│ + │
│ + // Always clear existing content comple │
│ tely to ensure clean slate │
│ + content.innerHTML = ""; │
│ + │
│ + if (!hasData) { │
│ + console.log(`[LeaderboardTable] Showi │
│ ng empty state`); │
│ content.innerHTML = ` │
│ <div class="empty-state"> │
│ <p>No runs found for this leaderb │
│ oard.</p> │
│ <p>Try selecting a different regi │
│ on, realm, or dungeon.</p> │
│ </div> │
│ `; │
│ + this.lastRenderWasEmpty = true; │
│ return; │
│ } │
│ │
│ + // Force DOM cleanup when transitioning │
│ from empty/error to populated │
│ + if (this.lastRenderWasEmpty) { │
│ + console.log(`[LeaderboardTable] Trans │
│ itioning from empty/error to populated - for │
│ cing DOM reflow`); │
│ + // Force reflow to ensure DOM is clea │
│ n │
│ + content.offsetHeight; │
│ + // Additional cleanup - remove any re │
│ sidual event listeners │
│ + const oldRows = this.container.queryS │
│ electorAll('.leaderboard-table-row'); │
│ + oldRows.forEach(row => row.remove()); │
│ + } │
│ + │
│ content.innerHTML = │
│ '<div id="leaderboard-rows" class="le │
│ aderboard-table"></div>'; │
│ const rowsContainer = content.querySele │
│ ctor( │
│ "#leaderboard-rows", │
│ ) as HTMLElement | null; │
│ + │
│ + if (!rowsContainer) { │
│ + console.error(`[LeaderboardTable] Fai │
│ led to create rowsContainer`); │
│ + return; │
│ + } │
│ + │
│ + console.log(`[LeaderboardTable] About t │
│ o render ${data.leading_groups.length} runs` │
│ ); │
│ + this.lastRenderWasEmpty = false; │
│ data.leading_groups.forEach((run: any, │
│ index: number) => { │
│ const rank = run.ranking || (this.cur │
│ rentPage - 1) * 25 + index + 1; │
│ const duration = formatDurationMMSS(r │
│ un.duration); │
│ @@ -278,6 +334,8 @@ class LeaderboardTable { │
│ `; │
│ rowsContainer?.appendChild(row); │
│ }); │
│ + │
│ + console.log(`[LeaderboardTable] Complet │
│ ed rendering ${data.leading_groups.length} r │
│ uns`); │
│ } │
│ │
│ private updatePagination(pagination: any) │
│ { │
└──────────────────────────────────────────────┘
┌─ web/src/client/player-filter-panel.ts ────────────────────────────────────┐
│ diff --git a/web/src/client/player-filter-panel.ts b/web/src/client/player-filter- │
│ panel.ts │
│ new file mode 100644 │
│ index 0000000..595dad2 │
│ --- /dev/null │
│ +++ b/web/src/client/player-filter-panel.ts │
│ @@ -0,0 +1,169 @@ │
│ +import { REALM_DATA } from "../lib/realms"; │
│ +import { buildPlayersLeaderboardURL } from "../lib/utils.ts"; │
│ + │
│ +class PlayerFilterPanelClient { │
│ + private container: HTMLElement; │
│ + private regionSelect: HTMLSelectElement; │
│ + private realmSelect: HTMLSelectElement; │
│ + private classSelect: HTMLSelectElement; │
│ + │
│ + constructor(container: HTMLElement) { │
│ + this.container = container; │
│ + this.regionSelect = container.querySelector( │
│ + "#player-region-select", │
│ + ) as HTMLSelectElement; │
│ + this.realmSelect = container.querySelector( │
│ + "#player-realm-select", │
│ + ) as HTMLSelectElement; │
│ + this.classSelect = container.querySelector( │
│ + "#player-class-select", │
│ + ) as HTMLSelectElement; │
│ + │
│ + this.bindEvents(); │
│ + this.updateRealmOptions(); │
│ + this.initFromURLOrDataset(); │
│ + } │
│ + │
│ + private bindEvents() { │
│ + this.regionSelect.addEventListener("change", () => { │
│ + this.updateRealmOptions(); │
│ + this.notifyLeaderboard(1); │
│ + }); │
│ + this.realmSelect.addEventListener("change", () => this.notifyLeaderboard(1)); │
│ + this.classSelect.addEventListener("change", () => this.notifyLeaderboard(1)); │
│ + } │
│ + │
│ + private updateRealmOptions() { │
│ + const region = this.regionSelect.value; │
│ + const options = this.realmSelect.querySelectorAll("option[data-region]"); │
│ + options.forEach((opt) => ((opt as HTMLOptionElement).style.display = "none")) │
│ ; │
│ + if (region === "global") { │
│ + this.realmSelect.disabled = true; │
│ + this.realmSelect.value = "all"; │
│ + } else { │
│ + this.realmSelect.disabled = false; │
│ + const regionOptions = this.realmSelect.querySelectorAll( │
│ + `option[data-region="${region}"]`, │
│ + ); │
│ + regionOptions.forEach( │
│ + (opt) => ((opt as HTMLOptionElement).style.display = "block"), │
│ + ); │
│ + // Ensure valid selection │
│ + const current = this.realmSelect.value; │
│ + if (current && current !== "all") { │
│ + const exists = Array.from(regionOptions).some( │
│ + (opt) => (opt as HTMLOptionElement).value === current, │
│ + ); │
│ + if (!exists) this.realmSelect.value = "all"; │
│ + } else { │
│ + this.realmSelect.value = "all"; │
│ + } │
│ + } │
│ + } │
│ + │
│ + private initFromURLOrDataset() { │
│ + const { scope, region, realm, klass, page } = this.parsePlayersPath(); │
│ + this.regionSelect.value = region || "global"; │
│ + this.updateRealmOptions(); │
│ + if (region && region !== "global") this.realmSelect.value = realm || "all"; │
│ + this.classSelect.value = klass || ""; │
│ + // Only notify/load if players section is currently active; otherwise wait fo │
│ r tab switch │
│ + if (this.isPlayersSectionActive()) { │
│ + this.notifyLeaderboard(page || 1); │
│ + } │
│ + } │
│ + │
│ + private notifyLeaderboard(page: number) { │
│ + const region = this.regionSelect.value; │
│ + const realmVal = this.realmSelect.value || "all"; │
│ + const classKey = this.classSelect.value || ""; │
│ + │
│ + const scope = region === "global" ? "global" : realmVal !== "all" ? "realm" : │
│ "regional"; │
│ + const realmSlug = scope === "realm" ? realmVal : undefined; │
│ + │
│ + const w: any = window as any; │
│ + const pl = w.playerLeaderboard; │
│ + if (pl && typeof pl.setFilters === "function") { │
│ + pl.setFilters({ scope, region: region === "global" ? undefined : region, re │
│ almSlug, classKey, page }); │
│ + } │
│ + │
│ + const newPath = buildPlayersLeaderboardURL(scope, { │
│ + region: region === "global" ? undefined : region, │
│ + realmSlug, │
│ + classKey, │
│ + page, │
│ + }); │
│ + try { sessionStorage.setItem("lastPlayersPath", newPath); } catch {} │
│ + window.history.pushState({}, "", newPath); │
│ + } │
│ + │
│ + private isPlayersSectionActive(): boolean { │
│ + try { │
│ + const section = this.container.closest("#players-section") as │
│ + | HTMLElement │
│ + | null; │
│ + return !!section && section.classList.contains("active"); │
│ + } catch { │
│ + return false; │
│ + } │
│ + } │
│ + │
│ + private parsePlayersPath(): { scope: "global" | "regional" | "realm"; region?: │
│ string; realm?: string; klass?: string; page?: number } { │
│ + const path = window.location.pathname.replace(/\/+$/, ""); │
│ + const parts = path.split("/").filter(Boolean); │
│ + // Expect: ["challenge-mode","players", ...] │
│ + if (parts.length >= 2 && parts[0] === "challenge-mode" && parts[1] === "playe │
│ rs") { │
│ + const rest = parts.slice(2); │
│ + if (rest.length === 0) { │
│ + return { scope: "global", klass: undefined, page: this.readPageParam() }; │
│ + } │
│ + if (rest[0] === "global") { │
│ + const klass = rest[1] || ""; │
│ + return { scope: "global", klass, page: this.readPageParam() }; │
│ + } │
│ + const region = rest[0]; │
│ + if (rest.length === 1) { │
│ + return { scope: "regional", region, page: this.readPageParam() }; │
│ + } │
│ + // Two or more segments: could be region/class or region/realm[/class] │
│ + if (rest.length === 2) { │
│ + // Ambiguous; assume region/class if second looks like a class (letters/u │
│ nderscores) │
│ + const second = rest[1]; │
│ + if (/^[a-z_]+$/.test(second)) { │
│ + return { scope: "regional", region, klass: second, page: this.readPageP │
│ aram() }; │
│ + } │
│ + return { scope: "realm", region, realm: second, page: this.readPageParam( │
│ ) }; │
│ + } │
│ + // region/realm/class │
│ + return { scope: "realm", region, realm: rest[1], klass: rest[2], page: this │
│ .readPageParam() }; │
│ + } │
│ + // Fallback: derive from query params as legacy │
│ + const url = new URL(window.location.href); │
│ + const region = url.searchParams.get("region") || undefined; │
│ + const realm = url.searchParams.get("realm") || undefined; │
│ + const klass = url.searchParams.get("class") || undefined; │
│ + const page = this.readPageParam(); │
│ + if (!region) return { scope: "global", klass, page }; │
│ + if (realm) return { scope: "realm", region, realm, klass, page }; │
│ + return { scope: "regional", region, klass, page }; │
│ + } │
│ + │
│ + private readPageParam(): number | undefined { │
│ + const url = new URL(window.location.href); │
│ + const page = parseInt(url.searchParams.get("page") || "1", 10); │
│ + return isNaN(page) || page <= 1 ? undefined : page; │
│ + } │
│ +} │
│ + │
│ +function initPlayerFilterPanel() { │
│ + const el = document.querySelector( │
│ + ".player-filter-panel", │
│ + ) as HTMLElement | null; │
│ + if (el) new PlayerFilterPanelClient(el); │
│ +} │
│ + │
│ +if (document.readyState === "loading") { │
│ + document.addEventListener("DOMContentLoaded", initPlayerFilterPanel); │
│ +} else { │
│ + initPlayerFilterPanel(); │
│ +} │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...client/player-filter-panel.ts ───┐
│ diff --git a/web/src/client/player-filter-pa │
│ nel.ts b/web/src/client/player-filter-panel. │
│ ts │
│ new file mode 100644 │
│ index 0000000..595dad2 │
│ --- /dev/null │
│ +++ b/web/src/client/player-filter-panel.ts │
│ @@ -0,0 +1,169 @@ │
│ +import { REALM_DATA } from "../lib/realms"; │
│ +import { buildPlayersLeaderboardURL } from │
│ "../lib/utils.ts"; │
│ + │
│ +class PlayerFilterPanelClient { │
│ + private container: HTMLElement; │
│ + private regionSelect: HTMLSelectElement; │
│ + private realmSelect: HTMLSelectElement; │
│ + private classSelect: HTMLSelectElement; │
│ + │
│ + constructor(container: HTMLElement) { │
│ + this.container = container; │
│ + this.regionSelect = container.querySele │
│ ctor( │
│ + "#player-region-select", │
│ + ) as HTMLSelectElement; │
│ + this.realmSelect = container.querySelec │
│ tor( │
│ + "#player-realm-select", │
│ + ) as HTMLSelectElement; │
│ + this.classSelect = container.querySelec │
│ tor( │
│ + "#player-class-select", │
│ + ) as HTMLSelectElement; │
│ + │
│ + this.bindEvents(); │
│ + this.updateRealmOptions(); │
│ + this.initFromURLOrDataset(); │
│ + } │
│ + │
│ + private bindEvents() { │
│ + this.regionSelect.addEventListener("cha │
│ nge", () => { │
│ + this.updateRealmOptions(); │
│ + this.notifyLeaderboard(1); │
│ + }); │
│ + this.realmSelect.addEventListener("chan │
│ ge", () => this.notifyLeaderboard(1)); │
│ + this.classSelect.addEventListener("chan │
│ ge", () => this.notifyLeaderboard(1)); │
│ + } │
│ + │
│ + private updateRealmOptions() { │
│ + const region = this.regionSelect.value; │
│ + const options = this.realmSelect.queryS │
│ electorAll("option[data-region]"); │
│ + options.forEach((opt) => ((opt as HTMLO │
│ ptionElement).style.display = "none")); │
│ + if (region === "global") { │
│ + this.realmSelect.disabled = true; │
│ + this.realmSelect.value = "all"; │
│ + } else { │
│ + this.realmSelect.disabled = false; │
│ + const regionOptions = this.realmSelec │
│ t.querySelectorAll( │
│ + `option[data-region="${region}"]`, │
│ + ); │
│ + regionOptions.forEach( │
│ + (opt) => ((opt as HTMLOptionElement │
│ ).style.display = "block"), │
│ + ); │
│ + // Ensure valid selection │
│ + const current = this.realmSelect.valu │
│ e; │
│ + if (current && current !== "all") { │
│ + const exists = Array.from(regionOpt │
│ ions).some( │
│ + (opt) => (opt as HTMLOptionElemen │
│ t).value === current, │
│ + ); │
│ + if (!exists) this.realmSelect.value │
│ = "all"; │
│ + } else { │
│ + this.realmSelect.value = "all"; │
│ + } │
│ + } │
│ + } │
│ + │
│ + private initFromURLOrDataset() { │
│ + const { scope, region, realm, klass, pa │
│ ge } = this.parsePlayersPath(); │
│ + this.regionSelect.value = region || "gl │
│ obal"; │
│ + this.updateRealmOptions(); │
│ + if (region && region !== "global") this │
│ .realmSelect.value = realm || "all"; │
│ + this.classSelect.value = klass || ""; │
│ + // Only notify/load if players section │
│ is currently active; otherwise wait for tab │
│ switch │
│ + if (this.isPlayersSectionActive()) { │
│ + this.notifyLeaderboard(page || 1); │
│ + } │
│ + } │
│ + │
│ + private notifyLeaderboard(page: number) { │
│ + const region = this.regionSelect.value; │
│ + const realmVal = this.realmSelect.value │
│ || "all"; │
│ + const classKey = this.classSelect.value │
│ || ""; │
│ + │
│ + const scope = region === "global" ? "gl │
│ obal" : realmVal !== "all" ? "realm" : "regi │
│ onal"; │
│ + const realmSlug = scope === "realm" ? r │
│ ealmVal : undefined; │
│ + │
│ + const w: any = window as any; │
│ + const pl = w.playerLeaderboard; │
│ + if (pl && typeof pl.setFilters === "fun │
│ ction") { │
│ + pl.setFilters({ scope, region: region │
│ === "global" ? undefined : region, realmSlu │
│ g, classKey, page }); │
│ + } │
│ + │
│ + const newPath = buildPlayersLeaderboard │
│ URL(scope, { │
│ + region: region === "global" ? undefin │
│ ed : region, │
│ + realmSlug, │
│ + classKey, │
│ + page, │
│ + }); │
│ + try { sessionStorage.setItem("lastPlaye │
│ rsPath", newPath); } catch {} │
│ + window.history.pushState({}, "", newPat │
│ h); │
│ + } │
│ + │
│ + private isPlayersSectionActive(): boolean │
│ { │
│ + try { │
│ + const section = this.container.closes │
│ t("#players-section") as │
│ + | HTMLElement │
│ + | null; │
│ + return !!section && section.classList │
│ .contains("active"); │
│ + } catch { │
│ + return false; │
│ + } │
│ + } │
│ + │
│ + private parsePlayersPath(): { scope: "glo │
│ bal" | "regional" | "realm"; region?: string │
│ ; realm?: string; klass?: string; page?: num │
│ ber } { │
│ + const path = window.location.pathname.r │
│ eplace(/\/+$/, ""); │
│ + const parts = path.split("/").filter(Bo │
│ olean); │
│ + // Expect: ["challenge-mode","players", │
│ ...] │
│ + if (parts.length >= 2 && parts[0] === " │
│ challenge-mode" && parts[1] === "players") { │
│ + const rest = parts.slice(2); │
│ + if (rest.length === 0) { │
│ + return { scope: "global", klass: un │
│ defined, page: this.readPageParam() }; │
│ + } │
│ + if (rest[0] === "global") { │
│ + const klass = rest[1] || ""; │
│ + return { scope: "global", klass, pa │
│ ge: this.readPageParam() }; │
│ + } │
│ + const region = rest[0]; │
│ + if (rest.length === 1) { │
│ + return { scope: "regional", region, │
│ page: this.readPageParam() }; │
│ + } │
│ + // Two or more segments: could be reg │
│ ion/class or region/realm[/class] │
│ + if (rest.length === 2) { │
│ + // Ambiguous; assume region/class i │
│ f second looks like a class (letters/undersc │
│ ores) │
│ + const second = rest[1]; │
│ + if (/^[a-z_]+$/.test(second)) { │
│ + return { scope: "regional", regio │
│ n, klass: second, page: this.readPageParam() │
│ }; │
│ + } │
│ + return { scope: "realm", region, re │
│ alm: second, page: this.readPageParam() }; │
│ + } │
│ + // region/realm/class │
│ + return { scope: "realm", region, real │
│ m: rest[1], klass: rest[2], page: this.readP │
│ ageParam() }; │
│ + } │
│ + // Fallback: derive from query params a │
│ s legacy │
│ + const url = new URL(window.location.hre │
│ f); │
│ + const region = url.searchParams.get("re │
│ gion") || undefined; │
│ + const realm = url.searchParams.get("rea │
│ lm") || undefined; │
│ + const klass = url.searchParams.get("cla │
│ ss") || undefined; │
│ + const page = this.readPageParam(); │
│ + if (!region) return { scope: "global", │
│ klass, page }; │
│ + if (realm) return { scope: "realm", reg │
│ ion, realm, klass, page }; │
│ + return { scope: "regional", region, kla │
│ ss, page }; │
│ + } │
│ + │
│ + private readPageParam(): number | undefin │
│ ed { │
│ + const url = new URL(window.location.hre │
│ f); │
│ + const page = parseInt(url.searchParams. │
│ get("page") || "1", 10); │
│ + return isNaN(page) || page <= 1 ? undef │
│ ined : page; │
│ + } │
│ +} │
│ + │
│ +function initPlayerFilterPanel() { │
│ + const el = document.querySelector( │
│ + ".player-filter-panel", │
│ + ) as HTMLElement | null; │
│ + if (el) new PlayerFilterPanelClient(el); │
│ +} │
│ + │
│ +if (document.readyState === "loading") { │
│ + document.addEventListener("DOMContentLoad │
│ ed", initPlayerFilterPanel); │
│ +} else { │
│ + initPlayerFilterPanel(); │
│ +} │
└──────────────────────────────────────────────┘
┌─ web/src/client/player-leaderboard.ts ─────────────────────────────────────┐
│ diff --git a/web/src/client/player-leaderboard.ts b/web/src/client/player-leaderbo │
│ ard.ts │
│ index 0280c1a..62e00c3 100644 │
│ --- a/web/src/client/player-leaderboard.ts │
│ +++ b/web/src/client/player-leaderboard.ts │
│ @@ -1,4 +1,4 @@ │
│ -import { buildPlayerProfileURL } from "../lib/utils.ts"; │
│ +import { buildPlayerProfileURL, buildStaticPlayerLeaderboardPath } from "../lib/u │
│ tils.ts"; │
│ import { │
│ getClassColor, │
│ getSpecInfo, │
│ @@ -11,8 +11,10 @@ class PlayerLeaderboardClient { │
│ private error: HTMLElement | null; │
│ private loading: HTMLElement | null; │
│ private currentPage = 1; │
│ - private scope: "global" | "regional" = "global"; │
│ + private scope: "global" | "regional" | "realm" = "global"; │
│ private region: string | null = null; │
│ + private realmSlug: string | null = null; │
│ + private classKey: string | null = null; │
│ │
│ constructor(container: HTMLElement) { │
│ this.container = container; │
│ @@ -27,19 +29,80 @@ class PlayerLeaderboardClient { │
│ ) as HTMLElement | null; │
│ │
│ this.bindPagination(); │
│ - // initial load │
│ - this.refreshCurrentPage(); │
│ + // initial load only if players section is active; otherwise the layout will │
│ trigger refresh on tab switch │
│ + if (this.isPlayersSectionActive()) { │
│ + this.refreshCurrentPage(); │
│ + } │
│ + } │
│ + │
│ + private isPlayersSectionActive(): boolean { │
│ + try { │
│ + const section = this.container.closest("#players-section") as │
│ + | HTMLElement │
│ + | null; │
│ + return !!section && section.classList.contains("active"); │
│ + } catch { │
│ + return false; │
│ + } │
│ } │
│ │
│ public refreshCurrentPage() { │
│ - const urlParams = new URLSearchParams(window.location.search); │
│ - const pageParam = urlParams.get("page"); │
│ - this.currentPage = pageParam ? Math.max(1, parseInt(pageParam)) : 1; │
│ + const { scope, region, realm, klass, page } = this.parsePlayersPath(); │
│ + this.currentPage = page || 1; │
│ + this.scope = scope; │
│ + this.region = region || null; │
│ + this.realmSlug = realm || null; │
│ + this.classKey = klass || null; │
│ this.load(this.currentPage).catch((e) => │
│ console.error("Player leaderboard load error:", e), │
│ ); │
│ } │
│ │
│ + private parsePlayersPath(): { scope: "global" | "regional" | "realm"; region?: │
│ string; realm?: string; klass?: string; page?: number } { │
│ + const path = window.location.pathname.replace(/\/+$/, ""); │
│ + const parts = path.split("/").filter(Boolean); │
│ + const url = new URL(window.location.href); │
│ + const page = parseInt(url.searchParams.get("page") || "1", 10); │
│ + const pageNum = isNaN(page) || page <= 1 ? undefined : page; │
│ + if (parts.length >= 2 && parts[0] === "challenge-mode" && parts[1] === "playe │
│ rs") { │
│ + const rest = parts.slice(2); │
│ + if (rest.length === 0) return { scope: "global", page: pageNum }; │
│ + if (rest[0] === "global") return { scope: "global", klass: rest[1], page: p │
│ ageNum }; │
│ + const region = rest[0]; │
│ + if (rest.length === 1) return { scope: "regional", region, page: pageNum }; │
│ + if (rest.length === 2) { │
│ + const second = rest[1]; │
│ + if (/^[a-z_]+$/.test(second)) return { scope: "regional", region, klass: │
│ second, page: pageNum }; │
│ + return { scope: "realm", region, realm: second, page: pageNum }; │
│ + } │
│ + return { scope: "realm", region, realm: rest[1], klass: rest[2], page: page │
│ Num }; │
│ + } │
│ + // Fallback to query params │
│ + const regionQ = url.searchParams.get("region") || undefined; │
│ + const realmQ = url.searchParams.get("realm") || undefined; │
│ + const klassQ = url.searchParams.get("class") || undefined; │
│ + if (!regionQ) return { scope: "global", klass: klassQ, page: pageNum }; │
│ + if (realmQ) return { scope: "realm", region: regionQ, realm: realmQ, klass: k │
│ lassQ, page: pageNum }; │
│ + return { scope: "regional", region: regionQ, klass: klassQ, page: pageNum }; │
│ + } │
│ + │
│ + public setFilters(opts: { │
│ + scope: "global" | "regional" | "realm"; │
│ + region?: string; │
│ + realmSlug?: string; │
│ + classKey?: string; │
│ + page?: number; │
│ + }) { │
│ + this.scope = opts.scope; │
│ + this.region = opts.region || null; │
│ + this.realmSlug = opts.realmSlug || null; │
│ + this.classKey = opts.classKey || null; │
│ + this.currentPage = Math.max(1, opts.page || 1); │
│ + this.load(this.currentPage).catch((e) => │
│ + console.error("Player leaderboard setFilters error:", e), │
│ + ); │
│ + } │
│ + │
│ private bindPagination() { │
│ const prevBtn = this.container.querySelector( │
│ "#prev-page", │
│ @@ -50,25 +113,22 @@ class PlayerLeaderboardClient { │
│ prevBtn?.addEventListener("click", () => { │
│ if (this.currentPage > 1) { │
│ this.currentPage--; │
│ - this.load(this.currentPage, { preserveScroll: false }); │
│ + this.load(this.currentPage); │
│ } │
│ }); │
│ nextBtn?.addEventListener("click", () => { │
│ this.currentPage++; │
│ - this.load(this.currentPage, { preserveScroll: false }); │
│ + this.load(this.currentPage); │
│ }); │
│ } │
│ │
│ private async fetchData(page: number) { │
│ - let url: string; │
│ - │
│ - if (this.scope === "global") { │
│ - url = `/api/leaderboard/players/global/${page}.json`; │
│ - } else if (this.region) { │
│ - url = `/api/leaderboard/players/${this.region}/${page}.json`; │
│ - } else { │
│ - url = `/api/leaderboard/players/global/${page}.json`; │
│ - } │
│ + const url = buildStaticPlayerLeaderboardPath( │
│ + this.scope, │
│ + this.region || undefined, │
│ + page, │
│ + { realmSlug: this.realmSlug || undefined, classKey: this.classKey || undefi │
│ ned }, │
│ + ); │
│ │
│ console.log("[PlayerLeaderboard] fetching:", url); │
│ const res = await fetch(url); │
│ @@ -256,6 +316,13 @@ class PlayerLeaderboardClient { │
│ try { │
│ const data = await this.fetchData(page); │
│ this.render(data); │
│ + // Update URL to reflect current filters + page │
│ + try { │
│ + // Keep current path; only update page param to reflect pagination │
│ + const url = new URL(window.location.href); │
│ + if (this.currentPage > 1) url.searchParams.set("page", String(this.curren │
│ tPage)); else url.searchParams.delete("page"); │
│ + window.history.replaceState({}, "", url.toString()); │
│ + } catch {} │
│ } catch (e: any) { │
│ this.showError(String(e?.message || e)); │
│ } finally { │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ .../client/player-leaderboard.ts ───┐
│ diff --git a/web/src/client/player-leaderboa │
│ rd.ts b/web/src/client/player-leaderboard.ts │
│ index 0280c1a..62e00c3 100644 │
│ --- a/web/src/client/player-leaderboard.ts │
│ +++ b/web/src/client/player-leaderboard.ts │
│ @@ -1,4 +1,4 @@ │
│ -import { buildPlayerProfileURL } from "../l │
│ ib/utils.ts"; │
│ +import { buildPlayerProfileURL, buildStatic │
│ PlayerLeaderboardPath } from "../lib/utils.t │
│ s"; │
│ import { │
│ getClassColor, │
│ getSpecInfo, │
│ @@ -11,8 +11,10 @@ class PlayerLeaderboardCl │
│ ient { │
│ private error: HTMLElement | null; │
│ private loading: HTMLElement | null; │
│ private currentPage = 1; │
│ - private scope: "global" | "regional" = "g │
│ lobal"; │
│ + private scope: "global" | "regional" | "r │
│ ealm" = "global"; │
│ private region: string | null = null; │
│ + private realmSlug: string | null = null; │
│ + private classKey: string | null = null; │
│ │
│ constructor(container: HTMLElement) { │
│ this.container = container; │
│ @@ -27,19 +29,80 @@ class PlayerLeaderboardC │
│ lient { │
│ ) as HTMLElement | null; │
│ │
│ this.bindPagination(); │
│ - // initial load │
│ - this.refreshCurrentPage(); │
│ + // initial load only if players section │
│ is active; otherwise the layout will trigge │
│ r refresh on tab switch │
│ + if (this.isPlayersSectionActive()) { │
│ + this.refreshCurrentPage(); │
│ + } │
│ + } │
│ + │
│ + private isPlayersSectionActive(): boolean │
│ { │
│ + try { │
│ + const section = this.container.closes │
│ t("#players-section") as │
│ + | HTMLElement │
│ + | null; │
│ + return !!section && section.classList │
│ .contains("active"); │
│ + } catch { │
│ + return false; │
│ + } │
│ } │
│ │
│ public refreshCurrentPage() { │
│ - const urlParams = new URLSearchParams(w │
│ indow.location.search); │
│ - const pageParam = urlParams.get("page") │
│ ; │
│ - this.currentPage = pageParam ? Math.max │
│ (1, parseInt(pageParam)) : 1; │
│ + const { scope, region, realm, klass, pa │
│ ge } = this.parsePlayersPath(); │
│ + this.currentPage = page || 1; │
│ + this.scope = scope; │
│ + this.region = region || null; │
│ + this.realmSlug = realm || null; │
│ + this.classKey = klass || null; │
│ this.load(this.currentPage).catch((e) = │
│ > │
│ console.error("Player leaderboard loa │
│ d error:", e), │
│ ); │
│ } │
│ │
│ + private parsePlayersPath(): { scope: "glo │
│ bal" | "regional" | "realm"; region?: string │
│ ; realm?: string; klass?: string; page?: num │
│ ber } { │
│ + const path = window.location.pathname.r │
│ eplace(/\/+$/, ""); │
│ + const parts = path.split("/").filter(Bo │
│ olean); │
│ + const url = new URL(window.location.hre │
│ f); │
│ + const page = parseInt(url.searchParams. │
│ get("page") || "1", 10); │
│ + const pageNum = isNaN(page) || page <= │
│ 1 ? undefined : page; │
│ + if (parts.length >= 2 && parts[0] === " │
│ challenge-mode" && parts[1] === "players") { │
│ + const rest = parts.slice(2); │
│ + if (rest.length === 0) return { scope │
│ : "global", page: pageNum }; │
│ + if (rest[0] === "global") return { sc │
│ ope: "global", klass: rest[1], page: pageNum │
│ }; │
│ + const region = rest[0]; │
│ + if (rest.length === 1) return { scope │
│ : "regional", region, page: pageNum }; │
│ + if (rest.length === 2) { │
│ + const second = rest[1]; │
│ + if (/^[a-z_]+$/.test(second)) retur │
│ n { scope: "regional", region, klass: second │
│ , page: pageNum }; │
│ + return { scope: "realm", region, re │
│ alm: second, page: pageNum }; │
│ + } │
│ + return { scope: "realm", region, real │
│ m: rest[1], klass: rest[2], page: pageNum }; │
│ + } │
│ + // Fallback to query params │
│ + const regionQ = url.searchParams.get("r │
│ egion") || undefined; │
│ + const realmQ = url.searchParams.get("re │
│ alm") || undefined; │
│ + const klassQ = url.searchParams.get("cl │
│ ass") || undefined; │
│ + if (!regionQ) return { scope: "global", │
│ klass: klassQ, page: pageNum }; │
│ + if (realmQ) return { scope: "realm", re │
│ gion: regionQ, realm: realmQ, klass: klassQ, │
│ page: pageNum }; │
│ + return { scope: "regional", region: reg │
│ ionQ, klass: klassQ, page: pageNum }; │
│ + } │
│ + │
│ + public setFilters(opts: { │
│ + scope: "global" | "regional" | "realm"; │
│ + region?: string; │
│ + realmSlug?: string; │
│ + classKey?: string; │
│ + page?: number; │
│ + }) { │
│ + this.scope = opts.scope; │
│ + this.region = opts.region || null; │
│ + this.realmSlug = opts.realmSlug || null │
│ ; │
│ + this.classKey = opts.classKey || null; │
│ + this.currentPage = Math.max(1, opts.pag │
│ e || 1); │
│ + this.load(this.currentPage).catch((e) = │
│ > │
│ + console.error("Player leaderboard set │
│ Filters error:", e), │
│ + ); │
│ + } │
│ + │
│ private bindPagination() { │
│ const prevBtn = this.container.querySel │
│ ector( │
│ "#prev-page", │
│ @@ -50,25 +113,22 @@ class PlayerLeaderboard │
│ Client { │
│ prevBtn?.addEventListener("click", () = │
│ > { │
│ if (this.currentPage > 1) { │
│ this.currentPage--; │
│ - this.load(this.currentPage, { prese │
│ rveScroll: false }); │
│ + this.load(this.currentPage); │
│ } │
│ }); │
│ nextBtn?.addEventListener("click", () = │
│ > { │
│ this.currentPage++; │
│ - this.load(this.currentPage, { preserv │
│ eScroll: false }); │
│ + this.load(this.currentPage); │
│ }); │
│ } │
│ │
│ private async fetchData(page: number) { │
│ - let url: string; │
│ - │
│ - if (this.scope === "global") { │
│ - url = `/api/leaderboard/players/globa │
│ l/${page}.json`; │
│ - } else if (this.region) { │
│ - url = `/api/leaderboard/players/${thi │
│ s.region}/${page}.json`; │
│ - } else { │
│ - url = `/api/leaderboard/players/globa │
│ l/${page}.json`; │
│ - } │
│ + const url = buildStaticPlayerLeaderboar │
│ dPath( │
│ + this.scope, │
│ + this.region || undefined, │
│ + page, │
│ + { realmSlug: this.realmSlug || undefi │
│ ned, classKey: this.classKey || undefined }, │
│ + ); │
│ │
│ console.log("[PlayerLeaderboard] fetchi │
│ ng:", url); │
│ const res = await fetch(url); │
│ @@ -256,6 +316,13 @@ class PlayerLeaderboard │
│ Client { │
│ try { │
│ const data = await this.fetchData(pag │
│ e); │
│ this.render(data); │
│ + // Update URL to reflect current filt │
│ ers + page │
│ + try { │
│ + // Keep current path; only update p │
│ age param to reflect pagination │
│ + const url = new URL(window.location │
│ .href); │
│ + if (this.currentPage > 1) url.searc │
│ hParams.set("page", String(this.currentPage) │
│ ); else url.searchParams.delete("page"); │
│ + window.history.replaceState({}, "", │
│ url.toString()); │
│ + } catch {} │
│ } catch (e: any) { │
│ this.showError(String(e?.message || e │
│ )); │
│ } finally { │
└──────────────────────────────────────────────┘
┌─ web/src/components/islands/PlayerFilterPanel.astro ───────────────────────┐
│ diff --git a/web/src/components/islands/PlayerFilterPanel.astro b/web/src/componen │
│ ts/islands/PlayerFilterPanel.astro │
│ new file mode 100644 │
│ index 0000000..417247d │
│ --- /dev/null │
│ +++ b/web/src/components/islands/PlayerFilterPanel.astro │
│ @@ -0,0 +1,98 @@ │
│ +--- │
│ +import { REALM_DATA } from "../../lib/realms"; │
│ +import { CLASS_COLORS } from "../../lib/wow-constants"; │
│ + │
│ +export interface Props { │
│ + initialRegion?: string; │
│ + initialRealm?: string; │
│ + initialClass?: string; │
│ + class?: string; │
│ +} │
│ + │
│ +const { │
│ + initialRegion = "global", │
│ + initialRealm = "", │
│ + initialClass = "", │
│ + class: className, │
│ +} = Astro.props; │
│ +--- │
│ + │
│ +<div │
│ + class={`player-filter-panel ${className || ""}`.trim()} │
│ + data-initial-region={initialRegion} │
│ + data-initial-realm={initialRealm} │
│ + data-initial-class={initialClass} │
│ +> │
│ + <div class="filter-row"> │
│ + <div class="filter-group"> │
│ + <label for="player-region-select" class="filter-label">Region</label> │
│ + <select id="player-region-select" class="filter-select"> │
│ + <option value="global" selected={initialRegion === "global"}>Global</opti │
│ on> │
│ + <option value="us" selected={initialRegion === "us"}>US</option> │
│ + <option value="eu" selected={initialRegion === "eu"}>EU</option> │
│ + <option value="kr" selected={initialRegion === "kr"}>KR</option> │
│ + </select> │
│ + </div> │
│ + │
│ + <div class="filter-group"> │
│ + <label for="player-realm-select" class="filter-label">Realm</label> │
│ + <select id="player-realm-select" class="filter-select" disabled={initialReg │
│ ion === "global"}> │
│ + <option value="all">All Realms</option> │
│ + { │
│ + Object.entries(REALM_DATA.us) │
│ + .sort((a, b) => a[1].localeCompare(b[1])) │
│ + .map(([slug, name]) => ( │
│ + <option value={slug} data-region="us" style="display: none;">{name} │
│ </option> │
│ + )) │
│ + } │
│ + { │
│ + Object.entries(REALM_DATA.eu) │
│ + .sort((a, b) => a[1].localeCompare(b[1])) │
│ + .map(([slug, name]) => ( │
│ + <option value={slug} data-region="eu" style="display: none;">{name} │
│ </option> │
│ + )) │
│ + } │
│ + { │
│ + Object.entries(REALM_DATA.kr) │
│ + .sort((a, b) => a[1].localeCompare(b[1])) │
│ + .map(([slug, name]) => ( │
│ + <option value={slug} data-region="kr" style="display: none;">{name} │
│ </option> │
│ + )) │
│ + } │
│ + </select> │
│ + </div> │
│ + │
│ + <div class="filter-group"> │
│ + <label for="player-class-select" class="filter-label">Class</label> │
│ + <select id="player-class-select" class="filter-select"> │
│ + <option value="">All Classes</option> │
│ + { │
│ + Object.keys(CLASS_COLORS) │
│ + .sort() │
│ + .map((key) => { │
│ + const label = key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUp │
│ perCase()); │
│ + return <option value={key} selected={initialClass === key}>{label}< │
│ /option>; │
│ + }) │
│ + } │
│ + </select> │
│ + </div> │
│ + </div> │
│ +</div> │
│ + │
│ +<script src="../../client/player-filter-panel.ts"></script> │
│ + │
│ +<style> │
│ + .player-filter-panel { │
│ + background-color: var(--bg-secondary); │
│ + border-radius: 8px; │
│ + border: 1px solid var(--border-color); │
│ + padding: 16px; │
│ + margin-bottom: 16px; │
│ + } │
│ + .filter-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; } │
│ + .filter-group { display: flex; flex-direction: column; gap: 6px; } │
│ + .filter-label { color: var(--text-secondary); font-size: 0.85em; font-weight: 5 │
│ 00; text-transform: uppercase; } │
│ + .filter-select { background: var(--bg-primary); color: var(--text-primary); bor │
│ der: 1px solid var(--border-color); border-radius: 4px; padding: 8px 10px; } │
│ + @media (max-width: 768px) { .filter-row { grid-template-columns: 1fr; } } │
│ +</style> │
│ + │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...lands/PlayerFilterPanel.astro ───┐
│ diff --git a/web/src/components/islands/Play │
│ erFilterPanel.astro b/web/src/components/isl │
│ ands/PlayerFilterPanel.astro │
│ new file mode 100644 │
│ index 0000000..417247d │
│ --- /dev/null │
│ +++ b/web/src/components/islands/PlayerFilte │
│ rPanel.astro │
│ @@ -0,0 +1,98 @@ │
│ +--- │
│ +import { REALM_DATA } from "../../lib/realm │
│ s"; │
│ +import { CLASS_COLORS } from "../../lib/wow │
│ -constants"; │
│ + │
│ +export interface Props { │
│ + initialRegion?: string; │
│ + initialRealm?: string; │
│ + initialClass?: string; │
│ + class?: string; │
│ +} │
│ + │
│ +const { │
│ + initialRegion = "global", │
│ + initialRealm = "", │
│ + initialClass = "", │
│ + class: className, │
│ +} = Astro.props; │
│ +--- │
│ + │
│ +<div │
│ + class={`player-filter-panel ${className | │
│ | ""}`.trim()} │
│ + data-initial-region={initialRegion} │
│ + data-initial-realm={initialRealm} │
│ + data-initial-class={initialClass} │
│ +> │
│ + <div class="filter-row"> │
│ + <div class="filter-group"> │
│ + <label for="player-region-select" cla │
│ ss="filter-label">Region</label> │
│ + <select id="player-region-select" cla │
│ ss="filter-select"> │
│ + <option value="global" selected={in │
│ itialRegion === "global"}>Global</option> │
│ + <option value="us" selected={initia │
│ lRegion === "us"}>US</option> │
│ + <option value="eu" selected={initia │
│ lRegion === "eu"}>EU</option> │
│ + <option value="kr" selected={initia │
│ lRegion === "kr"}>KR</option> │
│ + </select> │
│ + </div> │
│ + │
│ + <div class="filter-group"> │
│ + <label for="player-realm-select" clas │
│ s="filter-label">Realm</label> │
│ + <select id="player-realm-select" clas │
│ s="filter-select" disabled={initialRegion == │
│ = "global"}> │
│ + <option value="all">All Realms</opt │
│ ion> │
│ + { │
│ + Object.entries(REALM_DATA.us) │
│ + .sort((a, b) => a[1].localeComp │
│ are(b[1])) │
│ + .map(([slug, name]) => ( │
│ + <option value={slug} data-reg │
│ ion="us" style="display: none;">{name}</opti │
│ on> │
│ + )) │
│ + } │
│ + { │
│ + Object.entries(REALM_DATA.eu) │
│ + .sort((a, b) => a[1].localeComp │
│ are(b[1])) │
│ + .map(([slug, name]) => ( │
│ + <option value={slug} data-reg │
│ ion="eu" style="display: none;">{name}</opti │
│ on> │
│ + )) │
│ + } │
│ + { │
│ + Object.entries(REALM_DATA.kr) │
│ + .sort((a, b) => a[1].localeComp │
│ are(b[1])) │
│ + .map(([slug, name]) => ( │
│ + <option value={slug} data-reg │
│ ion="kr" style="display: none;">{name}</opti │
│ on> │
│ + )) │
│ + } │
│ + </select> │
│ + </div> │
│ + │
│ + <div class="filter-group"> │
│ + <label for="player-class-select" clas │
│ s="filter-label">Class</label> │
│ + <select id="player-class-select" clas │
│ s="filter-select"> │
│ + <option value="">All Classes</optio │
│ n> │
│ + { │
│ + Object.keys(CLASS_COLORS) │
│ + .sort() │
│ + .map((key) => { │
│ + const label = key.replace(/_/ │
│ g, " ").replace(/\b\w/g, (l) => l.toUpperCas │
│ e()); │
│ + return <option value={key} se │
│ lected={initialClass === key}>{label}</optio │
│ n>; │
│ + }) │
│ + } │
│ + </select> │
│ + </div> │
│ + </div> │
│ +</div> │
│ + │
│ +<script src="../../client/player-filter-pan │
│ el.ts"></script> │
│ + │
│ +<style> │
│ + .player-filter-panel { │
│ + background-color: var(--bg-secondary); │
│ + border-radius: 8px; │
│ + border: 1px solid var(--border-color); │
│ + padding: 16px; │
│ + margin-bottom: 16px; │
│ + } │
│ + .filter-row { display: grid; grid-templat │
│ e-columns: 1fr 1fr 1fr; gap: 12px; } │
│ + .filter-group { display: flex; flex-direc │
│ tion: column; gap: 6px; } │
│ + .filter-label { color: var(--text-seconda │
│ ry); font-size: 0.85em; font-weight: 500; te │
│ xt-transform: uppercase; } │
│ + .filter-select { background: var(--bg-pri │
│ mary); color: var(--text-primary); border: 1 │
│ px solid var(--border-color); border-radius: │
│ 4px; padding: 8px 10px; } │
│ + @media (max-width: 768px) { .filter-row { │
│ grid-template-columns: 1fr; } } │
│ +</style> │
│ + │
└──────────────────────────────────────────────┘
┌─ web/src/layouts/ModernChallengeModeLayout.astro ──────────────────────────┐
│ diff --git a/web/src/layouts/ModernChallengeModeLayout.astro b/web/src/layouts/Mod │
│ ernChallengeModeLayout.astro │
│ index 177da73..0e4adc0 100644 │
│ --- a/web/src/layouts/ModernChallengeModeLayout.astro │
│ +++ b/web/src/layouts/ModernChallengeModeLayout.astro │
│ @@ -6,6 +6,7 @@ import LeaderboardTable from "../components/islands/LeaderboardTab │
│ le.astro"; │
│ import { getDungeonName } from "../lib/wow-constants.ts"; │
│ import { buildStaticLeaderboardPath } from "../lib/utils.ts"; │
│ import PlayerLeaderboard from "../components/islands/PlayerLeaderboard.astro"; │
│ +import PlayerFilterPanel from "../components/islands/PlayerFilterPanel.astro"; │
│ import "../styles/components.scss"; │
│ │
│ export interface Props { │
│ @@ -136,6 +137,7 @@ if ( │
│ </div> │
│ │
│ <div class="player-leaderboard-wrapper"> │
│ + <PlayerFilterPanel /> │
│ <PlayerLeaderboard /> │
│ </div> │
│ </section> │
│ @@ -158,13 +160,13 @@ if ( │
│ │
│ navTabs.forEach((tab) => { │
│ tab.addEventListener("click", (e) => { │
│ - const button = e.target; │
│ - const type = │
│ - button && button.dataset ? button.dataset.type : undefined; │
│ + e.preventDefault(); │
│ + const el = e.currentTarget; │
│ + const type = el && el.getAttribute │
│ + ? el.getAttribute("data-type") │
│ + : undefined; │
│ console.log("[INFO] Tab clicked:", type); │
│ - if (type) { │
│ - this.switchTab(type); │
│ - } │
│ + if (type) this.switchTab(type); │
│ }); │
│ }); │
│ } │
│ @@ -202,27 +204,43 @@ if ( │
│ // update URL only if not responding to browser navigation │
│ if (updateHistory) { │
│ if (type === "players") { │
│ - // always update to players URL when switching to players tab │
│ - const newURL = "/challenge-mode/players"; │
│ - console.log("[INFO] Updating URL to players:", newURL); │
│ + const current = window.location.pathname + window.location.search; │
│ + let newURL = current; │
│ + if (!current.startsWith("/challenge-mode/players")) { │
│ + // Try to restore last players URL from sessionStorage, else derive │
│ from current filter state │
│ + const last = sessionStorage.getItem("lastPlayersPath"); │
│ + if (last && last.startsWith("/challenge-mode/players")) { │
│ + newURL = last; │
│ + } else { │
│ + // Build from client state if available │
│ + const w = window; │
│ + const pl = (w).playerLeaderboard; │
│ + if (pl) { │
│ + const scope = pl["scope"] || "global"; │
│ + const region = (pl["region"] || "").toLowerCase(); │
│ + const realm = (pl["realmSlug"] || "").toLowerCase(); │
│ + const cls = (pl["classKey"] || "").toLowerCase(); │
│ + if (scope === "global") newURL = `/challenge-mode/players/globa │
│ l${cls ? "/" + cls : ""}`; │
│ + else if (scope === "regional") newURL = `/challenge-mode/player │
│ s/${region}${cls ? "/" + cls : ""}`; │
│ + else newURL = `/challenge-mode/players/${region}/${realm}${cls │
│ ? "/" + cls : ""}`; │
│ + } else { │
│ + newURL = "/challenge-mode/players"; │
│ + } │
│ + } │
│ + } │
│ + console.log("[INFO] Switching to players URL:", newURL); │
│ window.history.pushState({}, "", newURL); │
│ } else if (type === "dungeon") { │
│ - // for dungeon tab, only update if we're on a generic URL │
│ - const currentPath = window.location.pathname; │
│ - if (currentPath === "/challenge-mode/players") { │
│ - // coming from players tab, go to generic dungeon URL │
│ + // If currently on any players path, remember it and go to base dunge │
│ on URL │
│ + const currentPath = window.location.pathname + window.location.search │
│ ; │
│ + if (currentPath.startsWith("/challenge-mode/players")) { │
│ + try { sessionStorage.setItem("lastPlayersPath", currentPath); } cat │
│ ch {} │
│ const newURL = "/challenge-mode"; │
│ - console.log( │
│ - "[INFO] Updating URL from players to dungeon:", │
│ - newURL, │
│ - ); │
│ + console.log("[INFO] Switching to dungeon URL:", newURL); │
│ window.history.pushState({}, "", newURL); │
│ } else { │
│ - // preserve specific dungeon URLs │
│ - console.log( │
│ - "[INFO] Preserving specific dungeon URL:", │
│ - currentPath, │
│ - ); │
│ + // preserve specific dungeon URLs if already there │
│ + console.log("[INFO] Preserving specific dungeon URL:", currentPath) │
│ ; │
│ } │
│ } │
│ } else { │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...dernChallengeModeLayout.astro ───┐
│ diff --git a/web/src/layouts/ModernChallenge │
│ ModeLayout.astro b/web/src/layouts/ModernCha │
│ llengeModeLayout.astro │
│ index 177da73..0e4adc0 100644 │
│ --- a/web/src/layouts/ModernChallengeModeLay │
│ out.astro │
│ +++ b/web/src/layouts/ModernChallengeModeLay │
│ out.astro │
│ @@ -6,6 +6,7 @@ import LeaderboardTable from │
│ "../components/islands/LeaderboardTable.ast │
│ ro"; │
│ import { getDungeonName } from "../lib/wow- │
│ constants.ts"; │
│ import { buildStaticLeaderboardPath } from │
│ "../lib/utils.ts"; │
│ import PlayerLeaderboard from "../component │
│ s/islands/PlayerLeaderboard.astro"; │
│ +import PlayerFilterPanel from "../component │
│ s/islands/PlayerFilterPanel.astro"; │
│ import "../styles/components.scss"; │
│ │
│ export interface Props { │
│ @@ -136,6 +137,7 @@ if ( │
│ </div> │
│ │
│ <div class="player-leaderboard-wrap │
│ per"> │
│ + <PlayerFilterPanel /> │
│ <PlayerLeaderboard /> │
│ </div> │
│ </section> │
│ @@ -158,13 +160,13 @@ if ( │
│ │
│ navTabs.forEach((tab) => { │
│ tab.addEventListener("click", (e) │
│ => { │
│ - const button = e.target; │
│ - const type = │
│ - button && button.dataset ? bu │
│ tton.dataset.type : undefined; │
│ + e.preventDefault(); │
│ + const el = e.currentTarget; │
│ + const type = el && el.getAttrib │
│ ute │
│ + ? el.getAttribute("data-type" │
│ ) │
│ + : undefined; │
│ console.log("[INFO] Tab clicked │
│ :", type); │
│ - if (type) { │
│ - this.switchTab(type); │
│ - } │
│ + if (type) this.switchTab(type); │
│ }); │
│ }); │
│ } │
│ @@ -202,27 +204,43 @@ if ( │
│ // update URL only if not respondin │
│ g to browser navigation │
│ if (updateHistory) { │
│ if (type === "players") { │
│ - // always update to players URL │
│ when switching to players tab │
│ - const newURL = "/challenge-mode │
│ /players"; │
│ - console.log("[INFO] Updating UR │
│ L to players:", newURL); │
│ + const current = window.location │
│ .pathname + window.location.search; │
│ + let newURL = current; │
│ + if (!current.startsWith("/chall │
│ enge-mode/players")) { │
│ + // Try to restore last player │
│ s URL from sessionStorage, else derive from │
│ current filter state │
│ + const last = sessionStorage.g │
│ etItem("lastPlayersPath"); │
│ + if (last && last.startsWith(" │
│ /challenge-mode/players")) { │
│ + newURL = last; │
│ + } else { │
│ + // Build from client state │
│ if available │
│ + const w = window; │
│ + const pl = (w).playerLeader │
│ board; │
│ + if (pl) { │
│ + const scope = pl["scope"] │
│ || "global"; │
│ + const region = (pl["regio │
│ n"] || "").toLowerCase(); │
│ + const realm = (pl["realmS │
│ lug"] || "").toLowerCase(); │
│ + const cls = (pl["classKey │
│ "] || "").toLowerCase(); │
│ + if (scope === "global") n │
│ ewURL = `/challenge-mode/players/global${cls │
│ ? "/" + cls : ""}`; │
│ + else if (scope === "regio │
│ nal") newURL = `/challenge-mode/players/${re │
│ gion}${cls ? "/" + cls : ""}`; │
│ + else newURL = `/challenge │
│ -mode/players/${region}/${realm}${cls ? "/" │
│ + cls : ""}`; │
│ + } else { │
│ + newURL = "/challenge-mode │
│ /players"; │
│ + } │
│ + } │
│ + } │
│ + console.log("[INFO] Switching t │
│ o players URL:", newURL); │
│ window.history.pushState({}, "" │
│ , newURL); │
│ } else if (type === "dungeon") { │
│ - // for dungeon tab, only update │
│ if we're on a generic URL │
│ - const currentPath = window.loca │
│ tion.pathname; │
│ - if (currentPath === "/challenge │
│ -mode/players") { │
│ - // coming from players tab, g │
│ o to generic dungeon URL │
│ + // If currently on any players │
│ path, remember it and go to base dungeon URL │
│ + const currentPath = window.loca │
│ tion.pathname + window.location.search; │
│ + if (currentPath.startsWith("/ch │
│ allenge-mode/players")) { │
│ + try { sessionStorage.setItem( │
│ "lastPlayersPath", currentPath); } catch {} │
│ const newURL = "/challenge-mo │
│ de"; │
│ - console.log( │
│ - "[INFO] Updating URL from p │
│ layers to dungeon:", │
│ - newURL, │
│ - ); │
│ + console.log("[INFO] Switching │
│ to dungeon URL:", newURL); │
│ window.history.pushState({}, │
│ "", newURL); │
│ } else { │
│ - // preserve specific dungeon │
│ URLs │
│ - console.log( │
│ - "[INFO] Preserving specific │
│ dungeon URL:", │
│ - currentPath, │
│ - ); │
│ + // preserve specific dungeon │
│ URLs if already there │
│ + console.log("[INFO] Preservin │
│ g specific dungeon URL:", currentPath); │
│ } │
│ } │
│ } else { │
└──────────────────────────────────────────────┘
┌─ web/src/lib/api.ts ───────────────────────────────────────────────────────┐
│ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts │
│ index 1622b61..5a7fae7 100644 │
│ --- a/web/src/lib/api.ts │
│ +++ b/web/src/lib/api.ts │
│ @@ -60,12 +60,13 @@ export async function fetchRealmLeaderboard( │
│ } │
│ │
│ export async function fetchPlayerLeaderboard( │
│ - scope: string = "global", │
│ + scope: "global" | "regional" | "realm" = "global", │
│ region?: string, │
│ page: number = 1, │
│ pageSize: number = 25, │
│ + opts?: { realmSlug?: string; classKey?: string }, │
│ ): Promise<any> { │
│ - const url = `${API_BASE}${buildStaticPlayerLeaderboardPath(scope, region, page) │
│ }`; │
│ + const url = `${API_BASE}${buildStaticPlayerLeaderboardPath(scope, region, page, │
│ opts)}`; │
│ console.log("Fetching player leaderboard:", url); │
│ return apiRequest(url); │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ web/src/lib/api.ts ─────────────────┐
│ diff --git a/web/src/lib/api.ts b/web/src/li │
│ b/api.ts │
│ index 1622b61..5a7fae7 100644 │
│ --- a/web/src/lib/api.ts │
│ +++ b/web/src/lib/api.ts │
│ @@ -60,12 +60,13 @@ export async function fe │
│ tchRealmLeaderboard( │
│ } │
│ │
│ export async function fetchPlayerLeaderboar │
│ d( │
│ - scope: string = "global", │
│ + scope: "global" | "regional" | "realm" = │
│ "global", │
│ region?: string, │
│ page: number = 1, │
│ pageSize: number = 25, │
│ + opts?: { realmSlug?: string; classKey?: s │
│ tring }, │
│ ): Promise<any> { │
│ - const url = `${API_BASE}${buildStaticPlay │
│ erLeaderboardPath(scope, region, page)}`; │
│ + const url = `${API_BASE}${buildStaticPlay │
│ erLeaderboardPath(scope, region, page, opts) │
│ }`; │
│ console.log("Fetching player leaderboard: │
│ ", url); │
│ return apiRequest(url); │
│ } │
└──────────────────────────────────────────────┘
┌─ web/src/lib/utils.ts ─────────────────────────────────────────────────────┐
│ diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts │
│ index 37e1ed8..7b332ce 100644 │
│ --- a/web/src/lib/utils.ts │
│ +++ b/web/src/lib/utils.ts │
│ @@ -172,6 +172,30 @@ export function buildPlayerProfileURL( │
│ return `/player/${region}/${realmSlug}/${playerName.toLowerCase()}`; │
│ } │
│ │
│ +// Build Players tab URL path from filters │
│ +export function buildPlayersLeaderboardURL( │
│ + scope: "global" | "regional" | "realm", │
│ + opts?: { region?: string; realmSlug?: string; classKey?: string; page?: number │
│ }, │
│ +): string { │
│ + const cls = (opts?.classKey || "").toLowerCase(); │
│ + const reg = (opts?.region || "").toLowerCase(); │
│ + const realm = (opts?.realmSlug || "").toLowerCase(); │
│ + let path = "/challenge-mode/players"; │
│ + if (scope === "global") { │
│ + if (cls) path += `/global/${cls}`; else path += `/global`; │
│ + } else if (scope === "regional") { │
│ + if (reg) path += `/${reg}`; else path += `/us`; │
│ + if (cls) path += `/${cls}`; │
│ + } else if (scope === "realm") { │
│ + if (reg) path += `/${reg}`; │
│ + if (realm) path += `/${realm}`; │
│ + if (cls) path += `/${cls}`; │
│ + } │
│ + const page = opts?.page || 1; │
│ + if (page > 1) path += `?page=${page}`; │
│ + return path; │
│ +} │
│ + │
│ // Form utilities │
│ export function getFormValue(elementId: string): string { │
│ const element = document.getElementById(elementId) as │
│ @@ -291,15 +315,39 @@ export function buildStaticLeaderboardPath( │
│ } │
│ │
│ export function buildStaticPlayerLeaderboardPath( │
│ - scope: string, │
│ + scope: "global" | "regional" | "realm", │
│ region?: string, │
│ page: number = 1, │
│ + opts?: { realmSlug?: string; classKey?: string }, │
│ ): string { │
│ + const cls = (opts?.classKey || "").toLowerCase(); │
│ + const realm = (opts?.realmSlug || "").toLowerCase(); │
│ + │
│ + // Class-filtered variants │
│ + if (cls) { │
│ + if (scope === "global") { │
│ + return `/api/leaderboard/players/class/${cls}/global/${page}.json`; │
│ + } │
│ + if (scope === "regional" && region) { │
│ + return `/api/leaderboard/players/class/${cls}/regional/${region}/${page}.js │
│ on`; │
│ + } │
│ + if (scope === "realm" && region && realm) { │
│ + return `/api/leaderboard/players/class/${cls}/realm/${region}/${realm}/${pa │
│ ge}.json`; │
│ + } │
│ + } │
│ + │
│ + // Unfiltered │
│ + if (scope === "global") { │
│ + return `/api/leaderboard/players/global/${page}.json`; │
│ + } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/players/regional/${region}/${page}.json`; │
│ - } else { │
│ - return `/api/leaderboard/players/global/${page}.json`; │
│ } │
│ + if (scope === "realm" && region && realm) { │
│ + return `/api/leaderboard/players/realm/${region}/${realm}/${page}.json`; │
│ + } │
│ + // Fallback to global │
│ + return `/api/leaderboard/players/global/${page}.json`; │
│ } │
│ │
│ export function buildStaticPlayerProfilePath( │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ web/src/lib/utils.ts ───────────────┐
│ diff --git a/web/src/lib/utils.ts b/web/src/ │
│ lib/utils.ts │
│ index 37e1ed8..7b332ce 100644 │
│ --- a/web/src/lib/utils.ts │
│ +++ b/web/src/lib/utils.ts │
│ @@ -172,6 +172,30 @@ export function buildPl │
│ ayerProfileURL( │
│ return `/player/${region}/${realmSlug}/${ │
│ playerName.toLowerCase()}`; │
│ } │
│ │
│ +// Build Players tab URL path from filters │
│ +export function buildPlayersLeaderboardURL( │
│ + scope: "global" | "regional" | "realm", │
│ + opts?: { region?: string; realmSlug?: str │
│ ing; classKey?: string; page?: number }, │
│ +): string { │
│ + const cls = (opts?.classKey || "").toLowe │
│ rCase(); │
│ + const reg = (opts?.region || "").toLowerC │
│ ase(); │
│ + const realm = (opts?.realmSlug || "").toL │
│ owerCase(); │
│ + let path = "/challenge-mode/players"; │
│ + if (scope === "global") { │
│ + if (cls) path += `/global/${cls}`; else │
│ path += `/global`; │
│ + } else if (scope === "regional") { │
│ + if (reg) path += `/${reg}`; else path + │
│ = `/us`; │
│ + if (cls) path += `/${cls}`; │
│ + } else if (scope === "realm") { │
│ + if (reg) path += `/${reg}`; │
│ + if (realm) path += `/${realm}`; │
│ + if (cls) path += `/${cls}`; │
│ + } │
│ + const page = opts?.page || 1; │
│ + if (page > 1) path += `?page=${page}`; │
│ + return path; │
│ +} │
│ + │
│ // Form utilities │
│ export function getFormValue(elementId: str │
│ ing): string { │
│ const element = document.getElementById(e │
│ lementId) as │
│ @@ -291,15 +315,39 @@ export function buildS │
│ taticLeaderboardPath( │
│ } │
│ │
│ export function buildStaticPlayerLeaderboar │
│ dPath( │
│ - scope: string, │
│ + scope: "global" | "regional" | "realm", │
│ region?: string, │
│ page: number = 1, │
│ + opts?: { realmSlug?: string; classKey?: s │
│ tring }, │
│ ): string { │
│ + const cls = (opts?.classKey || "").toLowe │
│ rCase(); │
│ + const realm = (opts?.realmSlug || "").toL │
│ owerCase(); │
│ + │
│ + // Class-filtered variants │
│ + if (cls) { │
│ + if (scope === "global") { │
│ + return `/api/leaderboard/players/clas │
│ s/${cls}/global/${page}.json`; │
│ + } │
│ + if (scope === "regional" && region) { │
│ + return `/api/leaderboard/players/clas │
│ s/${cls}/regional/${region}/${page}.json`; │
│ + } │
│ + if (scope === "realm" && region && real │
│ m) { │
│ + return `/api/leaderboard/players/clas │
│ s/${cls}/realm/${region}/${realm}/${page}.js │
│ on`; │
│ + } │
│ + } │
│ + │
│ + // Unfiltered │
│ + if (scope === "global") { │
│ + return `/api/leaderboard/players/global │
│ /${page}.json`; │
│ + } │
│ if (scope === "regional" && region) { │
│ return `/api/leaderboard/players/region │
│ al/${region}/${page}.json`; │
│ - } else { │
│ - return `/api/leaderboard/players/global │
│ /${page}.json`; │
│ } │
│ + if (scope === "realm" && region && realm) │
│ { │
│ + return `/api/leaderboard/players/realm/ │
│ ${region}/${realm}/${page}.json`; │
│ + } │
│ + // Fallback to global │
│ + return `/api/leaderboard/players/global/$ │
│ {page}.json`; │
│ } │
│ │
│ export function buildStaticPlayerProfilePat │
│ h( │
└──────────────────────────────────────────────┘
┌─ web/src/pages/challenge-mode/[...slug].astro ─────────────────────────────┐
│ diff --git a/web/src/pages/challenge-mode/[...slug].astro b/web/src/pages/challeng │
│ e-mode/[...slug].astro │
│ index 4e09506..3b4ab8c 100644 │
│ --- a/web/src/pages/challenge-mode/[...slug].astro │
│ +++ b/web/src/pages/challenge-mode/[...slug].astro │
│ @@ -15,11 +15,9 @@ let initialRealm = ""; │
│ let initialDungeon = "2"; // Default dungeon │
│ │
│ // Parse different URL patterns │
│ -if (slugParts.length === 1) { │
│ - // /challenge-mode/players or /challenge-mode/teams │
│ - if (slugParts[0] === "players" || slugParts[0] === "teams") { │
│ - initialLeaderboardType = slugParts[0]; │
│ - } │
│ +if (slugParts.length >= 1 && (slugParts[0] === "players" || slugParts[0] === "tea │
│ ms")) { │
│ + // Any /challenge-mode/players[...] or /challenge-mode/teams[...] path should l │
│ and on that tab │
│ + initialLeaderboardType = slugParts[0]; │
│ } else if (slugParts.length === 2) { │
│ // /challenge-mode/global/dungeon-slug │
│ if (slugParts[0] === "global") { │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ...hallenge-mode/[...slug].astro ───┐
│ diff --git a/web/src/pages/challenge-mode/[. │
│ ..slug].astro b/web/src/pages/challenge-mode │
│ /[...slug].astro │
│ index 4e09506..3b4ab8c 100644 │
│ --- a/web/src/pages/challenge-mode/[...slug] │
│ .astro │
│ +++ b/web/src/pages/challenge-mode/[...slug] │
│ .astro │
│ @@ -15,11 +15,9 @@ let initialRealm = ""; │
│ let initialDungeon = "2"; // Default dungeo │
│ n │
│ │
│ // Parse different URL patterns │
│ -if (slugParts.length === 1) { │
│ - // /challenge-mode/players or /challenge- │
│ mode/teams │
│ - if (slugParts[0] === "players" || slugPar │
│ ts[0] === "teams") { │
│ - initialLeaderboardType = slugParts[0]; │
│ - } │
│ +if (slugParts.length >= 1 && (slugParts[0] │
│ === "players" || slugParts[0] === "teams")) │
│ { │
│ + // Any /challenge-mode/players[...] or /c │
│ hallenge-mode/teams[...] path should land on │
│ that tab │
│ + initialLeaderboardType = slugParts[0]; │
│ } else if (slugParts.length === 2) { │
│ // /challenge-mode/global/dungeon-slug │
│ if (slugParts[0] === "global") { │
└──────────────────────────────────────────────┘
[ BACK TO LOG ]
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET