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:
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user