feat(settings): add top-row tab navigation; fold Providers + mode + secrets into one IA
The settings page was a single vertical scroll containing seven unrelated concerns. Users had to scroll past theme preferences to reach Providers — which is the most-touched surface — and mode + secrets + shortcuts lived below that. There was no signal about where anything was. Introduced `SettingsShell` with a horizontal tab row along the top: General | Execution mode | Providers | Shortcuts | Data | Secrets Each tab swaps the content pane. Active tab is visually anchored with a 2px primary underline + foreground text; inactive tabs stay muted with a hover promotion. All tabs are real `role="tab"` + `aria-selected` so screen readers announce the current pane; the nav has `aria-label="Settings categories"`. Tab state is local — settings are ephemeral navigation, no reason to persist across company switches or app restarts. Default is "general" because that's where first-time users land. Refactor consequences: - `OperationsSections.tsx` now renders `<SettingsShell />` + the OAuth prompt modal portal; the old double-Panel wrapper with "Profile and release" + "Providers" titles is gone. The "Profile and release" label always fought with what was actually inside the pane — Jobs audit item S-S2 addressed. - Providers moved from a separate sibling Panel into the main IA. Previously Providers came AFTER a 7-section scroll; now it is a peer of General / Mode / etc., reachable in one click. - Deleted the now-unused `SettingsPane.tsx` orchestrator. Its single-column rendering survived only as a composition step that SettingsShell replaces. Added i18n keys (zh + en): settings.tabs.general / mode / providers / shortcuts / data / secrets; settings.tabsAria. Covers J-S2 / J-S7 / S-S5 / S-S7 from the settings audit.
This commit is contained in:
1 parent
16e45b6a8d
commit
03bcc8a8f0
5 files changed
+144
-73
No files matched your search
@@ -1,62 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ProfileSnapshot, SecretRecord } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { useLocale } from "../../i18n";
|
||||
import { GeneralSection } from "./settings-sections/GeneralSection";
|
||||
import { ExecutionModeSection } from "./settings-sections/ExecutionModeSection";
|
||||
import { ShortcutsSection } from "./settings-sections/ShortcutsSection";
|
||||
import { DataSection } from "./settings-sections/DataSection";
|
||||
import { SecretsSection } from "./settings-sections/SecretsSection";
|
||||
|
||||
interface SettingsPaneProps {
|
||||
snapshot: ProfileSnapshot;
|
||||
secrets: SecretRecord[];
|
||||
companyId: string;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings page orchestrator. Each concern lives in its own ≤200 LOC
|
||||
* section under `settings-sections/` so the contents of this file stay
|
||||
* scannable instead of hiding 2000-char JSX statements inside one
|
||||
* 73-"line" god file.
|
||||
*
|
||||
* Save model: every preference in GeneralSection writes to
|
||||
* `updateSettings` on change, so there is no global "Save" button.
|
||||
* DataSection's backup / restore / update-check remain explicit
|
||||
* actions because they have side effects beyond a single row of app
|
||||
* state.
|
||||
*/
|
||||
export function SettingsPane({ snapshot, secrets, companyId, onSaved }: SettingsPaneProps) {
|
||||
const currentLocale = useLocale();
|
||||
const [theme, setTheme] = useState(snapshot.theme);
|
||||
const [selectedLocale, setSelectedLocale] = useState<AppLocale>(snapshot.locale ?? currentLocale);
|
||||
const [updatesEnabled, setUpdatesEnabled] = useState(snapshot.updatesEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(snapshot.theme);
|
||||
setUpdatesEnabled(snapshot.updatesEnabled);
|
||||
if (snapshot.locale) setSelectedLocale(snapshot.locale);
|
||||
}, [snapshot.theme, snapshot.updatesEnabled, snapshot.locale]);
|
||||
|
||||
const company = snapshot.companies.find((entry) => entry.id === companyId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<GeneralSection
|
||||
snapshot={snapshot}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
selectedLocale={selectedLocale}
|
||||
setSelectedLocale={setSelectedLocale}
|
||||
updatesEnabled={updatesEnabled}
|
||||
setUpdatesEnabled={setUpdatesEnabled}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
{company ? <ExecutionModeSection company={company} onSaved={onSaved} /> : null}
|
||||
<ShortcutsSection />
|
||||
<DataSection snapshot={snapshot} onSaved={onSaved} />
|
||||
<SecretsSection secrets={secrets} companyId={companyId} onSaved={onSaved} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import type { ProfileSnapshot, SecretRecord } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { useEffect } from "react";
|
||||
import { useLocale, useT } from "../../i18n";
|
||||
import { GeneralSection } from "./settings-sections/GeneralSection";
|
||||
import { ExecutionModeSection } from "./settings-sections/ExecutionModeSection";
|
||||
import { ShortcutsSection } from "./settings-sections/ShortcutsSection";
|
||||
import { DataSection } from "./settings-sections/DataSection";
|
||||
import { SecretsSection } from "./settings-sections/SecretsSection";
|
||||
import { ProvidersPanel } from "../settings/providers/ProvidersPanel";
|
||||
|
||||
type SettingsTab = "general" | "mode" | "providers" | "shortcuts" | "data" | "secrets";
|
||||
|
||||
interface SettingsShellProps {
|
||||
snapshot: ProfileSnapshot;
|
||||
secrets: SecretRecord[];
|
||||
companyId: string;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings page with top-row tab navigation. Replaces the previous
|
||||
* single-scroll layout that stacked seven unrelated concerns
|
||||
* (preferences, execution mode, providers, keyboard reference, data
|
||||
* ops, secrets) in one column forcing users to scroll past everything
|
||||
* they didn't need.
|
||||
*
|
||||
* Tabs live as local state — settings are transient; there is no
|
||||
* benefit to persisting the active tab across company switches or
|
||||
* app restarts. Default is "general" because first-time users most
|
||||
* often land here for theme / language.
|
||||
*/
|
||||
export function SettingsShell({ snapshot, secrets, companyId, onSaved }: SettingsShellProps) {
|
||||
const t = useT();
|
||||
const currentLocale = useLocale();
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
|
||||
const [theme, setTheme] = useState(snapshot.theme);
|
||||
const [selectedLocale, setSelectedLocale] = useState<AppLocale>(snapshot.locale ?? currentLocale);
|
||||
const [updatesEnabled, setUpdatesEnabled] = useState(snapshot.updatesEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(snapshot.theme);
|
||||
setUpdatesEnabled(snapshot.updatesEnabled);
|
||||
if (snapshot.locale) setSelectedLocale(snapshot.locale);
|
||||
}, [snapshot.theme, snapshot.updatesEnabled, snapshot.locale]);
|
||||
|
||||
const company = useMemo(
|
||||
() => snapshot.companies.find((entry) => entry.id === companyId) ?? null,
|
||||
[snapshot.companies, companyId],
|
||||
);
|
||||
|
||||
const tabs: Array<{ id: SettingsTab; label: string }> = [
|
||||
{ id: "general", label: t("settings.tabs.general") },
|
||||
{ id: "mode", label: t("settings.tabs.mode") },
|
||||
{ id: "providers", label: t("settings.tabs.providers") },
|
||||
{ id: "shortcuts", label: t("settings.tabs.shortcuts") },
|
||||
{ id: "data", label: t("settings.tabs.data") },
|
||||
{ id: "secrets", label: t("settings.tabs.secrets") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<nav
|
||||
role="tablist"
|
||||
aria-label={t("settings.tabsAria")}
|
||||
className="flex flex-wrap gap-1 border-b border-border"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative px-4 py-2 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
||||
active
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{active ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-2 bottom-[-1px] h-[2px] bg-primary"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
{activeTab === "general" ? (
|
||||
<GeneralSection
|
||||
snapshot={snapshot}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
selectedLocale={selectedLocale}
|
||||
setSelectedLocale={setSelectedLocale}
|
||||
updatesEnabled={updatesEnabled}
|
||||
setUpdatesEnabled={setUpdatesEnabled}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === "mode" && company ? (
|
||||
<ExecutionModeSection company={company} onSaved={onSaved} />
|
||||
) : null}
|
||||
{activeTab === "providers" ? <ProvidersPanel /> : null}
|
||||
{activeTab === "shortcuts" ? <ShortcutsSection /> : null}
|
||||
{activeTab === "data" ? <DataSection snapshot={snapshot} onSaved={onSaved} /> : null}
|
||||
{activeTab === "secrets" ? (
|
||||
<SecretsSection secrets={secrets} companyId={companyId} onSaved={onSaved} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user