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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1276,6 +1276,13 @@ export const en: Record<string, string> = {
|
|||||||
"org.statusAria": "Status: {status}",
|
"org.statusAria": "Status: {status}",
|
||||||
|
|
||||||
// ── Settings pane ──
|
// ── Settings pane ──
|
||||||
|
"settings.tabs.general": "General",
|
||||||
|
"settings.tabs.mode": "Execution mode",
|
||||||
|
"settings.tabs.providers": "Providers",
|
||||||
|
"settings.tabs.shortcuts": "Shortcuts",
|
||||||
|
"settings.tabs.data": "Data",
|
||||||
|
"settings.tabs.secrets": "Secrets",
|
||||||
|
"settings.tabsAria": "Settings categories",
|
||||||
"settings.updaterHint": "Check for application updates at launch",
|
"settings.updaterHint": "Check for application updates at launch",
|
||||||
"settings.modeScope": "Applies to current company: {company}",
|
"settings.modeScope": "Applies to current company: {company}",
|
||||||
"settings.backupRestore": "Backup & restore",
|
"settings.backupRestore": "Backup & restore",
|
||||||
|
|||||||
@@ -1276,6 +1276,13 @@ export const zh: Record<string, string> = {
|
|||||||
"org.statusAria": "状态:{status}",
|
"org.statusAria": "状态:{status}",
|
||||||
|
|
||||||
// ── 设置面板 ──
|
// ── 设置面板 ──
|
||||||
|
"settings.tabs.general": "通用",
|
||||||
|
"settings.tabs.mode": "运营模式",
|
||||||
|
"settings.tabs.providers": "连接器",
|
||||||
|
"settings.tabs.shortcuts": "快捷键",
|
||||||
|
"settings.tabs.data": "数据",
|
||||||
|
"settings.tabs.secrets": "密钥",
|
||||||
|
"settings.tabsAria": "设置分类",
|
||||||
"settings.updaterHint": "启动时自动检查应用更新",
|
"settings.updaterHint": "启动时自动检查应用更新",
|
||||||
"settings.modeScope": "应用于当前公司:{company}",
|
"settings.modeScope": "应用于当前公司:{company}",
|
||||||
"settings.backupRestore": "备份与恢复",
|
"settings.backupRestore": "备份与恢复",
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import { StandupView } from "../../components/inbox/StandupView";
|
|||||||
import { PerformanceView } from "../../components/inbox/PerformanceView";
|
import { PerformanceView } from "../../components/inbox/PerformanceView";
|
||||||
import { Dashboard } from "../../components/dashboard/Dashboard";
|
import { Dashboard } from "../../components/dashboard/Dashboard";
|
||||||
import { TwoPaneLayout } from "../../components/TwoPaneLayout";
|
import { TwoPaneLayout } from "../../components/TwoPaneLayout";
|
||||||
import { SettingsPane } from "../../components/execution/SettingsPane";
|
import { SettingsShell } from "../../components/execution/SettingsShell";
|
||||||
import { WorkspaceCreator } from "../../components/execution/WorkspaceCreator";
|
import { WorkspaceCreator } from "../../components/execution/WorkspaceCreator";
|
||||||
import { WorkspaceInspector } from "../../components/execution/WorkspaceInspector";
|
import { WorkspaceInspector } from "../../components/execution/WorkspaceInspector";
|
||||||
import { WorkspaceList } from "../../components/execution/WorkspaceList";
|
import { WorkspaceList } from "../../components/execution/WorkspaceList";
|
||||||
import { ProvidersPanel } from "../../components/settings/providers";
|
|
||||||
import { OAuthPromptDialog } from "../../components/settings/dialogs/OAuthPromptDialog";
|
import { OAuthPromptDialog } from "../../components/settings/dialogs/OAuthPromptDialog";
|
||||||
import { SocialAccountsPanel } from "../../components/social/SocialPanel";
|
import { SocialAccountsPanel } from "../../components/social/SocialPanel";
|
||||||
import { AutomationPanel } from "../../components/automation/AutomationPanel";
|
import { AutomationPanel } from "../../components/automation/AutomationPanel";
|
||||||
@@ -49,7 +48,6 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
|||||||
} = useSectionContext();
|
} = useSectionContext();
|
||||||
const { refresh, openPathWithFeedback, setError } = useAppActions();
|
const { refresh, openPathWithFeedback, setError } = useAppActions();
|
||||||
|
|
||||||
const locale = useAppStore((s) => s.locale);
|
|
||||||
const inboxItems = useAppStore((s) => s.inboxItems);
|
const inboxItems = useAppStore((s) => s.inboxItems);
|
||||||
const companyMetrics = useAppStore((s) => s.companyMetrics);
|
const companyMetrics = useAppStore((s) => s.companyMetrics);
|
||||||
const standupReport = useAppStore((s) => s.standupReport);
|
const standupReport = useAppStore((s) => s.standupReport);
|
||||||
@@ -272,15 +270,15 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{section === "settings" && currentCompany ? (
|
{section === "settings" && currentCompany ? (
|
||||||
<div className="space-y-10">
|
<>
|
||||||
<Panel title={locale === "zh" ? "配置" : "Profile and release"}>
|
<SettingsShell
|
||||||
<SettingsPane snapshot={snapshot} secrets={companySecrets} companyId={currentCompany.id} onSaved={refresh} />
|
snapshot={snapshot}
|
||||||
</Panel>
|
secrets={companySecrets}
|
||||||
<Panel title={locale === "zh" ? "Providers" : "Providers"}>
|
companyId={currentCompany.id}
|
||||||
<ProvidersPanel />
|
onSaved={refresh}
|
||||||
</Panel>
|
/>
|
||||||
<OAuthPromptDialog />
|
<OAuthPromptDialog />
|
||||||
</div>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user