diff --git a/bun.lock b/bun.lock index 064dbf6a..b0e06811 100644 --- a/bun.lock +++ b/bun.lock @@ -65,6 +65,7 @@ "hono": "^4.11.1", "hooks": "workspace:*", "html2canvas": "^1.4.1", + "i18n": "workspace:*", "idb-keyval": "^6.2.2", "injector": "workspace:*", "input-otp": "^1.4.2", @@ -78,7 +79,6 @@ "nodemailer": "^7.0.12", "officerdb": "workspace:*", "pg": "^8.16.3", - "plugins": "workspace:*", "postgres": "^3.4.5", "react": "^19", "react-countup": "^6.5.3", @@ -228,14 +228,14 @@ "types": "workspace:*", }, }, + "src/workspaces/i18n": { + "name": "i18n", + "version": "0.0.1", + }, "src/workspaces/injector": { "name": "injector", "version": "1.0.0", }, - "src/workspaces/plugins": { - "name": "plugins", - "version": "0.0.1", - }, "src/workspaces/sounds": { "name": "sounds", "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=="], + "i18n": ["i18n@workspace:src/workspaces/i18n"], + "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], "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=="], - "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-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=="], diff --git a/package.json b/package.json index f31b7b11..d374bd0a 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "helpers": "workspace:*", "hono": "^4.11.1", "hooks": "workspace:*", + "i18n": "workspace:*", "html2canvas": "^1.4.1", "idb-keyval": "^6.2.2", "injector": "workspace:*", @@ -98,7 +99,6 @@ "officerdb": "workspace:*", "pg": "^8.16.3", "postgres": "^3.4.5", - "plugins": "workspace:*", "widgets": "workspace:*", "react": "^19", "react-countup": "^6.5.3", diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx index 1892f559..e24e5e73 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx @@ -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() { - Profile + {t('header.userMenu.profile')} - AI Settings + {t('header.userMenu.aiSettings')} - Server Settings + {t('header.userMenu.serverSettings')} - Resources + {t('header.userMenu.resources')} - Sign Out + {t('header.userMenu.signOut')} ); -}; +} diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ChangePassword.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ChangePassword.tsx index 6f670829..a8c2e708 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ChangePassword.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/ChangePassword.tsx @@ -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) => { export const ChangePassword = () => { const { changePassword } = useAuth(); + const { t } = useTranslation(); const [isChanging, setIsChanging] = useState(false); const form = useForm({}, 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 = () => {
@@ -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')}
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx index 2c9bf9c7..d33cb627 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/Languages.tsx @@ -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 = () => {
{/* Spoken languages */}
- 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) => (
); 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);