Files
headquarter/apps/web/src/pages/settings.tsx
T
Alex Blank ab79080f0b refactor: consolidate loading/error states and extract instance actions hook
Frontend:
- Create reusable DataStates components (LoadingState, ErrorState, EmptyState)
- Refactor 12 pages to use shared state components instead of inline JSX
- Extract useInstanceActions hook to eliminate session action duplication
- Update dashboard and sessions pages to use shared hook

OpenSpec:
- Archive completed mobile-app-usability change (44/44 tasks)
- Archive completed add-config-profiles change (15/15 tasks)

Quality: TypeScript check passes, production build succeeds
2026-05-25 22:50:18 +02:00

154 lines
5.4 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
import { Icon } from "../components/icon";
import { useAsyncData } from "../hooks/use-async-data";
const TABS = [
{ label: "General", path: "general" },
{ label: "SSH Keys", path: "ssh-keys" },
] as const;
const THEME_OPTIONS = [
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
type SettingsOutletContext = {
config: UserConfig;
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
handleSave: () => Promise<void>;
saveStatus: "idle" | "saving" | "saved" | "error";
};
export const SettingsPage = () => {
const location = useLocation();
const { data: loadedConfig, status, reload } = useAsyncData<UserConfig>(getUserConfig, []);
const [config, setConfig] = useState<UserConfig>({
theme: "system",
default_editor: null,
git_user_name: null,
git_user_email: null,
last_session_id: null,
});
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
// Sync loaded config into local editable state
useEffect(() => {
if (loadedConfig) {
setConfig(loadedConfig);
}
}, [loadedConfig]);
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,
git_user_name: config.git_user_name,
git_user_email: config.git_user_email,
};
const updated = await updateUserConfig(update);
setConfig(updated);
setSaveStatus("saved");
if (updated.theme === "system") {
document.documentElement.removeAttribute("data-theme");
} else {
document.documentElement.setAttribute("data-theme", updated.theme);
}
window.setTimeout(() => setSaveStatus("idle"), 2000);
} catch {
setSaveStatus("error");
}
};
if (status === "loading") {
return <section className="stack"><LoadingState message="Loading settings..." /></section>;
}
if (status === "error") {
return (
<section className="stack">
<ErrorState message="Failed to load settings" onRetry={reload} />
</section>
);
}
const parts = location.pathname.split("/").filter(Boolean);
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
return (
<section className="stack settings-page">
<header className="settings-header card stack-sm">
<div>
<p className="eyebrow">Configuration</p>
<h1>Settings</h1>
</div>
<p className="muted">General preferences, SSH keys, and config profiles.</p>
</header>
<nav className="settings-tabs" aria-label="Settings sections">
{TABS.map((tab) => (
<Link
key={tab.path}
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
>
{tab.label}
</Link>
))}
</nav>
<div className="settings-panel card">
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
</div>
</section>
);
};
export const GeneralSettingsTab = () => {
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
return (
<div className="stack">
<h2>General</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>
<label className="form-field">
Git 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">
Git 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>
<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 className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
</div>
</div>
);
};