localization

This commit is contained in:
2026-02-17 21:36:12 +00:00
parent 3733e99ba8
commit 680f623b41
17 changed files with 341 additions and 71 deletions
@@ -3,11 +3,13 @@ import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, LogOut, Server, Package, Bot } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } = Dropdown;
export function UserMenu() {
const { user, isLoading } = useAuth();
const { t } = useTranslation();
if (isLoading) return null;
return (
@@ -26,34 +28,34 @@ export function UserMenu() {
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/profile">
<User className="mr-2 h-4 w-4" />
Profile
{t('header.userMenu.profile')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/ai">
<Bot className="mr-2 h-4 w-4" />
AI Settings
{t('header.userMenu.aiSettings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/server">
<Server className="mr-2 h-4 w-4" />
Server Settings
{t('header.userMenu.serverSettings')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/resources">
<Package className="mr-2 h-4 w-4" />
Resources
{t('header.userMenu.resources')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/signout">
<LogOut className="mr-2 h-4 w-4" />
Sign Out
{t('header.userMenu.signOut')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
}
@@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type PasswordFormState = {
password?: string;
@@ -19,6 +20,7 @@ const validate = (state: Partial<PasswordFormState>) => {
export const ChangePassword = () => {
const { changePassword } = useAuth();
const { t } = useTranslation();
const [isChanging, setIsChanging] = useState(false);
const form = useForm<PasswordFormState>({}, validate);
@@ -33,11 +35,11 @@ export const ChangePassword = () => {
newPassword: form.state.newPassword!,
confirmPassword: form.state.confirmPassword!,
});
toast.success('Password changed');
toast.success(t('settings.profile.changePassword.passwordChanged'));
form.update({ password: '', newPassword: '', confirmPassword: '' });
} catch (ex) {
const error = ex as { message?: string };
toast.error(error.message || 'Failed to change password');
toast.error(error.message || t('settings.profile.changePassword.passwordChangeFailed'));
form.update({ password: '', newPassword: '', confirmPassword: '' });
} finally {
setIsChanging(false);
@@ -48,34 +50,34 @@ export const ChangePassword = () => {
<div>
<form ref={form.formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Current Password</span>
<span className="text-duck-dark/70">{t('settings.profile.changePassword.currentPasswordLabel')}</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="password"
placeholder="Current password"
placeholder={t('settings.profile.changePassword.currentPasswordPlaceholder')}
autoComplete="current-password"
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">New Password</span>
<span className="text-duck-dark/70">{t('settings.profile.changePassword.newPasswordLabel')}</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="newPassword"
placeholder="New password"
placeholder={t('settings.profile.changePassword.newPasswordPlaceholder')}
autoComplete="new-password"
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70">Confirm New Password</span>
<span className="text-duck-dark/70">{t('settings.profile.changePassword.confirmPasswordLabel')}</span>
<Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
name="confirmPassword"
placeholder="Confirm new password"
placeholder={t('settings.profile.changePassword.confirmPasswordPlaceholder')}
autoComplete="new-password"
/>
</Label>
@@ -85,7 +87,9 @@ export const ChangePassword = () => {
disabled={!form.isValid || isChanging}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isChanging ? 'Changing...' : 'Change Password'}
{isChanging
? t('settings.profile.changePassword.changingButton')
: t('settings.profile.changePassword.changeButton')}
</Button>
</form>
</div>
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { X } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { useSettings } from '@/state/useSettings';
import { useTranslation } from '@/lib/i18n';
const LANGUAGES = [
{ code: 'en', label: 'English' },
@@ -38,6 +39,7 @@ const getLabel = (code: string) => LANGUAGES.find((l) => l.code === code)?.label
export const Languages = () => {
const { settings, saveSettings } = useSettings();
const { t } = useTranslation();
const { spoken, default: defaultLang, translateTo } = settings.languages;
const [addingLang, setAddingLang] = useState('');
@@ -65,7 +67,7 @@ export const Languages = () => {
<div className="grid gap-5">
{/* Spoken languages */}
<div className="grid gap-2">
<span className="text-sm font-medium text-duck-dark/70">Languages you speak</span>
<span className="text-sm font-medium text-duck-dark/70">{t('settings.profile.languages.spokenLabel')}</span>
<div className="flex flex-wrap gap-2">
{spoken.map((code) => (
<span
@@ -91,7 +93,7 @@ export const Languages = () => {
onChange={(ev) => addSpoken(ev.target.value)}
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-white/60 text-duck-dark cursor-pointer"
>
<option value="">Add a language...</option>
<option value="">{t('settings.profile.languages.addLanguage')}</option>
{availableToAdd.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
@@ -104,7 +106,7 @@ export const Languages = () => {
{/* Default language */}
<Label className="grid gap-2">
<span className="text-duck-dark/70">Default language</span>
<span className="text-duck-dark/70">{t('settings.profile.languages.defaultLabel')}</span>
<select
value={defaultLang}
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
@@ -116,12 +118,12 @@ export const Languages = () => {
</option>
))}
</select>
<span className="text-xs text-duck-dark/40">Used for future UI localization.</span>
<span className="text-xs text-duck-dark/40">{t('settings.profile.languages.defaultHint')}</span>
</Label>
{/* Translate from */}
{/* Translate to */}
<Label className="grid gap-2">
<span className="text-duck-dark/70">Translate to</span>
<span className="text-duck-dark/70">{t('settings.profile.languages.translateToLabel')}</span>
<select
value={translateTo}
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
@@ -133,7 +135,7 @@ export const Languages = () => {
</option>
))}
</select>
<span className="text-xs text-duck-dark/40">Target language when translating content you don't speak.</span>
<span className="text-xs text-duck-dark/40">{t('settings.profile.languages.translateToHint')}</span>
</Label>
</div>
);
@@ -1,51 +1,56 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { Search, User, Lock, Globe, ListChecks, Palette } from 'lucide-react';
import { Search, User, Lock, Globe, ListChecks } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Card } from '@/components/Card';
import { useTranslation } from '@/lib/i18n';
import { UserData } from './UserData';
import { ChangePassword } from './ChangePassword';
import { Languages } from './Languages';
import { TaskDefaults } from './TaskDefaults';
const sections = [
{
key: 'profile',
icon: User,
title: 'Profile',
description: 'Update your name and avatar.',
content: <UserData />,
},
{
key: 'change-password',
icon: Lock,
title: 'Change Password',
description: 'Update your account password.',
content: <ChangePassword />,
},
{
key: 'tasks',
icon: ListChecks,
title: 'Tasks',
description: 'Default model for file browser tasks.',
content: <TaskDefaults />,
},
{
key: 'languages',
icon: Globe,
title: 'Languages',
description: 'Set your spoken languages and translation preferences.',
content: <Languages />,
},
];
const allKeys = sections.map((s) => s.key);
export const ProfileSettings = () => {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<string[]>([]);
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
const sections = useMemo(
() => [
{
key: 'profile',
icon: User,
title: t('settings.profile.sections.profileTitle'),
description: t('settings.profile.sections.profileDescription'),
content: <UserData />,
},
{
key: 'change-password',
icon: Lock,
title: t('settings.profile.sections.changePasswordTitle'),
description: t('settings.profile.sections.changePasswordDescription'),
content: <ChangePassword />,
},
{
key: 'tasks',
icon: ListChecks,
title: t('settings.profile.sections.tasksTitle'),
description: t('settings.profile.sections.tasksDescription'),
content: <TaskDefaults />,
},
{
key: 'languages',
icon: Globe,
title: t('settings.profile.sections.languagesTitle'),
description: t('settings.profile.sections.languagesDescription'),
content: <Languages />,
},
],
[t],
);
const allKeys = useMemo(() => sections.map((s) => s.key), [sections]);
const matchingKeys = useMemo(() => {
if (!search) return allKeys;
const query = search.toLowerCase();
@@ -55,7 +60,7 @@ export const ProfileSettings = () => {
return (el?.textContent?.toLowerCase() ?? '').includes(query);
})
.map((s) => s.key);
}, [search]);
}, [search, allKeys, sections]);
useEffect(() => {
if (search) setExpanded(matchingKeys);
@@ -67,7 +72,7 @@ export const ProfileSettings = () => {
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
<Input
placeholder="Search settings..."
placeholder={t('settings.profile.searchPlaceholder')}
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="pl-9"