feat(frontend): /config page + icon-only header (profile/settings/logout)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
90
frontend/src/pages/Config.tsx
Normal file
90
frontend/src/pages/Config.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user