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:
2026-08-23 04:46:07 +00:00
parent 926034750f
commit c3c62828e3
8 changed files with 301 additions and 153 deletions
@@ -11,6 +11,7 @@ import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import type { UserSettings } from 'state/useSettings'; import type { UserSettings } from 'state/useSettings';
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels'; import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
import { useTranslation } from '@/lib/i18n';
import { useAccessPolicy } from 'state/useAccessPolicy'; import { useAccessPolicy } from 'state/useAccessPolicy';
import { useUserState } from 'state/useUserState'; import { useUserState } from 'state/useUserState';
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection'; import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
@@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
// --- My Models Section (per-user hidden models) --- // --- My Models Section (per-user hidden models) ---
function MyModelsSection() { function MyModelsSection() {
const { t } = useTranslation();
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const visibleModels = useModels(); const visibleModels = useModels();
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', ''); const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
@@ -174,11 +176,7 @@ function MyModelsSection() {
}, []); }, []);
if (providerGroups.length === 0) { if (providerGroups.length === 0) {
return ( return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in Providers.
</p>
);
} }
return ( return (
@@ -199,9 +197,9 @@ function MyModelsSection() {
))} ))}
</div> </div>
<DropZone label="Visible" onDrop={showModel}> <DropZone label={t('settings.ai.visible')} onDrop={showModel}>
{shown.length === 0 && ( {shown.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to show</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.dragToShow')}</span>
)} )}
{shown.map((m) => ( {shown.map((m) => (
<ModelPill <ModelPill
@@ -215,9 +213,9 @@ function MyModelsSection() {
))} ))}
</DropZone> </DropZone>
<DropZone label="Hidden" onDrop={hideModel}> <DropZone label={t('settings.ai.hidden')} onDrop={hideModel}>
{hidden.length === 0 && ( {hidden.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models visible</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allVisible')}</span>
)} )}
{hidden.map((m) => ( {hidden.map((m) => (
<ModelPill <ModelPill
@@ -237,6 +235,7 @@ function MyModelsSection() {
// --- Member Models Section (system-wide policy, admin only) --- // --- Member Models Section (system-wide policy, admin only) ---
function MemberModelsSection() { function MemberModelsSection() {
const { t } = useTranslation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { policy, savePolicy } = useAccessPolicy(); const { policy, savePolicy } = useAccessPolicy();
@@ -249,9 +248,9 @@ function MemberModelsSection() {
try { try {
await client.post('/server-settings/chat-providers/local-providers/refresh'); await client.post('/server-settings/chat-providers/local-providers/refresh');
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] }); queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
toast.success('Models refreshed'); toast.success(t('settings.ai.refreshed'));
} catch { } catch {
toast.error('Failed to refresh models'); toast.error(t('settings.ai.refreshFailed'));
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
@@ -325,11 +324,7 @@ function MemberModelsSection() {
}, []); }, []);
if (providerGroups.length === 0) { if (providerGroups.length === 0) {
return ( return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
No models available. Configure API keys in Providers.
</p>
);
} }
return ( return (
@@ -353,15 +348,17 @@ function MemberModelsSection() {
onClick={refreshModels} onClick={refreshModels}
disabled={refreshing} disabled={refreshing}
className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30" className="ml-auto p-1.5 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors disabled:opacity-30"
title="Refresh models from providers" title={t('settings.ai.refresh')}
> >
<RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-3.5 w-3.5 text-duck-teal ${refreshing ? 'animate-spin' : ''}`} />
</button> </button>
</div> </div>
<DropZone label="Enabled" onDrop={enableModel}> <DropZone label={t('settings.ai.enabled')} onDrop={enableModel}>
{enabled.length === 0 && ( {enabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag models here to enable</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
{t('settings.ai.dragToEnable')}
</span>
)} )}
{enabled.map((m) => ( {enabled.map((m) => (
<ModelPill <ModelPill
@@ -375,9 +372,9 @@ function MemberModelsSection() {
))} ))}
</DropZone> </DropZone>
<DropZone label="Disabled" onDrop={disableModel}> <DropZone label={t('settings.ai.disabled')} onDrop={disableModel}>
{disabled.length === 0 && ( {disabled.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All models enabled</span> <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">{t('settings.ai.allEnabled')}</span>
)} )}
{disabled.map((m) => ( {disabled.map((m) => (
<ModelPill <ModelPill
@@ -410,49 +407,49 @@ function MemberModelsSection() {
function useAISettingsGroups(): SettingsSectionGroup[] { function useAISettingsGroups(): SettingsSectionGroup[] {
return useMemo(() => { return useMemo(() => {
const modelsGroup: SettingsSectionGroup = { const modelsGroup: SettingsSectionGroup = {
label: 'Models', labelKey: 'settings.groups.models',
icon: Eye, icon: Eye,
sections: [ sections: [
{ {
key: 'my-models', key: 'my-models',
icon: Eye, icon: Eye,
title: 'My Models', titleKey: 'settings.sections.myModels.label',
description: 'Show or hide models for yourself', descriptionKey: 'settings.sections.myModels.description',
content: <MyModelsSection />, content: <MyModelsSection />,
}, },
{ {
key: 'member-models', key: 'member-models',
icon: Eye, icon: Eye,
title: 'Channel Models', titleKey: 'settings.sections.channelModels.label',
description: 'Models reachable by non-owner accounts', descriptionKey: 'settings.sections.channelModels.description',
content: <MemberModelsSection />, content: <MemberModelsSection />,
}, },
], ],
}; };
const defaultsGroup: SettingsSectionGroup = { const defaultsGroup: SettingsSectionGroup = {
label: 'Defaults', labelKey: 'settings.groups.defaults',
icon: Sparkles, icon: Sparkles,
sections: [ sections: [
{ {
key: 'default-models', key: 'default-models',
icon: Sparkles, icon: Sparkles,
title: 'Default Models', titleKey: 'settings.sections.defaultModels.label',
description: 'Default models for chat, projects, and tasks', descriptionKey: 'settings.sections.defaultModels.description',
content: <AIModels />, content: <AIModels />,
}, },
], ],
}; };
const providersGroup: SettingsSectionGroup = { const providersGroup: SettingsSectionGroup = {
label: 'Providers', labelKey: 'settings.groups.providers',
icon: Terminal, icon: Terminal,
sections: [ sections: [
{ {
key: 'ai-harnesses', key: 'ai-harnesses',
icon: Terminal, icon: Terminal,
title: 'Providers', titleKey: 'settings.sections.providers.label',
description: 'Remote and local AI providers', descriptionKey: 'settings.sections.providers.description',
content: <AIHarnessesSection />, content: <AIHarnessesSection />,
}, },
], ],
@@ -482,7 +479,7 @@ export const AISettings = () => {
createSettingsPanelComponents({ createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: Bot, sidebarIcon: Bot,
sidebarLabel: 'AI', sidebarLabelKey: 'settings.nav.ai',
groups, groups,
}), }),
[groups], [groups],
@@ -10,6 +10,7 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now // import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
import { ApifyConfig } from './ApifyConfig'; import { ApifyConfig } from './ApifyConfig';
import { DavAppPasswords } from './DavAppPasswords'; import { DavAppPasswords } from './DavAppPasswords';
import { useTranslation } from '@/lib/i18n';
const BASE_PATH = '/settings/integrations'; const BASE_PATH = '/settings/integrations';
@@ -17,15 +18,15 @@ const enterpriseSections: SettingsSection[] = [
{ {
key: 'google-oauth', key: 'google-oauth',
icon: KeyRound, icon: KeyRound,
title: 'Google OAuth', titleKey: 'settings.sections.googleOauth.label',
description: 'Client ID and secret for Google APIs', descriptionKey: 'settings.sections.googleOauth.description',
content: <GoogleOAuthConfig />, content: <GoogleOAuthConfig />,
}, },
{ {
key: 'apify', key: 'apify',
icon: Wrench, icon: Wrench,
title: 'Apify', titleKey: 'settings.sections.apify.label',
description: 'API token for web scraping actors', descriptionKey: 'settings.sections.apify.description',
content: <ApifyConfig />, content: <ApifyConfig />,
}, },
]; ];
@@ -39,14 +40,14 @@ const personalSections: SettingsSection[] = [
// key: 'google-account', // key: 'google-account',
// icon: UserCircle, // icon: UserCircle,
// title: 'Google Account', // title: 'Google Account',
// description: 'Connect your Google account', // descriptionKey: 'settings.sections.apify.description',
// content: <GoogleAccount />, // content: <GoogleAccount />,
// }, // },
{ {
key: 'dav-app-passwords', key: 'dav-app-passwords',
icon: CalendarDays, icon: CalendarDays,
title: 'Calendar & Contacts sync', titleKey: 'settings.sections.davSync.label',
description: 'Per-device passwords for CalDAV/CardDAV clients', descriptionKey: 'settings.sections.davSync.description',
content: <DavAppPasswords />, content: <DavAppPasswords />,
}, },
]; ];
@@ -60,6 +61,7 @@ const tabForSection = (section: string | undefined) =>
personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise'; personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise';
const IntegrationsSidebar = () => { const IntegrationsSidebar = () => {
const { t } = useTranslation();
const { section } = useParams<{ section?: string }>(); const { section } = useParams<{ section?: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const tab = tabForSection(section); const tab = tabForSection(section);
@@ -77,22 +79,28 @@ const IntegrationsSidebar = () => {
<div className="p-3 pb-2"> <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"> <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">
<Puzzle className="h-4 w-4" /> <Puzzle className="h-4 w-4" />
Integrations {t('settings.nav.integrations')}
</div> </div>
</div> </div>
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<Tabs value={tab} onValueChange={setTab}> <Tabs value={tab} onValueChange={setTab}>
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer"> <TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
Enterprise {t('settings.integrations.enterprise')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="personal" className="flex-1 cursor-pointer"> <TabsTrigger value="personal" className="flex-1 cursor-pointer">
Personal {t('settings.integrations.personal')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
</div> </div>
<SettingsSidebar basePath={BASE_PATH} icon={Puzzle} label="Integrations" sections={sections} hideHeader /> <SettingsSidebar
basePath={BASE_PATH}
icon={Puzzle}
labelKey="settings.nav.integrations"
sections={sections}
hideHeader
/>
</div> </div>
); );
}; };
@@ -14,12 +14,18 @@ import { VoicePreference } from './VoicePreference';
const BASE_PATH = '/settings/profile'; const BASE_PATH = '/settings/profile';
const sections: SettingsSection[] = [ const sections: SettingsSection[] = [
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> }, {
key: 'profile',
icon: User,
titleKey: 'settings.sections.profile.label',
descriptionKey: 'settings.sections.profile.description',
content: <UserData />,
},
{ {
key: 'change-password', key: 'change-password',
icon: Lock, icon: Lock,
title: 'Change Password', titleKey: 'settings.sections.changePassword.label',
description: 'Update your password', descriptionKey: 'settings.sections.changePassword.description',
content: <ChangePassword />, content: <ChangePassword />,
}, },
// Beside the password rather than under Integrations, where it was until 2026-08-21. A key is not an // Beside the password rather than under Integrations, where it was until 2026-08-21. A key is not an
@@ -29,29 +35,29 @@ const sections: SettingsSection[] = [
{ {
key: 'api-keys', key: 'api-keys',
icon: KeyRound, icon: KeyRound,
title: 'API keys', titleKey: 'settings.sections.apiKeys.label',
description: 'Keys that sign in as you, revocable one at a time', descriptionKey: 'settings.sections.apiKeys.description',
content: <ApiKeys />, content: <ApiKeys />,
}, },
{ {
key: 'languages', key: 'languages',
icon: Globe, icon: Globe,
title: 'Languages', titleKey: 'settings.sections.languages.label',
description: 'Spoken, default, and translation', descriptionKey: 'settings.sections.languages.description',
content: <Languages />, content: <Languages />,
}, },
{ {
key: 'voice', key: 'voice',
icon: Volume2, icon: Volume2,
title: 'Voice', titleKey: 'settings.sections.voice.label',
description: 'Text-to-speech voice preference', descriptionKey: 'settings.sections.voice.description',
content: <VoicePreference />, content: <VoicePreference />,
}, },
{ {
key: 'dock', key: 'dock',
icon: LayoutGrid, icon: LayoutGrid,
title: 'Dock', titleKey: 'settings.sections.dock.label',
description: 'Choose and reorder dock items', descriptionKey: 'settings.sections.dock.description',
content: <DockSettings />, content: <DockSettings />,
}, },
]; ];
@@ -59,7 +65,7 @@ const sections: SettingsSection[] = [
const { Sidebar, Content } = createSettingsPanelComponents({ const { Sidebar, Content } = createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: User, sidebarIcon: User,
sidebarLabel: 'Profile', sidebarLabelKey: 'settings.nav.profile',
sections, sections,
}); });
@@ -3,17 +3,24 @@ import { useState } from 'react';
import { NavLink, Navigate, useParams } from 'react-router'; import { NavLink, Navigate, useParams } from 'react-router';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { Input } from '@/components/ui/input'; 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 = { export type SettingsSection = {
key: string; key: string;
icon: LucideIcon; icon: LucideIcon;
title: string; titleKey: AppTranslationKey;
description: string; descriptionKey: AppTranslationKey;
content: ReactNode; content: ReactNode;
}; };
export type SettingsSectionGroup = { export type SettingsSectionGroup = {
label: string; labelKey: AppTranslationKey;
icon: LucideIcon; icon: LucideIcon;
sections: SettingsSection[]; sections: SettingsSection[];
}; };
@@ -21,7 +28,7 @@ export type SettingsSectionGroup = {
type SettingsSidebarProps = { type SettingsSidebarProps = {
basePath: string; basePath: string;
icon: LucideIcon; icon: LucideIcon;
label: string; labelKey: AppTranslationKey;
sections: SettingsSection[]; sections: SettingsSection[];
groups?: SettingsSectionGroup[]; groups?: SettingsSectionGroup[];
hideHeader?: boolean; hideHeader?: boolean;
@@ -32,7 +39,9 @@ type SettingsSidebarProps = {
* the browser knows it has been visited, and `isActive` comes from the router instead of from a global * 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. * that the content pane had to be told about separately.
*/ */
const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => ( const SectionLink = ({ section, to }: { section: SettingsSection; to: string }) => {
const { t } = useTranslation();
return (
<NavLink <NavLink
to={to} to={to}
className={({ isActive }) => className={({ isActive }) =>
@@ -49,27 +58,32 @@ const SectionLink = ({ section, to }: { section: SettingsSection; to: string })
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} 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="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{section.title}</div> <div className="text-sm font-medium truncate">{t(section.titleKey)}</div>
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{section.description}</div> <div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">
{t(section.descriptionKey)}
</div>
</div> </div>
</> </>
)} )}
</NavLink> </NavLink>
); );
};
export const SettingsSidebar = ({ export const SettingsSidebar = ({
basePath, basePath,
icon: Icon, icon: Icon,
label, labelKey,
sections, sections,
groups, groups,
hideHeader, hideHeader,
}: SettingsSidebarProps) => { }: SettingsSidebarProps) => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { t } = useTranslation();
const query = search.toLowerCase(); const query = search.toLowerCase();
// Matched against the RESOLVED words, so searching works in the language on screen.
const matchesSearch = (s: SettingsSection) => 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 ( return (
<div className="flex flex-col h-full overflow-y-auto"> <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="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"> <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" /> <Icon className="h-4 w-4" />
{label} {t(labelKey)}
</div> </div>
</div> </div>
)} )}
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<Input <Input
placeholder="Search..." placeholder={t('settings.searchPlaceholder')}
value={search} value={search}
onChange={(ev) => setSearch(ev.target.value)} onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs" className="h-8 text-xs"
@@ -95,11 +109,11 @@ export const SettingsSidebar = ({
const filtered = group.sections.filter(matchesSearch); const filtered = group.sections.filter(matchesSearch);
if (filtered.length === 0) return null; if (filtered.length === 0) return null;
return ( return (
<div key={group.label}> <div key={group.labelKey}>
<div className="flex items-center gap-2 px-3 pt-3 pb-1"> <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" /> <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"> <span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/30 dark:text-foreground/30">
{group.label} {t(group.labelKey)}
</span> </span>
</div> </div>
{filtered.map((s) => ( {filtered.map((s) => (
@@ -121,6 +135,7 @@ type SettingsContentProps = {
}; };
export const SettingsContent = ({ sections }: 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. // 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 // 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). // 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) { if (!section) {
return ( return (
<div className="h-full flex items-center justify-center"> <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> </div>
); );
} }
@@ -139,9 +154,9 @@ export const SettingsContent = ({ sections }: SettingsContentProps) => {
<div className="h-full overflow-y-auto p-6"> <div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2.5 mb-1"> <div className="flex items-center gap-2.5 mb-1">
<section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" /> <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> </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>{section.content}</div>
</div> </div>
); );
@@ -166,7 +181,7 @@ export const SettingsRoute = ({ basePath, sections, children }: SettingsRoutePro
type CreateSettingsPanelParams = { type CreateSettingsPanelParams = {
basePath: string; basePath: string;
sidebarIcon: LucideIcon; sidebarIcon: LucideIcon;
sidebarLabel: string; sidebarLabelKey: AppTranslationKey;
sections?: SettingsSection[]; sections?: SettingsSection[];
groups?: SettingsSectionGroup[]; groups?: SettingsSectionGroup[];
}; };
@@ -174,7 +189,7 @@ type CreateSettingsPanelParams = {
export const createSettingsPanelComponents = ({ export const createSettingsPanelComponents = ({
basePath, basePath,
sidebarIcon, sidebarIcon,
sidebarLabel, sidebarLabelKey,
sections = [], sections = [],
groups, groups,
}: CreateSettingsPanelParams) => { }: CreateSettingsPanelParams) => {
@@ -183,7 +198,7 @@ export const createSettingsPanelComponents = ({
<SettingsSidebar <SettingsSidebar
basePath={basePath} basePath={basePath}
icon={sidebarIcon} icon={sidebarIcon}
label={sidebarLabel} labelKey={sidebarLabelKey}
sections={allSections} sections={allSections}
groups={groups} groups={groups}
/> />
@@ -12,44 +12,56 @@ import { OCRSection } from './ServerSettings/OCRSection';
const groups: SettingsSectionGroup[] = [ const groups: SettingsSectionGroup[] = [
{ {
label: 'AI', labelKey: 'settings.groups.ai',
icon: Bot, icon: Bot,
sections: [ sections: [
{ {
key: 'tts', key: 'tts',
icon: Volume2, icon: Volume2,
title: 'Text to Speech', titleKey: 'settings.sections.tts.label',
description: 'TTS provider and voice', descriptionKey: 'settings.sections.tts.description',
content: <TTSSection />, content: <TTSSection />,
}, },
{ key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: <STTSection /> }, {
key: 'stt',
icon: Mic,
titleKey: 'settings.sections.stt.label',
descriptionKey: 'settings.sections.stt.description',
content: <STTSection />,
},
{ {
key: 'ocr', key: 'ocr',
icon: ScanText, icon: ScanText,
title: 'OCR', titleKey: 'settings.sections.ocr.label',
description: 'Vision model for text extraction', descriptionKey: 'settings.sections.ocr.description',
content: <OCRSection />, content: <OCRSection />,
}, },
], ],
}, },
{ {
label: 'Maintenance', labelKey: 'settings.groups.maintenance',
icon: Hammer, icon: Hammer,
sections: [ sections: [
{ {
key: 'maintenance', key: 'maintenance',
icon: Hammer, icon: Hammer,
title: 'Rebuild & restart', titleKey: 'settings.sections.maintenance.label',
description: 'Load changes without a terminal', descriptionKey: 'settings.sections.maintenance.description',
content: <MaintenanceSection />, content: <MaintenanceSection />,
}, },
], ],
}, },
{ {
label: 'Email', labelKey: 'settings.groups.email',
icon: Mail, icon: Mail,
sections: [ sections: [
{ key: 'smtp', icon: Mail, title: 'SMTP', description: 'Email delivery provider', content: <SMTPSection /> }, {
key: 'smtp',
icon: Mail,
titleKey: 'settings.sections.smtp.label',
descriptionKey: 'settings.sections.smtp.description',
content: <SMTPSection />,
},
], ],
}, },
]; ];
@@ -59,7 +71,7 @@ const BASE_PATH = '/settings/system';
const { Sidebar, Content, allSections } = createSettingsPanelComponents({ const { Sidebar, Content, allSections } = createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: Settings, sidebarIcon: Settings,
sidebarLabel: 'System', sidebarLabelKey: 'settings.nav.system',
groups, groups,
}); });
@@ -15,15 +15,15 @@ const sections: SettingsSection[] = [
{ {
key: 'users', key: 'users',
icon: Users, icon: Users,
title: 'Accounts', titleKey: 'settings.sections.accounts.label',
description: 'Who has access, and as what', descriptionKey: 'settings.sections.accounts.description',
content: <UsersSection />, content: <UsersSection />,
}, },
{ {
key: 'permissions', key: 'permissions',
icon: ShieldCheck, icon: ShieldCheck,
title: 'Permissions', titleKey: 'settings.sections.permissions.label',
description: 'What each role can reach', descriptionKey: 'settings.sections.permissions.description',
content: <PermissionsSection />, content: <PermissionsSection />,
}, },
]; ];
@@ -33,7 +33,7 @@ const BASE_PATH = '/settings/user-management';
const { Sidebar, Content } = createSettingsPanelComponents({ const { Sidebar, Content } = createSettingsPanelComponents({
basePath: BASE_PATH, basePath: BASE_PATH,
sidebarIcon: UserCog, sidebarIcon: UserCog,
sidebarLabel: 'Users', sidebarLabelKey: 'settings.nav.users',
sections, sections,
}); });
+75 -20
View File
@@ -50,74 +50,129 @@
"sections": { "sections": {
"profile": { "profile": {
"label": "Profile", "label": "Profile",
"hint": "avatar, name, email" "hint": "avatar, name, email",
"description": "Avatar, name, and email"
}, },
"changePassword": { "changePassword": {
"label": "Change password" "label": "Change password",
"description": "Update your password"
}, },
"apiKeys": { "apiKeys": {
"label": "API keys", "label": "API keys",
"hint": "keys that sign in as you" "hint": "keys that sign in as you",
"description": "Keys that sign in as you, revocable one at a time"
}, },
"languages": { "languages": {
"label": "Languages", "label": "Languages",
"hint": "spoken and translation" "hint": "spoken and translation",
"description": "Spoken, default, and translation"
}, },
"voice": { "voice": {
"label": "Voice", "label": "Voice",
"hint": "text-to-speech" "hint": "text-to-speech",
"description": "Text-to-speech voice preference"
}, },
"dock": { "dock": {
"label": "Dock", "label": "Dock",
"hint": "choose and reorder" "hint": "choose and reorder",
"description": "Choose and reorder dock items"
}, },
"myModels": { "myModels": {
"label": "My Models" "label": "My Models",
"description": "Show or hide models for yourself"
}, },
"channelModels": { "channelModels": {
"label": "Channel Models" "label": "Channel Models",
"description": "Models reachable by non-owner accounts"
}, },
"defaultModels": { "defaultModels": {
"label": "Default Models" "label": "Default Models",
"description": "Default models for chat, projects, and tasks"
}, },
"providers": { "providers": {
"label": "Providers", "label": "Providers",
"hint": "harnesses" "hint": "harnesses",
"description": "Remote and local AI providers"
}, },
"tts": { "tts": {
"label": "Text to Speech" "label": "Text to Speech",
"description": "TTS provider and voice"
}, },
"stt": { "stt": {
"label": "Speech to Text" "label": "Speech to Text",
"description": "Whisper server URL"
}, },
"ocr": { "ocr": {
"label": "OCR" "label": "OCR",
"description": "Vision model for text extraction"
}, },
"maintenance": { "maintenance": {
"label": "Rebuild & restart", "label": "Rebuild & restart",
"hint": "maintenance" "hint": "maintenance",
"description": "Load changes without a terminal"
}, },
"smtp": { "smtp": {
"label": "SMTP", "label": "SMTP",
"hint": "outgoing mail" "hint": "outgoing mail",
"description": "Email delivery provider"
}, },
"googleOauth": { "googleOauth": {
"label": "Google OAuth" "label": "Google OAuth",
"description": "Client ID and secret for Google APIs"
}, },
"apify": { "apify": {
"label": "Apify" "label": "Apify",
"description": "API token for web scraping actors"
}, },
"davSync": { "davSync": {
"label": "Calendar & Contacts sync", "label": "Calendar & Contacts sync",
"hint": "app passwords" "hint": "app passwords",
"description": "Per-device passwords for CalDAV/CardDAV clients"
}, },
"accounts": { "accounts": {
"label": "Accounts", "label": "Accounts",
"hint": "users" "hint": "users",
"description": "Who has access, and as what"
}, },
"permissions": { "permissions": {
"label": "Permissions" "label": "Permissions",
"description": "What each role can reach"
} }
},
"searchPlaceholder": "Search settings…",
"selectSection": "Select a section",
"groups": {
"models": "Models",
"defaults": "Defaults",
"providers": "Providers",
"ai": "AI",
"maintenance": "Maintenance",
"email": "Email"
},
"nav": {
"profile": "Profile",
"ai": "AI",
"system": "System",
"integrations": "Integrations",
"users": "Users"
},
"ai": {
"noModels": "No models available. Configure API keys in Providers.",
"dragToShow": "Drag models here to show",
"allVisible": "All models visible",
"dragToEnable": "Drag models here to enable",
"allEnabled": "All models enabled",
"visible": "Visible",
"hidden": "Hidden",
"enabled": "Enabled",
"disabled": "Disabled",
"refresh": "Refresh models from providers",
"refreshed": "Models refreshed",
"refreshFailed": "Failed to refresh models"
},
"integrations": {
"enterprise": "Enterprise",
"personal": "Personal"
} }
}, },
"header": { "header": {
+75 -20
View File
@@ -50,74 +50,129 @@
"sections": { "sections": {
"profile": { "profile": {
"label": "Perfil", "label": "Perfil",
"hint": "avatar, nome, email" "hint": "avatar, nome, email",
"description": "Avatar, nome e email"
}, },
"changePassword": { "changePassword": {
"label": "Alterar palavra-passe" "label": "Alterar palavra-passe",
"description": "Atualize a sua palavra-passe"
}, },
"apiKeys": { "apiKeys": {
"label": "Chaves de API", "label": "Chaves de API",
"hint": "chaves que iniciam sessão como você" "hint": "chaves que iniciam sessão como você",
"description": "Chaves que iniciam sessão como você, revogáveis uma a uma"
}, },
"languages": { "languages": {
"label": "Idiomas", "label": "Idiomas",
"hint": "falados e tradução" "hint": "falados e tradução",
"description": "Falados, predefinido e tradução"
}, },
"voice": { "voice": {
"label": "Voz", "label": "Voz",
"hint": "conversão de texto em fala" "hint": "conversão de texto em fala",
"description": "Preferência de voz para conversão de texto em fala"
}, },
"dock": { "dock": {
"label": "Dock", "label": "Dock",
"hint": "escolher e reordenar" "hint": "escolher e reordenar",
"description": "Escolher e reordenar os itens do dock"
}, },
"myModels": { "myModels": {
"label": "Os meus modelos" "label": "Os meus modelos",
"description": "Mostrar ou ocultar modelos para si"
}, },
"channelModels": { "channelModels": {
"label": "Modelos do canal" "label": "Modelos do canal",
"description": "Modelos acessíveis a contas que não são do proprietário"
}, },
"defaultModels": { "defaultModels": {
"label": "Modelos predefinidos" "label": "Modelos predefinidos",
"description": "Modelos predefinidos para chat, projetos e tarefas"
}, },
"providers": { "providers": {
"label": "Fornecedores", "label": "Fornecedores",
"hint": "harnesses" "hint": "harnesses",
"description": "Fornecedores de IA remotos e locais"
}, },
"tts": { "tts": {
"label": "Texto para fala" "label": "Texto para fala",
"description": "Fornecedor de TTS e voz"
}, },
"stt": { "stt": {
"label": "Fala para texto" "label": "Fala para texto",
"description": "URL do servidor Whisper"
}, },
"ocr": { "ocr": {
"label": "OCR" "label": "OCR",
"description": "Modelo de visão para extração de texto"
}, },
"maintenance": { "maintenance": {
"label": "Reconstruir e reiniciar", "label": "Reconstruir e reiniciar",
"hint": "manutenção" "hint": "manutenção",
"description": "Carregar alterações sem um terminal"
}, },
"smtp": { "smtp": {
"label": "SMTP", "label": "SMTP",
"hint": "correio a enviar" "hint": "correio a enviar",
"description": "Fornecedor de entrega de email"
}, },
"googleOauth": { "googleOauth": {
"label": "Google OAuth" "label": "Google OAuth",
"description": "Client ID e secret para as APIs Google"
}, },
"apify": { "apify": {
"label": "Apify" "label": "Apify",
"description": "Token de API para atores de web scraping"
}, },
"davSync": { "davSync": {
"label": "Sincronização de Calendário e Contactos", "label": "Sincronização de Calendário e Contactos",
"hint": "palavras-passe de aplicação" "hint": "palavras-passe de aplicação",
"description": "Palavras-passe por dispositivo para clientes CalDAV/CardDAV"
}, },
"accounts": { "accounts": {
"label": "Contas", "label": "Contas",
"hint": "utilizadores" "hint": "utilizadores",
"description": "Quem tem acesso, e como"
}, },
"permissions": { "permissions": {
"label": "Permissões" "label": "Permissões",
"description": "O que cada função pode alcançar"
} }
},
"searchPlaceholder": "Pesquisar definições…",
"selectSection": "Selecione uma secção",
"groups": {
"models": "Modelos",
"defaults": "Predefinições",
"providers": "Fornecedores",
"ai": "IA",
"maintenance": "Manutenção",
"email": "Email"
},
"nav": {
"profile": "Perfil",
"ai": "IA",
"system": "Sistema",
"integrations": "Integrações",
"users": "Utilizadores"
},
"ai": {
"noModels": "Nenhum modelo disponível. Configure as chaves de API em Fornecedores.",
"dragToShow": "Arraste modelos para aqui para os mostrar",
"allVisible": "Todos os modelos visíveis",
"dragToEnable": "Arraste modelos para aqui para os ativar",
"allEnabled": "Todos os modelos ativados",
"visible": "Visíveis",
"hidden": "Ocultos",
"enabled": "Ativados",
"disabled": "Desativados",
"refresh": "Atualizar modelos a partir dos fornecedores",
"refreshed": "Modelos atualizados",
"refreshFailed": "Falha ao atualizar modelos"
},
"integrations": {
"enterprise": "Empresa",
"personal": "Pessoal"
} }
}, },
"header": { "header": {