Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
344 lines
11 KiB
JavaScript
344 lines
11 KiB
JavaScript
/* global window, history, location */
|
|
|
|
/**
|
|
* Path-based router for the Climperor web site (web/frontend).
|
|
*
|
|
* Synchronizes the browser URL with app state across these dimensions:
|
|
* - page: home | heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
|
|
* - hero: selected hero key + detail sub-tab
|
|
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats → trends)
|
|
* - rankings: Immortal leaderboard region
|
|
* /rankings[/region] (legacy /rankings/meta[/bracket] → players / china)
|
|
* - matches: star-player recent matches (pro_matches)
|
|
* /matches[/account_id][?origin=pro|china][&page=N]
|
|
* (default origin all omitted; page=1 omitted)
|
|
* - home: logged-in Steam self homepage (players view + auth.account_id)
|
|
* /home[/{match_id}]
|
|
* - players: PC post-match player home + match detail (local/OSS JSON)
|
|
* /players/{account_id}[/{match_id}]
|
|
* - streamers: curated streamer directory
|
|
* /streamers
|
|
* - trends: medal bracket for the 8-week win/pick board
|
|
* /trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted)
|
|
* - mechanics: applies-effect query (dispel / CC)
|
|
* /mechanics[/{effect}] (default effect basic_dispel omitted)
|
|
* - item: selected shop item key (items page)
|
|
* - patch: selected version string (patches page; latest when absent)
|
|
* - query params: tags=csv (heroes), q=search text (heroes + items)
|
|
*
|
|
* Legacy hash URLs (#/heroes/...) are migrated once to path URLs via replaceState.
|
|
*
|
|
* Two-way binding without feedback loops:
|
|
* - App calls syncStateToUrl() after each state mutation. We use
|
|
* history.pushState/replaceState, which update the URL silently
|
|
* (no popstate) — so applyUrlToState is never re-entered.
|
|
* - Back/forward navigation fires popstate → applyUrlToState parses
|
|
* the new path, hands a validated patch back to app, and app re-renders.
|
|
*
|
|
* API names parseHash / serializeHash are kept for call-site stability; both
|
|
* now operate on pathname + search (not location.hash).
|
|
*
|
|
* Depends on app.js for state shape and render(); installed via installRouter().
|
|
*/
|
|
|
|
const ROUTE_DEFAULT = "/heroes";
|
|
const VALID_PAGES = [
|
|
"home",
|
|
"heroes",
|
|
"rankings",
|
|
"matches",
|
|
"streamers",
|
|
"trends",
|
|
"mechanics",
|
|
"items",
|
|
"patches",
|
|
"players",
|
|
];
|
|
const VALID_DETAIL_TABS = [
|
|
"skills",
|
|
"core",
|
|
"fears",
|
|
"trends",
|
|
"stats",
|
|
"matchups",
|
|
"matches",
|
|
"streamers",
|
|
"patches",
|
|
];
|
|
const DEFAULT_RANKING_REGION = "china";
|
|
const DEFAULT_TRENDS_BRACKET = "legend";
|
|
const DEFAULT_TRENDS_SORT = "wr_end";
|
|
const VALID_TRENDS_SORTS = ["wr_end", "pr_end"];
|
|
const TRENDS_SORT_URL = { wr_end: "wr", pr_end: "pr" };
|
|
const TRENDS_SORT_FROM_URL = { wr: "wr_end", pr: "pr_end", wr_end: "wr_end", pr_end: "pr_end" };
|
|
const DEFAULT_MATCHES_ORIGIN = "all";
|
|
const VALID_MATCHES_ORIGINS = ["all", "pro", "china"];
|
|
const DEFAULT_MECHANIC_EFFECT = "basic_dispel";
|
|
const VALID_MECHANIC_EFFECTS = [
|
|
"basic_dispel",
|
|
"strong_dispel",
|
|
"root",
|
|
"disarm",
|
|
"silence",
|
|
"mute",
|
|
"stun",
|
|
"hex",
|
|
"break",
|
|
"sleep",
|
|
"fear",
|
|
"taunt",
|
|
"blind",
|
|
"leash",
|
|
"invis",
|
|
"ethereal",
|
|
"cyclone",
|
|
];
|
|
|
|
let _deps = null;
|
|
|
|
/**
|
|
* Bind the router. deps = {
|
|
* getState: () => state,
|
|
* applyPatch: (patch) => void // validates + merges + renders
|
|
* }
|
|
* Call once after state.data is loaded.
|
|
*/
|
|
function installRouter(deps) {
|
|
_deps = deps;
|
|
migrateLegacyHashRoute();
|
|
window.addEventListener("popstate", applyUrlToState);
|
|
}
|
|
|
|
/** One-shot: #/heroes/... → /heroes/... (bookmarks / shared links). */
|
|
function migrateLegacyHashRoute() {
|
|
const hash = window.location.hash || "";
|
|
if (!hash.startsWith("#/")) return;
|
|
const next = hash.slice(1) || ROUTE_DEFAULT;
|
|
history.replaceState(null, "", next);
|
|
}
|
|
|
|
/** Parse a path (+ optional query) into a state patch (all fields nullable). */
|
|
function parseHash(hashOrPath) {
|
|
const out = {
|
|
page: null,
|
|
heroKey: null,
|
|
detailTab: null,
|
|
itemKey: null,
|
|
patchVersion: null,
|
|
rankingRegion: null,
|
|
matchesPlayerId: null,
|
|
matchesOrigin: null,
|
|
matchesPage: null,
|
|
playerAccountId: null,
|
|
playerMatchId: null,
|
|
trendsBracket: null,
|
|
trendsSort: null,
|
|
mechanicEffect: null,
|
|
tags: null,
|
|
query: null,
|
|
itemQuery: null,
|
|
};
|
|
let raw = hashOrPath || "";
|
|
if (raw.startsWith("#")) raw = raw.slice(1);
|
|
if (!raw.startsWith("/")) raw = "/" + raw;
|
|
const qIdx = raw.indexOf("?");
|
|
const pathPart = qIdx >= 0 ? raw.slice(0, qIdx) : raw;
|
|
const queryPart = qIdx >= 0 ? raw.slice(qIdx + 1) : "";
|
|
const segs = pathPart.split("/").filter(Boolean);
|
|
if (!segs.length) {
|
|
out.page = "heroes";
|
|
return out;
|
|
}
|
|
const page = segs[0];
|
|
if (!VALID_PAGES.includes(page)) return out;
|
|
out.page = page;
|
|
if (page === "heroes") {
|
|
if (segs[1]) out.heroKey = safeDecode(segs[1]);
|
|
if (segs[2]) out.detailTab = safeDecode(segs[2]);
|
|
} else if (page === "rankings") {
|
|
// Legacy /rankings/meta[/bracket] → Immortal players board (default region).
|
|
if (segs[1] && segs[1] !== "meta") {
|
|
out.rankingRegion = safeDecode(segs[1]);
|
|
}
|
|
} else if (page === "matches") {
|
|
if (segs[1] && /^\d+$/.test(segs[1])) {
|
|
out.matchesPlayerId = segs[1];
|
|
}
|
|
} else if (page === "home") {
|
|
// Logged-in self homepage; optional match detail: /home/{match_id}
|
|
if (segs[1] && /^\d+$/.test(segs[1])) {
|
|
out.playerMatchId = segs[1];
|
|
}
|
|
} else if (page === "players") {
|
|
if (segs[1] && /^\d+$/.test(segs[1])) {
|
|
out.playerAccountId = segs[1];
|
|
}
|
|
if (segs[2] && /^\d+$/.test(segs[2])) {
|
|
out.playerMatchId = segs[2];
|
|
}
|
|
} else if (page === "trends") {
|
|
if (segs[1]) out.trendsBracket = safeDecode(segs[1]);
|
|
} else if (page === "mechanics") {
|
|
if (segs[1]) out.mechanicEffect = safeDecode(segs[1]);
|
|
} else if (page === "items") {
|
|
if (segs[1]) out.itemKey = safeDecode(segs[1]);
|
|
} else if (page === "patches") {
|
|
if (segs[1]) out.patchVersion = safeDecode(segs[1]);
|
|
}
|
|
const params = new URLSearchParams(queryPart);
|
|
const tagsParam = params.get("tags");
|
|
if (tagsParam) {
|
|
out.tags = new Set(
|
|
tagsParam.split(",").map((s) => safeDecode(s)).filter(Boolean)
|
|
);
|
|
}
|
|
const qParam = params.get("q");
|
|
if (qParam != null) {
|
|
if (page === "items") out.itemQuery = qParam;
|
|
else out.query = qParam;
|
|
}
|
|
if (page === "trends") {
|
|
const sortParam = params.get("sort");
|
|
if (sortParam && TRENDS_SORT_FROM_URL[sortParam]) {
|
|
out.trendsSort = TRENDS_SORT_FROM_URL[sortParam];
|
|
}
|
|
}
|
|
if (page === "matches") {
|
|
const originParam = params.get("origin");
|
|
if (originParam && VALID_MATCHES_ORIGINS.includes(originParam)) {
|
|
out.matchesOrigin = originParam;
|
|
}
|
|
const pageParam = params.get("page");
|
|
if (pageParam && /^\d+$/.test(pageParam)) {
|
|
const n = parseInt(pageParam, 10);
|
|
if (n >= 1) out.matchesPage = n;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function safeDecode(s) {
|
|
try {
|
|
return decodeURIComponent(s);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/** Serialize current app state into a path string (leading '/', optional ?query). */
|
|
function serializeHash(state) {
|
|
if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT;
|
|
let path = "/" + state.page;
|
|
if (state.page === "heroes") {
|
|
if (state.selectedKey) {
|
|
path += "/" + encodeURIComponent(state.selectedKey);
|
|
let tab = state.detailTab;
|
|
if (tab === "stats") tab = "trends"; // normalize legacy alias in URL
|
|
if (tab && VALID_DETAIL_TABS.includes(tab) && tab !== "stats") {
|
|
path += "/" + encodeURIComponent(tab);
|
|
}
|
|
}
|
|
} else if (state.page === "rankings") {
|
|
// Omit region when it is the default (china) — bare /rankings means China.
|
|
const region = state.rankingRegion || DEFAULT_RANKING_REGION;
|
|
if (region && region !== DEFAULT_RANKING_REGION) {
|
|
path += "/" + encodeURIComponent(region);
|
|
}
|
|
} else if (state.page === "matches") {
|
|
if (state.matchesPlayerId) {
|
|
path += "/" + encodeURIComponent(String(state.matchesPlayerId));
|
|
}
|
|
} else if (state.page === "home") {
|
|
if (state.playerMatchId) {
|
|
path += "/" + encodeURIComponent(String(state.playerMatchId));
|
|
}
|
|
} else if (state.page === "players") {
|
|
if (state.playerAccountId) {
|
|
path += "/" + encodeURIComponent(String(state.playerAccountId));
|
|
if (state.playerMatchId) {
|
|
path += "/" + encodeURIComponent(String(state.playerMatchId));
|
|
}
|
|
}
|
|
} else if (state.page === "trends") {
|
|
// Omit bracket when it is the default (legend) — bare /trends means legend.
|
|
const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET;
|
|
if (bracket && bracket !== DEFAULT_TRENDS_BRACKET) {
|
|
path += "/" + encodeURIComponent(bracket);
|
|
}
|
|
const sort = state.trendsSort || DEFAULT_TRENDS_SORT;
|
|
if (VALID_TRENDS_SORTS.includes(sort) && sort !== DEFAULT_TRENDS_SORT) {
|
|
path += "?sort=" + encodeURIComponent(TRENDS_SORT_URL[sort] || sort);
|
|
}
|
|
} else if (state.page === "mechanics") {
|
|
const effect = state.mechanicEffect || DEFAULT_MECHANIC_EFFECT;
|
|
if (effect && effect !== DEFAULT_MECHANIC_EFFECT) {
|
|
path += "/" + encodeURIComponent(effect);
|
|
}
|
|
} else if (state.page === "items") {
|
|
if (state.selectedItemKey) {
|
|
path += "/" + encodeURIComponent(state.selectedItemKey);
|
|
}
|
|
} else if (state.page === "patches") {
|
|
// Omit version when it is the latest — bare /patches means "latest".
|
|
if (state.selectedPatch) {
|
|
const patches = (state.data && state.data.patches) || [];
|
|
const isLatest =
|
|
patches.length > 0 && patches[0].version === state.selectedPatch;
|
|
if (!isLatest) {
|
|
path += "/" + encodeURIComponent(state.selectedPatch);
|
|
}
|
|
}
|
|
}
|
|
if (state.page === "heroes") {
|
|
const qs = new URLSearchParams();
|
|
if (state.tagFilters && state.tagFilters.size) {
|
|
qs.set("tags", Array.from(state.tagFilters).join(","));
|
|
}
|
|
if (state.query) qs.set("q", state.query);
|
|
const s = qs.toString();
|
|
if (s) path += "?" + s;
|
|
} else if (state.page === "items") {
|
|
const qs = new URLSearchParams();
|
|
if (state.itemQuery) qs.set("q", state.itemQuery);
|
|
const s = qs.toString();
|
|
if (s) path += "?" + s;
|
|
} else if (state.page === "matches") {
|
|
const qs = new URLSearchParams();
|
|
const origin = state.matchesOrigin || DEFAULT_MATCHES_ORIGIN;
|
|
if (VALID_MATCHES_ORIGINS.includes(origin) && origin !== DEFAULT_MATCHES_ORIGIN) {
|
|
qs.set("origin", origin);
|
|
}
|
|
const pageNum = Number(state.matchesPage) || 1;
|
|
if (pageNum > 1) qs.set("page", String(pageNum));
|
|
const s = qs.toString();
|
|
if (s) path += "?" + s;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function currentRoutePath() {
|
|
return (window.location.pathname || "/") + (window.location.search || "");
|
|
}
|
|
|
|
/** Write current state into the URL. Use replace:true for high-frequency
|
|
* updates (search typing, tag toggles) to avoid history-stack spam. */
|
|
function syncStateToUrl({ replace = false } = {}) {
|
|
if (!_deps) return;
|
|
const path = serializeHash(_deps.getState());
|
|
if (path === currentRoutePath()) return; // no-op when state matches URL
|
|
if (replace) {
|
|
history.replaceState(null, "", path);
|
|
} else {
|
|
history.pushState(null, "", path);
|
|
}
|
|
}
|
|
|
|
/** Parse current location path+search, hand it to app for validation+merge.
|
|
* Invoked on popstate (back/forward) and once at startup for deep-link support. */
|
|
function applyUrlToState() {
|
|
if (!_deps) return;
|
|
const path = currentRoutePath();
|
|
const patch = parseHash(path === "/" ? "/heroes" : path);
|
|
_deps.applyPatch(patch);
|
|
}
|