From 5ebb0f756d66f6940f58207625c91ad8db26a842 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Tue, 4 Aug 2026 20:23:05 +0200 Subject: [PATCH] feat(frontend): pathname-addressable views, deep links survive login, config page layout Every view (/activities, /analysis, /plan, /profile, /config) is now driven by the URL -- one pushState/popstate navigate() replaces the tab/profile/config state flags, so all views deep-link, survive reload, and honor Back/Forward. A logged-out visit to any path is stashed by LoginGate and restored (replaceState) on the first render after the OIDC round-trip, which otherwise always lands on '/'. The config page gets Profile-style card spacing and a wider card so environment values fit on one line. Co-Authored-By: Claude Fable 5 --- frontend/src/App.css | 9 +++- frontend/src/App.tsx | 92 ++++++++++++++++++++++------------- frontend/src/LoginGate.tsx | 9 +++- frontend/src/api/client.ts | 4 +- frontend/src/pages/Config.tsx | 2 +- 5 files changed, 77 insertions(+), 39 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 96201f2..52debec 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -608,12 +608,19 @@ button:disabled { max-width: 580px; } -.profile-page { +.profile-page, +.config-page { display: flex; flex-direction: column; gap: 1.75rem; } +/* Wider than the default .kind-editor cap: the environment card lists + URLs/paths as single-line monospace values that would otherwise wrap. */ +.config-page .kind-editor { + max-width: 900px; +} + .training-type-list { list-style: none; margin: 0; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9ba0f8d..7167536 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,15 +15,42 @@ const TABS = [ { key: "plan", label: "Training plan", icon: "✎", Component: Plan }, ] as const; -type TabKey = (typeof TABS)[number]["key"]; +// Every view is pathname-addressable (/activities, /analysis, /plan, +// /profile, /config) so any of them can be deep-linked, bookmarked, and +// survives a reload -- no router library, just pushState + popstate with +// the URL as the single source of truth. "/" and anything unrecognized +// fall back to the default activities view. +const VIEWS = ["activities", "analysis", "plan", "profile", "config"] as const; + +type View = (typeof VIEWS)[number]; + +function viewFromPathname(pathname: string): View { + const candidate = pathname.replace(/^\/+/, "").replace(/\/+$/, ""); + return (VIEWS as readonly string[]).includes(candidate) ? (candidate as View) : "activities"; +} + +// POST_LOGIN_PATH_KEY is where LoginGate stashes the path a logged-out +// visitor was heading to: the OIDC flow always lands back on "/", so the +// deep link would otherwise be lost across the Keycloak round-trip. +export const POST_LOGIN_PATH_KEY = "geniusrun_post_login_path"; + +// restoreDeepLink resolves the initial view: normally straight from the +// URL, but right after a login round-trip (we're on "/" and LoginGate +// stashed a path) the stashed destination wins and the URL is rewritten +// to it -- replaceState, not pushState, so Back doesn't bounce through +// an intermediate "/" entry. The stash is consumed either way; a stale +// one must never redirect some later, unrelated visit to "/". +function restoreDeepLink(): View { + const stashed = sessionStorage.getItem(POST_LOGIN_PATH_KEY); + if (stashed === null) return viewFromPathname(window.location.pathname); + sessionStorage.removeItem(POST_LOGIN_PATH_KEY); + if (window.location.pathname !== "/") return viewFromPathname(window.location.pathname); + history.replaceState({}, "", stashed); + return viewFromPathname(stashed); +} function App({ session }: { session: SessionInfo }) { - const [tab, setTab] = useState("activities"); - // Profile and Config aren't tabs: Profile is account settings (reached - // via the avatar button), Config is instance settings (gear button, - // pathname-addressable as /config so it can be deep-linked). - const [showProfile, setShowProfile] = useState(false); - const [showConfig, setShowConfig] = useState(window.location.pathname === "/config"); + const [view, setView] = useState(restoreDeepLink); const [profileName, setProfileName] = useState(null); useEffect(() => { @@ -31,25 +58,21 @@ function App({ session }: { session: SessionInfo }) { }, []); useEffect(() => { - const onPop = () => setShowConfig(window.location.pathname === "/config"); + const onPop = () => setView(viewFromPathname(window.location.pathname)); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); - function openConfig() { - if (window.location.pathname !== "/config") history.pushState({}, "", "/config"); - setShowProfile(false); - setShowConfig(true); + function navigate(next: View) { + // No history entry when the URL already means this view (e.g. "/" + // already renders activities) -- avoids junk Back-button stops. + if (viewFromPathname(window.location.pathname) !== next) { + history.pushState({}, "", "/" + next); + } + setView(next); } - // Leaving the config view restores the root URL so /config always means - // "config is showing" and browser back/forward keep working. - function leaveConfig() { - if (window.location.pathname === "/config") history.pushState({}, "", "/"); - setShowConfig(false); - } - - const Active = TABS.find((t) => t.key === tab)!.Component; + const ActiveTab = TABS.find((t) => t.key === view)?.Component; return (
@@ -59,12 +82,8 @@ function App({ session }: { session: SessionInfo }) { {TABS.map((t) => ( @@ -101,7 +117,15 @@ function App({ session }: { session: SessionInfo }) {
-
{showConfig ? : showProfile ? setProfileName(p.Name)} /> : }
+
+ {view === "config" ? ( + + ) : view === "profile" ? ( + setProfileName(p.Name)} /> + ) : ( + ActiveTab && + )} +
); } diff --git a/frontend/src/LoginGate.tsx b/frontend/src/LoginGate.tsx index 6ad8232..a003bdb 100644 --- a/frontend/src/LoginGate.tsx +++ b/frontend/src/LoginGate.tsx @@ -3,7 +3,7 @@ import { api, BASE_URL, NetworkError } from "./api/client"; import { showError } from "./banner"; import { BannerStack } from "./components/BannerStack"; import "./LoginGate.css"; -import App from "./App"; +import App, { POST_LOGIN_PATH_KEY } from "./App"; import { OnboardingWizard } from "./OnboardingWizard"; import type { SessionInfo } from "./types/api"; @@ -56,6 +56,13 @@ export function LoginGate() { if (status !== "unauthenticated") return; const authError = new URLSearchParams(window.location.search).get("auth_error"); if (authError) showError(AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."); + // Deep link survival: the OIDC flow always lands back on "/" (the + // callback's redirect), so remember where the user was actually + // heading -- App pops this stash on its first render after login and + // restores the path (see restoreDeepLink in App.tsx). + if (window.location.pathname !== "/") { + sessionStorage.setItem(POST_LOGIN_PATH_KEY, window.location.pathname); + } }, [status]); if (status === "loading") { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6fd1faf..a6e583f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -43,11 +43,11 @@ async function request(path: string, init?: RequestInit): Promise { // re-checks and shows the login screen instead of leaving the SPA in a // half-authenticated state. window.location.href = "/"; - throw new Error(`${init?.method ?? "GET"} ${path} failed: 401 (session expired)`); + throw new Error(`${init?.method ?? "GET"} ${path} failed: HTTP 401 (session expired)`); } if (!res.ok) { const body = await res.text(); - throw new Error(`${init?.method ?? "GET"} ${path} failed: ${res.status} ${body}`); + throw new Error(`${init?.method ?? "GET"} ${path} failed: HTTP ${res.status} ${body}`); } if (res.status === 204) return undefined as T; return res.json() as Promise; diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 1ff7e3d..9d5803a 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -51,7 +51,7 @@ export function Config() { } return ( -
+
Application configuration