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
+7 -7
View File
@@ -65,6 +65,7 @@
"hono": "^4.11.1", "hono": "^4.11.1",
"hooks": "workspace:*", "hooks": "workspace:*",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"i18n": "workspace:*",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"injector": "workspace:*", "injector": "workspace:*",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
@@ -78,7 +79,6 @@
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",
"officerdb": "workspace:*", "officerdb": "workspace:*",
"pg": "^8.16.3", "pg": "^8.16.3",
"plugins": "workspace:*",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"react": "^19", "react": "^19",
"react-countup": "^6.5.3", "react-countup": "^6.5.3",
@@ -228,14 +228,14 @@
"types": "workspace:*", "types": "workspace:*",
}, },
}, },
"src/workspaces/i18n": {
"name": "i18n",
"version": "0.0.1",
},
"src/workspaces/injector": { "src/workspaces/injector": {
"name": "injector", "name": "injector",
"version": "1.0.0", "version": "1.0.0",
}, },
"src/workspaces/plugins": {
"name": "plugins",
"version": "0.0.1",
},
"src/workspaces/sounds": { "src/workspaces/sounds": {
"name": "sounds", "name": "sounds",
"version": "1.0.0", "version": "1.0.0",
@@ -1413,6 +1413,8 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"i18n": ["i18n@workspace:src/workspaces/i18n"],
"idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -1727,8 +1729,6 @@
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
"plugins": ["plugins@workspace:src/workspaces/plugins"],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
+1 -1
View File
@@ -83,6 +83,7 @@
"helpers": "workspace:*", "helpers": "workspace:*",
"hono": "^4.11.1", "hono": "^4.11.1",
"hooks": "workspace:*", "hooks": "workspace:*",
"i18n": "workspace:*",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"injector": "workspace:*", "injector": "workspace:*",
@@ -98,7 +99,6 @@
"officerdb": "workspace:*", "officerdb": "workspace:*",
"pg": "^8.16.3", "pg": "^8.16.3",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"plugins": "workspace:*",
"widgets": "workspace:*", "widgets": "workspace:*",
"react": "^19", "react": "^19",
"react-countup": "^6.5.3", "react-countup": "^6.5.3",
@@ -3,11 +3,13 @@ import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, LogOut, Server, Package, Bot } from 'lucide-react'; import { User, LogOut, Server, Package, Bot } from 'lucide-react';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } = Dropdown; const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } = Dropdown;
export function UserMenu() { export function UserMenu() {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
const { t } = useTranslation();
if (isLoading) return null; if (isLoading) return null;
return ( return (
@@ -26,34 +28,34 @@ export function UserMenu() {
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/profile"> <Link to="/settings/profile">
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
Profile {t('header.userMenu.profile')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/ai"> <Link to="/settings/ai">
<Bot className="mr-2 h-4 w-4" /> <Bot className="mr-2 h-4 w-4" />
AI Settings {t('header.userMenu.aiSettings')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/server"> <Link to="/settings/server">
<Server className="mr-2 h-4 w-4" /> <Server className="mr-2 h-4 w-4" />
Server Settings {t('header.userMenu.serverSettings')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/resources"> <Link to="/settings/resources">
<Package className="mr-2 h-4 w-4" /> <Package className="mr-2 h-4 w-4" />
Resources {t('header.userMenu.resources')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/auth/signout"> <Link to="/auth/signout">
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
Sign Out {t('header.userMenu.signOut')}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
}; }
@@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useForm } from 'hooks/useForm'; import { useForm } from 'hooks/useForm';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n';
type PasswordFormState = { type PasswordFormState = {
password?: string; password?: string;
@@ -19,6 +20,7 @@ const validate = (state: Partial<PasswordFormState>) => {
export const ChangePassword = () => { export const ChangePassword = () => {
const { changePassword } = useAuth(); const { changePassword } = useAuth();
const { t } = useTranslation();
const [isChanging, setIsChanging] = useState(false); const [isChanging, setIsChanging] = useState(false);
const form = useForm<PasswordFormState>({}, validate); const form = useForm<PasswordFormState>({}, validate);
@@ -33,11 +35,11 @@ export const ChangePassword = () => {
newPassword: form.state.newPassword!, newPassword: form.state.newPassword!,
confirmPassword: form.state.confirmPassword!, confirmPassword: form.state.confirmPassword!,
}); });
toast.success('Password changed'); toast.success(t('settings.profile.changePassword.passwordChanged'));
form.update({ password: '', newPassword: '', confirmPassword: '' }); form.update({ password: '', newPassword: '', confirmPassword: '' });
} catch (ex) { } catch (ex) {
const error = ex as { message?: string }; 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: '' }); form.update({ password: '', newPassword: '', confirmPassword: '' });
} finally { } finally {
setIsChanging(false); setIsChanging(false);
@@ -48,34 +50,34 @@ export const ChangePassword = () => {
<div> <div>
<form ref={form.formRef} onSubmit={handleSubmit} className="grid gap-4"> <form ref={form.formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2"> <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 <Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
name="password" name="password"
placeholder="Current password" placeholder={t('settings.profile.changePassword.currentPasswordPlaceholder')}
autoComplete="current-password" autoComplete="current-password"
/> />
</Label> </Label>
<Label className="grid gap-2"> <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 <Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
name="newPassword" name="newPassword"
placeholder="New password" placeholder={t('settings.profile.changePassword.newPasswordPlaceholder')}
autoComplete="new-password" autoComplete="new-password"
/> />
</Label> </Label>
<Label className="grid gap-2"> <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 <Input
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40" className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password" type="password"
name="confirmPassword" name="confirmPassword"
placeholder="Confirm new password" placeholder={t('settings.profile.changePassword.confirmPasswordPlaceholder')}
autoComplete="new-password" autoComplete="new-password"
/> />
</Label> </Label>
@@ -85,7 +87,9 @@ export const ChangePassword = () => {
disabled={!form.isValid || isChanging} 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" 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> </Button>
</form> </form>
</div> </div>
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useSettings } from '@/state/useSettings'; import { useSettings } from '@/state/useSettings';
import { useTranslation } from '@/lib/i18n';
const LANGUAGES = [ const LANGUAGES = [
{ code: 'en', label: 'English' }, { code: 'en', label: 'English' },
@@ -38,6 +39,7 @@ const getLabel = (code: string) => LANGUAGES.find((l) => l.code === code)?.label
export const Languages = () => { export const Languages = () => {
const { settings, saveSettings } = useSettings(); const { settings, saveSettings } = useSettings();
const { t } = useTranslation();
const { spoken, default: defaultLang, translateTo } = settings.languages; const { spoken, default: defaultLang, translateTo } = settings.languages;
const [addingLang, setAddingLang] = useState(''); const [addingLang, setAddingLang] = useState('');
@@ -65,7 +67,7 @@ export const Languages = () => {
<div className="grid gap-5"> <div className="grid gap-5">
{/* Spoken languages */} {/* Spoken languages */}
<div className="grid gap-2"> <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"> <div className="flex flex-wrap gap-2">
{spoken.map((code) => ( {spoken.map((code) => (
<span <span
@@ -91,7 +93,7 @@ export const Languages = () => {
onChange={(ev) => addSpoken(ev.target.value)} 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" 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) => ( {availableToAdd.map((l) => (
<option key={l.code} value={l.code}> <option key={l.code} value={l.code}>
{l.label} {l.label}
@@ -104,7 +106,7 @@ export const Languages = () => {
{/* Default language */} {/* Default language */}
<Label className="grid gap-2"> <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 <select
value={defaultLang} value={defaultLang}
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })} onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
@@ -116,12 +118,12 @@ export const Languages = () => {
</option> </option>
))} ))}
</select> </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> </Label>
{/* Translate from */} {/* Translate to */}
<Label className="grid gap-2"> <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 <select
value={translateTo} value={translateTo}
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })} onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
@@ -133,7 +135,7 @@ export const Languages = () => {
</option> </option>
))} ))}
</select> </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> </Label>
</div> </div>
); );
@@ -1,51 +1,56 @@
import { useState, useEffect, useRef, useMemo } from 'react'; 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 { Input } from '@/components/ui/input';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Card } from '@/components/Card'; import { Card } from '@/components/Card';
import { useTranslation } from '@/lib/i18n';
import { UserData } from './UserData'; import { UserData } from './UserData';
import { ChangePassword } from './ChangePassword'; import { ChangePassword } from './ChangePassword';
import { Languages } from './Languages'; import { Languages } from './Languages';
import { TaskDefaults } from './TaskDefaults'; 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 = () => { export const ProfileSettings = () => {
const { t } = useTranslation();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<string[]>([]); const [expanded, setExpanded] = useState<string[]>([]);
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({}); 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(() => { const matchingKeys = useMemo(() => {
if (!search) return allKeys; if (!search) return allKeys;
const query = search.toLowerCase(); const query = search.toLowerCase();
@@ -55,7 +60,7 @@ export const ProfileSettings = () => {
return (el?.textContent?.toLowerCase() ?? '').includes(query); return (el?.textContent?.toLowerCase() ?? '').includes(query);
}) })
.map((s) => s.key); .map((s) => s.key);
}, [search]); }, [search, allKeys, sections]);
useEffect(() => { useEffect(() => {
if (search) setExpanded(matchingKeys); if (search) setExpanded(matchingKeys);
@@ -67,7 +72,7 @@ export const ProfileSettings = () => {
<div className="relative mb-6"> <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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
<Input <Input
placeholder="Search settings..." placeholder={t('settings.profile.searchPlaceholder')}
value={search} value={search}
onChange={(ev) => setSearch(ev.target.value)} onChange={(ev) => setSearch(ev.target.value)}
className="pl-9" className="pl-9"
+8 -5
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { App } from './App'; import { App } from './App';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/components/ui/ThemeProvider'; import { ThemeProvider } from '@/components/ui/ThemeProvider';
import { I18nBridge } from '@/lib/I18nBridge';
import { Toaster } from '@/components/ui/toaster'; import { Toaster } from '@/components/ui/toaster';
import { Toaster as Sonner } from 'sonner'; import { Toaster as Sonner } from 'sonner';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
@@ -34,11 +35,13 @@ const app = (
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light" storageKey="officer-theme"> <ThemeProvider defaultTheme="light" storageKey="officer-theme">
<TooltipProvider> <I18nBridge>
<Toaster /> <TooltipProvider>
<Sonner position="top-center" toastOptions={{ style: { padding: '16px 20px', fontSize: '16px' } }} /> <Toaster />
<App /> <Sonner position="top-center" toastOptions={{ style: { padding: '16px 20px', fontSize: '16px' } }} />
</TooltipProvider> <App />
</TooltipProvider>
</I18nBridge>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode> </StrictMode>
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import type { TranslationMap } from 'i18n';
import { I18nProvider } from 'i18n';
import { useSettings } from '@/state/useSettings';
import en from '../locales/en.json';
import pt from '../locales/pt.json';
const translations: TranslationMap = { en, pt };
type I18nBridgeProps = {
children: ReactNode;
};
export const I18nBridge = ({ children }: I18nBridgeProps) => {
const { settings } = useSettings();
const locale = settings.languages.default;
return (
<I18nProvider translations={translations} locale={locale}>
{children}
</I18nProvider>
);
};
+12
View File
@@ -0,0 +1,12 @@
import type { TranslationKeys } from 'i18n';
import type { InterpolationParams } from 'i18n';
import { useTranslation as useTranslationBase } from 'i18n';
import type en from '../locales/en.json';
type AppTranslationKey = TranslationKeys<typeof en>;
export const useTranslation = () => {
const { t: baseT, locale } = useTranslationBase();
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params);
return { t, locale };
};
+70
View File
@@ -0,0 +1,70 @@
{
"common": {
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel"
},
"settings": {
"profile": {
"searchPlaceholder": "Search settings...",
"sections": {
"profileTitle": "Profile",
"profileDescription": "Update your name and avatar.",
"changePasswordTitle": "Change Password",
"changePasswordDescription": "Update your account password.",
"tasksTitle": "Tasks",
"tasksDescription": "Default model for file browser tasks.",
"languagesTitle": "Languages",
"languagesDescription": "Set your spoken languages and translation preferences."
},
"userData": {
"emailLabel": "Email",
"nameLabel": "Name",
"namePlaceholder": "Your name",
"profileUpdated": "Profile updated",
"profileUpdateFailed": "Failed to update profile",
"selectImageFile": "Please select an image file",
"imageTooLarge": "Image must be smaller than 384KB"
},
"changePassword": {
"currentPasswordLabel": "Current Password",
"currentPasswordPlaceholder": "Current password",
"newPasswordLabel": "New Password",
"newPasswordPlaceholder": "New password",
"confirmPasswordLabel": "Confirm New Password",
"confirmPasswordPlaceholder": "Confirm new password",
"changeButton": "Change Password",
"changingButton": "Changing...",
"passwordChanged": "Password changed",
"passwordChangeFailed": "Failed to change password"
},
"languages": {
"spokenLabel": "Languages you speak",
"addLanguage": "Add a language...",
"defaultLabel": "Default language",
"defaultHint": "Used for UI localization.",
"translateToLabel": "Translate to",
"translateToHint": "Target language when translating content you don't speak."
}
}
},
"header": {
"userMenu": {
"profile": "Profile",
"aiSettings": "AI Settings",
"serverSettings": "Server Settings",
"resources": "Resources",
"signOut": "Sign Out"
}
},
"dock": {
"chat": "Chat",
"files": "Files",
"terminal": "Terminal",
"plans": "Plans",
"skills": "Skills",
"tasks": "Tasks",
"processes": "Processes",
"logs": "Logs"
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"common": {
"save": "Guardar",
"saving": "A guardar...",
"cancel": "Cancelar"
},
"settings": {
"profile": {
"searchPlaceholder": "Pesquisar definições...",
"sections": {
"profileTitle": "Perfil",
"profileDescription": "Atualiza o teu nome e avatar.",
"changePasswordTitle": "Alterar Palavra-passe",
"changePasswordDescription": "Atualiza a palavra-passe da tua conta.",
"tasksTitle": "Tarefas",
"tasksDescription": "Modelo predefinido para tarefas do explorador de ficheiros.",
"languagesTitle": "Idiomas",
"languagesDescription": "Define os teus idiomas e preferências de tradução."
},
"userData": {
"emailLabel": "Email",
"nameLabel": "Nome",
"namePlaceholder": "O teu nome",
"profileUpdated": "Perfil atualizado",
"profileUpdateFailed": "Falha ao atualizar perfil",
"selectImageFile": "Por favor seleciona um ficheiro de imagem",
"imageTooLarge": "A imagem deve ter menos de 384KB"
},
"changePassword": {
"currentPasswordLabel": "Palavra-passe Atual",
"currentPasswordPlaceholder": "Palavra-passe atual",
"newPasswordLabel": "Nova Palavra-passe",
"newPasswordPlaceholder": "Nova palavra-passe",
"confirmPasswordLabel": "Confirmar Nova Palavra-passe",
"confirmPasswordPlaceholder": "Confirmar nova palavra-passe",
"changeButton": "Alterar Palavra-passe",
"changingButton": "A alterar...",
"passwordChanged": "Palavra-passe alterada",
"passwordChangeFailed": "Falha ao alterar palavra-passe"
},
"languages": {
"spokenLabel": "Idiomas que falas",
"addLanguage": "Adicionar idioma...",
"defaultLabel": "Idioma predefinido",
"defaultHint": "Usado para localização da interface.",
"translateToLabel": "Traduzir para",
"translateToHint": "Idioma alvo ao traduzir conteúdo que não falas."
}
}
},
"header": {
"userMenu": {
"profile": "Perfil",
"aiSettings": "Definições de IA",
"serverSettings": "Definições do Servidor",
"resources": "Recursos",
"signOut": "Terminar Sessão"
}
},
"dock": {
"chat": "Chat",
"files": "Ficheiros",
"terminal": "Terminal",
"plans": "Planos",
"skills": "Skills",
"tasks": "Tarefas",
"processes": "Processos",
"logs": "Registos"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "i18n",
"version": "0.0.1",
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
}
}
+25
View File
@@ -0,0 +1,25 @@
import { createContext, useMemo } from 'react';
import type { ReactNode } from 'react';
import type { I18nContextValue, InterpolationParams, TranslationMap } from './types';
import { interpolate, resolveKey } from './resolve';
export const I18nContext = createContext<I18nContextValue>({
t: (key: string) => key,
locale: 'en',
});
type I18nProviderProps = {
translations: TranslationMap;
locale: string;
children: ReactNode;
};
export const I18nProvider = ({ translations, locale, children }: I18nProviderProps) => {
const value = useMemo<I18nContextValue>(() => {
const t = (key: string, params?: InterpolationParams) =>
interpolate(resolveKey(translations, locale, key), params);
return { t, locale };
}, [translations, locale]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
};
+4
View File
@@ -0,0 +1,4 @@
export { I18nProvider, I18nContext } from './I18nProvider';
export { useTranslation } from './useTranslation';
export { resolveKey, interpolate } from './resolve';
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys } from './types';
+20
View File
@@ -0,0 +1,20 @@
import type { InterpolationParams, TranslationMap } from './types';
export const resolveKey = (translations: TranslationMap, locale: string, key: string): string => {
const value = walkPath(translations[locale], key) ?? walkPath(translations['en'], key);
return typeof value === 'string' ? value : key;
};
const walkPath = (obj: unknown, path: string): unknown => {
let current = obj;
for (const segment of path.split('.')) {
if (current == null || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[segment];
}
return current;
};
export const interpolate = (template: string, params?: InterpolationParams): string => {
if (!params) return template;
return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(params[key] ?? `{{${key}}}`));
};
+16
View File
@@ -0,0 +1,16 @@
export type TranslationMap = Record<string, Record<string, unknown>>;
export type InterpolationParams = Record<string, string | number>;
export type I18nContextValue = {
t: (key: string, params?: InterpolationParams) => string;
locale: string;
};
export type TranslationKeys<T, Prefix extends string = ''> = T extends string
? Prefix
: T extends Record<string, unknown>
? {
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
}[keyof T & string]
: never;
@@ -0,0 +1,4 @@
import { useContext } from 'react';
import { I18nContext } from './I18nProvider';
export const useTranslation = () => useContext(I18nContext);