put the settings section in the url

Five settings pages moved from a `*_SELECTED` global to `/settings/:page/:section`. The
sidebar entry is a react-router `<NavLink>` rather than a button holding the key in its
onClick closure, so a section is linkable, cmd-clickable and gets its active state from the
router; each page renders one `SettingsRoute` guard that canonicalises both the bare route
and a section that does not exist.

Integrations needed more than the shared factory. It builds its own sidebar, and it kept the
Enterprise/Personal tab in a second global — which is why a deep link to a Personal section
could never have worked: the link set the section, the tab stayed on Enterprise, and the
content pane said "Select a section" about a section that existed. The tab is derived from
the section key now.

Also removes the `/settings/resources` menu item (audit M8) and its two locale keys: there
has never been such a route, so it bounced to the catch-all and out to `/`.
This commit is contained in:
2026-08-07 11:38:21 +00:00
parent 98ba61f135
commit 2502c33804
12 changed files with 155 additions and 99 deletions
+8 -6
View File
@@ -37,7 +37,10 @@ That fix is the template for the HIGH items below. **Caveat:** the fix only did
**Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/projects/:id`, `/email/:emailId`.
**Flat screens:** `/` `/files` `/music` `/soulseek` `/tasks` `/skills` `/processes` `/activity`
`/system-monitor` `/task-logs` `/plans` `/terminal` `/desktop` `/browser` `/code-editor` `/settings/{profile,ai,system,integrations,apps}`.
`/system-monitor` `/task-logs` `/plans` `/terminal` `/desktop` `/browser` `/code-editor`.
The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}`
plus a `:section` each (M6). There has never been a `/settings/apps`; that entry was wrong when this list
was written.
**What's already correct** (lean on these in the refactor): the **Dock**, **Header** (logo + mobile sheet),
**UserMenu**, **JobsIndicator** are all real `<Link>`s. Shared `NavLink.tsx` (query-string-appending `<Link>`
@@ -72,9 +75,9 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
| M3 | `Screens/Dashboard/Activity/ActivityScreen.tsx:63,73` | background task / detached job | `/activity/:id` | two row types; unify under one param, screen re-derives `task=`/`path=`. |
| M4 | `FileBrowser/.../useFileBrowserApp.ts:269`, `FileItem.tsx:516`, `Breadcrumb.tsx:16`, search-hit `:249` | a folder | `/files?path=<dir>` | **files** already open via `?view=`; **folders** are pure `currentPath` state — no URL, no back/forward. Folder rows + crumbs → `<Link>` on a `?path=` param. |
| M5 | `CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33` | open source file / active tab | `/code-editor?file=<path>` (+ `open=` for tabs) | tree file rows → `<Link>`; active file in a param. |
| M6 | `Layout/Settings/SettingsPanel.tsx:39` | a settings sub-section | `/settings/:page/:section` or `?section=` | `<button onClick={setSelectedKey}>``useGlobal`; nothing in URL, not deep-linkable. **One change covers all settings pages** (all go through `createSettingsPanelComponents`). |
| ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `<NavLink>` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was *almost* right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. |
| M7 | `workspaces/components/Combobox.tsx:53` | caller-supplied route | (existing) | shared widget: `href`-bearing options do `navigate(option.href)`; render them as `<Link>`. Every caller inherits the opaque click. |
| M8 | `Layout/Header/UserMenu.tsx:51` | — | — | **dead target:** `<Link to="/settings/resources">` — route doesn't exist (falls through to `/`). Add the route or remove the item. |
| ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. |
| M9 | `Screens/Dashboard/Plans/index.tsx:36` | a plan document | `/plans/:name` | native `<select>` → local state; a plan is a real addressable doc. Have the select `navigate()` or use a link list. |
| M10 | `SystemMonitor/ScopeList.tsx:14` | monitor scope (btop/pm2/docker) | `/system-monitor/:scope` | small fixed set; low urgency but genuinely addressable. |
@@ -120,7 +123,6 @@ This is the primary surface to convert to URL-driven selection.
| `soulseek:section` | active Soulseek section | `/soulseek/:section` | `SoulseekNav/View/Dashboard/UserMenu` |
| `soulseek:user` | selected peer | `/soulseek/users/:name` | `UserMenu.tsx:28`, `SoulseekUsers.tsx:34` |
| `monitor:scope` | monitor scope | `/system-monitor/:scope` | `ScopeList.tsx` (via `shared.ts:15`) |
| settings `useGlobal(*_SELECTED)` | settings section | `/settings/:page/:section` | `SettingsPanel.tsx:56` (M6) |
| local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` |
**Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-signal`,
@@ -186,11 +188,11 @@ publishers means changing the chat panel, which is another agent's, so it is wri
- [x] **H3** Projects rows → `<Link>`; `SELECTED_PROJECT` global replaced by `?selected=` URL param across `ProjectListApp`/`ProjectListScreen`/`ProjectPreview`. Done — `2aaacc8`. **Needs runtime test.**
- [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**. *Deferred: overlaps the in-flight `sidecars-*` chat-comms work; do after that lands.*
- [ ] **H5** Email rows → `<Link>` driven by `useParams().emailId`; delete the state↔URL sync effects (`EmailList.tsx:336`, `EmailScreen.tsx:27-33`).
- [ ] **M8** Fix or remove the dead `/settings/resources` link (`UserMenu.tsx:51`).
- [x] **M8** Dead `/settings/resources` menu item removed from `UserMenu.tsx`, along with its now-orphaned `en`/`pt` locale keys. **Needs runtime test.**
- [ ] Verify + convert the 4 preview "open" navigates (`DashboardPreview:355,377`, `ProjectPreview:430,457`); leave genuine post-mutation redirects.
### Phase 2 — Add a route, then link (per-entity, medium effort)
- [ ] **M6** Settings sub-sections → nested route or `?section=`, render `SectionButton` as `<NavLink>` (`SettingsPanel.tsx:39`) — one change covers all settings pages.
- [x] **M6** Settings sub-sections → `/settings/:page/:section`; `SectionButton` is now a `SectionLink` (`<NavLink>`), the five `*_SELECTED` globals and `INTEGRATIONS_SETTINGS_TAB` are gone, and each page renders one `SettingsRoute` guard that canonicalises the bare route and a bogus section. **Needs runtime test.**
- [ ] **M1** Capabilities → `/tasks|skills|processes/:dir`, rows → `<Link>` (`CapabilityPage.tsx:431`) — covers 3 screens.
- [ ] **M2** TaskLogs → `/task-logs/:id` (`TaskLogs/index.tsx:104`).
- [ ] **M3** Activity → `/activity/:id` (`ActivityScreen.tsx:63,73`).
+13
View File
@@ -634,6 +634,19 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
(`frontend.tsx:15-21`) with no per-route scoping, and `reset` is called nowhere.
Authority: `docs/navigation-audit.md`.
**Settings is done** (audit M6). Five pages moved to `/settings/:page/:section`: the section is
`useParams`, the sidebar entry is a `<NavLink>` instead of a `<button>` with the key in its closure,
and each page renders one `SettingsRoute` guard that canonicalises the bare route and an unknown
section. Six globals went with it — the five `*_SELECTED` keys and `INTEGRATIONS_SETTINGS_TAB`.
Two things the doc's "one fix point" framing had wrong. Integrations does **not** go through
`createSettingsPanelComponents` — it builds its own sidebar, so it needed its own conversion. And
its Enterprise/Personal tab was a *second* global, which is why deep-linking a Personal section
could never have worked: the link set the section, the tab stayed on Enterprise, and the content
pane rendered "Select a section" for a section that existed. The tab is derived from the section
key now, so there is nothing left to disagree with.
Remaining here: Music, Soulseek, SystemMonitor, Email, Browser. **Chat detail
(`chat:selected-session`) is chat-owned — leave it; it is written up in `COMMS/`.**
### 5.9 The context has grown an app-config section — _(found 2026-08-07)_
`WorkspaceContext` is 18 fields, of which the framework itself reads none of the first six. Apps never
+9
View File
@@ -30,11 +30,20 @@ export function App() {
<Dashboard.DashboardLayout>
<Routes>
<Route path="/" element={<Dashboard.HomeScreen />} />
{/* Route pairs, like /headscale and /photos below: the bare page route exists so the
existing links into it keep working, and its `SettingsRoute` guard redirects to the
first section. The section is the URL rather than a `*_SELECTED` global, so a settings
sub-page is linkable, bookmarkable and cmd-clickable. */}
<Route path="/settings/profile" element={<Dashboard.ProfileSettings />} />
<Route path="/settings/profile/:section" element={<Dashboard.ProfileSettings />} />
<Route path="/settings/ai" element={<Dashboard.AISettings />} />
<Route path="/settings/ai/:section" element={<Dashboard.AISettings />} />
<Route path="/settings/system" element={<Dashboard.SystemSettings />} />
<Route path="/settings/system/:section" element={<Dashboard.SystemSettings />} />
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
<Route path="/settings/integrations/:section" element={<Dashboard.IntegrationsSettings />} />
<Route path="/settings/user-management" element={<Dashboard.UserManagementSettings />} />
<Route path="/settings/user-management/:section" element={<Dashboard.UserManagementSettings />} />
{/* A project group is a working directory, so it is spelled as a path, not ?cwd= — see
apps/ChatHistory/chat-routes.ts. The splat has to be last in a pattern, which is why
"new" comes before the group rather than after it. A session carries no group: the
@@ -1,7 +1,7 @@
import { Link } from 'react-router';
import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, LogOut, Settings, Package, Puzzle, Sun, Moon, Bot, UserCog } from 'lucide-react';
import { User, LogOut, Settings, Puzzle, Sun, Moon, Bot, UserCog } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
import { useColorMode } from '@/components/ui/ThemeProvider';
@@ -47,12 +47,9 @@ export function UserMenu() {
{t('header.userMenu.systemSettings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/resources">
<Package className="mr-2 h-4 w-4" />
{t('header.userMenu.resources')}
</Link>
</DropdownMenuItem>
{/* No Resources entry. `/settings/resources` has never been a route, so this item sent you to
the catch-all and out to `/` — a menu entry that looks like navigation and is a bounce.
(navigation-audit M8.) */}
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/ai">
<Bot className="mr-2 h-4 w-4" />
@@ -7,7 +7,7 @@ import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { useSettings } from 'state/useSettings';
import type { UserSettings } from 'state/useSettings';
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
@@ -462,6 +462,8 @@ function useAISettingsGroups(): SettingsSectionGroup[] {
}, []);
}
const BASE_PATH = '/settings/ai';
const layout: LayoutNode = {
type: 'group',
id: 'ai-root',
@@ -475,10 +477,10 @@ const layout: LayoutNode = {
export const AISettings = () => {
const groups = useAISettingsGroups();
const { Sidebar, Content } = useMemo(
const { Sidebar, Content, allSections } = useMemo(
() =>
createSettingsPanelComponents({
globalKey: 'AI_SETTINGS_SELECTED',
basePath: BASE_PATH,
sidebarIcon: Bot,
sidebarLabel: 'AI',
groups,
@@ -495,8 +497,10 @@ export const AISettings = () => {
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
<SettingsRoute basePath={BASE_PATH} sections={allSections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};
@@ -1,12 +1,12 @@
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Puzzle, KeyRound, UserCircle, Globe, Wrench, Mail, CalendarDays } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { useGlobal } from 'hooks/useGlobal';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
import { SettingsRoute, SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { BrowserRelay } from './BrowserRelay';
@@ -14,8 +14,7 @@ import { ApifyConfig } from './ApifyConfig';
import { EmailAccounts } from './EmailAccounts';
import { DavAppPasswords } from './DavAppPasswords';
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
const BASE_PATH = '/settings/integrations';
const enterpriseSections: SettingsSection[] = [
{
@@ -67,10 +66,27 @@ const personalSections: SettingsSection[] = [
},
];
// The Enterprise/Personal tab is not a second thing to remember — it is a fact about which section is
// open, so it is derived from the URL rather than kept in its own global. That also fixes the deep link:
// with the tab held separately, `/settings/integrations/email-accounts` would have arrived with the tab
// still on Enterprise and the content pane showing "Select a section".
const allSections = [...enterpriseSections, ...personalSections];
const tabForSection = (section: string | undefined) =>
personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise';
const IntegrationsSidebar = () => {
const [tab, setTab] = useGlobal<string>(TAB_KEY, 'enterprise');
const { section } = useParams<{ section?: string }>();
const navigate = useNavigate();
const tab = tabForSection(section);
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
// Switching tab is a navigation, so it lands on that tab's first section rather than leaving the
// content pane showing a section the sidebar no longer lists.
const setTab = (next: string) => {
const first = (next === 'enterprise' ? enterpriseSections : personalSections)[0];
if (first) navigate(`${BASE_PATH}/${first.key}`);
};
return (
<div className="flex flex-col h-full">
<div className="p-3 pb-2">
@@ -91,17 +107,14 @@ const IntegrationsSidebar = () => {
</TabsList>
</Tabs>
</div>
<SettingsSidebar globalKey={GLOBAL_KEY} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
<SettingsSidebar basePath={BASE_PATH} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
</div>
);
};
const IntegrationsContent = () => {
const [tab] = useGlobal<string>(TAB_KEY, 'enterprise');
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
return <SettingsContent globalKey={GLOBAL_KEY} sections={sections} />;
};
// Both tabs' sections, because the URL alone says which one is open and the pane no longer has to agree
// with the sidebar about the tab to find it.
const IntegrationsContent = () => <SettingsContent sections={allSections} />;
const layout: LayoutNode = {
type: 'group',
@@ -123,8 +136,10 @@ export const IntegrationsSettings = () => {
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
<SettingsRoute basePath={BASE_PATH} sections={allSections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};
@@ -3,14 +3,14 @@ import { User, Lock, Globe, LayoutGrid, Volume2 } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { SettingsRoute, createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { UserData } from './UserData';
import { ChangePassword } from './ChangePassword';
import { Languages } from './Languages';
import { DockSettings } from './DockSettings';
import { VoicePreference } from './VoicePreference';
const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
const BASE_PATH = '/settings/profile';
const sections: SettingsSection[] = [
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> },
@@ -45,7 +45,7 @@ const sections: SettingsSection[] = [
];
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: GLOBAL_KEY,
basePath: BASE_PATH,
sidebarIcon: User,
sidebarLabel: 'Profile',
sections,
@@ -71,8 +71,10 @@ export const ProfileSettings = () => {
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
<SettingsRoute basePath={BASE_PATH} sections={sections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};
@@ -1,7 +1,7 @@
import type { ComponentType, ReactNode } from 'react';
import { useState } from 'react';
import { NavLink, Navigate, useParams } from 'react-router';
import type { LucideIcon } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { Input } from '@/components/ui/input';
export type SettingsSection = {
@@ -19,7 +19,7 @@ export type SettingsSectionGroup = {
};
type SettingsSidebarProps = {
globalKey: string;
basePath: string;
icon: LucideIcon;
label: string;
sections: SettingsSection[];
@@ -27,33 +27,33 @@ type SettingsSidebarProps = {
hideHeader?: boolean;
};
const SectionButton = ({
section,
isActive,
onClick,
}: {
section: SettingsSection;
isActive: boolean;
onClick: () => void;
}) => (
<button
key={section.key}
onClick={onClick}
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`}
/**
* A real link, not a button with an onClick. The section is in the URL, so cmd-click opens it in a tab,
* the browser knows it has been visited, and `isActive` comes from the router instead of from a global
* that the content pane had to be told about separately.
*/
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => (
<NavLink
to={to}
className={({ isActive }) =>
`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
}`
}
>
<section.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{section.title}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
</div>
</button>
{({ isActive }) => (
<>
<section.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{section.title}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div>
</div>
</>
)}
</NavLink>
);
export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
const [selectedKey, setSelectedKey] = useGlobal<string | null>(globalKey, allSections[0]?.key ?? null);
export const SettingsSidebar = ({ basePath, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
const [search, setSearch] = useState('');
const query = search.toLowerCase();
@@ -87,23 +87,13 @@ export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups
</span>
</div>
{filtered.map((s) => (
<SectionButton
key={s.key}
section={s}
isActive={selectedKey === s.key}
onClick={() => setSelectedKey(s.key)}
/>
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
))}
</div>
);
})
: sections.filter(matchesSearch).map((s) => (
<SectionButton
key={s.key}
section={s}
isActive={selectedKey === s.key}
onClick={() => setSelectedKey(s.key)}
/>
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
))}
</div>
</div>
@@ -111,12 +101,14 @@ export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups
};
type SettingsContentProps = {
globalKey: string;
sections: SettingsSection[];
};
export const SettingsContent = ({ globalKey, sections }: SettingsContentProps) => {
const [selectedKey] = useGlobal<string | null>(globalKey, sections[0]?.key ?? null);
export const SettingsContent = ({ sections }: SettingsContentProps) => {
// The sidebar and this pane are two sibling panels that used to agree via a shared `useGlobal` key.
// They agree via the URL now, so neither has to be told about the other — and neither can be the one
// that seeds the slot, which is what made the old defaulting order matter (see §5.6).
const { section: selectedKey } = useParams<{ section?: string }>();
const section = sections.find((s) => s.key === selectedKey);
if (!section) {
@@ -139,19 +131,35 @@ export const SettingsContent = ({ globalKey, sections }: SettingsContentProps) =
);
};
/**
* The one guard each settings screen renders, per the route-pair convention: it canonicalises both the
* bare page route and a section that does not exist, so `/settings/ai` and `/settings/ai/nonsense` both
* land on the first section instead of on "Select a section".
*
* Place it after the screen's hooks — it returns early.
*/
type SettingsRouteProps = { basePath: string; sections: SettingsSection[]; children: ReactNode };
export const SettingsRoute = ({ basePath, sections, children }: SettingsRouteProps) => {
const { section } = useParams<{ section?: string }>();
const first = sections[0];
if (first && !sections.some((s) => s.key === section)) return <Navigate to={`${basePath}/${first.key}`} replace />;
return <>{children}</>;
};
type CreateSettingsPanelParams = {
globalKey: string;
basePath: string;
sidebarIcon: LucideIcon;
sidebarLabel: string;
sections?: SettingsSection[];
groups?: SettingsSectionGroup[];
};
export const createSettingsPanelComponents = ({ globalKey, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => {
export const createSettingsPanelComponents = ({ basePath, sidebarIcon, sidebarLabel, sections = [], groups }: CreateSettingsPanelParams) => {
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
const Sidebar: ComponentType = () => (
<SettingsSidebar globalKey={globalKey} icon={sidebarIcon} label={sidebarLabel} sections={allSections} groups={groups} />
<SettingsSidebar basePath={basePath} icon={sidebarIcon} label={sidebarLabel} sections={allSections} groups={groups} />
);
const Content: ComponentType = () => <SettingsContent globalKey={globalKey} sections={allSections} />;
return { Sidebar, Content };
const Content: ComponentType = () => <SettingsContent sections={allSections} />;
return { Sidebar, Content, allSections };
};
@@ -3,7 +3,7 @@ import { Bot, Settings, Mail, Volume2, Mic, ScanText } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
import { SMTPSection } from './ServerSettings/SMTPSection';
import { TTSSection } from './ServerSettings/TTSSection';
import { STTSection } from './ServerSettings/STTSection';
@@ -40,8 +40,10 @@ const groups: SettingsSectionGroup[] = [
},
];
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: 'SYSTEM_SETTINGS_SELECTED',
const BASE_PATH = '/settings/system';
const { Sidebar, Content, allSections } = createSettingsPanelComponents({
basePath: BASE_PATH,
sidebarIcon: Settings,
sidebarLabel: 'System',
groups,
@@ -73,8 +75,10 @@ export const SystemSettings = () => {
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={baseLayout} onLayoutChange={() => {}} components={panelComponents} />
</div>
<SettingsRoute basePath={BASE_PATH} sections={allSections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={baseLayout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Users, UserCog, ShieldCheck } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { SettingsRoute, createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { UsersSection } from './UsersSection';
import { PermissionsSection } from './PermissionsSection';
@@ -28,8 +28,10 @@ const sections: SettingsSection[] = [
},
];
const BASE_PATH = '/settings/user-management';
const { Sidebar, Content } = createSettingsPanelComponents({
globalKey: 'USER_MANAGEMENT_SELECTED',
basePath: BASE_PATH,
sidebarIcon: UserCog,
sidebarLabel: 'Users',
sections,
@@ -52,8 +54,10 @@ export const UserManagementSettings = () => {
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
<SettingsRoute basePath={BASE_PATH} sections={sections}>
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
</SettingsRoute>
);
};
-1
View File
@@ -52,7 +52,6 @@
"userMenu": {
"profile": "Profile",
"systemSettings": "System Settings",
"resources": "Resources",
"signOut": "Sign Out"
}
},
-1
View File
@@ -52,7 +52,6 @@
"userMenu": {
"profile": "Perfil",
"systemSettings": "Definições do Sistema",
"resources": "Recursos",
"signOut": "Terminar Sessão"
}
},