feat: complete user config management

- Add theme support with dark/light/system modes
- Add useTheme hook for applying user config theme
- Update router to use SettingsPage
- Update app-shell to apply theme on load
- Add CSS variables for dark theme
- Fix mypy errors in user_config.py
- Quality gates pass: ruff, mypy, typecheck, lint, build
This commit is contained in:
Fusion
2026-05-18 15:58:01 +02:00
parent 9f7a750898
commit 94254ee3fd
18 changed files with 463 additions and 171 deletions
+25
View File
@@ -0,0 +1,25 @@
import { apiClient } from "./client";
export interface UserConfig {
default_editor: string | null;
theme: string;
git_user_name: string | null;
git_user_email: string | null;
}
export interface UserConfigUpdate {
default_editor?: string;
theme?: string;
git_user_name?: string;
git_user_email?: string;
}
export const getUserConfig = async (): Promise<UserConfig> => {
const response = await apiClient.get<UserConfig>("/users/me/config");
return response.data;
};
export const updateUserConfig = async (data: UserConfigUpdate): Promise<UserConfig> => {
const response = await apiClient.patch<UserConfig>("/users/me/config", data);
return response.data;
};
+2
View File
@@ -1,5 +1,6 @@
import { Link, NavLink, Outlet } from "react-router-dom";
import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth";
const NAV_ITEMS = [
@@ -10,6 +11,7 @@ const NAV_ITEMS = [
];
export const AppShell = () => {
useTheme();
const { user, logout } = useAuth();
return (
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from "react";
import { getUserConfig } from "../api/settings";
export function useTheme() {
useEffect(() => {
void (async () => {
try {
const config = await getUserConfig();
const theme = config.theme ?? "system";
if (theme === "system") {
// Remove any explicit theme class and let system decide
document.documentElement.removeAttribute("data-theme");
} else {
document.documentElement.setAttribute("data-theme", theme);
}
} catch {
// Silently fail - theme is not critical
}
})();
}, []);
}
+142
View File
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useState } from "react";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
type SettingsStatus = "loading" | "ready" | "error";
const THEME_OPTIONS = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
export const SettingsPage = () => {
const [status, setStatus] = useState<SettingsStatus>("loading");
const [config, setConfig] = useState<UserConfig>({
theme: "system",
default_editor: null,
git_user_name: null,
git_user_email: null,
});
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const loadConfig = useCallback(async () => {
try {
const data = await getUserConfig();
setConfig(data);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadConfig();
}, [loadConfig]);
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
setConfig((prev) => ({ ...prev, [key]: value }));
setSaveStatus("idle");
};
const handleSave = async () => {
setSaveStatus("saving");
try {
const update: UserConfigUpdate = {
theme: config.theme,
default_editor: config.default_editor ?? undefined,
git_user_name: config.git_user_name ?? undefined,
git_user_email: config.git_user_email ?? undefined,
};
const updated = await updateUserConfig(update);
setConfig(updated);
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2000);
} catch {
setSaveStatus("error");
}
};
if (status === "loading") {
return <section className="stack"><p className="muted">Loading settings...</p></section>;
}
if (status === "error") {
return (
<section className="stack">
<p>Failed to load settings</p>
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
Retry
</button>
</section>
);
}
return (
<section className="stack">
<div className="page-header">
<h1>Settings</h1>
</div>
<div className="card stack">
<h2>Appearance</h2>
<label className="form-field">
Theme
<select
value={config.theme}
onChange={(e) => handleChange("theme", e.target.value)}
>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
</div>
<div className="card stack">
<h2>Git Identity</h2>
<label className="form-field">
User Name
<input
type="text"
value={config.git_user_name ?? ""}
onChange={(e) => handleChange("git_user_name", e.target.value || null)}
placeholder="Your git commit name"
/>
</label>
<label className="form-field">
User Email
<input
type="email"
value={config.git_user_email ?? ""}
onChange={(e) => handleChange("git_user_email", e.target.value || null)}
placeholder="your.email@example.com"
/>
</label>
</div>
<div className="card stack">
<h2>Editor</h2>
<label className="form-field">
Default Editor
<input
type="text"
value={config.default_editor ?? ""}
onChange={(e) => handleChange("default_editor", e.target.value || null)}
placeholder="e.g., vscode, vim, cursor"
/>
</label>
</div>
<div className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? "Saving..." : "Save Settings"}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
</div>
</section>
);
};
+3 -2
View File
@@ -3,11 +3,12 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { AppShell } from "./components/app-shell";
import { ProtectedRoute } from "./components/protected-route";
import { DashboardPage } from "./pages/dashboard";
import { LoginRedirectPage, NotFoundPage, PlaceholderPage } from "./pages/placeholder";
import { LoginRedirectPage, NotFoundPage } from "./pages/placeholder";
import { ProfilePage } from "./pages/profile";
import { ProjectsPage } from "./pages/projects";
import { GitRepositoriesPage } from "./pages/git-repositories";
import { SSHKeysPage } from "./pages/ssh-keys";
import { SettingsPage } from "./pages/settings";
export const AppRouter = () => {
return (
@@ -26,7 +27,7 @@ export const AppRouter = () => {
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<PlaceholderPage title="Settings" />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
+16 -1
View File
@@ -10,16 +10,31 @@
--border: #d8d0c5;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #1a1a18;
--panel: #252522;
--ink: #e8e6e1;
--muted: #a39e96;
--brand: #4a9e7f;
--brand-strong: #3d8a6e;
--border: #3d3d38;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: radial-gradient(circle at top right, #fff5d6, var(--bg));
background: var(--bg);
color: var(--ink);
}
[data-theme="dark"] body {
background: radial-gradient(circle at top right, #2a2520, var(--bg));
}
a {
color: inherit;
text-decoration: none;