diff --git a/frontend/src/App.css b/frontend/src/App.css index 35699bd..96201f2 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -52,40 +52,6 @@ body { 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 { margin-left: auto; display: flex; @@ -93,19 +59,51 @@ body { gap: 0.6rem; } -.logout-link { - background: none; - border: none; - padding: 0; - font: inherit; +/* Icon-only header buttons (profile / settings / log out): circular chips, + deliberately unlike the rectangular .tab buttons -- these are account/ + instance actions, not application views. No visible text: the glyph is + the label (full text lives in title/aria-label). */ +.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; - font-size: 0.85rem; - text-decoration: none; + border-radius: 999px; cursor: pointer; + font-size: 0.95rem; } -.logout-link:hover { - color: #3b82f6; +.icon-button:hover:not(.active) { + 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 { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f5fb36..9ba0f8d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import "./App.css"; import { BannerStack } from "./components/BannerStack"; import { Activities } from "./pages/Activities"; import { Analysis } from "./pages/Analysis"; +import { Config } from "./pages/Config"; import { Plan } from "./pages/Plan"; import { Profile } from "./pages/Profile"; import type { SessionInfo } from "./types/api"; @@ -18,16 +19,36 @@ type TabKey = (typeof TABS)[number]["key"]; function App({ session }: { session: SessionInfo }) { const [tab, setTab] = useState("activities"); - // Profile isn't a tab: it's reached via the profile name in the top-right - // corner instead, since (for now, single-profile) it's account settings, - // not a content view alongside Activities/Analysis/Training plan. + // 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(null); useEffect(() => { 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; return ( @@ -38,8 +59,9 @@ function App({ session }: { session: SessionInfo }) { {TABS.map((t) => ( +
-
-
{showProfile ? setProfileName(p.Name)} /> : }
+
{showConfig ? : showProfile ? setProfileName(p.Name)} /> : }
); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 63d7dc2..e45375a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,6 +1,7 @@ import type { ActivitiesPage, AuthResponse, + ConfigResponse, Profile, ProgressionMetric, ProgressionPoint, @@ -155,6 +156,12 @@ export const api = { unassignActivity: (activityId: number) => 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("/api/config"), + updateConfig: (values: Record) => + request("/api/config", { method: "PUT", body: JSON.stringify(values) }), + // Progression progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => { const q = new URLSearchParams({ metric }); diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx new file mode 100644 index 0000000..1ff7e3d --- /dev/null +++ b/frontend/src/pages/Config.tsx @@ -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(null); + const [edits, setEdits] = useState>({}); + 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 ( +
+ {loadFailed ?

Couldn't load configuration.

:

Loading...

} +
+ ); + } + + 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 ( +
+
+ Application configuration +

+ Stored in the database, shared by all users. Changes take effect after the backend restarts. +

+ {config.application.map((entry) => ( + + ))} + +
+
+ Environment configuration +

Read-only — set through environment variables on the backend process.

+
+ {config.environment.map((entry) => ( +
+
{entry.name}
+
{entry.value === "" ? "(unset)" : entry.value}
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 8feb68b..1987eb0 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -201,3 +201,21 @@ export interface SyncStatus { } 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[]; +}