SettingsSection now carries titleKey/descriptionKey instead of title/description, and the sidebar and content pane resolve them. The section arrays are module-level consts holding JSX content, so they cannot call a hook — the data holds keys and the renderers translate. Sidebar search matches the RESOLVED words, so it works in the language on screen. Capital K in titleKey is load-bearing: docs-check-settings.ts greps key:\s*'…' for route segments, and a lowercase title_key would have fed it twenty bogus paths. Verified 0 bogus, 26 declared, 20 offered, 0 missing. Section titles now share one key with the Spotlight rows that name the same section, per 'one key per meaning'. Two casings had to converge, so Settings shows Spotlight's: 'Change Password' now reads 'Change password'. Settings sidebar search placeholder was 'Search...'; it is now 'Search settings…', which is what the skeleton catalog already said and what distinguishes it from the global Spotlight.
209 lines
7.4 KiB
TypeScript
209 lines
7.4 KiB
TypeScript
import type { ComponentType, ReactNode } from 'react';
|
|
import { useState } from 'react';
|
|
import { NavLink, Navigate, useParams } from 'react-router';
|
|
import type { LucideIcon } from 'lucide-react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { useTranslation, type AppTranslationKey } from '@/lib/i18n';
|
|
|
|
/**
|
|
* Sections are declared as module-level consts holding JSX `content`, so they cannot call a hook —
|
|
* they carry catalog KEYS and the renderers below resolve them. `titleKey` rather than `titleKeyName`
|
|
* and deliberately not `title_key`: docs-check-settings.ts greps `key:\s*'…'` for route segments, and
|
|
* a capital K keeps `titleKey` out of that match.
|
|
*/
|
|
export type SettingsSection = {
|
|
key: string;
|
|
icon: LucideIcon;
|
|
titleKey: AppTranslationKey;
|
|
descriptionKey: AppTranslationKey;
|
|
content: ReactNode;
|
|
};
|
|
|
|
export type SettingsSectionGroup = {
|
|
labelKey: AppTranslationKey;
|
|
icon: LucideIcon;
|
|
sections: SettingsSection[];
|
|
};
|
|
|
|
type SettingsSidebarProps = {
|
|
basePath: string;
|
|
icon: LucideIcon;
|
|
labelKey: AppTranslationKey;
|
|
sections: SettingsSection[];
|
|
groups?: SettingsSectionGroup[];
|
|
hideHeader?: boolean;
|
|
};
|
|
|
|
/**
|
|
* 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 }) => {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<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'
|
|
}`
|
|
}
|
|
>
|
|
{({ 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">{t(section.titleKey)}</div>
|
|
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">
|
|
{t(section.descriptionKey)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</NavLink>
|
|
);
|
|
};
|
|
|
|
export const SettingsSidebar = ({
|
|
basePath,
|
|
icon: Icon,
|
|
labelKey,
|
|
sections,
|
|
groups,
|
|
hideHeader,
|
|
}: SettingsSidebarProps) => {
|
|
const [search, setSearch] = useState('');
|
|
const { t } = useTranslation();
|
|
|
|
const query = search.toLowerCase();
|
|
// Matched against the RESOLVED words, so searching works in the language on screen.
|
|
const matchesSearch = (s: SettingsSection) =>
|
|
t(s.titleKey).toLowerCase().includes(query) || t(s.descriptionKey).toLowerCase().includes(query);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-y-auto">
|
|
{!hideHeader && (
|
|
<div className="p-3 pb-2">
|
|
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
|
<Icon className="h-4 w-4" />
|
|
{t(labelKey)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="px-3 pb-2">
|
|
<Input
|
|
placeholder={t('settings.searchPlaceholder')}
|
|
value={search}
|
|
onChange={(ev) => setSearch(ev.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
|
|
{groups
|
|
? groups.map((group) => {
|
|
const filtered = group.sections.filter(matchesSearch);
|
|
if (filtered.length === 0) return null;
|
|
return (
|
|
<div key={group.labelKey}>
|
|
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
|
<group.icon className="h-3 w-3 text-duck-dark/30 dark:text-foreground/30" />
|
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/30 dark:text-foreground/30">
|
|
{t(group.labelKey)}
|
|
</span>
|
|
</div>
|
|
{filtered.map((s) => (
|
|
<SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />
|
|
))}
|
|
</div>
|
|
);
|
|
})
|
|
: sections
|
|
.filter(matchesSearch)
|
|
.map((s) => <SectionLink key={s.key} section={s} to={`${basePath}/${s.key}`} />)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type SettingsContentProps = {
|
|
sections: SettingsSection[];
|
|
};
|
|
|
|
export const SettingsContent = ({ sections }: SettingsContentProps) => {
|
|
const { t } = useTranslation();
|
|
// 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) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center">
|
|
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">{t('settings.selectSection')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto p-6">
|
|
<div className="flex items-center gap-2.5 mb-1">
|
|
<section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" />
|
|
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{t(section.titleKey)}</h2>
|
|
</div>
|
|
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{t(section.descriptionKey)}</p>
|
|
<div>{section.content}</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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 = {
|
|
basePath: string;
|
|
sidebarIcon: LucideIcon;
|
|
sidebarLabelKey: AppTranslationKey;
|
|
sections?: SettingsSection[];
|
|
groups?: SettingsSectionGroup[];
|
|
};
|
|
|
|
export const createSettingsPanelComponents = ({
|
|
basePath,
|
|
sidebarIcon,
|
|
sidebarLabelKey,
|
|
sections = [],
|
|
groups,
|
|
}: CreateSettingsPanelParams) => {
|
|
const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
|
|
const Sidebar: ComponentType = () => (
|
|
<SettingsSidebar
|
|
basePath={basePath}
|
|
icon={sidebarIcon}
|
|
labelKey={sidebarLabelKey}
|
|
sections={allSections}
|
|
groups={groups}
|
|
/>
|
|
);
|
|
const Content: ComponentType = () => <SettingsContent sections={allSections} />;
|
|
return { Sidebar, Content, allSections };
|
|
};
|