localization
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '@/components/ui/ThemeProvider';
|
||||
import { I18nBridge } from '@/lib/I18nBridge';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
@@ -34,11 +35,13 @@ const app = (
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="light" storageKey="officer-theme">
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner position="top-center" toastOptions={{ style: { padding: '16px 20px', fontSize: '16px' } }} />
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
<I18nBridge>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner position="top-center" toastOptions={{ style: { padding: '16px 20px', fontSize: '16px' } }} />
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</I18nBridge>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "i18n",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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}}}`));
|
||||
};
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user