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 <noreply@anthropic.com>
This commit is contained in:
@@ -608,12 +608,19 @@ button:disabled {
|
|||||||
max-width: 580px;
|
max-width: 580px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-page {
|
.profile-page,
|
||||||
|
.config-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.75rem;
|
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 {
|
.training-type-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -15,15 +15,42 @@ const TABS = [
|
|||||||
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan },
|
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan },
|
||||||
] as const;
|
] 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 }) {
|
function App({ session }: { session: SessionInfo }) {
|
||||||
const [tab, setTab] = useState<TabKey>("activities");
|
const [view, setView] = useState<View>(restoreDeepLink);
|
||||||
// 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 [profileName, setProfileName] = useState<string | null>(null);
|
const [profileName, setProfileName] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,25 +58,21 @@ function App({ session }: { session: SessionInfo }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPop = () => setShowConfig(window.location.pathname === "/config");
|
const onPop = () => setView(viewFromPathname(window.location.pathname));
|
||||||
window.addEventListener("popstate", onPop);
|
window.addEventListener("popstate", onPop);
|
||||||
return () => window.removeEventListener("popstate", onPop);
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function openConfig() {
|
function navigate(next: View) {
|
||||||
if (window.location.pathname !== "/config") history.pushState({}, "", "/config");
|
// No history entry when the URL already means this view (e.g. "/"
|
||||||
setShowProfile(false);
|
// already renders activities) -- avoids junk Back-button stops.
|
||||||
setShowConfig(true);
|
if (viewFromPathname(window.location.pathname) !== next) {
|
||||||
|
history.pushState({}, "", "/" + next);
|
||||||
|
}
|
||||||
|
setView(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leaving the config view restores the root URL so /config always means
|
const ActiveTab = TABS.find((t) => t.key === view)?.Component;
|
||||||
// "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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -59,12 +82,8 @@ function App({ session }: { session: SessionInfo }) {
|
|||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
className={!showProfile && !showConfig && t.key === tab ? "tab active" : "tab"}
|
className={view === t.key ? "tab active" : "tab"}
|
||||||
onClick={() => {
|
onClick={() => navigate(t.key)}
|
||||||
leaveConfig();
|
|
||||||
setShowProfile(false);
|
|
||||||
setTab(t.key);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="tab-icon">{t.icon}</span>
|
<span className="tab-icon">{t.icon}</span>
|
||||||
{t.label}
|
{t.label}
|
||||||
@@ -74,22 +93,19 @@ function App({ session }: { session: SessionInfo }) {
|
|||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={showProfile ? "icon-button active" : "icon-button"}
|
className={view === "profile" ? "icon-button active" : "icon-button"}
|
||||||
title={profileName ?? "Profile"}
|
title={profileName ?? "Profile"}
|
||||||
aria-label={profileName ?? "Profile"}
|
aria-label={profileName ?? "Profile"}
|
||||||
onClick={() => {
|
onClick={() => navigate("profile")}
|
||||||
leaveConfig();
|
|
||||||
setShowProfile(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
👤
|
👤
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={showConfig ? "icon-button active" : "icon-button"}
|
className={view === "config" ? "icon-button active" : "icon-button"}
|
||||||
title="Settings"
|
title="Settings"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
onClick={openConfig}
|
onClick={() => navigate("config")}
|
||||||
>
|
>
|
||||||
⚙️
|
⚙️
|
||||||
</button>
|
</button>
|
||||||
@@ -101,7 +117,15 @@ function App({ session }: { session: SessionInfo }) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<BannerStack />
|
<BannerStack />
|
||||||
<main>{showConfig ? <Config /> : showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>
|
<main>
|
||||||
|
{view === "config" ? (
|
||||||
|
<Config />
|
||||||
|
) : view === "profile" ? (
|
||||||
|
<Profile onSaved={(p) => setProfileName(p.Name)} />
|
||||||
|
) : (
|
||||||
|
ActiveTab && <ActiveTab />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { api, BASE_URL, NetworkError } from "./api/client";
|
|||||||
import { showError } from "./banner";
|
import { showError } from "./banner";
|
||||||
import { BannerStack } from "./components/BannerStack";
|
import { BannerStack } from "./components/BannerStack";
|
||||||
import "./LoginGate.css";
|
import "./LoginGate.css";
|
||||||
import App from "./App";
|
import App, { POST_LOGIN_PATH_KEY } from "./App";
|
||||||
import { OnboardingWizard } from "./OnboardingWizard";
|
import { OnboardingWizard } from "./OnboardingWizard";
|
||||||
import type { SessionInfo } from "./types/api";
|
import type { SessionInfo } from "./types/api";
|
||||||
|
|
||||||
@@ -56,6 +56,13 @@ export function LoginGate() {
|
|||||||
if (status !== "unauthenticated") return;
|
if (status !== "unauthenticated") return;
|
||||||
const authError = new URLSearchParams(window.location.search).get("auth_error");
|
const authError = new URLSearchParams(window.location.search).get("auth_error");
|
||||||
if (authError) showError(AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again.");
|
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]);
|
}, [status]);
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
// re-checks and shows the login screen instead of leaving the SPA in a
|
// re-checks and shows the login screen instead of leaving the SPA in a
|
||||||
// half-authenticated state.
|
// half-authenticated state.
|
||||||
window.location.href = "/";
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.text();
|
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;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function Config() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page config-page">
|
||||||
<fieldset className="kind-editor">
|
<fieldset className="kind-editor">
|
||||||
<legend>Application configuration</legend>
|
<legend>Application configuration</legend>
|
||||||
<p className="field-hint">
|
<p className="field-hint">
|
||||||
|
|||||||
Reference in New Issue
Block a user