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:
voson
2026-07-27 13:09:14 +08:00
parent a91789b72f
commit 1d0428b4a3
13 changed files with 10306 additions and 17 deletions
+92 -1
View File
@@ -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) => {