dashboard / frontend /src /hashRouter.ts
timchen0618
Deploy research dashboard
b03f016
import { useState, useEffect, useCallback } from "react";
export interface HashRoute {
page: "experiments" | "viz";
tab: string;
segments: string[];
params: URLSearchParams;
}
const ROUTE_CHANGE = "routechange";
const STORAGE_KEY = "agg-viz-route";
/** Read the saved route hash from localStorage (fallback when hash is empty, e.g. in an iframe). */
function getSavedHash(): string {
try {
return localStorage.getItem(STORAGE_KEY) || "";
} catch {
return "";
}
}
/** Persist the current hash to localStorage so iframe reloads restore state. */
function saveHash(hash: string) {
try {
localStorage.setItem(STORAGE_KEY, hash);
} catch {
// localStorage unavailable — ignore
}
}
/** Get the effective hash: prefer URL hash, fall back to localStorage. */
function effectiveHash(): string {
const hash = window.location.hash;
if (hash && hash !== "#" && hash !== "#/") return hash;
return getSavedHash();
}
export function parseHash(hash?: string): HashRoute {
const raw = (hash ?? effectiveHash()).replace(/^#\/?/, "");
if (!raw) {
return { page: "experiments", tab: "", segments: [], params: new URLSearchParams() };
}
const qIdx = raw.indexOf("?");
const pathPart = qIdx >= 0 ? raw.slice(0, qIdx) : raw;
const params = new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
const parts = pathPart.split("/").filter(Boolean);
if (parts[0] === "viz") {
return { page: "viz", tab: parts[1] || "model", segments: parts.slice(2), params };
}
// "experiments" or anything else defaults to experiments
const segments = parts[0] === "experiments" ? parts.slice(1) : parts;
return { page: "experiments", tab: "", segments, params };
}
function buildHash(route: HashRoute): string {
const parts: string[] = [route.page === "viz" ? "viz" : "experiments"];
if (route.page === "viz" && route.tab) parts.push(route.tab);
if (route.segments.length) parts.push(...route.segments);
let hash = "#/" + parts.join("/");
const qs = route.params.toString();
if (qs) hash += "?" + qs;
return hash;
}
function applyRoute(hash: string, push: boolean) {
saveHash(hash);
if (window.location.hash === hash) return;
if (push) {
window.history.pushState(null, "", hash);
} else {
window.history.replaceState(null, "", hash);
}
window.dispatchEvent(new Event(ROUTE_CHANGE));
}
/** Navigate to a new route (creates browser history entry). */
export function navigateTo(update: Partial<HashRoute>) {
const current = parseHash();
const pageChanged = update.page !== undefined && update.page !== current.page;
const tabChanged = update.tab !== undefined && update.tab !== current.tab;
const merged: HashRoute = {
page: update.page ?? current.page,
tab: update.tab ?? (pageChanged ? (update.page === "viz" ? "model" : "") : current.tab),
segments: update.segments ?? ((pageChanged || tabChanged) ? [] : current.segments),
params: update.params ?? ((pageChanged || tabChanged) ? new URLSearchParams() : current.params),
};
applyRoute(buildHash(merged), true);
}
/** Replace current route (no history entry). Use for frequent state changes like indices. */
export function replaceRoute(update: Partial<HashRoute>) {
const current = parseHash();
const merged: HashRoute = {
page: update.page ?? current.page,
tab: update.tab ?? current.tab,
segments: update.segments ?? current.segments,
params: update.params ?? current.params,
};
applyRoute(buildHash(merged), false);
}
/** Build a shareable direct URL for the current route (bypasses HF iframe). */
export function getShareableUrl(): string {
const hash = effectiveHash();
// Use the app's direct origin (works for both .hf.space and localhost)
return `${window.location.origin}${window.location.pathname}${hash}`;
}
/** Hook that re-renders on hash route changes. */
export function useHashRoute(): HashRoute {
const [route, setRoute] = useState<HashRoute>(parseHash);
useEffect(() => {
const handler = () => setRoute(parseHash());
window.addEventListener(ROUTE_CHANGE, handler);
window.addEventListener("popstate", handler);
window.addEventListener("hashchange", handler);
return () => {
window.removeEventListener(ROUTE_CHANGE, handler);
window.removeEventListener("popstate", handler);
window.removeEventListener("hashchange", handler);
};
}, []);
// On mount, if URL hash is empty but localStorage has a saved route, apply it
useEffect(() => {
const urlHash = window.location.hash;
if (!urlHash || urlHash === "#" || urlHash === "#/") {
const saved = getSavedHash();
if (saved) {
window.history.replaceState(null, "", saved);
setRoute(parseHash(saved));
}
}
}, []);
return route;
}
/** Hook for the copy-link button. Returns a callback that copies the shareable URL. */
export function useCopyLink() {
const [copied, setCopied] = useState(false);
const copyLink = useCallback(async () => {
const url = getShareableUrl();
try {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: prompt user
window.prompt("Copy this link:", url);
}
}, []);
return { copyLink, copied };
}