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) => {
+6 -1
View File
@@ -8,6 +8,10 @@
</head>
<body>
<header class="topbar">
<div class="brand">
<img class="brand-logo" src="ui-icon/dota2_logo.png" alt="Dota 2" />
<span class="brand-title">上分帝</span>
</div>
<nav class="main-tabs" aria-label="主分页">
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
<button type="button" class="main-tab" data-page="items">物品</button>
@@ -47,7 +51,7 @@
<main id="patches-view" class="board patches-board hidden">
<div class="patches-header">
<h2 class="patches-title">游戏性更新 <span class="patches-ver" id="patch-ver"></span></h2>
<h2 class="patches-title"><span class="patches-kicker">游戏性更新</span><span class="patches-ver" id="patch-ver"></span></h2>
<select id="patch-select" aria-label="选择版本"></select>
</div>
<div class="patches-detail" id="patches-detail"></div>
@@ -55,6 +59,7 @@
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/router.js"></script>
<script src="/app.js"></script>
</body>
</html>
+151
View File
@@ -0,0 +1,151 @@
/* 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 (heroes page only): tags=csv, q=search text
*
* 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,
};
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) 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) {
// URLSearchParams.set already percent-encodes the value, so join raw
// tag names with "," — do NOT pre-encodeURIComponent (double encoding).
qs.set("tags", Array.from(state.tagFilters).join(","));
}
if (state.query) qs.set("q", state.query);
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);
}
+48 -6
View File
@@ -42,6 +42,39 @@ body {
background: rgba(8, 12, 20, 0.45);
flex: 0 0 auto;
}
/* Brand mark: official Dota 2 logo + 上分帝 title, pinned to the top-left
corner of the topbar. Absolutely positioned so the centered tab row and
toolbar layout are left untouched. */
.brand {
position: absolute;
left: 20px;
top: 12px;
height: 40px;
display: flex;
align-items: center;
gap: 12px;
pointer-events: none;
z-index: 2;
}
.brand-logo {
height: 34px;
width: auto;
display: block;
}
.brand-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--text);
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
@media (max-width: 880px) {
.brand-title { display: none; }
}
@media (max-width: 620px) {
.brand { display: none; }
}
.main-tabs {
display: flex;
justify-content: center;
@@ -1532,14 +1565,23 @@ body:has(#items-view:not(.hidden)) {
}
.patches-title {
margin: 0;
font-size: 26px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--text);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.patches-kicker {
font-size: 15px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.14em;
}
.patches-ver {
color: var(--sel);
margin-left: 4px;
font-size: 40px;
font-weight: 800;
color: var(--text);
letter-spacing: 0.02em;
line-height: 1;
}
#patch-select {
flex: 0 1 auto;