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:
@@ -11,6 +11,7 @@ import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import type { UserSettings } from 'state/useSettings';
|
||||
import { useModels, useVisibleModels, modelKey, getProviderDisplayName, type ModelOption } from 'state/useModels';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useAccessPolicy } from 'state/useAccessPolicy';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { AIHarnessesSection } from './ServerSettings/AIHarnessesSection';
|
||||
@@ -104,6 +105,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
// --- My Models Section (per-user hidden models) ---
|
||||
|
||||
function MyModelsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const visibleModels = useModels();
|
||||
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
|
||||
@@ -174,11 +176,7 @@ function MyModelsSection() {
|
||||
}, []);
|
||||
|
||||
if (providerGroups.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
No models available. Configure API keys in Providers.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -199,9 +197,9 @@ function MyModelsSection() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DropZone label="Visible" onDrop={showModel}>
|
||||
<DropZone label={t('settings.ai.visible')} onDrop={showModel}>
|
||||
{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) => (
|
||||
<ModelPill
|
||||
@@ -215,9 +213,9 @@ function MyModelsSection() {
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<DropZone label="Hidden" onDrop={hideModel}>
|
||||
<DropZone label={t('settings.ai.hidden')} onDrop={hideModel}>
|
||||
{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) => (
|
||||
<ModelPill
|
||||
@@ -237,6 +235,7 @@ function MyModelsSection() {
|
||||
// --- Member Models Section (system-wide policy, admin only) ---
|
||||
|
||||
function MemberModelsSection() {
|
||||
const { t } = useTranslation();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { policy, savePolicy } = useAccessPolicy();
|
||||
@@ -249,9 +248,9 @@ function MemberModelsSection() {
|
||||
try {
|
||||
await client.post('/server-settings/chat-providers/local-providers/refresh');
|
||||
queryClient.invalidateQueries({ queryKey: ['CHAT_MODELS'] });
|
||||
toast.success('Models refreshed');
|
||||
toast.success(t('settings.ai.refreshed'));
|
||||
} catch {
|
||||
toast.error('Failed to refresh models');
|
||||
toast.error(t('settings.ai.refreshFailed'));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -325,11 +324,7 @@ function MemberModelsSection() {
|
||||
}, []);
|
||||
|
||||
if (providerGroups.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
No models available. Configure API keys in Providers.
|
||||
</p>
|
||||
);
|
||||
return <p className="text-sm text-duck-dark/40 dark:text-foreground/40">{t('settings.ai.noModels')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -353,15 +348,17 @@ function MemberModelsSection() {
|
||||
onClick={refreshModels}
|
||||
disabled={refreshing}
|
||||
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' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DropZone label="Enabled" onDrop={enableModel}>
|
||||
<DropZone label={t('settings.ai.enabled')} onDrop={enableModel}>
|
||||
{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) => (
|
||||
<ModelPill
|
||||
@@ -375,9 +372,9 @@ function MemberModelsSection() {
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<DropZone label="Disabled" onDrop={disableModel}>
|
||||
<DropZone label={t('settings.ai.disabled')} onDrop={disableModel}>
|
||||
{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) => (
|
||||
<ModelPill
|
||||
@@ -410,49 +407,49 @@ function MemberModelsSection() {
|
||||
function useAISettingsGroups(): SettingsSectionGroup[] {
|
||||
return useMemo(() => {
|
||||
const modelsGroup: SettingsSectionGroup = {
|
||||
label: 'Models',
|
||||
labelKey: 'settings.groups.models',
|
||||
icon: Eye,
|
||||
sections: [
|
||||
{
|
||||
key: 'my-models',
|
||||
icon: Eye,
|
||||
title: 'My Models',
|
||||
description: 'Show or hide models for yourself',
|
||||
titleKey: 'settings.sections.myModels.label',
|
||||
descriptionKey: 'settings.sections.myModels.description',
|
||||
content: <MyModelsSection />,
|
||||
},
|
||||
{
|
||||
key: 'member-models',
|
||||
icon: Eye,
|
||||
title: 'Channel Models',
|
||||
description: 'Models reachable by non-owner accounts',
|
||||
titleKey: 'settings.sections.channelModels.label',
|
||||
descriptionKey: 'settings.sections.channelModels.description',
|
||||
content: <MemberModelsSection />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defaultsGroup: SettingsSectionGroup = {
|
||||
label: 'Defaults',
|
||||
labelKey: 'settings.groups.defaults',
|
||||
icon: Sparkles,
|
||||
sections: [
|
||||
{
|
||||
key: 'default-models',
|
||||
icon: Sparkles,
|
||||
title: 'Default Models',
|
||||
description: 'Default models for chat, projects, and tasks',
|
||||
titleKey: 'settings.sections.defaultModels.label',
|
||||
descriptionKey: 'settings.sections.defaultModels.description',
|
||||
content: <AIModels />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const providersGroup: SettingsSectionGroup = {
|
||||
label: 'Providers',
|
||||
labelKey: 'settings.groups.providers',
|
||||
icon: Terminal,
|
||||
sections: [
|
||||
{
|
||||
key: 'ai-harnesses',
|
||||
icon: Terminal,
|
||||
title: 'Providers',
|
||||
description: 'Remote and local AI providers',
|
||||
titleKey: 'settings.sections.providers.label',
|
||||
descriptionKey: 'settings.sections.providers.description',
|
||||
content: <AIHarnessesSection />,
|
||||
},
|
||||
],
|
||||
@@ -482,7 +479,7 @@ export const AISettings = () => {
|
||||
createSettingsPanelComponents({
|
||||
basePath: BASE_PATH,
|
||||
sidebarIcon: Bot,
|
||||
sidebarLabel: 'AI',
|
||||
sidebarLabelKey: 'settings.nav.ai',
|
||||
groups,
|
||||
}),
|
||||
[groups],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
||||
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
|
||||
import { ApifyConfig } from './ApifyConfig';
|
||||
import { DavAppPasswords } from './DavAppPasswords';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const BASE_PATH = '/settings/integrations';
|
||||
|
||||
@@ -17,15 +18,15 @@ const enterpriseSections: SettingsSection[] = [
|
||||
{
|
||||
key: 'google-oauth',
|
||||
icon: KeyRound,
|
||||
title: 'Google OAuth',
|
||||
description: 'Client ID and secret for Google APIs',
|
||||
titleKey: 'settings.sections.googleOauth.label',
|
||||
descriptionKey: 'settings.sections.googleOauth.description',
|
||||
content: <GoogleOAuthConfig />,
|
||||
},
|
||||
{
|
||||
key: 'apify',
|
||||
icon: Wrench,
|
||||
title: 'Apify',
|
||||
description: 'API token for web scraping actors',
|
||||
titleKey: 'settings.sections.apify.label',
|
||||
descriptionKey: 'settings.sections.apify.description',
|
||||
content: <ApifyConfig />,
|
||||
},
|
||||
];
|
||||
@@ -39,14 +40,14 @@ const personalSections: SettingsSection[] = [
|
||||
// key: 'google-account',
|
||||
// icon: UserCircle,
|
||||
// title: 'Google Account',
|
||||
// description: 'Connect your Google account',
|
||||
// descriptionKey: 'settings.sections.apify.description',
|
||||
// content: <GoogleAccount />,
|
||||
// },
|
||||
{
|
||||
key: 'dav-app-passwords',
|
||||
icon: CalendarDays,
|
||||
title: 'Calendar & Contacts sync',
|
||||
description: 'Per-device passwords for CalDAV/CardDAV clients',
|
||||
titleKey: 'settings.sections.davSync.label',
|
||||
descriptionKey: 'settings.sections.davSync.description',
|
||||
content: <DavAppPasswords />,
|
||||
},
|
||||
];
|
||||
@@ -60,6 +61,7 @@ const tabForSection = (section: string | undefined) =>
|
||||
personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise';
|
||||
|
||||
const IntegrationsSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const { section } = useParams<{ section?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const tab = tabForSection(section);
|
||||
@@ -77,22 +79,28 @@ const IntegrationsSidebar = () => {
|
||||
<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">
|
||||
<Puzzle className="h-4 w-4" />
|
||||
Integrations
|
||||
{t('settings.nav.integrations')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 pb-2">
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
|
||||
Enterprise
|
||||
{t('settings.integrations.enterprise')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="personal" className="flex-1 cursor-pointer">
|
||||
Personal
|
||||
{t('settings.integrations.personal')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,12 +14,18 @@ import { VoicePreference } from './VoicePreference';
|
||||
const BASE_PATH = '/settings/profile';
|
||||
|
||||
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',
|
||||
icon: Lock,
|
||||
title: 'Change Password',
|
||||
description: 'Update your password',
|
||||
titleKey: 'settings.sections.changePassword.label',
|
||||
descriptionKey: 'settings.sections.changePassword.description',
|
||||
content: <ChangePassword />,
|
||||
},
|
||||
// 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',
|
||||
icon: KeyRound,
|
||||
title: 'API keys',
|
||||
description: 'Keys that sign in as you, revocable one at a time',
|
||||
titleKey: 'settings.sections.apiKeys.label',
|
||||
descriptionKey: 'settings.sections.apiKeys.description',
|
||||
content: <ApiKeys />,
|
||||
},
|
||||
{
|
||||
key: 'languages',
|
||||
icon: Globe,
|
||||
title: 'Languages',
|
||||
description: 'Spoken, default, and translation',
|
||||
titleKey: 'settings.sections.languages.label',
|
||||
descriptionKey: 'settings.sections.languages.description',
|
||||
content: <Languages />,
|
||||
},
|
||||
{
|
||||
key: 'voice',
|
||||
icon: Volume2,
|
||||
title: 'Voice',
|
||||
description: 'Text-to-speech voice preference',
|
||||
titleKey: 'settings.sections.voice.label',
|
||||
descriptionKey: 'settings.sections.voice.description',
|
||||
content: <VoicePreference />,
|
||||
},
|
||||
{
|
||||
key: 'dock',
|
||||
icon: LayoutGrid,
|
||||
title: 'Dock',
|
||||
description: 'Choose and reorder dock items',
|
||||
titleKey: 'settings.sections.dock.label',
|
||||
descriptionKey: 'settings.sections.dock.description',
|
||||
content: <DockSettings />,
|
||||
},
|
||||
];
|
||||
@@ -59,7 +65,7 @@ const sections: SettingsSection[] = [
|
||||
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||
basePath: BASE_PATH,
|
||||
sidebarIcon: User,
|
||||
sidebarLabel: 'Profile',
|
||||
sidebarLabelKey: 'settings.nav.profile',
|
||||
sections,
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -12,44 +12,56 @@ import { OCRSection } from './ServerSettings/OCRSection';
|
||||
|
||||
const groups: SettingsSectionGroup[] = [
|
||||
{
|
||||
label: 'AI',
|
||||
labelKey: 'settings.groups.ai',
|
||||
icon: Bot,
|
||||
sections: [
|
||||
{
|
||||
key: 'tts',
|
||||
icon: Volume2,
|
||||
title: 'Text to Speech',
|
||||
description: 'TTS provider and voice',
|
||||
titleKey: 'settings.sections.tts.label',
|
||||
descriptionKey: 'settings.sections.tts.description',
|
||||
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',
|
||||
icon: ScanText,
|
||||
title: 'OCR',
|
||||
description: 'Vision model for text extraction',
|
||||
titleKey: 'settings.sections.ocr.label',
|
||||
descriptionKey: 'settings.sections.ocr.description',
|
||||
content: <OCRSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Maintenance',
|
||||
labelKey: 'settings.groups.maintenance',
|
||||
icon: Hammer,
|
||||
sections: [
|
||||
{
|
||||
key: 'maintenance',
|
||||
icon: Hammer,
|
||||
title: 'Rebuild & restart',
|
||||
description: 'Load changes without a terminal',
|
||||
titleKey: 'settings.sections.maintenance.label',
|
||||
descriptionKey: 'settings.sections.maintenance.description',
|
||||
content: <MaintenanceSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
labelKey: 'settings.groups.email',
|
||||
icon: Mail,
|
||||
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({
|
||||
basePath: BASE_PATH,
|
||||
sidebarIcon: Settings,
|
||||
sidebarLabel: 'System',
|
||||
sidebarLabelKey: 'settings.nav.system',
|
||||
groups,
|
||||
});
|
||||
|
||||
|
||||
@@ -15,15 +15,15 @@ const sections: SettingsSection[] = [
|
||||
{
|
||||
key: 'users',
|
||||
icon: Users,
|
||||
title: 'Accounts',
|
||||
description: 'Who has access, and as what',
|
||||
titleKey: 'settings.sections.accounts.label',
|
||||
descriptionKey: 'settings.sections.accounts.description',
|
||||
content: <UsersSection />,
|
||||
},
|
||||
{
|
||||
key: 'permissions',
|
||||
icon: ShieldCheck,
|
||||
title: 'Permissions',
|
||||
description: 'What each role can reach',
|
||||
titleKey: 'settings.sections.permissions.label',
|
||||
descriptionKey: 'settings.sections.permissions.description',
|
||||
content: <PermissionsSection />,
|
||||
},
|
||||
];
|
||||
@@ -33,7 +33,7 @@ const BASE_PATH = '/settings/user-management';
|
||||
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||
basePath: BASE_PATH,
|
||||
sidebarIcon: UserCog,
|
||||
sidebarLabel: 'Users',
|
||||
sidebarLabelKey: 'settings.nav.users',
|
||||
sections,
|
||||
});
|
||||
|
||||
|
||||
@@ -50,74 +50,129 @@
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Profile",
|
||||
"hint": "avatar, name, email"
|
||||
"hint": "avatar, name, email",
|
||||
"description": "Avatar, name, and email"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Change password"
|
||||
"label": "Change password",
|
||||
"description": "Update your password"
|
||||
},
|
||||
"apiKeys": {
|
||||
"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": {
|
||||
"label": "Languages",
|
||||
"hint": "spoken and translation"
|
||||
"hint": "spoken and translation",
|
||||
"description": "Spoken, default, and translation"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voice",
|
||||
"hint": "text-to-speech"
|
||||
"hint": "text-to-speech",
|
||||
"description": "Text-to-speech voice preference"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Dock",
|
||||
"hint": "choose and reorder"
|
||||
"hint": "choose and reorder",
|
||||
"description": "Choose and reorder dock items"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "My Models"
|
||||
"label": "My Models",
|
||||
"description": "Show or hide models for yourself"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Channel Models"
|
||||
"label": "Channel Models",
|
||||
"description": "Models reachable by non-owner accounts"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Default Models"
|
||||
"label": "Default Models",
|
||||
"description": "Default models for chat, projects, and tasks"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Providers",
|
||||
"hint": "harnesses"
|
||||
"hint": "harnesses",
|
||||
"description": "Remote and local AI providers"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Text to Speech"
|
||||
"label": "Text to Speech",
|
||||
"description": "TTS provider and voice"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Speech to Text"
|
||||
"label": "Speech to Text",
|
||||
"description": "Whisper server URL"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR"
|
||||
"label": "OCR",
|
||||
"description": "Vision model for text extraction"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Rebuild & restart",
|
||||
"hint": "maintenance"
|
||||
"hint": "maintenance",
|
||||
"description": "Load changes without a terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "outgoing mail"
|
||||
"hint": "outgoing mail",
|
||||
"description": "Email delivery provider"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth"
|
||||
"label": "Google OAuth",
|
||||
"description": "Client ID and secret for Google APIs"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify"
|
||||
"label": "Apify",
|
||||
"description": "API token for web scraping actors"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Calendar & Contacts sync",
|
||||
"hint": "app passwords"
|
||||
"hint": "app passwords",
|
||||
"description": "Per-device passwords for CalDAV/CardDAV clients"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Accounts",
|
||||
"hint": "users"
|
||||
"hint": "users",
|
||||
"description": "Who has access, and as what"
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -50,74 +50,129 @@
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Perfil",
|
||||
"hint": "avatar, nome, email"
|
||||
"hint": "avatar, nome, email",
|
||||
"description": "Avatar, nome e email"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Alterar palavra-passe"
|
||||
"label": "Alterar palavra-passe",
|
||||
"description": "Atualize a sua palavra-passe"
|
||||
},
|
||||
"apiKeys": {
|
||||
"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": {
|
||||
"label": "Idiomas",
|
||||
"hint": "falados e tradução"
|
||||
"hint": "falados e tradução",
|
||||
"description": "Falados, predefinido e tradução"
|
||||
},
|
||||
"voice": {
|
||||
"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": {
|
||||
"label": "Dock",
|
||||
"hint": "escolher e reordenar"
|
||||
"hint": "escolher e reordenar",
|
||||
"description": "Escolher e reordenar os itens do dock"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Os meus modelos"
|
||||
"label": "Os meus modelos",
|
||||
"description": "Mostrar ou ocultar modelos para si"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Modelos do canal"
|
||||
"label": "Modelos do canal",
|
||||
"description": "Modelos acessíveis a contas que não são do proprietário"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Modelos predefinidos"
|
||||
"label": "Modelos predefinidos",
|
||||
"description": "Modelos predefinidos para chat, projetos e tarefas"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Fornecedores",
|
||||
"hint": "harnesses"
|
||||
"hint": "harnesses",
|
||||
"description": "Fornecedores de IA remotos e locais"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Texto para fala"
|
||||
"label": "Texto para fala",
|
||||
"description": "Fornecedor de TTS e voz"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Fala para texto"
|
||||
"label": "Fala para texto",
|
||||
"description": "URL do servidor Whisper"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR"
|
||||
"label": "OCR",
|
||||
"description": "Modelo de visão para extração de texto"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Reconstruir e reiniciar",
|
||||
"hint": "manutenção"
|
||||
"hint": "manutenção",
|
||||
"description": "Carregar alterações sem um terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "correio a enviar"
|
||||
"hint": "correio a enviar",
|
||||
"description": "Fornecedor de entrega de email"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth"
|
||||
"label": "Google OAuth",
|
||||
"description": "Client ID e secret para as APIs Google"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify"
|
||||
"label": "Apify",
|
||||
"description": "Token de API para atores de web scraping"
|
||||
},
|
||||
"davSync": {
|
||||
"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": {
|
||||
"label": "Contas",
|
||||
"hint": "utilizadores"
|
||||
"hint": "utilizadores",
|
||||
"description": "Quem tem acesso, e como"
|
||||
},
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user