Add hash routing and Dota 2 logo to relations preview.
- New web/relations/router.js (parseHash/serializeHash/installRouter): top tabs, hero + detail sub-tab, item, patch version, tag filters and search query all sync to URL (#/heroes/axe/core, ?tags=...&q=...); pushState for discrete picks, replaceState for debounced search. - export_relations_site.py copies router.js so static export deep-links. - Header shows dota2_logo.png + title, served via /ui-icon/ whitelist. - fetch_dota2_logo.py fetches the transparent emblem asset. - Docs: CHANGELOG / README / DESIGN / AGENTS updated.
This commit is contained in:
+92
-1
@@ -147,6 +147,7 @@ function renderBoard() {
|
||||
function onHeroClick(key) {
|
||||
state.selectedKey = state.selectedKey === key ? null : key;
|
||||
state.inspect = null;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -161,6 +162,7 @@ function renderTagbar() {
|
||||
allBtn.className = state.tagFilters.size ? "" : "active";
|
||||
allBtn.addEventListener("click", () => {
|
||||
state.tagFilters.clear();
|
||||
syncStateToUrl({ replace: true });
|
||||
render();
|
||||
});
|
||||
bar.appendChild(allBtn);
|
||||
@@ -172,6 +174,7 @@ function renderTagbar() {
|
||||
btn.addEventListener("click", () => {
|
||||
if (state.tagFilters.has(tag)) state.tagFilters.delete(tag);
|
||||
else state.tagFilters.add(tag);
|
||||
syncStateToUrl({ replace: true });
|
||||
render();
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
@@ -1286,6 +1289,7 @@ function renderDetail() {
|
||||
// Reset inspect to the tab default (first skill / first item).
|
||||
state.inspect = null;
|
||||
ensureHeroInspectDefault(state.selectedKey);
|
||||
syncStateToUrl();
|
||||
renderDetail();
|
||||
});
|
||||
tabbar.appendChild(btn);
|
||||
@@ -1368,6 +1372,7 @@ function matchesItemQuery(key, meta) {
|
||||
function onShopItemClick(key) {
|
||||
state.selectedItemKey = state.selectedItemKey === key ? null : key;
|
||||
state.selectedKey = null;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -1522,6 +1527,7 @@ function renderItemDetail() {
|
||||
mountItemInspect(root, meta, {
|
||||
onPickItem: (key) => {
|
||||
state.selectedItemKey = key;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
},
|
||||
});
|
||||
@@ -1718,6 +1724,7 @@ function renderPatches() {
|
||||
.join("");
|
||||
select.onchange = () => {
|
||||
state.selectedPatch = select.value;
|
||||
syncStateToUrl();
|
||||
renderPatches();
|
||||
const board = $("#patches-view");
|
||||
if (board) board.scrollTo(0, 0);
|
||||
@@ -1780,6 +1787,7 @@ function setPage(page) {
|
||||
state.selectedKey = null;
|
||||
state.inspect = null;
|
||||
}
|
||||
syncStateToUrl();
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -1815,6 +1823,71 @@ function render() {
|
||||
}
|
||||
}
|
||||
|
||||
// Search-box URL sync timer (debounced replace so typing does not spam history).
|
||||
let searchSyncTimer = 0;
|
||||
const SEARCH_SYNC_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Apply a router-produced state patch (from a parsed URL) to state, then render.
|
||||
* Validates every field against loaded data so bad deep-links degrade gracefully
|
||||
* (unknown hero/item/version is dropped rather than crashing the UI).
|
||||
*/
|
||||
function applyPatch(patch) {
|
||||
if (!state.data) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
// Page (default heroes on bad/missing).
|
||||
if (patch.page && ["heroes", "items", "patches"].includes(patch.page)) {
|
||||
state.page = patch.page;
|
||||
} else {
|
||||
state.page = "heroes";
|
||||
}
|
||||
// Hero + detail sub-tab.
|
||||
if (patch.heroKey && heroByKey(patch.heroKey)) {
|
||||
state.selectedKey = patch.heroKey;
|
||||
if (
|
||||
patch.detailTab &&
|
||||
["skills", "core", "fears", "patches"].includes(patch.detailTab)
|
||||
) {
|
||||
state.detailTab = patch.detailTab;
|
||||
} else {
|
||||
state.detailTab = "skills";
|
||||
}
|
||||
} else {
|
||||
state.selectedKey = null;
|
||||
state.detailTab = "skills";
|
||||
}
|
||||
// Reset inspect so ensureHeroInspectDefault re-picks per tab (inspect is URL-less).
|
||||
state.inspect = null;
|
||||
// Item (items page) — must exist in the shop catalog.
|
||||
if (patch.itemKey && shopItem(patch.itemKey)) {
|
||||
state.selectedItemKey = patch.itemKey;
|
||||
} else {
|
||||
state.selectedItemKey = null;
|
||||
}
|
||||
// Patch version (patches page; null means latest).
|
||||
if (
|
||||
patch.patchVersion &&
|
||||
(state.data.patches || []).some((p) => p.version === patch.patchVersion)
|
||||
) {
|
||||
state.selectedPatch = patch.patchVersion;
|
||||
} else {
|
||||
state.selectedPatch = null;
|
||||
}
|
||||
// Tag filters (heroes page only) — drop unknown tag names.
|
||||
if (patch.tags instanceof Set) {
|
||||
const valid = new Set(state.data.tag_order || []);
|
||||
state.tagFilters = new Set([...patch.tags].filter((t) => valid.has(t)));
|
||||
}
|
||||
if (typeof patch.query === "string") {
|
||||
state.query = patch.query;
|
||||
const inp = $("#q");
|
||||
if (inp && inp.value !== state.query) inp.value = state.query;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function bindSearch() {
|
||||
const input = $("#q");
|
||||
if (input) {
|
||||
@@ -1822,6 +1895,12 @@ function bindSearch() {
|
||||
input.addEventListener("input", () => {
|
||||
state.query = input.value;
|
||||
if (state.page === "heroes") renderBoard();
|
||||
// Debounced replace so each keystroke does not push a history entry.
|
||||
if (searchSyncTimer) clearTimeout(searchSyncTimer);
|
||||
searchSyncTimer = setTimeout(() => {
|
||||
searchSyncTimer = 0;
|
||||
syncStateToUrl({ replace: true });
|
||||
}, SEARCH_SYNC_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
const itemInput = $("#q-item");
|
||||
@@ -1830,6 +1909,11 @@ function bindSearch() {
|
||||
itemInput.addEventListener("input", () => {
|
||||
state.itemQuery = itemInput.value;
|
||||
if (state.page === "items") renderItemShop();
|
||||
if (searchSyncTimer) clearTimeout(searchSyncTimer);
|
||||
searchSyncTimer = setTimeout(() => {
|
||||
searchSyncTimer = 0;
|
||||
syncStateToUrl({ replace: true });
|
||||
}, SEARCH_SYNC_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1851,12 +1935,14 @@ async function main() {
|
||||
if (state.query && document.activeElement === $("#q")) {
|
||||
state.query = "";
|
||||
$("#q").value = "";
|
||||
syncStateToUrl({ replace: true });
|
||||
renderBoard();
|
||||
return;
|
||||
}
|
||||
if (state.selectedKey) {
|
||||
state.selectedKey = null;
|
||||
state.inspect = null;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
}
|
||||
return;
|
||||
@@ -1864,11 +1950,13 @@ async function main() {
|
||||
if (state.itemQuery && document.activeElement === $("#q-item")) {
|
||||
state.itemQuery = "";
|
||||
$("#q-item").value = "";
|
||||
syncStateToUrl({ replace: true });
|
||||
renderItemShop();
|
||||
return;
|
||||
}
|
||||
if (state.selectedItemKey) {
|
||||
state.selectedItemKey = null;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
}
|
||||
});
|
||||
@@ -1909,7 +1997,10 @@ async function main() {
|
||||
state.data.patches = state.data.patches || [];
|
||||
state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
|
||||
state.data.patch_details = state.data.patch_details || {};
|
||||
render();
|
||||
// Install hash router now that state.data is loaded (validation needs it),
|
||||
// then apply the initial URL (deep-link support) which also renders.
|
||||
installRouter({ getState: () => state, applyPatch });
|
||||
applyUrlToState();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
Reference in New Issue
Block a user