Files
platform/src/apps/officer-web/Screens/Dashboard/Settings/SettingsPanel.tsx
T
pastilhas c3c62828e3 settings framework and section metadata keyed (487 -> 425)
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.
2026-08-23 04:46:07 +00:00

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 };
};