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.
This commit is contained in:
@@ -3,17 +3,24 @@ 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;
|
||||
title: string;
|
||||
description: string;
|
||||
titleKey: AppTranslationKey;
|
||||
descriptionKey: AppTranslationKey;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
export type SettingsSectionGroup = {
|
||||
label: string;
|
||||
labelKey: AppTranslationKey;
|
||||
icon: LucideIcon;
|
||||
sections: SettingsSection[];
|
||||
};
|
||||
@@ -21,7 +28,7 @@ export type SettingsSectionGroup = {
|
||||
type SettingsSidebarProps = {
|
||||
basePath: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
labelKey: AppTranslationKey;
|
||||
sections: SettingsSection[];
|
||||
groups?: SettingsSectionGroup[];
|
||||
hideHeader?: boolean;
|
||||
@@ -32,44 +39,51 @@ type SettingsSidebarProps = {
|
||||
* 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'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ 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>
|
||||
);
|
||||
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,
|
||||
label,
|
||||
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) =>
|
||||
s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query);
|
||||
t(s.titleKey).toLowerCase().includes(query) || t(s.descriptionKey).toLowerCase().includes(query);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
@@ -77,13 +91,13 @@ export const SettingsSidebar = ({
|
||||
<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" />
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
placeholder={t('settings.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="h-8 text-xs"
|
||||
@@ -95,11 +109,11 @@ export const SettingsSidebar = ({
|
||||
const filtered = group.sections.filter(matchesSearch);
|
||||
if (filtered.length === 0) return null;
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<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">
|
||||
{group.label}
|
||||
{t(group.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
{filtered.map((s) => (
|
||||
@@ -121,6 +135,7 @@ type SettingsContentProps = {
|
||||
};
|
||||
|
||||
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).
|
||||
@@ -130,7 +145,7 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
|
||||
if (!section) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">Select a section</p>
|
||||
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">{t('settings.selectSection')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -139,9 +154,9 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
|
||||
<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">{section.title}</h2>
|
||||
<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">{section.description}</p>
|
||||
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{t(section.descriptionKey)}</p>
|
||||
<div>{section.content}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -166,7 +181,7 @@ export const SettingsRoute = ({ basePath, sections, children }: SettingsRoutePro
|
||||
type CreateSettingsPanelParams = {
|
||||
basePath: string;
|
||||
sidebarIcon: LucideIcon;
|
||||
sidebarLabel: string;
|
||||
sidebarLabelKey: AppTranslationKey;
|
||||
sections?: SettingsSection[];
|
||||
groups?: SettingsSectionGroup[];
|
||||
};
|
||||
@@ -174,7 +189,7 @@ type CreateSettingsPanelParams = {
|
||||
export const createSettingsPanelComponents = ({
|
||||
basePath,
|
||||
sidebarIcon,
|
||||
sidebarLabel,
|
||||
sidebarLabelKey,
|
||||
sections = [],
|
||||
groups,
|
||||
}: CreateSettingsPanelParams) => {
|
||||
@@ -183,7 +198,7 @@ export const createSettingsPanelComponents = ({
|
||||
<SettingsSidebar
|
||||
basePath={basePath}
|
||||
icon={sidebarIcon}
|
||||
label={sidebarLabel}
|
||||
labelKey={sidebarLabelKey}
|
||||
sections={allSections}
|
||||
groups={groups}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user