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:
@@ -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;
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user