-
Languages you speak
+
{t('settings.profile.languages.spokenLabel')}
{spoken.map((code) => (
{
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"
>
-
+
{availableToAdd.map((l) => (
))}
- Used for future UI localization.
+ {t('settings.profile.languages.defaultHint')}
- {/* Translate from */}
+ {/* Translate to */}
);
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx
index 1739cd43..ae111ad1 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx
@@ -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:
,
- },
- {
- key: 'change-password',
- icon: Lock,
- title: 'Change Password',
- description: 'Update your account password.',
- content:
,
- },
- {
- key: 'tasks',
- icon: ListChecks,
- title: 'Tasks',
- description: 'Default model for file browser tasks.',
- content:
,
- },
- {
- key: 'languages',
- icon: Globe,
- title: 'Languages',
- description: 'Set your spoken languages and translation preferences.',
- content:
,
- },
-];
-
-const allKeys = sections.map((s) => s.key);
-
export const ProfileSettings = () => {
+ const { t } = useTranslation();
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState
([]);
const sectionRefs = useRef>({});
+ const sections = useMemo(
+ () => [
+ {
+ key: 'profile',
+ icon: User,
+ title: t('settings.profile.sections.profileTitle'),
+ description: t('settings.profile.sections.profileDescription'),
+ content: ,
+ },
+ {
+ key: 'change-password',
+ icon: Lock,
+ title: t('settings.profile.sections.changePasswordTitle'),
+ description: t('settings.profile.sections.changePasswordDescription'),
+ content: ,
+ },
+ {
+ key: 'tasks',
+ icon: ListChecks,
+ title: t('settings.profile.sections.tasksTitle'),
+ description: t('settings.profile.sections.tasksDescription'),
+ content: ,
+ },
+ {
+ key: 'languages',
+ icon: Globe,
+ title: t('settings.profile.sections.languagesTitle'),
+ description: t('settings.profile.sections.languagesDescription'),
+ content: ,
+ },
+ ],
+ [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 = () => {
setSearch(ev.target.value)}
className="pl-9"
diff --git a/src/apps/officer-web/frontend.tsx b/src/apps/officer-web/frontend.tsx
index 56e0db75..af77d018 100644
--- a/src/apps/officer-web/frontend.tsx
+++ b/src/apps/officer-web/frontend.tsx
@@ -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 = (
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/apps/officer-web/lib/I18nBridge.tsx b/src/apps/officer-web/lib/I18nBridge.tsx
new file mode 100644
index 00000000..e344e7f4
--- /dev/null
+++ b/src/apps/officer-web/lib/I18nBridge.tsx
@@ -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 (
+
+ {children}
+
+ );
+};
diff --git a/src/apps/officer-web/lib/i18n.ts b/src/apps/officer-web/lib/i18n.ts
new file mode 100644
index 00000000..905a309a
--- /dev/null
+++ b/src/apps/officer-web/lib/i18n.ts
@@ -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
;
+
+export const useTranslation = () => {
+ const { t: baseT, locale } = useTranslationBase();
+ const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(key, params);
+ return { t, locale };
+};
diff --git a/src/apps/officer-web/locales/en.json b/src/apps/officer-web/locales/en.json
new file mode 100644
index 00000000..139e40b9
--- /dev/null
+++ b/src/apps/officer-web/locales/en.json
@@ -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"
+ }
+}
diff --git a/src/apps/officer-web/locales/pt.json b/src/apps/officer-web/locales/pt.json
new file mode 100644
index 00000000..c5e41294
--- /dev/null
+++ b/src/apps/officer-web/locales/pt.json
@@ -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"
+ }
+}
diff --git a/src/workspaces/i18n/package.json b/src/workspaces/i18n/package.json
new file mode 100644
index 00000000..0ee8f051
--- /dev/null
+++ b/src/workspaces/i18n/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "i18n",
+ "version": "0.0.1",
+ "license": "MIT",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./*": "./src/*.ts"
+ }
+}
diff --git a/src/workspaces/i18n/src/I18nProvider.tsx b/src/workspaces/i18n/src/I18nProvider.tsx
new file mode 100644
index 00000000..41c66a74
--- /dev/null
+++ b/src/workspaces/i18n/src/I18nProvider.tsx
@@ -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({
+ t: (key: string) => key,
+ locale: 'en',
+});
+
+type I18nProviderProps = {
+ translations: TranslationMap;
+ locale: string;
+ children: ReactNode;
+};
+
+export const I18nProvider = ({ translations, locale, children }: I18nProviderProps) => {
+ const value = useMemo(() => {
+ const t = (key: string, params?: InterpolationParams) =>
+ interpolate(resolveKey(translations, locale, key), params);
+ return { t, locale };
+ }, [translations, locale]);
+
+ return {children};
+};
diff --git a/src/workspaces/i18n/src/index.ts b/src/workspaces/i18n/src/index.ts
new file mode 100644
index 00000000..ff005cfc
--- /dev/null
+++ b/src/workspaces/i18n/src/index.ts
@@ -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';
diff --git a/src/workspaces/i18n/src/resolve.ts b/src/workspaces/i18n/src/resolve.ts
new file mode 100644
index 00000000..e344c1bd
--- /dev/null
+++ b/src/workspaces/i18n/src/resolve.ts
@@ -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)[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}}}`));
+};
diff --git a/src/workspaces/i18n/src/types.ts b/src/workspaces/i18n/src/types.ts
new file mode 100644
index 00000000..a3636400
--- /dev/null
+++ b/src/workspaces/i18n/src/types.ts
@@ -0,0 +1,16 @@
+export type TranslationMap = Record>;
+
+export type InterpolationParams = Record;
+
+export type I18nContextValue = {
+ t: (key: string, params?: InterpolationParams) => string;
+ locale: string;
+};
+
+export type TranslationKeys = T extends string
+ ? Prefix
+ : T extends Record
+ ? {
+ [K in keyof T & string]: TranslationKeys;
+ }[keyof T & string]
+ : never;
diff --git a/src/workspaces/i18n/src/useTranslation.ts b/src/workspaces/i18n/src/useTranslation.ts
new file mode 100644
index 00000000..5b09737e
--- /dev/null
+++ b/src/workspaces/i18n/src/useTranslation.ts
@@ -0,0 +1,4 @@
+import { useContext } from 'react';
+import { I18nContext } from './I18nProvider';
+
+export const useTranslation = () => useContext(I18nContext);