feat(frontend): /config page + icon-only header (profile/settings/logout)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 00:20:02 +02:00
parent d45489714d
commit bee3d7e493
5 changed files with 202 additions and 53 deletions

View File

@@ -52,40 +52,6 @@ body {
color: white; color: white;
} }
/* Pill-shaped and icon-led, deliberately unlike the rectangular .tab
buttons: this opens account/profile settings, not an application view
alongside Activities/Progression/Training plan, so it reads more like an
account chip (à la Slack/GitHub's corner avatar) than another nav tab. */
.profile-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: #1a1d24;
border: 1px solid #2a2d35;
color: #9aa0ab;
padding: 0.4rem 0.9rem 0.4rem 0.7rem;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
}
.profile-name::before {
content: "⚙️";
font-size: 0.8rem;
}
.profile-name:hover:not(.active) {
border-color: #3b82f6;
color: #e6e6e6;
}
.profile-name.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.header-actions { .header-actions {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@@ -93,19 +59,51 @@ body {
gap: 0.6rem; gap: 0.6rem;
} }
.logout-link { /* Icon-only header buttons (profile / settings / log out): circular chips,
background: none; deliberately unlike the rectangular .tab buttons -- these are account/
border: none; instance actions, not application views. No visible text: the glyph is
padding: 0; the label (full text lives in title/aria-label). */
font: inherit; .icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
background: #1a1d24;
border: 1px solid #2a2d35;
color: #9aa0ab; color: #9aa0ab;
font-size: 0.85rem; border-radius: 999px;
text-decoration: none;
cursor: pointer; cursor: pointer;
font-size: 0.95rem;
} }
.logout-link:hover { .icon-button:hover:not(.active) {
color: #3b82f6; border-color: #3b82f6;
color: #e6e6e6;
}
.icon-button.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.env-config div {
display: flex;
gap: 1rem;
padding: 0.25rem 0;
}
.env-config dt {
min-width: 18rem;
color: #9aa0ab;
font-family: monospace;
}
.env-config dd {
margin: 0;
font-family: monospace;
overflow-wrap: anywhere;
} }
.garmin-connection { .garmin-connection {

View File

@@ -4,6 +4,7 @@ import "./App.css";
import { BannerStack } from "./components/BannerStack"; import { BannerStack } from "./components/BannerStack";
import { Activities } from "./pages/Activities"; import { Activities } from "./pages/Activities";
import { Analysis } from "./pages/Analysis"; import { Analysis } from "./pages/Analysis";
import { Config } from "./pages/Config";
import { Plan } from "./pages/Plan"; import { Plan } from "./pages/Plan";
import { Profile } from "./pages/Profile"; import { Profile } from "./pages/Profile";
import type { SessionInfo } from "./types/api"; import type { SessionInfo } from "./types/api";
@@ -18,16 +19,36 @@ type TabKey = (typeof TABS)[number]["key"];
function App({ session }: { session: SessionInfo }) { function App({ session }: { session: SessionInfo }) {
const [tab, setTab] = useState<TabKey>("activities"); const [tab, setTab] = useState<TabKey>("activities");
// Profile isn't a tab: it's reached via the profile name in the top-right // Profile and Config aren't tabs: Profile is account settings (reached
// corner instead, since (for now, single-profile) it's account settings, // via the avatar button), Config is instance settings (gear button,
// not a content view alongside Activities/Analysis/Training plan. // pathname-addressable as /config so it can be deep-linked).
const [showProfile, setShowProfile] = useState(false); 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(() => {
api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {}); api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {});
}, []); }, []);
useEffect(() => {
const onPop = () => setShowConfig(window.location.pathname === "/config");
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
function openConfig() {
if (window.location.pathname !== "/config") history.pushState({}, "", "/config");
setShowProfile(false);
setShowConfig(true);
}
// 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 Active = TABS.find((t) => t.key === tab)!.Component;
return ( return (
@@ -38,8 +59,9 @@ function App({ session }: { session: SessionInfo }) {
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
key={t.key} key={t.key}
className={!showProfile && t.key === tab ? "tab active" : "tab"} className={!showProfile && !showConfig && t.key === tab ? "tab active" : "tab"}
onClick={() => { onClick={() => {
leaveConfig();
setShowProfile(false); setShowProfile(false);
setTab(t.key); setTab(t.key);
}} }}
@@ -52,20 +74,34 @@ function App({ session }: { session: SessionInfo }) {
<div className="header-actions"> <div className="header-actions">
<button <button
type="button" type="button"
className={showProfile ? "profile-name active" : "profile-name"} className={showProfile ? "icon-button active" : "icon-button"}
onClick={() => setShowProfile(true)} title={profileName ?? "Profile"}
aria-label={profileName ?? "Profile"}
onClick={() => {
leaveConfig();
setShowProfile(true);
}}
> >
{profileName ?? "Profile"} 👤
</button>
<button
type="button"
className={showConfig ? "icon-button active" : "icon-button"}
title="Settings"
aria-label="Settings"
onClick={openConfig}
>
</button> </button>
<form method="post" action={`${BASE_URL}/api/session/logout`} title={session.email}> <form method="post" action={`${BASE_URL}/api/session/logout`} title={session.email}>
<button type="submit" className="logout-link"> <button type="submit" className="icon-button" aria-label="Log out" title="Log out">
Log out
</button> </button>
</form> </form>
</div> </div>
</header> </header>
<BannerStack /> <BannerStack />
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main> <main>{showConfig ? <Config /> : showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
import type { import type {
ActivitiesPage, ActivitiesPage,
AuthResponse, AuthResponse,
ConfigResponse,
Profile, Profile,
ProgressionMetric, ProgressionMetric,
ProgressionPoint, ProgressionPoint,
@@ -155,6 +156,12 @@ export const api = {
unassignActivity: (activityId: number) => unassignActivity: (activityId: number) =>
request<{ status: string }>(`/api/activities/${activityId}/unassign`, { method: "POST" }), request<{ status: string }>(`/api/activities/${activityId}/unassign`, { method: "POST" }),
// Application/environment configuration (the /config page). Cold: saved
// values take effect after the backend restarts.
getConfig: () => request<ConfigResponse>("/api/config"),
updateConfig: (values: Record<string, string>) =>
request<ConfigResponse>("/api/config", { method: "PUT", body: JSON.stringify(values) }),
// Progression // Progression
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => { progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {
const q = new URLSearchParams({ metric }); const q = new URLSearchParams({ metric });

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { showError, showSuccess } from "../banner";
import type { ConfigResponse } from "../types/api";
// Reached only via the header's gear button (or typing /config) -- not a
// nav tab. Application configuration is instance-global and cold: the
// backend reads it once at startup, so saves apply on the next restart.
export function Config() {
const [config, setConfig] = useState<ConfigResponse | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
api
.getConfig()
.then(setConfig)
.catch((e) => {
setLoadFailed(true);
showError(e instanceof Error ? e.message : String(e));
});
}, []);
if (!config) {
return (
<div className="page">
{loadFailed ? <p className="empty-state">Couldn't load configuration.</p> : <p>Loading...</p>}
</div>
);
}
const dirty = config.application.filter(
(entry) => edits[entry.key] !== undefined && edits[entry.key] !== entry.value,
);
async function save() {
setSaving(true);
try {
const updated = await api.updateConfig(
Object.fromEntries(dirty.map((entry) => [entry.key, edits[entry.key]])),
);
setConfig(updated);
setEdits({});
showSuccess("Saved — changes take effect after the backend restarts.");
} catch (e) {
showError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
}
return (
<div className="page">
<fieldset className="kind-editor">
<legend>Application configuration</legend>
<p className="field-hint">
Stored in the database, shared by all users. Changes take effect after the backend restarts.
</p>
{config.application.map((entry) => (
<label key={entry.key}>
{entry.key}
<input
value={edits[entry.key] ?? entry.value}
onChange={(e) => setEdits({ ...edits, [entry.key]: e.target.value })}
/>
<span className="field-hint">
{entry.description} (default: {entry.default})
</span>
</label>
))}
<button type="button" disabled={saving || dirty.length === 0} onClick={save}>
Save
</button>
</fieldset>
<fieldset className="kind-editor">
<legend>Environment configuration</legend>
<p className="field-hint">Read-only set through environment variables on the backend process.</p>
<dl className="env-config">
{config.environment.map((entry) => (
<div key={entry.name}>
<dt>{entry.name}</dt>
<dd>{entry.value === "" ? "(unset)" : entry.value}</dd>
</div>
))}
</dl>
</fieldset>
</div>
);
}

View File

@@ -201,3 +201,21 @@ export interface SyncStatus {
} }
export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te" | "efficiency_factor"; export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te" | "efficiency_factor";
export interface AppConfigEntry {
key: string;
value: string;
default: string;
overridden: boolean;
description: string;
}
export interface EnvVarEntry {
name: string;
value: string;
}
export interface ConfigResponse {
application: AppConfigEntry[];
environment: EnvVarEntry[];
}