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:
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"userMenu": {
|
||||
"profile": "Profile",
|
||||
"systemSettings": "System Settings",
|
||||
"resources": "Resources",
|
||||
"signOut": "Sign Out"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"userMenu": {
|
||||
"profile": "Perfil",
|
||||
"systemSettings": "Definições do Sistema",
|
||||
"resources": "Recursos",
|
||||
"signOut": "Terminar Sessão"
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user