Files
climperor/web/relations/router.js
T

159 lines
5.2 KiB
JavaScript

/* global window, history, location */
/**
* Hash-based router for the relations preview (web/relations).
*
* Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | items | patches (top-level tab)
* - hero: selected hero key + detail sub-tab (skills|core|fears|patches)
* - 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)
*
* Two-way binding without feedback loops:
* - App calls syncStateToUrl() after each state mutation. We use
* history.pushState/replaceState, which update the URL silently
* (no hashchange event fires) — so applyUrlToState is never re-entered.
* - Back/forward navigation fires hashchange → applyUrlToState parses
* the new hash, hands a validated patch back to app, and app re-renders.
*
* Depends on app.js for state shape and render(); installed via installRouter().
*/
const ROUTE_DEFAULT = "#/heroes";
const VALID_PAGES = ["heroes", "items", "patches"];
const VALID_DETAIL_TABS = ["skills", "core", "fears", "patches"];
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;
window.addEventListener("hashchange", applyUrlToState);
}
/** Parse a hash string into a state patch (all fields nullable). */
function parseHash(hash) {
const out = {
page: null,
heroKey: null,
detailTab: null,
itemKey: null,
patchVersion: null,
tags: null,
query: null,
itemQuery: null,
};
let raw = hash || "";
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) 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 === "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;
}
return out;
}
function safeDecode(s) {
try {
return decodeURIComponent(s);
} catch {
return "";
}
}
/** Serialize current app state into a hash string (with leading '#'). */
function serializeHash(state) {
if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT;
let hash = "#/" + state.page;
if (state.page === "heroes") {
if (state.selectedKey) {
hash += "/" + encodeURIComponent(state.selectedKey);
if (state.detailTab && VALID_DETAIL_TABS.includes(state.detailTab)) {
hash += "/" + encodeURIComponent(state.detailTab);
}
}
} else if (state.page === "items") {
if (state.selectedItemKey) {
hash += "/" + 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) {
hash += "/" + 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) hash += "?" + s;
} else if (state.page === "items") {
const qs = new URLSearchParams();
if (state.itemQuery) qs.set("q", state.itemQuery);
const s = qs.toString();
if (s) hash += "?" + s;
}
return hash;
}
/** 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 hash = serializeHash(_deps.getState());
if (hash === window.location.hash) return; // no-op when state matches URL
if (replace) {
history.replaceState(null, "", hash);
} else {
history.pushState(null, "", hash);
}
}
/** Parse current location.hash, hand it to app for validation+merge, render.
* Invoked on hashchange (back/forward / direct location.hash writes) and
* once at startup for deep-link support. */
function applyUrlToState() {
if (!_deps) return;
const patch = parseHash(window.location.hash);
_deps.applyPatch(patch);
}