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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user