diff --git a/frontend/README-localization.md b/frontend/README-localization.md new file mode 100644 index 00000000..a8290e00 --- /dev/null +++ b/frontend/README-localization.md @@ -0,0 +1,196 @@ +# Localization (i18n) Guide for Maple + +This document explains how the automatic UI localization system works in Maple and how to add new languages. + +## How It Works + +Maple automatically detects the user's operating system language and displays the UI in that language: + +1. **Locale Detection**: Uses `tauri-plugin-localization` to get the native OS locale +2. **Fallback**: Falls back to browser language (`navigator.language`) if native detection fails +3. **Translation Loading**: Dynamically loads the appropriate JSON translation file +4. **UI Rendering**: React components use `useTranslation()` hook to display localized strings + +## Current Supported Languages + +- **English** (`en`) - Default and fallback language +- **French** (`fr`) - Complete translations +- **Spanish** (`es`) - Complete translations + +## File Structure + +``` +frontend/ +├── public/locales/ # Translation files +│ ├── en.json # English (default) +│ ├── fr.json # French +│ └── es.json # Spanish +├── src/ +│ ├── utils/i18n.ts # i18n configuration +│ ├── main.tsx # i18n initialization +│ └── components/ # Components using translations +└── src-tauri/ + ├── Cargo.toml # Rust dependencies + ├── src/lib.rs # Plugin registration + ├── tauri.conf.json # Asset protocol config + └── gen/apple/maple_iOS/Info.plist # iOS language declarations +``` + +## Adding a New Language + +### 1. Create Translation File + +1. Copy `public/locales/en.json` to `public/locales/{code}.json` (e.g., `de.json` for German) +2. Translate all the strings while keeping the same key structure: + +```json +{ + "app": { + "title": "Maple - Private KI-Chat", + "welcome": "Willkommen bei Maple", + "description": "Private KI-Chat mit vertraulicher Datenverarbeitung" + }, + "auth": { + "signIn": "Anmelden", + "signOut": "Abmelden", + "email": "E-Mail", + "password": "Passwort" + } + // ... continue with all keys +} +``` + +### 2. Update iOS Configuration + +Edit `src-tauri/gen/apple/maple_iOS/Info.plist` and add your language code: + +```xml +CFBundleLocalizations + + en + fr + es + de + +``` + +### 3. Test the Implementation + +1. **Development**: `bun tauri dev` + - Change your OS language settings + - Restart the app to see the new language + +2. **iOS**: `bun tauri build --target ios` + - Build and run in iOS Simulator + - Change device language in Settings app + - Test the localized UI + +## Using Translations in Components + +### Basic Usage + +```tsx +import { useTranslation } from 'react-i18next'; + +function MyComponent() { + const { t } = useTranslation(); + + return ( +
+

{t('app.title')}

+ +
+ ); +} +``` + +### With Variables + +```tsx +// Translation with interpolation +const message = t('auth.welcome', { name: 'John' }); + +// In en.json: +// "auth": { "welcome": "Welcome, {{name}}!" } +``` + +### Language Switching (Optional) + +```tsx +const { i18n } = useTranslation(); + +// Manually change language (for testing/admin purposes) +i18n.changeLanguage('fr'); +``` + +## Technical Details + +### Dependencies + +- **Frontend**: `i18next`, `react-i18next` +- **Backend**: `tauri-plugin-localization` + +### Initialization Flow + +1. `main.tsx` calls `initI18n()` before rendering +2. `i18n.ts` resolves the locale using Tauri plugin +3. Appropriate JSON file is loaded dynamically +4. i18next is initialized with the translations +5. React app renders with localized strings + +### Fallback Strategy + +1. Try native OS locale (e.g., `en-US`) +2. Extract language code (`en-US` → `en`) +3. Load matching JSON file (`en.json`) +4. If not found, fall back to English +5. If English fails, use empty translations + +## Platform Support + +| Platform | Locale Detection | Status | +|----------|------------------|--------| +| **Desktop** (Windows/macOS/Linux) | ✅ Native OS locale | Fully supported | +| **iOS** | ✅ Device language | Fully supported | +| **Web** | ✅ Browser language | Fallback only | + +## Troubleshooting + +### Language Not Changing + +1. Check that the JSON file exists in `public/locales/` +2. Verify iOS `Info.plist` includes the language code +3. Restart the app after changing OS language +4. Check browser console for i18n loading errors + +### Missing Translations + +1. Compare your JSON structure with `en.json` +2. Ensure all keys match exactly (case-sensitive) +3. Check for syntax errors in JSON files +4. Use the `t()` function's fallback: `t('key', { defaultValue: 'fallback' })` + +### iOS Build Issues + +1. Ensure Xcode project is regenerated: `bun tauri build --target ios` +2. Check that `CFBundleLocalizations` is properly formatted +3. Clean build folder if needed + +## Performance Notes + +- Translation files are loaded asynchronously on startup +- Only the detected language file is loaded (not all languages) +- Vite's `import.meta.glob` ensures efficient bundling +- First render waits for i18n initialization to prevent FOUC + +## Future Enhancements + +- [ ] Add more languages (German, Italian, Portuguese, Japanese, etc.) +- [ ] Implement plural forms for complex languages +- [ ] Add context-aware translations +- [ ] Create translation management workflow +- [ ] Add RTL language support (Arabic, Hebrew) + +--- + +For questions or issues with localization, please check the main README or open an issue on GitHub. diff --git a/frontend/bun.lockb b/frontend/bun.lockb index d43d39d7..7335340e 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index 02a0c42b..d050d438 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,8 @@ }, "dependencies": { "@opensecret/react": "1.3.8", + "i18next": "^23.6.0", + "react-i18next": "^13.0.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", diff --git a/frontend/public/locales/en.json b/frontend/public/locales/en.json new file mode 100644 index 00000000..e51b0082 --- /dev/null +++ b/frontend/public/locales/en.json @@ -0,0 +1,178 @@ +{ + "app": { + "title": "Maple - Private AI Chat", + "welcome": "Welcome to Maple", + "description": "Private AI Chat" + }, + "navigation": { + "home": "Home", + "chat": "Chat", + "teams": "Teams", + "pricing": "Pricing", + "downloads": "Downloads", + "about": "About" + }, + "auth": { + "signIn": "Sign In", + "signOut": "Sign Out", + "signUp": "Sign Up", + "login": "Login", + "logout": "Logout", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "forgotPassword": "Forgot Password?", + "resetPassword": "Reset Password", + "createAccount": "Create Account", + "alreadyHaveAccount": "Already have an account?", + "dontHaveAccount": "Don't have an account?", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google", + "verification": "Verification", + "verifyEmail": "Verify your email address", + "checkEmail": "Please check your email for a verification link" + }, + "chat": { + "newChat": "New Chat", + "chatHistory": "Chat History", + "typeMessage": "Type your message...", + "send": "Send", + "thinking": "Thinking...", + "selectModel": "Select Model", + "systemPrompt": "System Prompt", + "attachFile": "Attach File", + "deleteChat": "Delete Chat", + "renameChat": "Rename Chat", + "confirmDelete": "Are you sure you want to delete this chat?", + "enterChatName": "Enter chat name", + "untitledChat": "Untitled Chat" + }, + "teams": { + "createTeam": "Create Team", + "joinTeam": "Join Team", + "teamName": "Team Name", + "teamMembers": "Team Members", + "inviteMembers": "Invite Members", + "manageTeam": "Manage Team", + "leaveTeam": "Leave Team", + "deleteTeam": "Delete Team", + "teamSettings": "Team Settings", + "pendingInvites": "Pending Invites", + "teamAdmin": "Team Admin", + "teamMember": "Team Member" + }, + "billing": { + "subscription": "Subscription", + "billing": "Billing", + "usage": "Usage", + "credits": "Credits", + "upgrade": "Upgrade", + "downgrade": "Downgrade", + "cancel": "Cancel", + "paymentMethod": "Payment Method", + "billingHistory": "Billing History", + "currentPlan": "Current Plan", + "freePlan": "Free Plan", + "proPlan": "Pro Plan", + "enterprisePlan": "Enterprise Plan" + }, + "button": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "update": "Update", + "confirm": "Confirm", + "close": "Close", + "next": "Next", + "previous": "Previous", + "submit": "Submit", + "retry": "Retry", + "refresh": "Refresh", + "loading": "Loading...", + "copy": "Copy", + "copied": "Copied!", + "download": "Download", + "upload": "Upload" + }, + "settings": { + "settings": "Settings", + "account": "Account", + "profile": "Profile", + "preferences": "Preferences", + "notifications": "Notifications", + "privacy": "Privacy", + "security": "Security", + "appearance": "Appearance", + "language": "Language", + "theme": "Theme", + "darkMode": "Dark Mode", + "lightMode": "Light Mode", + "systemDefault": "System Default" + }, + "error": { + "general": "An error occurred", + "network": "Network error - please try again later", + "unauthorized": "You are not authorized to perform this action", + "notFound": "The requested resource was not found", + "serverError": "Internal server error", + "validationError": "Please check your input and try again", + "timeout": "Request timed out", + "unknown": "An unknown error occurred", + "sessionExpired": "Your session has expired. Please sign in again.", + "invalidCredentials": "Invalid email or password", + "emailRequired": "Email is required", + "passwordRequired": "Password is required", + "emailInvalid": "Please enter a valid email address", + "passwordTooShort": "Password must be at least 8 characters long", + "passwordsDontMatch": "Passwords don't match" + }, + "success": { + "saved": "Successfully saved", + "deleted": "Successfully deleted", + "updated": "Successfully updated", + "created": "Successfully created", + "sent": "Successfully sent", + "copied": "Copied to clipboard", + "emailSent": "Email sent successfully", + "passwordReset": "Password reset successfully", + "accountCreated": "Account created successfully", + "signedIn": "Signed in successfully", + "signedOut": "Signed out successfully" + }, + "common": { + "yes": "Yes", + "no": "No", + "ok": "OK", + "or": "or", + "and": "and", + "optional": "Optional", + "required": "Required", + "search": "Search", + "filter": "Filter", + "sort": "Sort", + "name": "Name", + "email": "Email", + "date": "Date", + "time": "Time", + "status": "Status", + "active": "Active", + "inactive": "Inactive", + "enabled": "Enabled", + "disabled": "Disabled", + "public": "Public", + "private": "Private", + "examples": "Examples" + }, + "footer": { + "privacy": "Privacy Policy", + "terms": "Terms of Service", + "contact": "Contact", + "support": "Support", + "documentation": "Documentation", + "status": "Status", + "changelog": "Changelog", + "copyright": "© {{year}} OpenSecret. All rights reserved." + } +} diff --git a/frontend/public/locales/es.json b/frontend/public/locales/es.json new file mode 100644 index 00000000..ba146981 --- /dev/null +++ b/frontend/public/locales/es.json @@ -0,0 +1,178 @@ +{ + "app": { + "title": "Maple - Chat con IA Privada", + "welcome": "Bienvenido a Maple", + "description": "Chat con IA Privada" + }, + "navigation": { + "home": "Inicio", + "chat": "Chat", + "teams": "Equipos", + "pricing": "Precios", + "downloads": "Descargas", + "about": "Acerca de" + }, + "auth": { + "signIn": "Iniciar sesión", + "signOut": "Cerrar sesión", + "signUp": "Registrarse", + "login": "Iniciar sesión", + "logout": "Cerrar sesión", + "email": "Correo electrónico", + "password": "Contraseña", + "confirmPassword": "Confirmar contraseña", + "forgotPassword": "¿Olvidaste tu contraseña?", + "resetPassword": "Restablecer contraseña", + "createAccount": "Crear cuenta", + "alreadyHaveAccount": "¿Ya tienes una cuenta?", + "dontHaveAccount": "¿No tienes una cuenta?", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google", + "verification": "Verificación", + "verifyEmail": "Verifica tu dirección de correo", + "checkEmail": "Por favor revisa tu correo para el enlace de verificación" + }, + "chat": { + "newChat": "Chat nuevo", + "chatHistory": "Historial de chat", + "typeMessage": "Escribe tu mensaje...", + "send": "Enviar", + "thinking": "Pensando...", + "selectModel": "Seleccionar modelo", + "systemPrompt": "Prompt del sistema", + "attachFile": "Adjuntar archivo", + "deleteChat": "Eliminar chat", + "renameChat": "Renombrar chat", + "confirmDelete": "¿Seguro que quieres eliminar este chat?", + "enterChatName": "Ingresa un nombre para el chat", + "untitledChat": "Chat sin título" + }, + "teams": { + "createTeam": "Crear equipo", + "joinTeam": "Unirse a equipo", + "teamName": "Nombre del equipo", + "teamMembers": "Miembros del equipo", + "inviteMembers": "Invitar miembros", + "manageTeam": "Administrar equipo", + "leaveTeam": "Abandonar equipo", + "deleteTeam": "Eliminar equipo", + "teamSettings": "Configuración del equipo", + "pendingInvites": "Invitaciones pendientes", + "teamAdmin": "Administrador de equipo", + "teamMember": "Miembro de equipo" + }, + "billing": { + "subscription": "Suscripción", + "billing": "Facturación", + "usage": "Uso", + "credits": "Créditos", + "upgrade": "Mejorar plan", + "downgrade": "Reducir plan", + "cancel": "Cancelar", + "paymentMethod": "Método de pago", + "billingHistory": "Historial de facturación", + "currentPlan": "Plan actual", + "freePlan": "Plan gratuito", + "proPlan": "Plan Pro", + "enterprisePlan": "Plan Empresa" + }, + "button": { + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "create": "Crear", + "update": "Actualizar", + "confirm": "Confirmar", + "close": "Cerrar", + "next": "Siguiente", + "previous": "Anterior", + "submit": "Enviar", + "retry": "Reintentar", + "refresh": "Actualizar", + "loading": "Cargando...", + "copy": "Copiar", + "copied": "¡Copiado!", + "download": "Descargar", + "upload": "Subir" + }, + "settings": { + "settings": "Configuración", + "account": "Cuenta", + "profile": "Perfil", + "preferences": "Preferencias", + "notifications": "Notificaciones", + "privacy": "Privacidad", + "security": "Seguridad", + "appearance": "Apariencia", + "language": "Idioma", + "theme": "Tema", + "darkMode": "Modo oscuro", + "lightMode": "Modo claro", + "systemDefault": "Predeterminado del sistema" + }, + "error": { + "general": "Ocurrió un error", + "network": "Error de red - inténtalo más tarde", + "unauthorized": "No estás autorizado para esta acción", + "notFound": "Recurso no encontrado", + "serverError": "Error interno del servidor", + "validationError": "Verifica los datos e inténtalo de nuevo", + "timeout": "Tiempo de espera agotado", + "unknown": "Error desconocido", + "sessionExpired": "Sesión expirada. Inicia sesión de nuevo.", + "invalidCredentials": "Correo o contraseña inválidos", + "emailRequired": "Correo electrónico requerido", + "passwordRequired": "Contraseña requerida", + "emailInvalid": "Ingresa un correo válido", + "passwordTooShort": "La contraseña debe tener al menos 8 caracteres", + "passwordsDontMatch": "Las contraseñas no coinciden" + }, + "success": { + "saved": "Guardado exitosamente", + "deleted": "Eliminado exitosamente", + "updated": "Actualizado exitosamente", + "created": "Creado exitosamente", + "sent": "Enviado exitosamente", + "copied": "Copiado al portapapeles", + "emailSent": "Correo enviado exitosamente", + "passwordReset": "Contraseña restablecida exitosamente", + "accountCreated": "Cuenta creada exitosamente", + "signedIn": "Sesión iniciada exitosamente", + "signedOut": "Sesión cerrada exitosamente" + }, + "common": { + "yes": "Sí", + "no": "No", + "ok": "Aceptar", + "or": "o", + "and": "y", + "optional": "Opcional", + "required": "Requerido", + "search": "Buscar", + "filter": "Filtrar", + "sort": "Ordenar", + "name": "Nombre", + "email": "Correo electrónico", + "date": "Fecha", + "time": "Hora", + "status": "Estado", + "active": "Activo", + "inactive": "Inactivo", + "enabled": "Habilitado", + "disabled": "Deshabilitado", + "public": "Público", + "private": "Privado", + "examples": "Ejemplos" + }, + "footer": { + "privacy": "Política de privacidad", + "terms": "Términos del servicio", + "contact": "Contacto", + "support": "Soporte", + "documentation": "Documentación", + "status": "Estado", + "changelog": "Registro de cambios", + "copyright": "© {{year}} OpenSecret. Todos los derechos reservados." + } +} diff --git a/frontend/public/locales/fr.json b/frontend/public/locales/fr.json new file mode 100644 index 00000000..15a940ea --- /dev/null +++ b/frontend/public/locales/fr.json @@ -0,0 +1,178 @@ +{ + "app": { + "title": "Maple - Chat IA Privé", + "welcome": "Bienvenue sur Maple", + "description": "Chat IA Privé" + }, + "navigation": { + "home": "Accueil", + "chat": "Chat", + "teams": "Équipes", + "pricing": "Tarifs", + "downloads": "Téléchargements", + "about": "À propos" + }, + "auth": { + "signIn": "Se connecter", + "signOut": "Se déconnecter", + "signUp": "S'inscrire", + "login": "Connexion", + "logout": "Déconnexion", + "email": "E-mail", + "password": "Mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "forgotPassword": "Mot de passe oublié ?", + "resetPassword": "Réinitialiser le mot de passe", + "createAccount": "Créer un compte", + "alreadyHaveAccount": "Vous avez déjà un compte ?", + "dontHaveAccount": "Vous n'avez pas de compte ?", + "signInWithApple": "Se connecter avec Apple", + "signInWithGoogle": "Se connecter avec Google", + "verification": "Vérification", + "verifyEmail": "Vérifiez votre adresse e-mail", + "checkEmail": "Veuillez vérifier vos e-mails pour le lien de vérification" + }, + "chat": { + "newChat": "Nouveau chat", + "chatHistory": "Historique des chats", + "typeMessage": "Saisissez votre message...", + "send": "Envoyer", + "thinking": "Réflexion...", + "selectModel": "Sélectionner un modèle", + "systemPrompt": "Prompt système", + "attachFile": "Joindre un fichier", + "deleteChat": "Supprimer le chat", + "renameChat": "Renommer le chat", + "confirmDelete": "Confirmez-vous la suppression de ce chat ?", + "enterChatName": "Entrez un nom de chat", + "untitledChat": "Chat sans titre" + }, + "teams": { + "createTeam": "Créer une équipe", + "joinTeam": "Rejoindre une équipe", + "teamName": "Nom de l'équipe", + "teamMembers": "Membres de l'équipe", + "inviteMembers": "Inviter des membres", + "manageTeam": "Gérer l'équipe", + "leaveTeam": "Quitter l'équipe", + "deleteTeam": "Supprimer l'équipe", + "teamSettings": "Paramètres de l'équipe", + "pendingInvites": "Invitations en attente", + "teamAdmin": "Administrateur d'équipe", + "teamMember": "Membre d'équipe" + }, + "billing": { + "subscription": "Abonnement", + "billing": "Facturation", + "usage": "Utilisation", + "credits": "Crédits", + "upgrade": "Améliorer", + "downgrade": "Rétrograder", + "cancel": "Annuler", + "paymentMethod": "Moyen de paiement", + "billingHistory": "Historique de facturation", + "currentPlan": "Formule actuelle", + "freePlan": "Formule gratuite", + "proPlan": "Formule Pro", + "enterprisePlan": "Formule Entreprise" + }, + "button": { + "save": "Enregistrer", + "cancel": "Annuler", + "delete": "Supprimer", + "edit": "Modifier", + "create": "Créer", + "update": "Mettre à jour", + "confirm": "Confirmer", + "close": "Fermer", + "next": "Suivant", + "previous": "Précédent", + "submit": "Soumettre", + "retry": "Réessayer", + "refresh": "Actualiser", + "loading": "Chargement...", + "copy": "Copier", + "copied": "Copié !", + "download": "Télécharger", + "upload": "Téléverser" + }, + "settings": { + "settings": "Paramètres", + "account": "Compte", + "profile": "Profil", + "preferences": "Préférences", + "notifications": "Notifications", + "privacy": "Confidentialité", + "security": "Sécurité", + "appearance": "Apparence", + "language": "Langue", + "theme": "Thème", + "darkMode": "Mode sombre", + "lightMode": "Mode clair", + "systemDefault": "Par défaut du système" + }, + "error": { + "general": "Une erreur est survenue", + "network": "Erreur réseau - veuillez réessayer plus tard", + "unauthorized": "Action non autorisée", + "notFound": "Ressource introuvable", + "serverError": "Erreur interne du serveur", + "validationError": "Veuillez vérifier vos informations et réessayer", + "timeout": "Délai expiré", + "unknown": "Erreur inconnue", + "sessionExpired": "Session expirée. Veuillez vous reconnecter.", + "invalidCredentials": "E-mail ou mot de passe invalide", + "emailRequired": "E-mail requis", + "passwordRequired": "Mot de passe requis", + "emailInvalid": "Veuillez saisir un e-mail valide", + "passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères", + "passwordsDontMatch": "Les mots de passe ne correspondent pas" + }, + "success": { + "saved": "Enregistré avec succès", + "deleted": "Supprimé avec succès", + "updated": "Mis à jour avec succès", + "created": "Créé avec succès", + "sent": "Envoyé avec succès", + "copied": "Copié dans le presse-papiers", + "emailSent": "E-mail envoyé avec succès", + "passwordReset": "Mot de passe réinitialisé avec succès", + "accountCreated": "Compte créé avec succès", + "signedIn": "Connexion réussie", + "signedOut": "Déconnexion réussie" + }, + "common": { + "yes": "Oui", + "no": "Non", + "ok": "OK", + "or": "ou", + "and": "et", + "optional": "Optionnel", + "required": "Requis", + "search": "Rechercher", + "filter": "Filtrer", + "sort": "Trier", + "name": "Nom", + "email": "E-mail", + "date": "Date", + "time": "Heure", + "status": "Statut", + "active": "Actif", + "inactive": "Inactif", + "enabled": "Activé", + "disabled": "Désactivé", + "public": "Public", + "private": "Privé", + "examples": "Exemples" + }, + "footer": { + "privacy": "Politique de confidentialité", + "terms": "Conditions d'utilisation", + "contact": "Contact", + "support": "Support", + "documentation": "Documentation", + "status": "État", + "changelog": "Journal des modifications", + "copyright": "© {{year}} OpenSecret. Tous droits réservés." + } +} diff --git a/frontend/public/locales/pt.json b/frontend/public/locales/pt.json new file mode 100644 index 00000000..860f96a1 --- /dev/null +++ b/frontend/public/locales/pt.json @@ -0,0 +1,178 @@ +{ + "app": { + "title": "Maple - Chat IA Privado", + "welcome": "Bem-vindo ao Maple", + "description": "Chat com IA Privada" + }, + "navigation": { + "home": "Início", + "chat": "Chat", + "teams": "Equipes", + "pricing": "Preços", + "downloads": "Downloads", + "about": "Sobre" + }, + "auth": { + "signIn": "Entrar", + "signOut": "Sair", + "signUp": "Cadastrar", + "login": "Login", + "logout": "Logout", + "email": "E-mail", + "password": "Senha", + "confirmPassword": "Confirmar senha", + "forgotPassword": "Esqueceu a senha?", + "resetPassword": "Redefinir senha", + "createAccount": "Criar conta", + "alreadyHaveAccount": "Já tem uma conta?", + "dontHaveAccount": "Não tem uma conta?", + "signInWithApple": "Entrar com Apple", + "signInWithGoogle": "Entrar com Google", + "verification": "Verificação", + "verifyEmail": "Verifique seu e-mail", + "checkEmail": "Verifique seu e-mail para o link de verificação" + }, + "chat": { + "newChat": "Novo chat", + "chatHistory": "Histórico de chat", + "typeMessage": "Digite sua mensagem...", + "send": "Enviar", + "thinking": "Pensando...", + "selectModel": "Selecionar modelo", + "systemPrompt": "Prompt do sistema", + "attachFile": "Anexar arquivo", + "deleteChat": "Excluir chat", + "renameChat": "Renomear chat", + "confirmDelete": "Tem certeza que deseja excluir este chat?", + "enterChatName": "Digite um nome para o chat", + "untitledChat": "Chat sem título" + }, + "teams": { + "createTeam": "Criar equipe", + "joinTeam": "Entrar em equipe", + "teamName": "Nome da equipe", + "teamMembers": "Membros da equipe", + "inviteMembers": "Convidar membros", + "manageTeam": "Gerenciar equipe", + "leaveTeam": "Sair da equipe", + "deleteTeam": "Excluir equipe", + "teamSettings": "Configurações da equipe", + "pendingInvites": "Convites pendentes", + "teamAdmin": "Administrador da equipe", + "teamMember": "Membro da equipe" + }, + "billing": { + "subscription": "Assinatura", + "billing": "Cobrança", + "usage": "Uso", + "credits": "Créditos", + "upgrade": "Melhorar plano", + "downgrade": "Rebaixar plano", + "cancel": "Cancelar", + "paymentMethod": "Método de pagamento", + "billingHistory": "Histórico de cobrança", + "currentPlan": "Plano atual", + "freePlan": "Plano gratuito", + "proPlan": "Plano Pro", + "enterprisePlan": "Plano Empresarial" + }, + "button": { + "save": "Salvar", + "cancel": "Cancelar", + "delete": "Excluir", + "edit": "Editar", + "create": "Criar", + "update": "Atualizar", + "confirm": "Confirmar", + "close": "Fechar", + "next": "Próximo", + "previous": "Anterior", + "submit": "Enviar", + "retry": "Tentar novamente", + "refresh": "Atualizar", + "loading": "Carregando...", + "copy": "Copiar", + "copied": "Copiado!", + "download": "Baixar", + "upload": "Enviar" + }, + "settings": { + "settings": "Configurações", + "account": "Conta", + "profile": "Perfil", + "preferences": "Preferências", + "notifications": "Notificações", + "privacy": "Privacidade", + "security": "Segurança", + "appearance": "Aparência", + "language": "Idioma", + "theme": "Tema", + "darkMode": "Modo escuro", + "lightMode": "Modo claro", + "systemDefault": "Padrão do sistema" + }, + "error": { + "general": "Ocorreu um erro", + "network": "Erro de rede - tente novamente mais tarde", + "unauthorized": "Ação não autorizada", + "notFound": "Recurso não encontrado", + "serverError": "Erro interno do servidor", + "validationError": "Verifique os dados e tente novamente", + "timeout": "Tempo limite excedido", + "unknown": "Erro desconhecido", + "sessionExpired": "Sessão expirada. Faça login novamente.", + "invalidCredentials": "E-mail ou senha inválidos", + "emailRequired": "E-mail obrigatório", + "passwordRequired": "Senha obrigatória", + "emailInvalid": "Digite um e-mail válido", + "passwordTooShort": "Senha deve ter pelo menos 8 caracteres", + "passwordsDontMatch": "As senhas não coincidem" + }, + "success": { + "saved": "Salvo com sucesso", + "deleted": "Excluído com sucesso", + "updated": "Atualizado com sucesso", + "created": "Criado com sucesso", + "sent": "Enviado com sucesso", + "copied": "Copiado para a área de transferência", + "emailSent": "E-mail enviado com sucesso", + "passwordReset": "Senha redefinida com sucesso", + "accountCreated": "Conta criada com sucesso", + "signedIn": "Login realizado com sucesso", + "signedOut": "Logout realizado com sucesso" + }, + "common": { + "yes": "Sim", + "no": "Não", + "ok": "OK", + "or": "ou", + "and": "e", + "optional": "Opcional", + "required": "Obrigatório", + "search": "Pesquisar", + "filter": "Filtrar", + "sort": "Ordenar", + "name": "Nome", + "email": "E-mail", + "date": "Data", + "time": "Hora", + "status": "Status", + "active": "Ativo", + "inactive": "Inativo", + "enabled": "Habilitado", + "disabled": "Desabilitado", + "public": "Público", + "private": "Privado", + "examples": "Exemplos" + }, + "footer": { + "privacy": "Política de Privacidade", + "terms": "Termos de Serviço", + "contact": "Contato", + "support": "Suporte", + "documentation": "Documentação", + "status": "Status", + "changelog": "Registro de Alterações", + "copyright": "© {{year}} OpenSecret. Todos os direitos reservados." + } +} diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 9ce367f2..62d1e7da 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ tauri-plugin = "2.1.1" tauri-plugin-deep-link = "2" tauri-plugin-opener = "2" tauri-plugin-os = "2" +tauri-plugin-localization = "0.2" tauri-plugin-sign-in-with-apple = "1.0.2" tokio = { version = "1.0", features = ["time"] } once_cell = "1.18.0" diff --git a/frontend/src-tauri/gen/apple/maple_iOS/Info.plist b/frontend/src-tauri/gen/apple/maple_iOS/Info.plist index 51603792..4b3be393 100644 --- a/frontend/src-tauri/gen/apple/maple_iOS/Info.plist +++ b/frontend/src-tauri/gen/apple/maple_iOS/Info.plist @@ -62,5 +62,12 @@ Maple needs access to your photo library to upload images to your AI conversations. NSCameraUsageDescription Maple needs access to your camera to take photos for your AI conversations. + CFBundleLocalizations + + en + fr + es + pt + \ No newline at end of file diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index a78e400b..576617b5 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ pub fn run() { .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_localization::init()) .setup(|app| { // Set up the deep link handler // Use a cloned handle with 'static lifetime @@ -204,7 +205,8 @@ pub fn run() { ) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_os::init()); + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_localization::init()); // Only add the Apple Sign In plugin on iOS #[cfg(all(not(desktop), target_os = "ios"))] diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 70e75e77..c8e27d89 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -46,6 +46,13 @@ "csp": "default-src 'self'; connect-src 'self' https://opensecret.cloud https://*.opensecret.cloud https://trymaple.ai https://*.trymaple.ai https://secretgpt.ai https://*.secretgpt.ai https://*.maple-ca8.pages.dev https://raw.githubusercontent.com localhost:*; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:" } }, + "tauri": { + "allowlist": { + "protocol": { + "asset": true + } + } + }, "bundle": { "active": true, "targets": "all", diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index de8ed404..02a11a5b 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -4,8 +4,10 @@ import { Button } from "./ui/button"; import { useOpenSecret } from "@opensecret/react"; import { Menu, X } from "lucide-react"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; export function TopNav() { + const { t } = useTranslation(); const os = useOpenSecret(); const navigate = useNavigate(); const matchRoute = useMatchRoute(); @@ -50,9 +52,9 @@ export function TopNav() { {/* Desktop Navigation */}
- Pricing + {t('navigation.pricing')} Proof - Teams + {t('navigation.teams')} navigate({ to: "/" })} className="bg-[#9469F8] text-[#111111] hover:bg-[#A57FF9] transition-colors" > - Chat + {t('navigation.chat')} ) : ( )} @@ -99,13 +101,13 @@ export function TopNav() {
setMobileMenuOpen(false)}> - Pricing + {t('navigation.pricing')} setMobileMenuOpen(false)}> Proof setMobileMenuOpen(false)}> - Teams + {t('navigation.teams')} - - - ); -} +// Initialize localization BEFORE we render anything +initI18n().then(() => { + console.log('[main] i18n initialized, rendering app...'); + + // Render the app + const rootElement = document.getElementById("root")!; + if (!rootElement.innerHTML) { + const root = createRoot(rootElement); + root.render( + + + + ); + } +}).catch((error) => { + console.error('[main] Failed to initialize i18n:', error); + + // Still render the app even if i18n fails, but show a warning + const rootElement = document.getElementById("root")!; + if (!rootElement.innerHTML) { + const root = createRoot(rootElement); + root.render( + + + + ); + } +}); diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index f902daeb..54300b66 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -8,6 +8,7 @@ import { Sidebar, SidebarToggle } from "@/components/Sidebar"; import { cva } from "class-variance-authority"; import { InfoContent } from "@/components/Explainer"; import { useState, useCallback, useEffect } from "react"; +import { useTranslation } from 'react-i18next'; import { Card, CardHeader } from "@/components/ui/card"; import { VerificationModal } from "@/components/VerificationModal"; import { TopNav } from "@/components/TopNav"; @@ -50,6 +51,7 @@ export const Route = createFileRoute("/")({ }); function Index() { + const { t } = useTranslation(); const navigate = useNavigate(); const localState = useLocalState(); @@ -162,7 +164,7 @@ function Index() { />

- Private AI Chat + {t('app.description')}

diff --git a/frontend/src/utils/i18n.ts b/frontend/src/utils/i18n.ts new file mode 100644 index 00000000..b0d0477a --- /dev/null +++ b/frontend/src/utils/i18n.ts @@ -0,0 +1,83 @@ +import i18next, { InitOptions } from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { invoke } from '@tauri-apps/api/core'; + +/** + * Resolve the locale to use: + * 1. Try the native Tauri localization plugin. + * 2. Fallback to the browser language. + */ +async function resolveLocale(): Promise { + try { + const native = await invoke('plugin:localization|get_locale'); + if (native) { + console.log('[i18n] Native locale detected:', native); + return native; + } + } catch (e) { + console.warn('[i18n] Native locale unavailable, using navigator.language', e); + } + + const browserLocale = navigator.language || 'en-US'; + console.log('[i18n] Using browser locale:', browserLocale); + return browserLocale; +} + +/** + * Load the JSON file that matches the locale. + * Vite's import.meta.glob creates a map of all json files in the locales folder. + * The path needs to be relative to the project root where public/ is located. + */ +const localeModules = import.meta.glob('/public/locales/*.json') as Record< + string, + () => Promise<{ default: unknown }> +>; + +async function loadResources(requested: string) { + const short = requested.split('-')[0]; // en-US → en + const path = `/public/locales/${short}.json`; + + console.log('[i18n] Looking for locale file:', path); + console.log('[i18n] Available locale modules:', Object.keys(localeModules)); + + const loader = localeModules[path]; + if (!loader) { + console.warn(`[i18n] Locale ${short} not found – falling back to English`); + const englishLoader = localeModules['/public/locales/en.json']; + if (!englishLoader) { + console.error('[i18n] English fallback not found!'); + return { en: { translation: {} } }; + } + const mod = await englishLoader(); + return { en: { translation: mod.default as Record } }; + } + + const mod = await loader(); + return { [short]: { translation: mod.default as Record } }; +} + +/** + * Initialize i18next – call once at startup. + */ +export async function initI18n(): Promise { + console.log('[i18n] Initializing i18next...'); + + const locale = await resolveLocale(); + const resources = await loadResources(locale); + const short = locale.split('-')[0]; // en-US → en + + console.log('[i18n] Using locale:', short); + console.log('[i18n] Resources loaded:', Object.keys(resources)); + + const options: InitOptions = { + lng: short, + fallbackLng: 'en', + resources, + interpolation: { escapeValue: false }, + react: { useSuspense: false }, + debug: import.meta.env.DEV // Enable debug logging in development + }; + + await i18next.use(initReactI18next).init(options); + console.log('[i18n] i18next initialized successfully'); +}