diff --git a/apps/api/api/index.ts b/apps/api/api/index.ts index ae90b8bb4..f1fe36266 100644 --- a/apps/api/api/index.ts +++ b/apps/api/api/index.ts @@ -7,6 +7,7 @@ import express, { Express, Request, Response } from 'express'; import morgan from 'morgan'; import { stream as loggerStream } from './config/logger'; import { metricsMiddleware } from './middleware/metrics.middleware'; +import { languageMiddleware } from './middleware/language.middleware'; import metricsRouter from './routes/metrics.routes'; import admin from 'firebase-admin'; import cors from 'cors'; @@ -113,6 +114,10 @@ app.use( // Audit log for sensitive routes. app.use(auditLogger); +// Resolve the user's UI language (query > body > Accept-Language) and expose it to +// all downstream services so AI generation replies in the right language. +app.use(languageMiddleware); + app.use('/projects', projectRoutes); app.use('/project', brandingRoutes); app.use('/project', diagramRoutes); diff --git a/apps/api/api/interfaces/express.interface.ts b/apps/api/api/interfaces/express.interface.ts index a6b0933c5..583d89a7f 100644 --- a/apps/api/api/interfaces/express.interface.ts +++ b/apps/api/api/interfaces/express.interface.ts @@ -3,6 +3,8 @@ import admin from 'firebase-admin'; export interface CustomRequest extends Request { user?: admin.auth.DecodedIdToken; + /** Resolved UI language ('en' | 'fr'), set by languageMiddleware. */ + language?: string; policyWarning?: { requiresFinalization: boolean; finalizeEndpoint: string; diff --git a/apps/api/api/middleware/language.middleware.ts b/apps/api/api/middleware/language.middleware.ts new file mode 100644 index 000000000..f1d8534cf --- /dev/null +++ b/apps/api/api/middleware/language.middleware.ts @@ -0,0 +1,24 @@ +import { Request, Response, NextFunction } from 'express'; +import { normalizeLanguage, runWithLanguage } from '../utils/request-language'; + +/** + * Resolve the user's UI language for the request and expose it two ways: + * - `req.language` (typed on CustomRequest) for direct access in controllers; + * - an AsyncLocalStorage context so PromptService (and any downstream service) + * can read it without threading the value through every call. + * + * Resolution priority: `?lang=` query > `language` body field > Accept-Language + * header > default 'en'. + */ +export function languageMiddleware(req: Request, _res: Response, next: NextFunction): void { + const fromQuery = typeof req.query.lang === 'string' ? req.query.lang : undefined; + const fromBody = + req.body && typeof req.body.language === 'string' ? (req.body.language as string) : undefined; + const headerValue = req.headers['accept-language']; + const fromHeader = typeof headerValue === 'string' ? headerValue.split(',')[0] : undefined; + + const language = normalizeLanguage(fromQuery || fromBody || fromHeader); + (req as Request & { language?: string }).language = language; + + runWithLanguage(language, () => next()); +} diff --git a/apps/api/api/services/BusinessPlan/businessPlan.service.ts b/apps/api/api/services/BusinessPlan/businessPlan.service.ts index d8bc14dac..3815715ba 100644 --- a/apps/api/api/services/BusinessPlan/businessPlan.service.ts +++ b/apps/api/api/services/BusinessPlan/businessPlan.service.ts @@ -8,6 +8,7 @@ import { GenericService, IPromptStep, ISectionResult } from '../common/generic.s import { SectionModel } from '../../models/section.model'; import { PdfService } from '../pdf.service'; import { cacheService, CacheOptions } from '../cache.service'; +import { getRequestLanguage } from '../../utils/request-language'; import crypto from 'crypto'; import { AGENT_COVER_PROMPT } from './prompts/agent-cover.prompt'; import { AGENT_COMPANY_SUMMARY_PROMPT } from './prompts/agent-company-summary.prompt'; @@ -89,7 +90,9 @@ export class BusinessPlanService extends GenericService { const typography = project.analysisResultModel?.branding?.typography || { primary: 'Arial, sans-serif', }; - const language = 'fr'; + // Use the user's request language instead of a hard-coded 'fr' so the plan is + // generated in the language selected in the UI (falls back to 'en'). + const language = getRequestLanguage() === 'fr' ? 'French' : 'English'; // Create brand context for all agents const brandContext = `Brand: ${brandName}\nLogo SVG: ${logoSvg}\nBrand Colors: ${JSON.stringify( diff --git a/apps/api/api/services/prompt.service.ts b/apps/api/api/services/prompt.service.ts index 7ca1a5975..b1f128924 100644 --- a/apps/api/api/services/prompt.service.ts +++ b/apps/api/api/services/prompt.service.ts @@ -9,6 +9,7 @@ dotenv.config(); import { LLMProvider, LLMOptions, AI_CONFIG } from '../config/ai.config'; import { withGeminiFallback } from '../utils/gemini-fallback'; +import { getRequestLanguage } from '../utils/request-language'; export { LLMProvider, LLMOptions }; @@ -24,6 +25,12 @@ export interface PromptConfig { userId?: string; promptType?: string; skipQuotaCheck?: boolean; + /** + * User UI language ('en' | 'fr'). When set, a language directive is injected so + * the model generates content in the requested language. Resolved from the + * request (query `lang` / body `language` / Accept-Language header) upstream. + */ + language?: string; } export interface AIChatMessage { @@ -44,6 +51,7 @@ export interface PromptRequest { userId?: string; promptType?: string; skipQuotaCheck?: boolean; + language?: string; } export interface AIResponse { @@ -422,6 +430,27 @@ export class PromptService { } } + /** + * Build a strong directive that forces the model to answer in the user's language. + * Returns an empty string when no (or an unknown) language is provided, leaving + * existing behavior unchanged. + */ + private buildLanguageDirective(language?: string): string { + if (!language) { + return ''; + } + const normalized = language.toLowerCase(); + const label = normalized.startsWith('fr') + ? 'French (Français)' + : normalized.startsWith('en') + ? 'English' + : null; + if (!label) { + return ''; + } + return `RESPONSE LANGUAGE (CRITICAL): You MUST write ALL generated content — every section, title, sentence, label and value — in ${label}. Do not mix languages. This instruction overrides any language implied by the examples or prompts below.`; + } + public async runPrompt(request: PromptConfig, messages: AIChatMessage[]): Promise { logger.info( `Running prompt for provider: ${request.provider}, model: ${ @@ -436,6 +465,7 @@ export class PromptService { userId, promptType, skipQuotaCheck = false, + language, } = request; if (!messages || messages.length === 0) { @@ -492,6 +522,25 @@ export class PromptService { logger.info('Applied prompt modifications'); } + // Force the output language. This is the single choke point for every AI + // feature/provider, so one directive here guarantees generated content is in + // the user's language (prevents wrong-language output). An explicit + // config.language wins; otherwise fall back to the request-scoped language. + const effectiveLanguage = language ?? getRequestLanguage(); + const languageDirective = this.buildLanguageDirective(effectiveLanguage); + if (languageDirective && modifiedMessages.length > 0) { + // Append to the LAST message rather than inserting a new system message: + // this keeps message roles/adjacency intact (Gemini rejects consecutive + // same-role turns) and benefits from recency for stronger adherence. + const lastIdx = modifiedMessages.length - 1; + const last = modifiedMessages[lastIdx]; + modifiedMessages = [ + ...modifiedMessages.slice(0, lastIdx), + { ...last, content: `${last.content}\n\n${languageDirective}` }, + ]; + logger.info(`Injected language directive (language=${effectiveLanguage}).`); + } + try { let result: string; switch (provider) { diff --git a/apps/api/api/utils/request-language.ts b/apps/api/api/utils/request-language.ts new file mode 100644 index 000000000..81fd01392 --- /dev/null +++ b/apps/api/api/utils/request-language.ts @@ -0,0 +1,32 @@ +import { AsyncLocalStorage } from 'async_hooks'; + +export type SupportedLanguage = 'en' | 'fr'; + +interface LanguageStore { + language: SupportedLanguage; +} + +/** + * Request-scoped language store. The language middleware seeds this per request so + * that any service (notably PromptService) can read the user's language without it + * being threaded through every function signature. + */ +const storage = new AsyncLocalStorage(); + +export function runWithLanguage(language: SupportedLanguage, fn: () => T): T { + return storage.run({ language }, fn); +} + +/** Current request's language, or undefined outside a request context. */ +export function getRequestLanguage(): SupportedLanguage | undefined { + return storage.getStore()?.language; +} + +/** Normalize an arbitrary language hint to a supported language (defaults to 'en'). */ +export function normalizeLanguage(value: unknown): SupportedLanguage { + return String(value ?? '') + .toLowerCase() + .startsWith('fr') + ? 'fr' + : 'en'; +} diff --git a/apps/appgen/apps/we-dev-client/src/api/appInfo.ts b/apps/appgen/apps/we-dev-client/src/api/appInfo.ts index 0a0e5268d..3fe39056a 100644 --- a/apps/appgen/apps/we-dev-client/src/api/appInfo.ts +++ b/apps/appgen/apps/we-dev-client/src/api/appInfo.ts @@ -1,21 +1,9 @@ +import { resolveInitialLocale } from "../utils/localeCookie"; + export const authService = { async appInfo() { - let language = "en"; - try { - const settingsConfig = JSON.parse( - localStorage.getItem("settingsConfig") || "{}" - ); - if (settingsConfig.language) { - language = settingsConfig.language; - } else { - // Get browser language setting - const browserLang = navigator.language.toLowerCase(); - // Set to Chinese if browser language is Chinese, otherwise English - language = browserLang.startsWith("zh") ? "zh" : "en"; - } - } catch (error) { - console.log(error); - } + // Shared cross-app cookie is the source of truth; falls back to browser lang. + const language = resolveInitialLocale(); const res = await fetch( `${process.env.REACT_APP_BASE_URL}/api/appInfo?language=${language}`, diff --git a/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx b/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx index 493be4a52..280391943 100644 --- a/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/AiChat/chat/index.tsx @@ -186,7 +186,7 @@ export const BaseChat = ({ uuid: propUuid }: { uuid?: string }) => { const textareaRef = useRef(null); const messagesEndRef = useRef(null); const { otherConfig } = useChatStore(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [checkCount, setCheckCount] = useState(0); const [visible, setVisible] = useState(false); const [baseModal, setBaseModal] = useState({ @@ -493,6 +493,9 @@ export const BaseChat = ({ uuid: propUuid }: { uuid?: string }) => { body: { model: baseModal.value, mode: mode, + // User UI language so the AI answers/generates content in the right language. + // (Distinct from otherConfig.backendLanguage, which is the target programming language.) + language: i18n.language, otherConfig: { ...otherConfig, extra: { diff --git a/apps/appgen/apps/we-dev-client/src/components/Settings/GeneralSettings.tsx b/apps/appgen/apps/we-dev-client/src/components/Settings/GeneralSettings.tsx index 698a356cc..39dd9c46c 100644 --- a/apps/appgen/apps/we-dev-client/src/components/Settings/GeneralSettings.tsx +++ b/apps/appgen/apps/we-dev-client/src/components/Settings/GeneralSettings.tsx @@ -614,7 +614,7 @@ export function GeneralSettings() { onChange={(value) => handleLanguageChange(value)} options={[ { value: "en", label: "English" }, - { value: "zh", label: "中文" }, + { value: "fr", label: "Français" }, ]} /> @@ -643,7 +643,7 @@ export function GeneralSettings() { onChange={(value) => handleLanguageChange(value)} options={[ { value: "en", label: "English" }, - { value: "zh", label: "中文" }, + { value: "fr", label: "Français" }, ]} /> diff --git a/apps/appgen/apps/we-dev-client/src/hooks/useInit.ts b/apps/appgen/apps/we-dev-client/src/hooks/useInit.ts index e9561e6f4..d9ad1e1ff 100644 --- a/apps/appgen/apps/we-dev-client/src/hooks/useInit.ts +++ b/apps/appgen/apps/we-dev-client/src/hooks/useInit.ts @@ -1,6 +1,7 @@ import useThemeStore from '@/stores/themeSlice'; import useUserStore from '@/stores/userSlice'; import i18n from '@/utils/i18'; +import { readLocaleCookie } from '@/utils/localeCookie'; import { useEffect } from 'react'; import useMCPStore from '@/stores/useMCPSlice'; import { eventEmitter } from '@/components/AiChat/utils/EventEmitter'; @@ -90,17 +91,18 @@ const useInit = (): { isDarkMode: boolean } => { // Version web - pas d'accès au système de fichiers local console.log('Local filesystem not available in web mode'); + // Language priority: shared cross-app cookie > legacy settingsConfig > browser. + // The shared `idem_lang` cookie is the source of truth across all Idem apps. const settingsConfig = JSON.parse(localStorage.getItem('settingsConfig') || '{}'); - if (settingsConfig.language) { + const cookieLang = readLocaleCookie(); + if (cookieLang) { + i18n.changeLanguage(cookieLang); + } else if (settingsConfig.language === 'en' || settingsConfig.language === 'fr') { i18n.changeLanguage(settingsConfig.language); } else { - // Get browser language settings const browserLang = navigator.language.toLowerCase(); - // If Chinese environment (including simplified and traditional), set to Chinese, otherwise set to English - const defaultLang = browserLang.startsWith('zh') ? 'zh' : 'en'; - + const defaultLang = browserLang.startsWith('fr') ? 'fr' : 'en'; i18n.changeLanguage(defaultLang); - // Save to local settings } // Version web - pas de gestion de proxy système diff --git a/apps/appgen/apps/we-dev-client/src/locale/fr.json b/apps/appgen/apps/we-dev-client/src/locale/fr.json new file mode 100644 index 000000000..d2f9384bb --- /dev/null +++ b/apps/appgen/apps/we-dev-client/src/locale/fr.json @@ -0,0 +1,411 @@ +{ + "Welcome to React": "Bienvenue sur React", + "common": { + "name": "nom", + "description": "description", + "edit": "modifier", + "more": "plus", + "delete": "supprimer", + "cancel": "Annuler", + "confirm": "Confirmer", + "open_directory_quota": "panneau de quota", + "login": "Connexion", + "close": "Fermer", + "please_login": "Veuillez vous connecter pour consulter votre quota", + "format": "Formater", + "formatting": "Formatage en cours..." + }, + "chat": { + "urlInput": { + "title": "Saisir l'adresse du site web", + "placeholder": "Veuillez saisir l'adresse du site web à capturer", + "button": "Ouvrir le site web", + "errorInvalid": "Veuillez saisir une URL valide" + }, + "tips": { + "title": "ce que vous souhaitez créer", + "description": "la fonction sketch d2c est uniquement disponible dans la version en ligne", + "uploadSketch": "Importer un fichier Sketch ou Figma", + "uploadImg": "Importer une image", + "uploadWebsite": "Importer un site web", + "game": "Générer un jeu de snake", + "hello": "Générer une belle page" + }, + "errors": { + "file_size_limit": "Le fichier {fileName} dépasse la limite de 5 Mo", + "upload_failed": "Échec de l'importation", + "paste_failed": "Échec du collage de l'image", + "add_image_failed": "Échec de l'ajout de l'image" + }, + "success": { + "images_uploaded": "Image importée avec succès", + "images_uploaded_multiple": "{count} images importées avec succès", + "image_pasted": "Image collée avec succès", + "images_pasted_multiple": "{count} images collées avec succès", + "image_added": "Image ajoutée à la saisie", + "images_added_multiple": "{count} images ajoutées à la saisie" + }, + "placeholders": { + "input": "Saisissez un message, appuyez sur Entrée pour envoyer, Maj+Entrée pour un saut de ligne...", + "tips": "Comment puis-je vous aider ? (Appuyez sur Ctrl+V pour coller des images)" + }, + "buttons": { + "send": "Envoyer", + "upload": "Importer une image", + "upload_tips": "Cliquez pour importer ou coller des images", + "upload_sketch": "Importer un fichier Sketch ou Figma", + "upload_sketch_tips": "Cliquez pour importer des fichiers Sketch ou Figma", + "upload_disabled": "Importation désactivée", + "upload_image": "Importer une image", + "waiting": "Veuillez attendre la fin du processus en cours", + "not_support_image": "Le modèle actuel ne prend pas en charge l'importation d'images", + "click_to_upload": "Cliquez pour importer ou coller des images", + "figma_integration": "Intégration Figma", + "figma_settings": "Paramètres Figma", + "figma_url": "URL du fichier Figma", + "figma_token": "Jeton d'accès Figma", + "enter_figma_url": "Saisissez l'URL du fichier Figma", + "enter_figma_token": "Saisissez le jeton d'accès Figma", + "mcp_disabled": "Outils MCP désactivés", + "mcp_tools": "Outils MCP", + "not_support_mcp": "Le modèle actuel ne prend pas en charge les outils MCP", + "click_to_use_mcp": "Cliquez pour utiliser les outils MCP" + }, + "examples": { + "title": "Vous pouvez me demander par exemple", + "prompts": { + "todo": "Créer une application de tâches avec React et Tailwind", + "blog": "Créer un blog simple avec Astro", + "cookie": "Créer un formulaire de consentement aux cookies avec Material UI", + "game": "Créer un jeu Space Invaders", + "center": "Comment centrer un div ?" + } + }, + "loading": { + "generating": "L'IA génère une réponse..." + }, + "modePlaceholders": { + "chat": "Ceci est le mode chat. Vous pouvez discuter directement avec l'IA et obtenir la réponse souhaitée.", + "builder": "Ceci est le mode builder, utilisé pour construire votre projet par le dialogue" + }, + "optimizePrompt": { + "title": "Optimiser le prompt", + "placeholder": "Saisissez votre prompt ici, nous vous aiderons à l'optimiser...", + "processing": "Traitement en cours...", + "cancel": "Annuler", + "loading": "Génération du design : le contenu est", + "design": "Générer le design", + "confirm": "Confirmer", + "button": "Optimiser le prompt", + "error": "Échec de l'optimisation du prompt :", + "designPlaceholder": "Saisissez votre prompt ici, nous vous aiderons à générer un design..." + }, + "regenerate_incomplete": "Veuillez vérifier quels fichiers ont été générés la dernière fois, reconstruire et poursuivre les fichiers de code incomplets de la dernière fois, sans qu'il soit nécessaire de régénérer les fichiers de code déjà terminés" + }, + "settings": { + "title": "Paramètres", + "language": "Langue", + "general": "Général", + "Quota": "quota", + "General": "Général", + "Language": "Langue", + "keyPlaceholder": "Veuillez saisir votre clé API", + "save": "Enregistrer", + "themeMode": "Mode du thème", + "themeModeLight": "Mode clair", + "themeModeDark": "Mode sombre", + "MCPServer": "Serveur MCP", + "InvalidProxyUrl": "URL de proxy non valide", + "mcp": { + "actions": "Actions", + "active": "Actif", + "addError": "Échec de l'ajout du serveur", + "addServer": "Ajouter un serveur", + "addSuccess": "Serveur ajouté avec succès", + "args": "Arguments", + "argsTooltip": "Chaque argument sur une nouvelle ligne", + "baseUrlTooltip": "URL de base du serveur distant", + "command": "Commande", + "commandRequired": "Veuillez saisir une commande", + "config_description": "Configurer les serveurs Model Context Protocol", + "confirmDelete": "Supprimer le serveur", + "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer le serveur ?", + "deleteError": "Échec de la suppression du serveur", + "deleteSuccess": "Serveur supprimé avec succès", + "dependenciesInstall": "Installer les dépendances", + "dependenciesInstalling": "Installation des dépendances...", + "description": "Description", + "duplicateName": "Un serveur portant ce nom existe déjà", + "editJson": "Modifier le JSON", + "editServer": "Modifier le serveur", + "env": "Variables d'environnement", + "envTooltip": "Format : CLE=valeur, une par ligne", + "findMore": "Trouver plus de serveurs MCP", + "install": "Installer", + "installError": "Échec de l'installation des dépendances", + "installSuccess": "Dépendances installées avec succès", + "jsonFormatError": "Erreur de format JSON", + "jsonModeHint": "Modifiez ici le format JSON de la configuration du serveur MCP. Cliquez sur confirmer pour enregistrer les modifications après édition.", + "jsonSaveError": "Échec de l'enregistrement de la configuration", + "jsonSaveSuccess": "Configuration enregistrée avec succès", + "missingDependencies": "est manquant, veuillez l'installer pour continuer.", + "name": "Nom", + "nameRequired": "Veuillez saisir un nom de serveur", + "noServers": "Aucun serveur configuré", + "unavailable": "La version web ne prend pas en charge MCP pour le moment. Restez à l'écoute.", + "invoke_tool": "Invoquer l'outil", + "invoke_tooling": "Invocation de l'outil", + "npx_list": { + "actions": "Actions", + "desc": "Rechercher et ajouter des paquets npm en tant que serveurs MCP", + "description": "Description", + "no_packages": "Aucun paquet trouvé", + "npm": "NPM", + "package_name": "Nom du paquet", + "scope_placeholder": "Saisissez le scope npm (par ex. @votre-org)", + "scope_required": "Veuillez saisir le scope npm", + "search": "Rechercher", + "search_error": "Erreur de recherche", + "title": "Liste des paquets NPX", + "usage": "Utilisation", + "version": "Version" + }, + "serverPlural": "serveurs", + "serverSingular": "serveur", + "title": "Serveurs MCP", + "toggleError": "Échec du basculement", + "type": "Type", + "updateError": "Échec de la mise à jour du serveur", + "updateSuccess": "Serveur mis à jour avec succès", + "url": "URL", + "invalidMcpFormat": "Format de configuration MCP non valide" + }, + "proxy": "Paramètres du proxy", + "noProxy": "Aucun proxy", + "systemProxy": "Proxy système", + "customProxy": "Proxy personnalisé", + "customProxyPlaceholder": "Saisissez la configuration du proxy", + "proxyHint": "Chaque configuration de proxy doit être sur une nouvelle ligne.\nFormat : protocole=adresse_proxy\nExemple :\nhttp=http://127.0.0.1:7890\nhttps=http://127.0.0.1:7890\nsocks5=socks5://127.0.0.1:7890", + "packageMirrors": "Miroirs de paquets", + "pythonMirror": "Miroir Python", + "nodeMirror": "Miroir Node", + "customMirror": "Miroir personnalisé", + "customMirrorPlaceholder": "Saisissez l'URL du miroir personnalisé", + "proxyApplied": "Paramètres du proxy appliqués avec succès", + "proxyError": "Échec de l'application des paramètres du proxy", + "unsupportedProxyProtocol": "Protocole de proxy non pris en charge. Veuillez utiliser http, https, socks4 ou socks5", + "invalidProxyHost": "Adresse d'hôte du proxy non valide", + "invalidProxyPort": "Numéro de port du proxy non valide (doit être un nombre compris entre 1 et 65535)", + "invalidProxyFormat": "Format d'adresse du proxy non valide", + "proxyUrlRequired": "Veuillez saisir une adresse de proxy", + "socksPortRequired": "Un proxy SOCKS doit spécifier un numéro de port", + "themeSystem": "Suivre le système", + "backend": { + "enable": "Activer le backend (bêta)", + "language": "Langage du backend", + "database": { + "type": "Type de base de données", + "none": "Aucun", + "url": "URL de la base de données", + "username": "Nom d'utilisateur de la base de données", + "password": "Mot de passe de la base de données" + } + } + }, + "preview": { + "noserver": "Aucun serveur en cours d'exécution", + "wxminiPreview": "Ouvrir les outils de développement WeChat Mini Program pour l'aperçu" + }, + "editor": { + "editor": "Éditeur", + "preview": "Aperçu", + "apiTest": "APITest", + "search_in_files": "Rechercher dans les fichiers...", + "diff": "diff" + }, + "header": { + "download": "Télécharger le code", + "deploy": "Déployer", + "deploying": "Déploiement en cours...", + "deploySuccess": "Déploiement réussi !", + "deployToCloud": "Votre projet a été déployé avec succès dans le cloud", + "accessLink": "Lien d'accès :", + "copy": "Copier", + "close": "Fermer", + "visitSite": "Visiter le site", + "open_directory": "Ouvrir le répertoire", + "error": { + "open_directory": "Échec de l'ouverture du répertoire", + "deploy_failed": "Échec du déploiement, veuillez réessayer plus tard" + } + }, + "explorer": { + "clear_all": "Tout effacer", + "explorer": "Explorateur" + }, + "sidebar": { + "start_new_chat": "Démarrer une nouvelle discussion", + "search": "Rechercher", + "chat_history": "Historique des discussions", + "my_subscription": "Mon abonnement", + "settings": "Paramètres", + "contact_us": "Nous contacter", + "personal_plan": "Forfait personnel" + }, + "usage": { + "usage": "Utilisation", + "monthly_usage": "utilisation mensuelle du quota", + "quota_used": "quota utilisé", + "remaining": "restant", + "billing_cycle": "Cycle de facturation", + "next_reset": "Prochaine réinitialisation", + "days": "jours", + "type": "type", + "buy_quote": "Acheter plus de quota", + "personal_plan": "Forfait personnel" + }, + "login": { + "guest": "Invité", + "title": "Veuillez vous connecter", + "click_to_login": "Cliquez pour vous connecter", + "AI_powered_development_platform": "Plateforme de développement propulsée par l'IA", + "email": "E-mail", + "password": "Mot de passe", + "sign_in": "Se connecter", + "signing_in": "Connexion en cours...", + "Failed to fetch": "Échec de la récupération", + "remember_me": "Se souvenir de moi", + "forgot_password": "Mot de passe oublié", + "By_signing_in_you_agree_to_our": "En vous connectant, vous acceptez nos", + "terms_of_service": "Conditions d'utilisation", + "Invalid password": "Mot de passe non valide", + "privacy_policy": "Politique de confidentialité", + "and": "et", + "need_an_account": "Besoin d'un compte", + "register": "S'inscrire", + "weChat": "WeChat", + "basic_information": "Informations de base, avatar, etc.", + "continue_with_wechat": "Continuer avec WeChat", + "connecting": "Connexion en cours...", + "sign_up_with_wechat": "S'inscrire avec WeChat", + "WeDev_will_access_the_following_GitHub_information": "WeDev accédera aux informations GitHub suivantes", + "WeDev_will_access_the_following_wechat_information": "WeDev accédera aux informations WeChat suivantes", + "basic_profile": "Profil de base", + "public_repositories": "Dépôts publics", + "and_Gists": "et Gists", + "continue_with_github": "Continuer avec GitHub", + "chat_limit_reached": "Limite de discussion atteinte", + "chat_limit_reached_tips": "Veuillez vous connecter pour continuer à discuter", + "usage_limit_reached": "Limite d'utilisation atteinte", + "usage_limit_reached_tips": "Vous avez atteint la limite maximale d'utilisation. Veuillez ajouter du quota pour continuer à utiliser le service." + }, + "register": { + "register_success": "Inscription réussie !", + "register_success_account": "Nous avons envoyé un e-mail de vérification dans votre boîte de réception, veuillez le vérifier rapidement.", + "process_login": "Passer à la connexion", + "create_account": "Créez votre compte Idem Appgen", + "creating_account": "Création du compte...", + "create_account_button": "Créer un compte", + "already_account": "Vous avez déjà un compte ?" + }, + "sketch": { + "title": "Éditeur Sketch", + "guide": { + "title": "Guide de conversion de design Sketch ou Figma en code", + "uploadTitle": "Importez votre design Sketch ou Figma", + "description": "Importez simplement votre design Sketch ou Figma, et nous générerons automatiquement pour vous un code frontend de haute qualité.", + "startButton": "Démarrer l'expérience D2C", + "supportText": "Prend en charge les formats de fichier .sketch ou .figma, taille de fichier jusqu'à 10 Mo", + "slide0": { + "title": "Sélectionnez la zone de design à générer", + "description": "Après l'import -> Cliquez sur le fichier dans l'arborescence de gauche ou cliquez sur la zone de design pour la sélectionner -> L'aperçu apparaît en bas à droite -> Cliquez sur le bouton en bas à droite pour obtenir le code" + }, + "slide1": { + "title": "Génération automatique de code de haute qualité", + "description": "Patientez un instant pendant que notre moteur d'IA analyse votre design et génère un code frontend précis" + }, + "slide2": { + "title": "Aperçu instantané", + "description": "Voyez le code généré en action !!!" + } + }, + "generating": "Génération en cours...", + "generate_success": "Généré avec succès", + "prompt": "1. Encapsulez les composants à partir des images lorsque c'est possible ; 2. Utilisez des liens blob pour les images ; 3. Créez une réplique exacte de l'image, référez-vous aux marges, tailles, polices et arrière-plans dans le tableau JSON ! 4. Utilisez TypeScript ; 5. Pour les éléments répétés dans l'image, utilisez des composants lorsque c'est possible sans casser le style d'origine ; 6. Utilisez des styles de dépassement de texte, identifiez le texte sur une seule ligne ou sur plusieurs lignes, tronquez avec '...' en cas de dépassement pour préserver l'esthétique ; 7. Utilisez les balises
, évitez

etc. ; 8. Simplifiez les noms CSS, préférez la mise en page flex plutôt que le positionnement absolu lorsque c'est possible ; 9. Corrigez les problèmes de z-index si nécessaire ; 10. Conservez un espacement précis conforme aux données de référence. Important : n'omettez aucun code !!!!", + "generate_failed": "Échec de la génération", + "get_code": "Obtenir le code", + "select_tech": "Sélectionnez la pile technologique", + "tech": { + "react": "React avec TypeScript et Tailwind", + "vue": "Vue3 avec TypeScript et Tailwind", + "miniprogram": "WeChat Mini Program" + }, + "prompts": { + "base": "Veuillez générer à partir du design importé", + "react": "Code React utilisant TypeScript et Tailwind CSS, le projet doit avoir un point d'entrée de démarrage, garantir que le projet peut s'exécuter et que le code respecte les bonnes pratiques et l'optimisation des performances.", + "vue": "Code Vue3 utilisant TypeScript et l'API Composition, avec un style Tailwind CSS, en respectant les bonnes pratiques de Vue.", + "miniprogram": "Code WeChat Mini Program utilisant le framework natif avec un style WXSS, en respectant les standards et bonnes pratiques de développement des Mini Program. Utilisez les unités rpx pour l'interface, et supprimez la barre d'état si elle est présente dans le design. Faites attention aux styles et à la largeur lors de l'écriture des mini programmes" + } + }, + "update": { + "version_update": "Mise à jour de version", + "release_date": "Date de publication" + }, + "weapi": { + "title": "Test d'API", + "edit": "Modifier", + "save_changes": "Enregistrer les modifications", + "send_request": "Envoyer la requête", + "api_collection": "Collection d'API", + "request_failed": "Échec de la requête", + "api_saved": "API enregistrée avec succès", + "import_success": "Liste d'API importée avec succès", + "delete_api_title": "Confirmer la suppression de l'API", + "delete_folder_title": "Confirmer la suppression du dossier", + "delete_api_confirm": "Êtes-vous sûr de vouloir supprimer l'API ?", + "delete_folder_confirm": "Êtes-vous sûr de vouloir supprimer le dossier et tout son contenu ?", + "new_api": "Nouvelle API", + "new_folder": "Nouveau dossier", + "add_api": "Ajouter une API", + "add_folder": "Ajouter un dossier", + "import": "Importer", + "export": "Exporter", + "edit_item": "Modifier {type}", + "add_item": "Ajouter un nouvel élément", + "item_type": "Type", + "item_name": "Nom", + "type_api": "API", + "type_folder": "Dossier", + "name_required": "Veuillez saisir un nom", + "url_required": "Veuillez saisir une URL", + "url": "URL", + "method": { + "GET": "GET", + "POST": "POST", + "PUT": "PUT", + "DELETE": "DELETE", + "PATCH": "PATCH", + "HEAD": "HEAD", + "OPTIONS": "OPTIONS" + } + }, + "forgotPassword": { + "title": "Réinitialiser le mot de passe", + "email": "Adresse e-mail", + "emailPlaceholder": "Saisissez votre e-mail", + "oldPassword": "Ancien mot de passe", + "oldPasswordPlaceholder": "Saisissez votre ancien mot de passe", + "newPassword": "Nouveau mot de passe", + "newPasswordPlaceholder": "Saisissez votre nouveau mot de passe", + "submit": "Mettre à jour le mot de passe", + "submitting": "Mise à jour en cours...", + "backToLogin": "Retour à la connexion", + "success": "Mot de passe mis à jour avec succès", + "error": { + "missingFields": "Veuillez remplir tous les champs", + "userNotFound": "Utilisateur introuvable", + "invalidOldPassword": "Ancien mot de passe non valide", + "updateFailed": "Échec de la mise à jour du mot de passe" + } + } +} diff --git a/apps/appgen/apps/we-dev-client/src/utils/i18.ts b/apps/appgen/apps/we-dev-client/src/utils/i18.ts index 0e4163a7c..c5b766086 100644 --- a/apps/appgen/apps/we-dev-client/src/utils/i18.ts +++ b/apps/appgen/apps/we-dev-client/src/utils/i18.ts @@ -1,7 +1,13 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import en from "../locale/en.json"; -import zh from "../locale/zh.json"; +import fr from "../locale/fr.json"; +import { + resolveInitialLocale, + readLocaleCookie, + writeLocaleCookie, + isSupportedLocale, +} from "./localeCookie"; const resources = { en: { @@ -9,20 +15,43 @@ const resources = { ...en, }, }, - zh: { + fr: { translation: { - ...zh, + ...fr, }, }, }; i18n.use(initReactI18next).init({ resources, - lng: "en", + // Initial language comes from the shared cross-app cookie so the choice made in + // any other Idem app (landing, dashboard…) is honored here. + lng: resolveInitialLocale(), + fallbackLng: "en", interpolation: { escapeValue: false, }, }); +// Persist every language change to the shared cookie (source of truth across apps). +i18n.on("languageChanged", (lng) => { + if (isSupportedLocale(lng)) { + writeLocaleCookie(lng); + } +}); + +// When returning to this tab, pick up a language another Idem app may have set. +if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", () => { + if (document.visibilityState !== "visible") { + return; + } + const cookieLang = readLocaleCookie(); + if (cookieLang && cookieLang !== i18n.language) { + i18n.changeLanguage(cookieLang); + } + }); +} + export default i18n; diff --git a/apps/appgen/apps/we-dev-client/src/utils/localeCookie.ts b/apps/appgen/apps/we-dev-client/src/utils/localeCookie.ts new file mode 100644 index 000000000..60411478c --- /dev/null +++ b/apps/appgen/apps/we-dev-client/src/utils/localeCookie.ts @@ -0,0 +1,58 @@ +/** + * Cross-application locale cookie helper (shared contract across all Idem apps). + * + * Single source of truth for the UI language: the `idem_lang` cookie. In production + * every app lives under `*.idem.africa`, so the cookie is written with + * `domain=.idem.africa` (shared across sub-domains). In dev everything runs on + * `localhost` (different ports), where cookies are not isolated by port — a host-only + * cookie is therefore already shared across the dev apps. + * + * Keep this contract identical to the other Idem apps; any divergence breaks sync. + */ +export const LOCALE_COOKIE_NAME = "idem_lang"; + +export const SUPPORTED_LOCALES = ["en", "fr"] as const; +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +const ONE_YEAR_SECONDS = 31536000; + +export function isSupportedLocale( + value: string | null | undefined +): value is SupportedLocale { + return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +/** Read the shared locale cookie. Returns null when absent/unsupported. */ +export function readLocaleCookie(): SupportedLocale | null { + if (typeof document === "undefined") { + return null; + } + const match = document.cookie.match(/(?:^|;\s*)idem_lang=([^;]+)/); + const value = match ? decodeURIComponent(match[1]) : null; + return isSupportedLocale(value) ? value : null; +} + +/** Write the shared locale cookie with the correct domain scope for the current host. */ +export function writeLocaleCookie(lang: SupportedLocale): void { + if (typeof document === "undefined") { + return; + } + const host = typeof location !== "undefined" ? location.hostname : ""; + const onIdem = host.endsWith("idem.africa"); + const scope = onIdem ? "; domain=.idem.africa; Secure" : ""; + document.cookie = `${LOCALE_COOKIE_NAME}=${lang}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax${scope}`; +} + +/** + * Resolve the initial UI language: shared cookie > browser language > 'en'. + */ +export function resolveInitialLocale(): SupportedLocale { + const fromCookie = readLocaleCookie(); + if (fromCookie) { + return fromCookie; + } + if (typeof navigator !== "undefined" && navigator.language) { + return navigator.language.toLowerCase().startsWith("fr") ? "fr" : "en"; + } + return "en"; +} diff --git a/apps/appgen/apps/we-dev-next/src/config/prompts.ts b/apps/appgen/apps/we-dev-next/src/config/prompts.ts index d68b4bcda..0c2e6973f 100644 --- a/apps/appgen/apps/we-dev-next/src/config/prompts.ts +++ b/apps/appgen/apps/we-dev-next/src/config/prompts.ts @@ -10,8 +10,24 @@ export interface PromptExtra { extra: Record; } -export function getSystemPrompt(): string { +/** + * Build a directive forcing the AI to produce user-facing content in the user's + * UI language. Code, identifiers, tags and technical tokens stay unchanged. + */ +export function getLanguageDirective(language?: string): string { + const isFr = (language || 'en').toLowerCase().startsWith('fr'); + const label = isFr ? 'French (Français)' : 'English'; return ` + +RESPONSE LANGUAGE (CRITICAL): All user-facing content you generate — UI text, copy, +headings, labels, button text, placeholder/demo data, testimonials and any message +addressed to the user — MUST be written in ${label}. Keep code, identifiers, file +paths, HTML/JSX tags and technical tokens unchanged. +`; +} + +export function getSystemPrompt(language?: string): string { + return getLanguageDirective(language) + ` You are an expert web developer. Generate complete, production-ready code with professional architecture. TECHNICAL CONSTRAINTS: @@ -181,8 +197,8 @@ This platform primarily targets Sub-Saharan Africa. ALL generated content MUST r `; } -export function buildSystemPrompt(): string { - return getSystemPrompt(); +export function buildSystemPrompt(language?: string): string { + return getSystemPrompt(language); } export const CONTINUE_PROMPT = stripIndents` @@ -193,8 +209,9 @@ export const CONTINUE_PROMPT = stripIndents` export function buildMaxSystemPrompt( filesPath: string[], files: Record, - diffString: string + diffString: string, + language?: string ): string { - return `Current file directory tree: ${filesPath.join('\n')}\n\n,You can only modify the contents within the directory tree, requirements: ${getSystemPrompt()} + return `Current file directory tree: ${filesPath.join('\n')}\n\n,You can only modify the contents within the directory tree, requirements: ${getSystemPrompt(language)} Current requirement file contents:\n${JSON.stringify(files)}${diffString ? `,diff:\n${diffString}` : ''}`; } diff --git a/apps/appgen/apps/we-dev-next/src/handlers/builderHandler.ts b/apps/appgen/apps/we-dev-next/src/handlers/builderHandler.ts index 1d3e805e9..978656969 100644 --- a/apps/appgen/apps/we-dev-next/src/handlers/builderHandler.ts +++ b/apps/appgen/apps/we-dev-next/src/handlers/builderHandler.ts @@ -17,7 +17,8 @@ export async function handleBuilderMode( userId: string | null, otherConfig?: PromptExtra, tools?: ToolInfo[], - projectData?: ProjectModel + projectData?: ProjectModel, + language?: string ) { const startTime = Date.now(); @@ -172,7 +173,7 @@ export async function handleBuilderMode( console.log('🔧 Building system prompt...'); ChatLogger.info('SYSTEM_PROMPT', 'Building system prompt...'); - const systemPrompt = buildSystemPrompt(); + const systemPrompt = buildSystemPrompt(language); console.log(' System prompt length:', systemPrompt.length, 'characters'); console.log( ' System prompt preview (first 300 chars):', @@ -299,7 +300,7 @@ REQUIREMENTS: console.log('⚠️ USING FALLBACK CONTENT...'); ChatLogger.warn('FALLBACK', 'Using fallback content due to error'); - const systemInstructions = buildSystemPrompt(); + const systemInstructions = buildSystemPrompt(language); const userRequest = `Error processing project data: ${error instanceof Error ? error.message : 'Unknown error'}. ` + 'Please generate a basic web application structure.\n\n' + @@ -330,7 +331,7 @@ REQUIREMENTS: const historyDiffString = getHistoryDiff(historyMessages, filesPath, nowFiles); ChatLogger.info('MAX_PROMPT', 'Building max system prompt for large content'); - const maxPrompt = buildMaxSystemPrompt(filesPath, nowFiles, historyDiffString); + const maxPrompt = buildMaxSystemPrompt(filesPath, nowFiles, historyDiffString, language); const userRequest = 'My question is: ' + messages[messages.length - 1].content + @@ -344,7 +345,7 @@ REQUIREMENTS: }); } else { ChatLogger.info('STANDARD_PROMPT', 'Building standard system prompt'); - const standardPrompt = buildSystemPrompt(); + const standardPrompt = buildSystemPrompt(language); const userRequest = 'My question is: ' + messages[messages.length - 1].content + diff --git a/apps/appgen/apps/we-dev-next/src/handlers/chatHandler.ts b/apps/appgen/apps/we-dev-next/src/handlers/chatHandler.ts index d36f6323a..3c21932cd 100644 --- a/apps/appgen/apps/we-dev-next/src/handlers/chatHandler.ts +++ b/apps/appgen/apps/we-dev-next/src/handlers/chatHandler.ts @@ -1,7 +1,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Messages, ToolInfo } from '../types/project.js'; import { streamTextFn, StreamingOptions } from '../services/aiService.js'; -import { CONTINUE_PROMPT } from '../config/prompts.js'; +import { CONTINUE_PROMPT, getLanguageDirective } from '../config/prompts.js'; import { deductUserTokens, estimateTokens } from '../utils/tokens.js'; import SwitchableStream from '../utils/switchableStream.js'; import { tool } from 'ai'; @@ -14,11 +14,19 @@ export async function handleChatMode( messages: Messages, model: string, userId: string | null, - tools?: ToolInfo[] + tools?: ToolInfo[], + language?: string ) { const stream = new SwitchableStream(); let toolList = {}; + // Prepend a system message so the assistant replies in the user's UI language. + messages.unshift({ + id: uuidv4(), + role: 'system', + content: getLanguageDirective(language), + }); + if (tools && tools.length > 0) { toolList = tools.reduce( (obj, { name, ...args }) => { diff --git a/apps/appgen/apps/we-dev-next/src/routes/chat.ts b/apps/appgen/apps/we-dev-next/src/routes/chat.ts index 870de80d7..a56e56699 100644 --- a/apps/appgen/apps/we-dev-next/src/routes/chat.ts +++ b/apps/appgen/apps/we-dev-next/src/routes/chat.ts @@ -34,8 +34,18 @@ router.post('/', async (req: Request, res: Response) => { otherConfig, tools, projectData, + language, } = req.body as ChatRequest; + // User UI language (from the client) so the AI generates content in the right + // language. Falls back to the Accept-Language header, then English. + const acceptFr = (req.headers['accept-language'] || '') + .toString() + .toLowerCase() + .startsWith('fr'); + const resolvedLanguage = + language === 'fr' || language === 'en' ? language : acceptFr ? 'fr' : 'en'; + const userId = req.headers['userid'] as string | null; console.log('\n REQUEST DATA:'); @@ -110,11 +120,19 @@ router.post('/', async (req: Request, res: Response) => { if (mode === ChatMode.Chat) { console.log(' Delegating to handleChatMode'); ChatLogger.info('HANDLER', 'Delegating to handleChatMode'); - result = await handleChatMode(messages, model, userId, tools); + result = await handleChatMode(messages, model, userId, tools, resolvedLanguage); } else { console.log(' Delegating to handleBuilderMode'); ChatLogger.info('HANDLER', 'Delegating to handleBuilderMode'); - result = await handleBuilderMode(messages, model, userId, otherConfig, tools, projectData); + result = await handleBuilderMode( + messages, + model, + userId, + otherConfig, + tools, + projectData, + resolvedLanguage + ); } const duration = Date.now() - startTime; diff --git a/apps/appgen/apps/we-dev-next/src/types/project.ts b/apps/appgen/apps/we-dev-next/src/types/project.ts index 0561abe9a..70cb3dfec 100644 --- a/apps/appgen/apps/we-dev-next/src/types/project.ts +++ b/apps/appgen/apps/we-dev-next/src/types/project.ts @@ -121,4 +121,6 @@ export interface ChatRequest { }; tools?: ToolInfo[]; projectData?: ProjectModel; + /** User UI language ('en' | 'fr') so the AI generates content in the right language. */ + language?: string; } diff --git a/apps/ideploy-web/package.json b/apps/ideploy-web/package.json index cedd54d2a..4010ec47f 100644 --- a/apps/ideploy-web/package.json +++ b/apps/ideploy-web/package.json @@ -22,6 +22,8 @@ "@angular/router": "^20.0.0", "@fortawesome/fontawesome-free": "^6.7.2", "@idem/shared-styles": "file:../../packages/shared-styles", + "@ngx-translate/core": "^17.0.0", + "@ngx-translate/http-loader": "^17.0.0", "@primeng/themes": "^20.0.0", "@tailwindcss/postcss": "^4.1.16", "laravel-echo": "^2.1.5", diff --git a/apps/ideploy-web/public/assets/i18n/en.json b/apps/ideploy-web/public/assets/i18n/en.json new file mode 100644 index 000000000..b7421e861 --- /dev/null +++ b/apps/ideploy-web/public/assets/i18n/en.json @@ -0,0 +1,506 @@ +{ + "landing": { + "logoAlt": "EPLOY Logo", + "navShowcase": "Showcase", + "navPlatform": "Platform", + "navPricing": "Pricing", + "navDashboard": "Dashboard", + "logout": "Logout", + "login": "Log in", + "getStarted": "Get started", + "heroImageAlt": "Server Room", + "heroTitleLine1": "Deploy apps,", + "heroTitleAccent": "Not servers", + "heroSubtitle": "The leading platform to deploy, scale, and secure your applications without leaving your workspace. Powered by Idem.", + "getStartedFree": "Get started for free", + "talkToSales": "Talk to sales", + "supportedStacks": "Supported Stacks & Partners", + "showcaseTitle": "Explore what", + "showcaseTitleAccent": "people are building", + "showcaseSubtitle": "From complex microservices architectures to simple static portfolios, see how Eploy powers the independent web.", + "joinCommunity": "Join the community", + "pillar1Title": "Push to", + "pillar1TitleAccent": "deploy", + "pillar1TitleRest": "It's that simple.", + "pillar1Body": "Connect your GitHub repo, select your branch, and hit deploy. EPLOY automatically builds your Docker image, provisions an SSL cert, and launches your app securely.", + "pillar1Feature1": "Automatic builds based on Nixpacks", + "pillar1Feature2": "Pre-configured Let's Encrypt SSL", + "pillar1ImageAlt": "Code deployment log", + "productionReady": "Production Ready", + "deploymentLog": "deployment-39ffa2z completed in 12.4s", + "pillar2ImageAlt": "Server clusters data", + "pillar2Title": "Instantiate DBs", + "pillar2TitleMid": "in", + "pillar2TitleAccent": "1-Click", + "pillar2Body": "Stop manually configuring databases. Provision Postgres, Redis, MongoDB, or MySQL instances in one click. Completely managed, inherently secure, with native scheduled backups.", + "pillar2Feature1": "Scheduled S3 backups natively supported", + "pillar2Feature2": "Automatic environment variable tunneling", + "featuresTitle": "Everything you", + "featuresTitleAccent": "need", + "featuresTitleRest": "Nothing you don't.", + "feature1Title": "Any Language", + "feature1Body": "Nixpacks integration means if it builds on your machine, it builds on Eploy. No Dockerfile required.", + "feature2Title": "Auto SSL", + "feature2Body": "We automatically generate and renew Let's Encrypt certificates for all your connected custom domains.", + "feature3Title": "PR Previews", + "feature3Body": "Every pull request gets its own isolated deployment environment. Review changes live before merging to production. Automatically destroyed when merged, keeping costs at zero.", + "building": "Building...", + "testimonialQuote": "Since migrating our entire agency server fleet to Eploy, our deployment velocity increased by 400% while server overhead dropped to zero. It's the Heroku alternative we actually own.", + "testimonialRole": "Lead DevOps, Systema", + "rolesTitle": "Built for how you", + "rolesTitleAccent": "work", + "howItWorksTitleAccent": "How", + "howItWorksTitle": "it works", + "step1Title": "1. Connect Provider", + "step1Body": "Link your GitHub, GitLab, or Bitbucket account. We only ask for the permissions we absolutely need.", + "step2Title": "2. Define Destination", + "step2Body": "Add any Linux server using an SSH key. A $4/mo Hetzner VPS works just fine.", + "step3Title": "3. Deploy", + "step3Body": "Hit deploy. We automatically build the container and route the traffic. It just works.", + "ctaTitle": "Ready to", + "ctaTitleAccent": "host?", + "ctaSubtitle": "5 free deployments, a free domain with automatic SSL and commercial use allowed from day one. Paid plans start at 2 999 F/month.", + "viewPricing": "View pricing", + "ctaLicense": "MIT Licensed. Open Source forever.", + "footerCopyright": "© {{year}} EPLOY · Powered seamlessly by Idem Frameworks", + "signIn": "Sign In", + "githubRepo": "GitHub Repository" + }, + "pricing": { + "logoAlt": "EPLOY Logo", + "navShowcase": "Showcase", + "navPlatform": "Platform", + "navPricing": "Pricing", + "navDashboard": "Dashboard", + "login": "Log in", + "getStarted": "Get started", + "heroTitle": "Generous at entry.", + "heroTitleAccent": "Priced for growth.", + "heroSubtitle": "Every account gets 5 free deployments, a free yourapp.idem.africa domain and free custom domains with automatic SSL. Commercial use allowed from day one — even on the free plan.", + "heroNote": "Prices in FCFA · Mobile Money & card accepted · Annual = 2 months free", + "mostPopular": "Most popular", + "perMonth": "/month", + "payTitle": "No subscription?", + "payTitleAccent": "Pay per deployment.", + "paySubtitle": "Your first 5 deployments are free — enough to put your first app in production and iterate. After that, pay as you ship.", + "payCard1Label": "per deployment", + "payCard2Label": "pack of 10 deployments", + "payCard3Value": "Unlimited", + "payCard3Label": "on any paid plan", + "managedTitle": "Managed services", + "managedTitleAccent": "à la carte", + "managedSubtitle": "Like a cloud provider: activate each service in one click from the dashboard, billed monthly, no commitment.", + "overagesTitle": "Overages,", + "overagesTitleAccent": "head to head", + "overagesSubtitle": "When your app grows past its plan, the bill grows with it — not against it.", + "colResource": "Resource", + "byosTitle": "Bring your", + "byosTitleAccent": "own server", + "byosBody": "Connect your own VPS and let iDeploy manage the control plane — deployments, SSL, monitoring, backups. Total sovereignty over your hardware, zero PaaS tax. 1 BYOS server included on every plan (unlimited on Scale).", + "byosFeature1": "Your hardware, our automation", + "byosFeature2": "Extra BYOS server: 1 500 F/month", + "byosFeature3": "Code and data exportable at any time", + "ctaTitle": "Your first app is", + "ctaTitleAccent": "on us", + "ctaSubtitle": "Free domain, free SSL, 5 free deployments, commercial use allowed. Start now, upgrade when your app grows.", + "getStartedFree": "Get started for free", + "backToHome": "Back to home", + "footerCopyright": "© {{year}} EPLOY · Powered seamlessly by Idem Frameworks", + "signIn": "Sign In" + }, + "dashboard": { + "searchProjects": "Search Projects…", + "gridView": "Grid view", + "listView": "List view", + "newProject": "New Project", + "usage": "Usage", + "currentLimit": "Current Limit", + "upgrade": "Upgrade", + "alerts": "Alerts", + "getNotified": "Get notified about your deployments", + "alertsDescription": "Slack, Discord, Telegram, email — be alerted when a deploy fails or a server goes down.", + "configureNotifications": "Configure notifications", + "recentDeployments": "Recent Deployments", + "recentDeploymentsEmpty": "Deployments you trigger will appear here.", + "projectHistory": "Project History", + "noProjects": "No projects in history. Use the import block below to deploy your first project.", + "importProject": "Import Project", + "importProjectDescription": "Deploy your app directly from a GitHub repository or Git URL.", + "importRepository": "Import Repository", + "addServer": "Add a server", + "startWithTemplate": "Start with a template", + "available": "{{count}} available", + "deploy": "Deploy", + "browseTemplates": "Browse Templates", + "browseTemplatesDescription": "Databases, stacks and one-click apps", + "metricApplications": "Applications", + "metricServers": "Servers", + "metricDatabases": "Databases", + "metricProjects": "Projects", + "oneClickService": "One-click service", + "loadError": "Failed to load dashboard data. Please reload.", + "deploymentFailed": "Deployment failed" + }, + "shell": { + "admin": "ADMIN", + "apps": "Apps", + "srv": "Srv", + "userMenu": "User menu", + "logout": "Log out", + "myTeam": "My Team", + "aiSmartDeploy": "AI Smart Deploy", + "soon": "SOON", + "nav": { + "dashboard": "Dashboard", + "sectionDeploy": "Deploy", + "projects": "Projects", + "templates": "Templates", + "sectionResources": "Resources", + "servers": "Servers", + "applications": "Applications", + "databases": "Databases", + "services": "Services", + "sources": "Sources", + "destinations": "Destinations", + "storages": "S3 Storages", + "tags": "Tags", + "sectionConfiguration": "Configuration", + "settings": "Settings", + "sharedVariables": "Shared Variables", + "notifications": "Notifications", + "keysTokens": "Keys & Tokens", + "team": "Team" + } + }, + "applications": { + "open": "Open", + "deploy": "Deploy", + "branch": "Branch", + "loading": "Loading…", + "list": { + "title": "Applications", + "cancel": "Cancel", + "newApplication": "+ New application", + "name": "Name", + "environmentId": "Environment ID", + "gitRepositoryUrl": "Git repository URL", + "destinationId": "Destination ID", + "creating": "Creating…", + "createApplication": "Create application", + "empty": "No applications yet.", + "statusLabel": "status:", + "queuing": "Queuing…", + "createFailed": "Failed to create application" + }, + "detail": { + "restart": "Restart", + "stop": "Stop", + "configuration": "Configuration", + "gitRepository": "Git repository", + "buildPack": "Build pack", + "fqdn": "FQDN", + "save": "Save", + "environmentVariables": "Environment variables", + "remove": "remove", + "keyPlaceholder": "KEY", + "valuePlaceholder": "value", + "add": "Add", + "scheduledTasks": "Scheduled tasks", + "runNow": "run now", + "delete": "delete", + "namePlaceholder": "name", + "commandPlaceholder": "command", + "cronPlaceholder": "cron (e.g. 0 * * * *)", + "addTask": "Add task", + "persistentVolumes": "Persistent volumes", + "mountPathPlaceholder": "/mount/path", + "addVolume": "Add volume", + "operations": "Operations", + "status": "Status", + "metrics": "Metrics", + "execPlaceholder": "command to run in container", + "exec": "Exec", + "firewall": "Firewall (WAF)", + "firewallStats": "Enabled · {{blocked}} blocked / {{requests}} requests", + "ruleNamePlaceholder": "rule name", + "conditionsPlaceholder": "conditions JSON e.g. [{\"field\":\"uri\",\"operator\":\"contains\",\"value\":\"/admin\"}]", + "addRule": "Add rule", + "deployFirewall": "Deploy firewall", + "cicdPipeline": "CI/CD Pipeline", + "stagesLabel": "Stages:", + "enabled": "enabled", + "disabled": "disabled", + "runPipeline": "Run pipeline", + "deploymentHistory": "Deployment history", + "noDeployments": "No deployments yet.", + "redeployCommit": "Redeploy this commit" + } + }, + "projects": { + "common": { + "back": "Back", + "newProject": "New Project", + "cancel": "Cancel", + "deploy": "Deploy", + "loading": "Loading…", + "deploymentFailed": "Deployment failed", + "errCreateProject": "Failed to create project" + }, + "import": { + "importingFromGit": "Importing from Git", + "configureDeploy": "Configure your project parameters and deploy.", + "team": "Team", + "projectName": "Project Name", + "appPreset": "Application Preset", + "autoDetected": "Auto-detected from the repository — change if needed.", + "buildMethod": "Build method", + "useDocker": "Use Docker — build the repo's Dockerfile", + "withoutDocker": "Without Docker — run the app directly (no containerization)", + "dockerfileDetected": "A Dockerfile was detected — choose how to deploy.", + "noDockerfilePart1": "No Dockerfile detected — the app will be deployed", + "withoutDockerStrong": "without Docker", + "noDockerfilePart2": "(run directly in a base Node runtime).", + "rootDirectory": "Root Directory", + "rootDirHint": "The directory where your package.json / build settings are located.", + "buildOutputSettings": "Build and Output Settings", + "buildCommandPlaceholder": "Build command (optional, e.g. npm run build)", + "buildCommandLabel": "Build command", + "startCommandPlaceholder": "Start command (optional, e.g. npm run start)", + "startCommandLabel": "Start command", + "portPlaceholder": "Exposed port (e.g. 3000)", + "portLabel": "Exposed port", + "envVariables": "Environment Variables", + "envHint": "You can add environment variables after the first deploy, from the application's Environment tab.", + "settingUp": "Setting up…", + "useLocalMachine": "Use this machine (local Docker)", + "addServer": "Add a server", + "localDockerHint": "Runs the deployment on your local Docker — perfect for testing.", + "deploying": "Deploying…", + "dockerDetectedTitle": "Docker Detected", + "dockerModalDesc": "We detected a Dockerfile or Docker Compose configuration in your repository. Would you like to deploy using Docker containerization, or run the app directly in a base Node runtime?", + "deployWithDocker": "Deploy with Docker", + "deployWithDockerDesc": "Use your custom Docker configuration.", + "deployWithoutDocker": "Deploy without Docker", + "deployWithoutDockerDesc": "Run directly in our optimized Node runtime.", + "confirmDeploy": "Confirm & Deploy", + "errLocalDockerUnreachable": "Local server created, but Docker is not reachable. Is Docker Desktop running?", + "errLocalSetup": "Failed to set up local server", + "errMissingRepo": "Missing repository URL." + }, + "new": { + "heading": "Let's build something new", + "gitUrlPlaceholder": "Enter a Git repository URL…", + "gitUrlLabel": "Git repository URL", + "continue": "Continue", + "subheading": "Paste a public Git repository URL, pick one of your GitHub repos, or clone a template.", + "importGitRepo": "Import Git Repository", + "checkingGithub": "Checking GitHub connection…", + "connectGithubDesc": "Connect your GitHub account to import and deploy your repositories.", + "connectGithub": "Connect GitHub", + "searchReposPlaceholder": "Search repositories…", + "searchReposLabel": "Search repositories", + "noRepos": "No repositories found.", + "privateRepo": "Private repository", + "updated": "Updated:", + "import": "Import", + "disconnectGithub": "Disconnect GitHub", + "cloneTemplate": "Clone Template", + "browseAll": "Browse All", + "oneClickBoilerplate": "One-click boilerplate project", + "createEmpty": "Create Empty Project", + "createEmptyDesc": "Skip Git connection and start a blank environment from scratch.", + "errGithubNotConfigured": "GitHub integration is not configured on the Idem API (missing GITHUB_CLIENT_ID/SECRET). Ask an admin to set them, or paste a Git repository URL above instead.", + "errGithubUnreachable": "Could not reach the GitHub integration. Is the Idem API running?" + }, + "detail": { + "environment": "Environment:", + "envNumber": "env #", + "appNamePlaceholder": "app name", + "gitRepoPlaceholder": "git repository URL", + "branchPlaceholder": "branch", + "destinationIdPlaceholder": "destination id", + "newApplication": "New application" + }, + "list": { + "title": "Projects", + "newProject": "+ New project", + "name": "Name", + "namePlaceholder": "My project", + "description": "Description", + "creating": "Creating…", + "createProject": "Create project", + "noProjects": "No projects yet.", + "delete": "delete", + "viewDetails": "View details" + } + }, + "servers": { + "title": "Servers", + "addServerButton": "+ Add server", + "loading": "Loading…", + "empty": "No servers yet. Add one to get started.", + "reachable": "reachable", + "unreachable": "unreachable", + "installed": "installed", + "missing": "missing", + "proxyStatus": "Proxy: {{status}}", + "validate": "Validate", + "installDocker": "Install Docker", + "proxyStatusButton": "Proxy status", + "startProxy": "Start proxy", + "installCrowdSec": "Install CrowdSec", + "delete": "Delete", + "addServerTitle": "Add server", + "name": "Name", + "ipAddress": "IP address", + "sshUser": "SSH user", + "port": "Port", + "privateKeyId": "Private key ID", + "creating": "Creating…", + "createServer": "Create server", + "createError": "Failed to create server" + }, + "databases": { + "title": "Databases", + "loading": "Loading…", + "empty": "No databases yet.", + "start": "Start", + "stop": "Stop", + "backupNow": "Backup now", + "delete": "Delete", + "newDatabase": "New database", + "type": "Type", + "name": "Name", + "environmentId": "Environment ID", + "destinationId": "Destination ID", + "creating": "Creating…", + "createDatabase": "Create database", + "createError": "Failed to create database" + }, + "services": { + "title": "Services", + "loading": "Loading…", + "empty": "No services yet.", + "stop": "Stop", + "start": "Start", + "deployOneClick": "Deploy a one-click service", + "template": "Template", + "customNone": "— custom (none) —", + "name": "Name", + "environmentId": "Environment ID", + "destinationId": "Destination ID", + "dockerComposeLabel": "docker-compose.yml (ignored when a template is selected)", + "creating": "Creating…", + "createService": "Create service", + "createError": "Failed to create service" + }, + "sources": { + "title": "Git Sources", + "empty": "No Git sources configured (GitHub / GitLab apps)." + }, + "destinations": { + "title": "Destinations", + "empty": "No servers yet — add a server to create Docker destinations.", + "noDestinations": "No destinations.", + "network": "network", + "networkPlaceholder": "docker network (e.g. ideploy)", + "addDestination": "Add destination" + }, + "storages": { + "title": "S3 Storages", + "empty": "No S3 storages configured." + }, + "tags": { + "title": "Tags", + "loading": "Loading…", + "newTagPlaceholder": "New tag name", + "add": "Add" + }, + "settings": { + "title": "Settings", + "versionLabel": "iDeploy version:", + "autoUpdate": "Auto-update:", + "enabled": "enabled", + "disabled": "disabled", + "instanceSettings": "Instance settings", + "wildcardDomain": "Wildcard domain", + "registrationEnabled": "Registration enabled", + "save": "Save", + "globalSearch": "Global search", + "searchPlaceholder": "Search projects, servers, apps, services…" + }, + "notifications": { + "title": "Notifications", + "enabled": "Enabled", + "webhookPlaceholder": "Webhook URL / token", + "save": "Save", + "sendTest": "Send test", + "saved": "Saved", + "testSent": "Test sent ✓", + "testFailed": "Test failed" + }, + "security": { + "privateKeys": "Private keys", + "loading": "Loading…", + "noKeys": "No private keys yet.", + "addKeyTitle": "Add a key", + "name": "Name", + "privateKeyPem": "Private key (PEM)", + "saving": "Saving…", + "addKey": "Add key", + "addKeyFailed": "Failed to add key" + }, + "templates": { + "title": "Templates", + "searchPlaceholder": "Search templates (name, category, tag)…", + "addServer": "Add a server", + "noTemplates": "No templates available. (The service-templates catalog could not be loaded.)", + "deploying": "Deploying…", + "deploy": "Deploy", + "deploymentFailed": "Deployment failed" + }, + "subscription": { + "title": "Subscription", + "currentPlan": "Current plan:", + "apps": "Apps:", + "servers": "Servers:", + "limitReached": "(limit reached)", + "plans": "Plans", + "current": "CURRENT", + "subscribe": "Subscribe", + "switch": "Switch" + }, + "team": { + "title": "Team", + "members": "Members", + "invitations": "Invitations", + "revoke": "revoke", + "emailPlaceholder": "email", + "roleMember": "member", + "roleAdmin": "admin", + "invite": "Invite" + }, + "sharedVariables": { + "title": "Shared Variables", + "description": "Team-level variables reusable across all resources.", + "keyPlaceholder": "KEY", + "valuePlaceholder": "value", + "add": "Add" + }, + "deploy": { + "backToDashboard": "Back to Dashboard", + "deploymentLogs": "Deployment Logs", + "branch": "Branch:", + "statusQueued": "Queued", + "statusInProgress": "In Progress", + "statusSuccess": "Success", + "statusFailed": "Failed", + "visitApp": "Visit App", + "copyLogsTitle": "Copy logs to clipboard", + "copyLogs": "Copy logs", + "waitingForLogs": "Waiting for logs…" + }, + "auth": { + "signingIn": "Signing you in…" + } +} diff --git a/apps/ideploy-web/public/assets/i18n/fr.json b/apps/ideploy-web/public/assets/i18n/fr.json new file mode 100644 index 000000000..d09ee0159 --- /dev/null +++ b/apps/ideploy-web/public/assets/i18n/fr.json @@ -0,0 +1,506 @@ +{ + "landing": { + "logoAlt": "Logo EPLOY", + "navShowcase": "Vitrine", + "navPlatform": "Plateforme", + "navPricing": "Tarifs", + "navDashboard": "Tableau de bord", + "logout": "Déconnexion", + "login": "Se connecter", + "getStarted": "Commencer", + "heroImageAlt": "Salle des serveurs", + "heroTitleLine1": "Déployez des apps,", + "heroTitleAccent": "Pas des serveurs", + "heroSubtitle": "La plateforme de référence pour déployer, faire évoluer et sécuriser vos applications sans quitter votre espace de travail. Propulsé par Idem.", + "getStartedFree": "Commencer gratuitement", + "talkToSales": "Contacter l'équipe commerciale", + "supportedStacks": "Stacks & partenaires pris en charge", + "showcaseTitle": "Découvrez ce que", + "showcaseTitleAccent": "les gens construisent", + "showcaseSubtitle": "Des architectures microservices complexes aux simples portfolios statiques, découvrez comment Eploy propulse le web indépendant.", + "joinCommunity": "Rejoindre la communauté", + "pillar1Title": "Poussez pour", + "pillar1TitleAccent": "déployer", + "pillar1TitleRest": "C'est aussi simple que ça.", + "pillar1Body": "Connectez votre dépôt GitHub, choisissez votre branche et lancez le déploiement. EPLOY construit automatiquement votre image Docker, provisionne un certificat SSL et lance votre application en toute sécurité.", + "pillar1Feature1": "Builds automatiques basés sur Nixpacks", + "pillar1Feature2": "SSL Let's Encrypt préconfiguré", + "pillar1ImageAlt": "Journal de déploiement de code", + "productionReady": "Prêt pour la production", + "deploymentLog": "deployment-39ffa2z terminé en 12,4s", + "pillar2ImageAlt": "Données de clusters de serveurs", + "pillar2Title": "Créez des BDD", + "pillar2TitleMid": "en", + "pillar2TitleAccent": "1 clic", + "pillar2Body": "Finie la configuration manuelle des bases de données. Provisionnez des instances Postgres, Redis, MongoDB ou MySQL en un clic. Entièrement gérées, sécurisées par nature, avec des sauvegardes planifiées natives.", + "pillar2Feature1": "Sauvegardes S3 planifiées prises en charge nativement", + "pillar2Feature2": "Tunnelisation automatique des variables d'environnement", + "featuresTitle": "Tout ce dont vous", + "featuresTitleAccent": "avez besoin", + "featuresTitleRest": "Rien de superflu.", + "feature1Title": "N'importe quel langage", + "feature1Body": "Grâce à l'intégration de Nixpacks, si ça se compile sur votre machine, ça se compile sur Eploy. Aucun Dockerfile requis.", + "feature2Title": "SSL automatique", + "feature2Body": "Nous générons et renouvelons automatiquement les certificats Let's Encrypt pour tous vos domaines personnalisés connectés.", + "feature3Title": "Aperçus de PR", + "feature3Body": "Chaque pull request obtient son propre environnement de déploiement isolé. Passez les changements en revue en direct avant de fusionner en production. Détruit automatiquement lors de la fusion, ce qui maintient les coûts à zéro.", + "building": "Construction en cours...", + "testimonialQuote": "Depuis la migration de toute notre flotte de serveurs d'agence vers Eploy, notre vélocité de déploiement a augmenté de 400 % tandis que la charge serveur est tombée à zéro. C'est l'alternative à Heroku que nous possédons vraiment.", + "testimonialRole": "Lead DevOps, Systema", + "rolesTitle": "Conçu pour votre façon de", + "rolesTitleAccent": "travailler", + "howItWorksTitleAccent": "Comment", + "howItWorksTitle": "ça marche", + "step1Title": "1. Connecter le fournisseur", + "step1Body": "Reliez votre compte GitHub, GitLab ou Bitbucket. Nous ne demandons que les autorisations strictement nécessaires.", + "step2Title": "2. Définir la destination", + "step2Body": "Ajoutez n'importe quel serveur Linux à l'aide d'une clé SSH. Un VPS Hetzner à 4 $/mois fait très bien l'affaire.", + "step3Title": "3. Déployer", + "step3Body": "Lancez le déploiement. Nous construisons automatiquement le conteneur et acheminons le trafic. Ça fonctionne, tout simplement.", + "ctaTitle": "Prêt à", + "ctaTitleAccent": "héberger ?", + "ctaSubtitle": "5 déploiements gratuits, un domaine gratuit avec SSL automatique et usage commercial autorisé dès le premier jour. Les forfaits payants démarrent à 2 999 F/mois.", + "viewPricing": "Voir les tarifs", + "ctaLicense": "Sous licence MIT. Open source pour toujours.", + "footerCopyright": "© {{year}} EPLOY · Propulsé en toute fluidité par Idem Frameworks", + "signIn": "Se connecter", + "githubRepo": "Dépôt GitHub" + }, + "pricing": { + "logoAlt": "Logo EPLOY", + "navShowcase": "Vitrine", + "navPlatform": "Plateforme", + "navPricing": "Tarifs", + "navDashboard": "Tableau de bord", + "login": "Se connecter", + "getStarted": "Commencer", + "heroTitle": "Généreux à l'entrée.", + "heroTitleAccent": "Pensé pour la croissance.", + "heroSubtitle": "Chaque compte bénéficie de 5 déploiements gratuits, d'un domaine yourapp.idem.africa gratuit et de domaines personnalisés gratuits avec SSL automatique. Usage commercial autorisé dès le premier jour — même sur le forfait gratuit.", + "heroNote": "Prix en FCFA · Mobile Money & carte acceptés · Annuel = 2 mois offerts", + "mostPopular": "Le plus populaire", + "perMonth": "/mois", + "payTitle": "Pas d'abonnement ?", + "payTitleAccent": "Payez au déploiement.", + "paySubtitle": "Vos 5 premiers déploiements sont gratuits — de quoi mettre votre première app en production et itérer. Ensuite, payez au fil de vos livraisons.", + "payCard1Label": "par déploiement", + "payCard2Label": "pack de 10 déploiements", + "payCard3Value": "Illimité", + "payCard3Label": "sur tout forfait payant", + "managedTitle": "Services gérés", + "managedTitleAccent": "à la carte", + "managedSubtitle": "Comme un fournisseur cloud : activez chaque service en un clic depuis le tableau de bord, facturé mensuellement, sans engagement.", + "overagesTitle": "Dépassements,", + "overagesTitleAccent": "face à face", + "overagesSubtitle": "Quand votre app dépasse son forfait, la facture grandit avec elle — et non contre elle.", + "colResource": "Ressource", + "byosTitle": "Apportez votre", + "byosTitleAccent": "propre serveur", + "byosBody": "Connectez votre propre VPS et laissez iDeploy gérer le plan de contrôle — déploiements, SSL, supervision, sauvegardes. Souveraineté totale sur votre matériel, aucune taxe PaaS. 1 serveur BYOS inclus sur chaque forfait (illimité sur Scale).", + "byosFeature1": "Votre matériel, notre automatisation", + "byosFeature2": "Serveur BYOS supplémentaire : 1 500 F/mois", + "byosFeature3": "Code et données exportables à tout moment", + "ctaTitle": "Votre première app est", + "ctaTitleAccent": "offerte", + "ctaSubtitle": "Domaine gratuit, SSL gratuit, 5 déploiements gratuits, usage commercial autorisé. Commencez maintenant, montez en gamme quand votre app grandit.", + "getStartedFree": "Commencer gratuitement", + "backToHome": "Retour à l'accueil", + "footerCopyright": "© {{year}} EPLOY · Propulsé en toute fluidité par Idem Frameworks", + "signIn": "Se connecter" + }, + "dashboard": { + "searchProjects": "Rechercher des projets…", + "gridView": "Vue en grille", + "listView": "Vue en liste", + "newProject": "Nouveau projet", + "usage": "Utilisation", + "currentLimit": "Limite actuelle", + "upgrade": "Mettre à niveau", + "alerts": "Alertes", + "getNotified": "Soyez informé de vos déploiements", + "alertsDescription": "Slack, Discord, Telegram, e-mail — soyez alerté quand un déploiement échoue ou qu'un serveur tombe en panne.", + "configureNotifications": "Configurer les notifications", + "recentDeployments": "Déploiements récents", + "recentDeploymentsEmpty": "Les déploiements que vous lancez apparaîtront ici.", + "projectHistory": "Historique des projets", + "noProjects": "Aucun projet dans l'historique. Utilisez le bloc d'import ci-dessous pour déployer votre premier projet.", + "importProject": "Importer un projet", + "importProjectDescription": "Déployez votre application directement depuis un dépôt GitHub ou une URL Git.", + "importRepository": "Importer un dépôt", + "addServer": "Ajouter un serveur", + "startWithTemplate": "Commencer avec un modèle", + "available": "{{count}} disponibles", + "deploy": "Déployer", + "browseTemplates": "Parcourir les modèles", + "browseTemplatesDescription": "Bases de données, stacks et applications en un clic", + "metricApplications": "Applications", + "metricServers": "Serveurs", + "metricDatabases": "Bases de données", + "metricProjects": "Projets", + "oneClickService": "Service en un clic", + "loadError": "Échec du chargement des données du tableau de bord. Veuillez recharger.", + "deploymentFailed": "Échec du déploiement" + }, + "shell": { + "admin": "ADMIN", + "apps": "Apps", + "srv": "Srv", + "userMenu": "Menu utilisateur", + "logout": "Se déconnecter", + "myTeam": "Mon équipe", + "aiSmartDeploy": "Déploiement intelligent IA", + "soon": "BIENTÔT", + "nav": { + "dashboard": "Tableau de bord", + "sectionDeploy": "Déploiement", + "projects": "Projets", + "templates": "Modèles", + "sectionResources": "Ressources", + "servers": "Serveurs", + "applications": "Applications", + "databases": "Bases de données", + "services": "Services", + "sources": "Sources", + "destinations": "Destinations", + "storages": "Stockages S3", + "tags": "Étiquettes", + "sectionConfiguration": "Configuration", + "settings": "Paramètres", + "sharedVariables": "Variables partagées", + "notifications": "Notifications", + "keysTokens": "Clés et jetons", + "team": "Équipe" + } + }, + "applications": { + "open": "Ouvrir", + "deploy": "Déployer", + "branch": "Branche", + "loading": "Chargement…", + "list": { + "title": "Applications", + "cancel": "Annuler", + "newApplication": "+ Nouvelle application", + "name": "Nom", + "environmentId": "ID d'environnement", + "gitRepositoryUrl": "URL du dépôt Git", + "destinationId": "ID de destination", + "creating": "Création…", + "createApplication": "Créer l'application", + "empty": "Aucune application pour l'instant.", + "statusLabel": "statut :", + "queuing": "Mise en file…", + "createFailed": "Échec de la création de l'application" + }, + "detail": { + "restart": "Redémarrer", + "stop": "Arrêter", + "configuration": "Configuration", + "gitRepository": "Dépôt Git", + "buildPack": "Build pack", + "fqdn": "FQDN", + "save": "Enregistrer", + "environmentVariables": "Variables d'environnement", + "remove": "supprimer", + "keyPlaceholder": "CLÉ", + "valuePlaceholder": "valeur", + "add": "Ajouter", + "scheduledTasks": "Tâches planifiées", + "runNow": "exécuter maintenant", + "delete": "supprimer", + "namePlaceholder": "nom", + "commandPlaceholder": "commande", + "cronPlaceholder": "cron (p. ex. 0 * * * *)", + "addTask": "Ajouter une tâche", + "persistentVolumes": "Volumes persistants", + "mountPathPlaceholder": "/chemin/montage", + "addVolume": "Ajouter un volume", + "operations": "Opérations", + "status": "Statut", + "metrics": "Métriques", + "execPlaceholder": "commande à exécuter dans le conteneur", + "exec": "Exécuter", + "firewall": "Pare-feu (WAF)", + "firewallStats": "Activé · {{blocked}} bloquées / {{requests}} requêtes", + "ruleNamePlaceholder": "nom de la règle", + "conditionsPlaceholder": "JSON de conditions p. ex. [{\"field\":\"uri\",\"operator\":\"contains\",\"value\":\"/admin\"}]", + "addRule": "Ajouter une règle", + "deployFirewall": "Déployer le pare-feu", + "cicdPipeline": "Pipeline CI/CD", + "stagesLabel": "Étapes :", + "enabled": "activé", + "disabled": "désactivé", + "runPipeline": "Exécuter le pipeline", + "deploymentHistory": "Historique des déploiements", + "noDeployments": "Aucun déploiement pour l'instant.", + "redeployCommit": "Redéployer ce commit" + } + }, + "projects": { + "common": { + "back": "Retour", + "newProject": "Nouveau projet", + "cancel": "Annuler", + "deploy": "Déployer", + "loading": "Chargement…", + "deploymentFailed": "Échec du déploiement", + "errCreateProject": "Échec de la création du projet" + }, + "import": { + "importingFromGit": "Import depuis Git", + "configureDeploy": "Configurez les paramètres de votre projet et déployez.", + "team": "Équipe", + "projectName": "Nom du projet", + "appPreset": "Préréglage d'application", + "autoDetected": "Détecté automatiquement depuis le dépôt — modifiez si nécessaire.", + "buildMethod": "Méthode de build", + "useDocker": "Utiliser Docker — construire le Dockerfile du dépôt", + "withoutDocker": "Sans Docker — exécuter l'application directement (sans conteneurisation)", + "dockerfileDetected": "Un Dockerfile a été détecté — choisissez comment déployer.", + "noDockerfilePart1": "Aucun Dockerfile détecté — l'application sera déployée", + "withoutDockerStrong": "sans Docker", + "noDockerfilePart2": "(exécutée directement dans un runtime Node de base).", + "rootDirectory": "Répertoire racine", + "rootDirHint": "Le répertoire où se trouvent votre package.json / vos paramètres de build.", + "buildOutputSettings": "Paramètres de build et de sortie", + "buildCommandPlaceholder": "Commande de build (optionnel, ex. npm run build)", + "buildCommandLabel": "Commande de build", + "startCommandPlaceholder": "Commande de démarrage (optionnel, ex. npm run start)", + "startCommandLabel": "Commande de démarrage", + "portPlaceholder": "Port exposé (ex. 3000)", + "portLabel": "Port exposé", + "envVariables": "Variables d'environnement", + "envHint": "Vous pouvez ajouter des variables d'environnement après le premier déploiement, depuis l'onglet Environnement de l'application.", + "settingUp": "Configuration…", + "useLocalMachine": "Utiliser cette machine (Docker local)", + "addServer": "Ajouter un serveur", + "localDockerHint": "Exécute le déploiement sur votre Docker local — idéal pour les tests.", + "deploying": "Déploiement…", + "dockerDetectedTitle": "Docker détecté", + "dockerModalDesc": "Nous avons détecté une configuration Dockerfile ou Docker Compose dans votre dépôt. Souhaitez-vous déployer avec la conteneurisation Docker, ou exécuter l'application directement dans un runtime Node de base ?", + "deployWithDocker": "Déployer avec Docker", + "deployWithDockerDesc": "Utilisez votre configuration Docker personnalisée.", + "deployWithoutDocker": "Déployer sans Docker", + "deployWithoutDockerDesc": "Exécution directe dans notre runtime Node optimisé.", + "confirmDeploy": "Confirmer et déployer", + "errLocalDockerUnreachable": "Serveur local créé, mais Docker est injoignable. Docker Desktop est-il en cours d'exécution ?", + "errLocalSetup": "Échec de la configuration du serveur local", + "errMissingRepo": "URL du dépôt manquante." + }, + "new": { + "heading": "Créons quelque chose de nouveau", + "gitUrlPlaceholder": "Saisissez l'URL d'un dépôt Git…", + "gitUrlLabel": "URL du dépôt Git", + "continue": "Continuer", + "subheading": "Collez l'URL d'un dépôt Git public, choisissez l'un de vos dépôts GitHub ou clonez un modèle.", + "importGitRepo": "Importer un dépôt Git", + "checkingGithub": "Vérification de la connexion GitHub…", + "connectGithubDesc": "Connectez votre compte GitHub pour importer et déployer vos dépôts.", + "connectGithub": "Connecter GitHub", + "searchReposPlaceholder": "Rechercher des dépôts…", + "searchReposLabel": "Rechercher des dépôts", + "noRepos": "Aucun dépôt trouvé.", + "privateRepo": "Dépôt privé", + "updated": "Mis à jour :", + "import": "Importer", + "disconnectGithub": "Déconnecter GitHub", + "cloneTemplate": "Cloner un modèle", + "browseAll": "Tout parcourir", + "oneClickBoilerplate": "Projet prêt à l'emploi en un clic", + "createEmpty": "Créer un projet vide", + "createEmptyDesc": "Ignorez la connexion Git et démarrez un environnement vierge à partir de zéro.", + "errGithubNotConfigured": "L'intégration GitHub n'est pas configurée sur l'API Idem (GITHUB_CLIENT_ID/SECRET manquants). Demandez à un administrateur de les définir, ou collez plutôt l'URL d'un dépôt Git ci-dessus.", + "errGithubUnreachable": "Impossible de joindre l'intégration GitHub. L'API Idem est-elle en cours d'exécution ?" + }, + "detail": { + "environment": "Environnement :", + "envNumber": "env n°", + "appNamePlaceholder": "nom de l'application", + "gitRepoPlaceholder": "URL du dépôt git", + "branchPlaceholder": "branche", + "destinationIdPlaceholder": "id de destination", + "newApplication": "Nouvelle application" + }, + "list": { + "title": "Projets", + "newProject": "+ Nouveau projet", + "name": "Nom", + "namePlaceholder": "Mon projet", + "description": "Description", + "creating": "Création…", + "createProject": "Créer le projet", + "noProjects": "Aucun projet pour le moment.", + "delete": "supprimer", + "viewDetails": "Voir les détails" + } + }, + "servers": { + "title": "Serveurs", + "addServerButton": "+ Ajouter un serveur", + "loading": "Chargement…", + "empty": "Aucun serveur pour le moment. Ajoutez-en un pour commencer.", + "reachable": "accessible", + "unreachable": "inaccessible", + "installed": "installé", + "missing": "absent", + "proxyStatus": "Proxy : {{status}}", + "validate": "Valider", + "installDocker": "Installer Docker", + "proxyStatusButton": "État du proxy", + "startProxy": "Démarrer le proxy", + "installCrowdSec": "Installer CrowdSec", + "delete": "Supprimer", + "addServerTitle": "Ajouter un serveur", + "name": "Nom", + "ipAddress": "Adresse IP", + "sshUser": "Utilisateur SSH", + "port": "Port", + "privateKeyId": "ID de clé privée", + "creating": "Création…", + "createServer": "Créer le serveur", + "createError": "Échec de la création du serveur" + }, + "databases": { + "title": "Bases de données", + "loading": "Chargement…", + "empty": "Aucune base de données pour le moment.", + "start": "Démarrer", + "stop": "Arrêter", + "backupNow": "Sauvegarder maintenant", + "delete": "Supprimer", + "newDatabase": "Nouvelle base de données", + "type": "Type", + "name": "Nom", + "environmentId": "ID d'environnement", + "destinationId": "ID de destination", + "creating": "Création…", + "createDatabase": "Créer la base de données", + "createError": "Échec de la création de la base de données" + }, + "services": { + "title": "Services", + "loading": "Chargement…", + "empty": "Aucun service pour le moment.", + "stop": "Arrêter", + "start": "Démarrer", + "deployOneClick": "Déployer un service en un clic", + "template": "Modèle", + "customNone": "— personnalisé (aucun) —", + "name": "Nom", + "environmentId": "ID d'environnement", + "destinationId": "ID de destination", + "dockerComposeLabel": "docker-compose.yml (ignoré lorsqu'un modèle est sélectionné)", + "creating": "Création…", + "createService": "Créer le service", + "createError": "Échec de la création du service" + }, + "sources": { + "title": "Sources Git", + "empty": "Aucune source Git configurée (applications GitHub / GitLab)." + }, + "destinations": { + "title": "Destinations", + "empty": "Aucun serveur pour le moment — ajoutez un serveur pour créer des destinations Docker.", + "noDestinations": "Aucune destination.", + "network": "réseau", + "networkPlaceholder": "réseau docker (ex. ideploy)", + "addDestination": "Ajouter une destination" + }, + "storages": { + "title": "Stockages S3", + "empty": "Aucun stockage S3 configuré." + }, + "tags": { + "title": "Étiquettes", + "loading": "Chargement…", + "newTagPlaceholder": "Nom de la nouvelle étiquette", + "add": "Ajouter" + }, + "settings": { + "title": "Paramètres", + "versionLabel": "Version iDeploy :", + "autoUpdate": "Mise à jour automatique :", + "enabled": "activée", + "disabled": "désactivée", + "instanceSettings": "Paramètres de l'instance", + "wildcardDomain": "Domaine générique", + "registrationEnabled": "Inscription activée", + "save": "Enregistrer", + "globalSearch": "Recherche globale", + "searchPlaceholder": "Rechercher des projets, serveurs, applications, services…" + }, + "notifications": { + "title": "Notifications", + "enabled": "Activé", + "webhookPlaceholder": "URL du webhook / jeton", + "save": "Enregistrer", + "sendTest": "Envoyer un test", + "saved": "Enregistré", + "testSent": "Test envoyé ✓", + "testFailed": "Échec du test" + }, + "security": { + "privateKeys": "Clés privées", + "loading": "Chargement…", + "noKeys": "Aucune clé privée pour le moment.", + "addKeyTitle": "Ajouter une clé", + "name": "Nom", + "privateKeyPem": "Clé privée (PEM)", + "saving": "Enregistrement…", + "addKey": "Ajouter la clé", + "addKeyFailed": "Échec de l'ajout de la clé" + }, + "templates": { + "title": "Modèles", + "searchPlaceholder": "Rechercher des modèles (nom, catégorie, tag)…", + "addServer": "Ajouter un serveur", + "noTemplates": "Aucun modèle disponible. (Le catalogue de modèles de services n'a pas pu être chargé.)", + "deploying": "Déploiement…", + "deploy": "Déployer", + "deploymentFailed": "Échec du déploiement" + }, + "subscription": { + "title": "Abonnement", + "currentPlan": "Forfait actuel :", + "apps": "Applications :", + "servers": "Serveurs :", + "limitReached": "(limite atteinte)", + "plans": "Forfaits", + "current": "ACTUEL", + "subscribe": "S'abonner", + "switch": "Changer" + }, + "team": { + "title": "Équipe", + "members": "Membres", + "invitations": "Invitations", + "revoke": "révoquer", + "emailPlaceholder": "e-mail", + "roleMember": "membre", + "roleAdmin": "administrateur", + "invite": "Inviter" + }, + "sharedVariables": { + "title": "Variables partagées", + "description": "Variables au niveau de l'équipe réutilisables sur toutes les ressources.", + "keyPlaceholder": "CLÉ", + "valuePlaceholder": "valeur", + "add": "Ajouter" + }, + "deploy": { + "backToDashboard": "Retour au tableau de bord", + "deploymentLogs": "Journaux de déploiement", + "branch": "Branche :", + "statusQueued": "En file d'attente", + "statusInProgress": "En cours", + "statusSuccess": "Réussi", + "statusFailed": "Échoué", + "visitApp": "Ouvrir l'application", + "copyLogsTitle": "Copier les journaux dans le presse-papiers", + "copyLogs": "Copier les journaux", + "waitingForLogs": "En attente des journaux…" + }, + "auth": { + "signingIn": "Connexion en cours…" + } +} diff --git a/apps/ideploy-web/src/app/app.config.ts b/apps/ideploy-web/src/app/app.config.ts index 0bbadcff3..51eccb853 100644 --- a/apps/ideploy-web/src/app/app.config.ts +++ b/apps/ideploy-web/src/app/app.config.ts @@ -3,6 +3,8 @@ import { provideRouter } from '@angular/router'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideAnimations } from '@angular/platform-browser/animations'; import { providePrimeNG } from 'primeng/config'; +import { provideTranslateService } from '@ngx-translate/core'; +import { provideTranslateHttpLoader } from '@ngx-translate/http-loader'; import { routes } from './app.routes'; import { authInterceptor } from './shared/interceptors/auth.interceptor'; @@ -14,5 +16,13 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withInterceptors([authInterceptor])), provideAnimations(), providePrimeNG({}), + provideTranslateService({ + loader: provideTranslateHttpLoader({ + prefix: '/assets/i18n/', + suffix: '.json', + }), + fallbackLang: 'en', + lang: 'en', + }), ], }; diff --git a/apps/ideploy-web/src/app/app.ts b/apps/ideploy-web/src/app/app.ts index d54613c29..4a51af73f 100644 --- a/apps/ideploy-web/src/app/app.ts +++ b/apps/ideploy-web/src/app/app.ts @@ -1,5 +1,6 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { LanguageService } from './shared/services/language.service'; @Component({ selector: 'app-root', @@ -7,4 +8,8 @@ import { RouterOutlet } from '@angular/router'; changeDetection: ChangeDetectionStrategy.OnPush, template: ``, }) -export class App {} +export class App { + // Instantiated at bootstrap so it resolves the shared `idem_lang` cookie and + // applies the language before the UI renders. + private readonly languageService = inject(LanguageService); +} diff --git a/apps/ideploy-web/src/app/layouts/shell/shell.ts b/apps/ideploy-web/src/app/layouts/shell/shell.ts index d533d6330..7605acb6e 100644 --- a/apps/ideploy-web/src/app/layouts/shell/shell.ts +++ b/apps/ideploy-web/src/app/layouts/shell/shell.ts @@ -1,8 +1,10 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ApiService } from '../../shared/services/api.service'; import { AuthService } from '../../shared/services/auth.service'; +import { LanguageSelectorComponent } from '../../shared/components/language-selector/language-selector'; interface NavItem { path: string; @@ -21,7 +23,7 @@ interface NavSection { */ @Component({ selector: 'app-shell', - imports: [RouterOutlet, RouterLink, RouterLinkActive], + imports: [RouterOutlet, RouterLink, RouterLinkActive, TranslateModule, LanguageSelectorComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -36,7 +38,7 @@ interface NavSection {
- ADMIN + {{ 'shell.admin' | translate }}
}
@@ -92,20 +95,20 @@ interface NavSection {
- {{ me()?.team?.name ?? 'My Team' }} + {{ me()?.team?.name ?? ('shell.myTeam' | translate) }}
    @for (section of nav; track section.title || 'main') { @if (section.title) { -
  • {{ section.title }}
  • +
  • {{ section.title! | translate }}
  • } @for (item of section.items; track item.path) {
  • - {{ item.label }} + {{ item.label | translate }}
  • } @@ -114,9 +117,9 @@ interface NavSection {
    - AI Smart Deploy + {{ 'shell.aiSmartDeploy' | translate }}
    - SOON + {{ 'shell.soon' | translate }}
@@ -143,35 +146,35 @@ export class ShellComponent implements OnInit { protected readonly serversLimit = signal(0); protected readonly nav: NavSection[] = [ - { items: [{ path: '/dashboard', label: 'Dashboard', icon: 'fa-solid fa-house' }] }, + { items: [{ path: '/dashboard', label: 'shell.nav.dashboard', icon: 'fa-solid fa-house' }] }, { - title: 'Deploy', + title: 'shell.nav.sectionDeploy', items: [ - { path: '/projects', label: 'Projects', icon: 'fa-solid fa-layer-group' }, - { path: '/templates', label: 'Templates', icon: 'fa-solid fa-wand-magic-sparkles' }, + { path: '/projects', label: 'shell.nav.projects', icon: 'fa-solid fa-layer-group' }, + { path: '/templates', label: 'shell.nav.templates', icon: 'fa-solid fa-wand-magic-sparkles' }, ], }, { - title: 'Resources', + title: 'shell.nav.sectionResources', items: [ - { path: '/servers', label: 'Servers', icon: 'fa-solid fa-server' }, - { path: '/applications', label: 'Applications', icon: 'fa-solid fa-cube' }, - { path: '/databases', label: 'Databases', icon: 'fa-solid fa-database' }, - { path: '/services', label: 'Services', icon: 'fa-solid fa-cubes' }, - { path: '/sources', label: 'Sources', icon: 'fa-brands fa-git-alt' }, - { path: '/destinations', label: 'Destinations', icon: 'fa-solid fa-network-wired' }, - { path: '/storages', label: 'S3 Storages', icon: 'fa-solid fa-box-archive' }, - { path: '/tags', label: 'Tags', icon: 'fa-solid fa-tags' }, + { path: '/servers', label: 'shell.nav.servers', icon: 'fa-solid fa-server' }, + { path: '/applications', label: 'shell.nav.applications', icon: 'fa-solid fa-cube' }, + { path: '/databases', label: 'shell.nav.databases', icon: 'fa-solid fa-database' }, + { path: '/services', label: 'shell.nav.services', icon: 'fa-solid fa-cubes' }, + { path: '/sources', label: 'shell.nav.sources', icon: 'fa-brands fa-git-alt' }, + { path: '/destinations', label: 'shell.nav.destinations', icon: 'fa-solid fa-network-wired' }, + { path: '/storages', label: 'shell.nav.storages', icon: 'fa-solid fa-box-archive' }, + { path: '/tags', label: 'shell.nav.tags', icon: 'fa-solid fa-tags' }, ], }, { - title: 'Configuration', + title: 'shell.nav.sectionConfiguration', items: [ - { path: '/settings', label: 'Settings', icon: 'fa-solid fa-gear' }, - { path: '/shared-variables', label: 'Shared Variables', icon: 'fa-solid fa-code' }, - { path: '/notifications', label: 'Notifications', icon: 'fa-regular fa-bell' }, - { path: '/security/keys', label: 'Keys & Tokens', icon: 'fa-solid fa-key' }, - { path: '/team', label: 'Team', icon: 'fa-solid fa-users' }, + { path: '/settings', label: 'shell.nav.settings', icon: 'fa-solid fa-gear' }, + { path: '/shared-variables', label: 'shell.nav.sharedVariables', icon: 'fa-solid fa-code' }, + { path: '/notifications', label: 'shell.nav.notifications', icon: 'fa-regular fa-bell' }, + { path: '/security/keys', label: 'shell.nav.keysTokens', icon: 'fa-solid fa-key' }, + { path: '/team', label: 'shell.nav.team', icon: 'fa-solid fa-users' }, ], }, ]; diff --git a/apps/ideploy-web/src/app/modules/applications/application-detail/application-detail.ts b/apps/ideploy-web/src/app/modules/applications/application-detail/application-detail.ts index 914c26083..93b991a5e 100644 --- a/apps/ideploy-web/src/app/modules/applications/application-detail/application-detail.ts +++ b/apps/ideploy-web/src/app/modules/applications/application-detail/application-detail.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { Application, @@ -19,7 +20,7 @@ import { */ @Component({ selector: 'app-application-detail', - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (app(); as a) { @@ -28,142 +29,142 @@ import {
@if (a.link) { - Open + {{ 'applications.open' | translate }} } - - - + + +
-

Configuration

+

{{ 'applications.detail.configuration' | translate }}

- +
- +
- +
- +
- +
-

Environment variables

+

{{ 'applications.detail.environmentVariables' | translate }}

@for (env of envVars(); track env.key) {
{{ env.key }} = {{ env.value }} - +
}
- - - + + +
-

Scheduled tasks

+

{{ 'applications.detail.scheduledTasks' | translate }}

@for (task of tasks(); track task.uuid) {
{{ task.name }} {{ task.command }} {{ task.frequency }} - - + +
}
- - - - + + + +
-

Persistent volumes

+

{{ 'applications.detail.persistentVolumes' | translate }}

@for (vol of volumes()?.persistent ?? []; track vol.id) {
{{ vol.name }} → {{ vol.mount_path }}
}
- - - + + +
-

Operations

+

{{ 'applications.detail.operations' | translate }}

- - + +
@if (opsOutput()) {
{{ opsOutput() }}
}
- - + +
-

Firewall (WAF)

+

{{ 'applications.detail.firewall' | translate }}

@if (firewall(); as fw) { @for (rule of firewallRules(); track rule.id) {
{{ rule.name }} {{ rule.action }} (p{{ rule.priority }}) - +
}
- - - + + +
- + }
-

CI/CD Pipeline

+

{{ 'applications.detail.cicdPipeline' | translate }}

@if (pipeline(); as p) {
- Stages: {{ p.stages.join(' → ') }} · {{ p.enabled ? 'enabled' : 'disabled' }} + {{ 'applications.detail.stagesLabel' | translate }} {{ p.stages.join(' → ') }} · {{ (p.enabled ? 'applications.detail.enabled' : 'applications.detail.disabled') | translate }}
} - +
@for (ex of pipelineExecutions(); track $index) {
@@ -175,23 +176,23 @@ import {
-

Deployment history

+

{{ 'applications.detail.deploymentHistory' | translate }}

@if (deployments().length === 0) { -

No deployments yet.

+

{{ 'applications.detail.noDeployments' | translate }}

} @else {
@for (dep of deployments(); track dep.deployment_uuid) {
{{ dep.commit }} {{ dep.status }} - +
}
}
} @else { -

Loading…

+

{{ 'applications.loading' | translate }}

} `, }) diff --git a/apps/ideploy-web/src/app/modules/applications/applications-list/applications-list.ts b/apps/ideploy-web/src/app/modules/applications/applications-list/applications-list.ts index 0e7b4b967..2cf9ac52f 100644 --- a/apps/ideploy-web/src/app/modules/applications/applications-list/applications-list.ts +++ b/apps/ideploy-web/src/app/modules/applications/applications-list/applications-list.ts @@ -1,42 +1,43 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { Application } from '../../../shared/models/ideploy.models'; @Component({ selector: 'app-applications-list', - imports: [RouterLink, ReactiveFormsModule], + imports: [RouterLink, ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-

Applications

- +

{{ 'applications.list.title' | translate }}

+
@if (creating()) {
- +
- +
- +
- +
- +
@@ -44,15 +45,15 @@ import { Application } from '../../../shared/models/ideploy.models';

{{ error() }}

}
} @if (loading()) { -

Loading…

+

{{ 'applications.loading' | translate }}

} @else if (applications().length === 0) { -
No applications yet.
+
{{ 'applications.list.empty' | translate }}
} @else {
@for (app of applications(); track app.uuid) { @@ -63,17 +64,17 @@ import { Application } from '../../../shared/models/ideploy.models'; {{ app.git_repository }} ({{ app.git_branch }}) · {{ app.build_pack }}
- status: {{ app.status }} + {{ 'applications.list.statusLabel' | translate }} {{ app.status }}
@if (app.link) { - Open + {{ 'applications.open' | translate }} }
@@ -86,6 +87,7 @@ export class ApplicationsListComponent implements OnInit { private api = inject(ApiService); private router = inject(Router); private fb = inject(FormBuilder); + private translate = inject(TranslateService); protected readonly applications = signal([]); protected readonly loading = signal(true); @@ -138,7 +140,7 @@ export class ApplicationsListComponent implements OnInit { this.load(); }, error: (e) => { - this.error.set(e?.error?.error?.message ?? 'Failed to create application'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('applications.list.createFailed')); this.saving.set(false); }, }); diff --git a/apps/ideploy-web/src/app/modules/auth/sso-callback/sso-callback.ts b/apps/ideploy-web/src/app/modules/auth/sso-callback/sso-callback.ts index 01d5b0bac..a84f7cfe1 100644 --- a/apps/ideploy-web/src/app/modules/auth/sso-callback/sso-callback.ts +++ b/apps/ideploy-web/src/app/modules/auth/sso-callback/sso-callback.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { AuthService } from '../../../shared/services/auth.service'; /** @@ -10,12 +11,13 @@ import { AuthService } from '../../../shared/services/auth.service'; */ @Component({ selector: 'app-sso-callback', + imports: [TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-

Signing you in…

+

{{ 'auth.signingIn' | translate }}

`, diff --git a/apps/ideploy-web/src/app/modules/dashboard/dashboard/dashboard.ts b/apps/ideploy-web/src/app/modules/dashboard/dashboard/dashboard.ts index 8571e1e73..bb913e191 100644 --- a/apps/ideploy-web/src/app/modules/dashboard/dashboard/dashboard.ts +++ b/apps/ideploy-web/src/app/modules/dashboard/dashboard/dashboard.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, inject, signal, OnInit } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { forkJoin } from 'rxjs'; import { ApiService } from '../../../shared/services/api.service'; import { Project, Server, ServiceTemplate } from '../../../shared/models/ideploy.models'; @@ -22,7 +23,7 @@ interface DeployRow { */ @Component({ selector: 'app-dashboard', - imports: [RouterLink, FormsModule], + imports: [RouterLink, FormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -32,17 +33,17 @@ interface DeployRow { [class.focus-within:ring-2]="true" [class.focus-within:ring-blue-500/20]="true"> -

- -
@@ -50,12 +51,12 @@ interface DeployRow {
-

Usage

+

{{ 'dashboard.usage' | translate }}

- Current Limit + {{ 'dashboard.currentLimit' | translate }} Upgrade + style="background:var(--color-surface-2);color:var(--color-text-primary);">{{ 'dashboard.upgrade' | translate }}
@if (loading()) { @@ -80,21 +81,21 @@ interface DeployRow {
-

Alerts

+

{{ 'dashboard.alerts' | translate }}

-

Get notified about your deployments

+

{{ 'dashboard.getNotified' | translate }}

- Slack, Discord, Telegram, email — be alerted when a deploy fails or a server goes down. + {{ 'dashboard.alertsDescription' | translate }}

- Configure notifications + {{ 'dashboard.configureNotifications' | translate }}
-

Recent Deployments

+

{{ 'dashboard.recentDeployments' | translate }}

-

Deployments you trigger will appear here.

+

{{ 'dashboard.recentDeploymentsEmpty' | translate }}

@@ -102,7 +103,7 @@ interface DeployRow {
-

Project History

+

{{ 'dashboard.projectHistory' | translate }}

@if (loading()) { @@ -164,7 +165,7 @@ interface DeployRow { } @else {
- No projects in history. Use the import block below to deploy your first project. + {{ 'dashboard.noProjects' | translate }}
}
@@ -178,12 +179,12 @@ interface DeployRow {
-

Import Project

-

Deploy your app directly from a GitHub repository or Git URL.

+

{{ 'dashboard.importProject' | translate }}

+

{{ 'dashboard.importProjectDescription' | translate }}

@@ -191,18 +192,18 @@ interface DeployRow {
{{ error() }} @if (error()!.toLowerCase().includes('server')) { - · Add a server + · {{ 'dashboard.addServer' | translate }} }
}
-

Start with a template

+

{{ 'dashboard.startWithTemplate' | translate }}

@if (loading()) {
} @else { - {{ templateRows().length }} available + {{ 'dashboard.available' | translate: { count: templateRows().length } }} }
@@ -230,7 +231,7 @@ interface DeployRow {
{{ row.title }}
{{ row.description }}
- + } } @@ -241,8 +242,8 @@ interface DeployRow {
-
Browse Templates
-
Databases, stacks and one-click apps
+
{{ 'dashboard.browseTemplates' | translate }}
+
{{ 'dashboard.browseTemplatesDescription' | translate }}
@@ -255,6 +256,7 @@ interface DeployRow { export class DashboardComponent implements OnInit { private api = inject(ApiService); private router = inject(Router); + private translate = inject(TranslateService); protected readonly projects = signal([]); protected readonly servers = signal([]); @@ -287,10 +289,10 @@ export class DashboardComponent implements OnInit { protected readonly usageMetrics = computed(() => { const q = this.quota(); return [ - { label: 'Applications', icon: 'fa-solid fa-cube', color: '#60a5fa', value: `${q.apps.used} / ${q.apps.limit || '∞'}` }, - { label: 'Servers', icon: 'fa-solid fa-server', color: '#4ade80', value: `${q.servers.used} / ${q.servers.limit || '∞'}` }, - { label: 'Databases', icon: 'fa-solid fa-database', color: '#a78bfa', value: `${this.dbCount()}` }, - { label: 'Projects', icon: 'fa-solid fa-layer-group', color: '#2563eb', value: `${this.projects().length}` }, + { label: this.translate.instant('dashboard.metricApplications'), icon: 'fa-solid fa-cube', color: '#60a5fa', value: `${q.apps.used} / ${q.apps.limit || '∞'}` }, + { label: this.translate.instant('dashboard.metricServers'), icon: 'fa-solid fa-server', color: '#4ade80', value: `${q.servers.used} / ${q.servers.limit || '∞'}` }, + { label: this.translate.instant('dashboard.metricDatabases'), icon: 'fa-solid fa-database', color: '#a78bfa', value: `${this.dbCount()}` }, + { label: this.translate.instant('dashboard.metricProjects'), icon: 'fa-solid fa-layer-group', color: '#2563eb', value: `${this.projects().length}` }, ]; }); @@ -302,7 +304,7 @@ export class DashboardComponent implements OnInit { icon: 'fa-solid fa-cube', iconColor: '#60a5fa', title: t.name, - description: t.slogan || 'One-click service', + description: t.slogan || this.translate.instant('dashboard.oneClickService'), action: 'deploy' as const, template: t.name, })); @@ -326,7 +328,7 @@ export class DashboardComponent implements OnInit { }, error: (e) => { this.loading.set(false); - this.error.set(e?.error?.error?.message ?? 'Failed to load dashboard data. Please reload.'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('dashboard.loadError')); }, }); } @@ -360,7 +362,7 @@ export class DashboardComponent implements OnInit { }, error: (e) => { this.busy.set(false); - this.error.set(e?.error?.error?.message ?? 'Deployment failed'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('dashboard.deploymentFailed')); }, }); } diff --git a/apps/ideploy-web/src/app/modules/databases/databases-list/databases-list.ts b/apps/ideploy-web/src/app/modules/databases/databases-list/databases-list.ts index 71c82be9e..a4cd7f591 100644 --- a/apps/ideploy-web/src/app/modules/databases/databases-list/databases-list.ts +++ b/apps/ideploy-web/src/app/modules/databases/databases-list/databases-list.ts @@ -1,21 +1,22 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { Database, DatabaseType } from '../../../shared/models/ideploy.models'; @Component({ selector: 'app-databases-list', - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Databases

+

{{ 'databases.title' | translate }}

@if (loading()) { -

Loading…

+

{{ 'databases.loading' | translate }}

} @else if (databases().length === 0) { -
No databases yet.
+
{{ 'databases.empty' | translate }}
} @else {
@for (db of databases(); track db.uuid) { @@ -27,10 +28,10 @@ import { Database, DatabaseType } from '../../../shared/models/ideploy.models';
- - - - + + + +
} @@ -39,9 +40,9 @@ import { Database, DatabaseType } from '../../../shared/models/ideploy.models';
-

New database

+

{{ 'databases.newDatabase' | translate }}

- +
- +
- +
- +
@@ -66,7 +67,7 @@ import { Database, DatabaseType } from '../../../shared/models/ideploy.models';

{{ error() }}

}
@@ -75,6 +76,7 @@ import { Database, DatabaseType } from '../../../shared/models/ideploy.models'; export class DatabasesListComponent implements OnInit { private api = inject(ApiService); private fb = inject(FormBuilder); + private translate = inject(TranslateService); protected readonly types: DatabaseType[] = [ 'postgresql', @@ -125,7 +127,7 @@ export class DatabasesListComponent implements OnInit { this.load(); }, error: (e) => { - this.error.set(e?.error?.error?.message ?? 'Failed to create database'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('databases.createError')); this.saving.set(false); }, }); diff --git a/apps/ideploy-web/src/app/modules/deploy/deployment-logs/deployment-logs.ts b/apps/ideploy-web/src/app/modules/deploy/deployment-logs/deployment-logs.ts index dfd076dc2..06905904a 100644 --- a/apps/ideploy-web/src/app/modules/deploy/deployment-logs/deployment-logs.ts +++ b/apps/ideploy-web/src/app/modules/deploy/deployment-logs/deployment-logs.ts @@ -7,6 +7,7 @@ import { OnDestroy, } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { RealtimeService } from '../../../shared/services/realtime.service'; import { interval, Subscription } from 'rxjs'; @@ -19,15 +20,15 @@ import { startWith, switchMap, takeWhile } from 'rxjs/operators'; */ @Component({ selector: 'app-deployment-logs', - imports: [RouterLink], + imports: [RouterLink, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
- Back to Dashboard + {{ 'deploy.backToDashboard' | translate }} - Deployment Logs + {{ 'deploy.deploymentLogs' | translate }}
@@ -41,7 +42,7 @@ import { startWith, switchMap, takeWhile } from 'rxjs/operators'; {{ deployment().application_name }}

- Branch: {{ deployment().application_git_branch || 'main' }} + {{ 'deploy.branch' | translate }} {{ deployment().application_git_branch || 'main' }}

@@ -50,31 +51,31 @@ import { startWith, switchMap, takeWhile } from 'rxjs/operators'; @switch (deployment().status) { @case ('queued') { - Queued + {{ 'deploy.statusQueued' | translate }} } @case ('in_progress') { - In Progress + {{ 'deploy.statusInProgress' | translate }} } @case ('finished') { - ✓ Success + ✓ {{ 'deploy.statusSuccess' | translate }} } @case ('failed') { - ✗ Failed + ✗ {{ 'deploy.statusFailed' | translate }} } } @if (deployment().status === 'finished' && deployment().application_url) { - - Visit App + {{ 'deploy.visitApp' | translate }} } @@ -99,8 +100,8 @@ import { startWith, switchMap, takeWhile } from 'rxjs/operators'; build-console - @@ -109,7 +110,7 @@ import { startWith, switchMap, takeWhile } from 'rxjs/operators'; class="p-4 overflow-auto whitespace-pre-wrap font-mono text-xs leading-relaxed text-[#c9d1d9]" style="max-height: 65vh; min-height: 250px; background-color: #080b12;" >@for (line of lines(); track $index) {{{ line }} -}@if (lines().length === 0) {Waiting for logs…} +}@if (lines().length === 0) {{{ 'deploy.waitingForLogs' | translate }}} `, diff --git a/apps/ideploy-web/src/app/modules/destinations/destinations-list/destinations-list.ts b/apps/ideploy-web/src/app/modules/destinations/destinations-list/destinations-list.ts index 8950a8d95..9a867c18e 100644 --- a/apps/ideploy-web/src/app/modules/destinations/destinations-list/destinations-list.ts +++ b/apps/ideploy-web/src/app/modules/destinations/destinations-list/destinations-list.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { Destination, Server } from '../../../shared/models/ideploy.models'; @@ -11,12 +12,12 @@ interface ServerDestinations { /** Destinations across all servers (Coolify destination.index) + creation. */ @Component({ selector: 'app-destinations-list', - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Destinations

+

{{ 'destinations.title' | translate }}

@if (rows().length === 0) { -
No servers yet — add a server to create Docker destinations.
+
{{ 'destinations.empty' | translate }}
} @else {
@for (row of rows(); track row.server.uuid) { @@ -26,18 +27,18 @@ interface ServerDestinations { {{ row.server.name }}
@if (row.destinations.length === 0) { -

No destinations.

+

{{ 'destinations.noDestinations' | translate }}

} @else { @for (d of row.destinations; track d.uuid) {
- {{ d.name }} · network {{ d.network }} + {{ d.name }} · {{ 'destinations.network' | translate }} {{ d.network }}
} }
- - + +
} diff --git a/apps/ideploy-web/src/app/modules/landing/landing/landing.ts b/apps/ideploy-web/src/app/modules/landing/landing/landing.ts index 43189364f..44be71180 100644 --- a/apps/ideploy-web/src/app/modules/landing/landing/landing.ts +++ b/apps/ideploy-web/src/app/modules/landing/landing/landing.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; +import { TranslateModule } from '@ngx-translate/core'; import { AuthService } from '../../../shared/services/auth.service'; import { environment } from '../../../../environments/environment'; @@ -13,7 +14,7 @@ import { environment } from '../../../../environments/environment'; */ @Component({ selector: 'app-landing', - imports: [RouterLink], + imports: [RouterLink, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -31,13 +32,13 @@ import { environment } from '../../../../environments/environment'; style="background: rgba(6,8,13,0.6); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);">
- EPLOY Logo
@if (user(); as u) {
@@ -49,16 +50,16 @@ import { environment } from '../../../../environments/environment'; @if (menuOpen()) {
- Dashboard - + {{ 'landing.navDashboard' | translate }} +
}
} @else { }
@@ -69,20 +70,20 @@ import { environment } from '../../../../environments/environment';
Server Room + [alt]="'landing.heroImageAlt' | translate" class="w-full h-full object-cover opacity-40 mix-blend-lighten" style="filter: grayscale(50%);" />

- Deploy apps,
Not servers + {{ 'landing.heroTitleLine1' | translate }}
{{ 'landing.heroTitleAccent' | translate }}

- The leading platform to deploy, scale, and secure your applications without leaving your workspace. Powered by Idem. + {{ 'landing.heroSubtitle' | translate }}

@@ -91,7 +92,7 @@ import { environment } from '../../../../environments/environment';
-

Supported Stacks & Partners

+

{{ 'landing.supportedStacks' | translate }}

@@ -110,9 +111,9 @@ import { environment } from '../../../../environments/environment';

- Explore what people are building + {{ 'landing.showcaseTitle' | translate }} {{ 'landing.showcaseTitleAccent' | translate }}

-

From complex microservices architectures to simple static portfolios, see how Eploy powers the independent web.

+

{{ 'landing.showcaseSubtitle' | translate }}

@for (p of showcase; track p.title) { @@ -128,7 +129,7 @@ import { environment } from '../../../../environments/environment'; }
@@ -141,30 +142,30 @@ import { environment } from '../../../../environments/environment';
1
-

Push to deploy.
It's that simple.

-

Connect your GitHub repo, select your branch, and hit deploy. EPLOY automatically builds your Docker image, provisions an SSL cert, and launches your app securely.

+

{{ 'landing.pillar1Title' | translate }} {{ 'landing.pillar1TitleAccent' | translate }}.
{{ 'landing.pillar1TitleRest' | translate }}

+

{{ 'landing.pillar1Body' | translate }}

- Automatic builds based on Nixpacks + {{ 'landing.pillar1Feature1' | translate }}
- Pre-configured Let's Encrypt SSL + {{ 'landing.pillar1Feature2' | translate }}
- Code deployment log +
-
Production Ready
-
deployment-39ffa2z completed in 12.4s
+
{{ 'landing.productionReady' | translate }}
+
{{ 'landing.deploymentLog' | translate }}
@@ -174,23 +175,23 @@ import { environment } from '../../../../environments/environment';
- Server clusters data +
2
-

Instantiate DBs
in 1-Click.

-

Stop manually configuring databases. Provision Postgres, Redis, MongoDB, or MySQL instances in one click. Completely managed, inherently secure, with native scheduled backups.

+

{{ 'landing.pillar2Title' | translate }}
{{ 'landing.pillar2TitleMid' | translate }} {{ 'landing.pillar2TitleAccent' | translate }}.

+

{{ 'landing.pillar2Body' | translate }}

- Scheduled S3 backups natively supported + {{ 'landing.pillar2Feature1' | translate }}
- Automatic environment variable tunneling + {{ 'landing.pillar2Feature2' | translate }}
@@ -201,26 +202,26 @@ import { environment } from '../../../../environments/environment';
-

Everything you need. Nothing you don't.

+

{{ 'landing.featuresTitle' | translate }} {{ 'landing.featuresTitleAccent' | translate }}. {{ 'landing.featuresTitleRest' | translate }}

-

Any Language

-

Nixpacks integration means if it builds on your machine, it builds on Eploy. No Dockerfile required.

+

{{ 'landing.feature1Title' | translate }}

+

{{ 'landing.feature1Body' | translate }}

-

Auto SSL

-

We automatically generate and renew Let's Encrypt certificates for all your connected custom domains.

+

{{ 'landing.feature2Title' | translate }}

+

{{ 'landing.feature2Body' | translate }}

-

PR Previews

-

Every pull request gets its own isolated deployment environment. Review changes live before merging to production. Automatically destroyed when merged, keeping costs at zero.

+

{{ 'landing.feature3Title' | translate }}

+

{{ 'landing.feature3Body' | translate }}

- Building... + {{ 'landing.building' | translate }}
@@ -232,13 +233,13 @@ import { environment } from '../../../../environments/environment';
"

- Since migrating our entire agency server fleet to Eploy, our deployment velocity increased by 400% while server overhead dropped to zero. It's the Heroku alternative we actually own. + {{ 'landing.testimonialQuote' | translate }}

David J.

-

Lead DevOps, Systema

+

{{ 'landing.testimonialRole' | translate }}

@@ -247,7 +248,7 @@ import { environment } from '../../../../environments/environment';
-

Built for how you work

+

{{ 'landing.rolesTitle' | translate }} {{ 'landing.rolesTitleAccent' | translate }}

@for (role of roles; track role.title) {
@@ -267,13 +268,13 @@ import { environment } from '../../../../environments/environment';
-

How it works

+

{{ 'landing.howItWorksTitleAccent' | translate }} {{ 'landing.howItWorksTitle' | translate }}

-

1. Connect Provider

-

Link your GitHub, GitLab, or Bitbucket account. We only ask for the permissions we absolutely need.

+

{{ 'landing.step1Title' | translate }}

+

{{ 'landing.step1Body' | translate }}

1
@@ -282,14 +283,14 @@ import { environment } from '../../../../environments/environment';
2
-

2. Define Destination

-

Add any Linux server using an SSH key. A $4/mo Hetzner VPS works just fine.

+

{{ 'landing.step2Title' | translate }}

+

{{ 'landing.step2Body' | translate }}

-

3. Deploy

-

Hit deploy. We automatically build the container and route the traffic. It just works.

+

{{ 'landing.step3Title' | translate }}

+

{{ 'landing.step3Body' | translate }}

3
@@ -304,13 +305,13 @@ import { environment } from '../../../../environments/environment';
-

Ready to host?

-

5 free deployments, a free domain with automatic SSL and commercial use allowed from day one. Paid plans start at 2 999 F/month.

+

{{ 'landing.ctaTitle' | translate }} {{ 'landing.ctaTitleAccent' | translate }}

+

{{ 'landing.ctaSubtitle' | translate }}

-

MIT Licensed. Open Source forever.

+

{{ 'landing.ctaLicense' | translate }}

@@ -324,10 +325,10 @@ import { environment } from '../../../../environments/environment';
EPLOY
-

© {{ year }} EPLOY · Powered seamlessly by Idem Frameworks

+

{{ 'landing.footerCopyright' | translate: { year: year } }}

diff --git a/apps/ideploy-web/src/app/modules/landing/pricing/pricing.ts b/apps/ideploy-web/src/app/modules/landing/pricing/pricing.ts index 2e888a80b..bcade3ba8 100644 --- a/apps/ideploy-web/src/app/modules/landing/pricing/pricing.ts +++ b/apps/ideploy-web/src/app/modules/landing/pricing/pricing.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { RouterLink } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; +import { TranslateModule } from '@ngx-translate/core'; import { AuthService } from '../../../shared/services/auth.service'; import { environment } from '../../../../environments/environment'; @@ -35,7 +36,7 @@ interface OverageRow { */ @Component({ selector: 'app-pricing', - imports: [RouterLink], + imports: [RouterLink, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -53,20 +54,20 @@ interface OverageRow { style="background: rgba(6,8,13,0.6); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);"> @@ -76,13 +77,12 @@ interface OverageRow {

- Generous at entry.
Priced for growth. + {{ 'pricing.heroTitle' | translate }}
{{ 'pricing.heroTitleAccent' | translate }}

- Every account gets 5 free deployments, a free yourapp.idem.africa domain and free custom - domains with automatic SSL. Commercial use allowed from day one — even on the free plan. + {{ 'pricing.heroSubtitle' | translate }}

-

Prices in FCFA · Mobile Money & card accepted · Annual = 2 months free

+

{{ 'pricing.heroNote' | translate }}

@@ -95,12 +95,12 @@ interface OverageRow { [style.border-color]="plan.popular ? 'var(--color-primary-500)' : null"> @if (plan.popular) {
Most popular
+ style="background: var(--color-primary-500)">{{ 'pricing.mostPopular' | translate }}
}

{{ plan.name }}

{{ plan.price }} - /month + {{ 'pricing.perMonth' | translate }}
@if (plan.usd) {
{{ plan.usd }}
@@ -126,24 +126,23 @@ interface OverageRow {

- No subscription? Pay per deployment. + {{ 'pricing.payTitle' | translate }} {{ 'pricing.payTitleAccent' | translate }}

- Your first 5 deployments are free — enough to put your first app in production and iterate. - After that, pay as you ship. + {{ 'pricing.paySubtitle' | translate }}

100 F
-
per deployment
+
{{ 'pricing.payCard1Label' | translate }}
900 F
-
pack of 10 deployments
+
{{ 'pricing.payCard2Label' | translate }}
-
Unlimited
-
on any paid plan
+
{{ 'pricing.payCard3Value' | translate }}
+
{{ 'pricing.payCard3Label' | translate }}
@@ -153,17 +152,16 @@ interface OverageRow {

- Managed services à la carte + {{ 'pricing.managedTitle' | translate }} {{ 'pricing.managedTitleAccent' | translate }}

- Like a cloud provider: activate each service in one click from the dashboard, - billed monthly, no commitment. + {{ 'pricing.managedSubtitle' | translate }}

@for (svc of managedServices; track svc.name) {
- {{ svc.price }}/month + {{ svc.price }}{{ 'pricing.perMonth' | translate }}
{{ svc.name }}
{{ svc.note }}
@@ -177,17 +175,17 @@ interface OverageRow {

- Overages, head to head + {{ 'pricing.overagesTitle' | translate }} {{ 'pricing.overagesTitleAccent' | translate }}

- When your app grows past its plan, the bill grows with it — not against it. + {{ 'pricing.overagesSubtitle' | translate }}

- + @@ -214,26 +212,24 @@ interface OverageRow {

- Bring your own server + {{ 'pricing.byosTitle' | translate }} {{ 'pricing.byosTitleAccent' | translate }}

- Connect your own VPS and let iDeploy manage the control plane — deployments, SSL, - monitoring, backups. Total sovereignty over your hardware, zero PaaS tax. - 1 BYOS server included on every plan (unlimited on Scale). + {{ 'pricing.byosBody' | translate }}

- Your hardware, our automation + {{ 'pricing.byosFeature1' | translate }}
- Extra BYOS server: 1 500 F/month + {{ 'pricing.byosFeature2' | translate }}
- Code and data exportable at any time + {{ 'pricing.byosFeature3' | translate }}
@@ -245,14 +241,14 @@ interface OverageRow {

- Your first app is on us + {{ 'pricing.ctaTitle' | translate }} {{ 'pricing.ctaTitleAccent' | translate }}

- Free domain, free SSL, 5 free deployments, commercial use allowed. Start now, upgrade when your app grows. + {{ 'pricing.ctaSubtitle' | translate }}

@@ -267,10 +263,10 @@ interface OverageRow { EPLOY -

© {{ year }} EPLOY · Powered seamlessly by Idem Frameworks

+

{{ 'pricing.footerCopyright' | translate: { year: year } }}

diff --git a/apps/ideploy-web/src/app/modules/notifications/notifications/notifications.ts b/apps/ideploy-web/src/app/modules/notifications/notifications/notifications.ts index 8ac739e81..b446a0c50 100644 --- a/apps/ideploy-web/src/app/modules/notifications/notifications/notifications.ts +++ b/apps/ideploy-web/src/app/modules/notifications/notifications/notifications.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; interface ChannelState { @@ -11,26 +12,26 @@ interface ChannelState { @Component({ selector: 'app-notifications', - imports: [FormsModule], + imports: [FormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Notifications

+

{{ 'notifications.title' | translate }}

@for (ch of channels(); track ch.channel) {

{{ ch.label }}

- - + +
@if (status()[ch.channel]; as st) {

@@ -44,6 +45,7 @@ interface ChannelState { }) export class NotificationsComponent implements OnInit { private api = inject(ApiService); + private translate = inject(TranslateService); protected readonly channels = signal([ { channel: 'slack', label: 'Slack', enabled: false, webhook: '' }, @@ -73,14 +75,14 @@ export class NotificationsComponent implements OnInit { if (ch.channel === 'telegram') body['telegram_token'] = ch.webhook; if (ch.channel === 'pushover') body['pushover_user_key'] = ch.webhook; this.api.updateNotificationSettings(ch.channel, body).subscribe(() => - this.setStatus(ch.channel, true, 'Saved') + this.setStatus(ch.channel, true, this.translate.instant('notifications.saved')) ); } protected test(ch: ChannelState): void { this.api.testNotification(ch.channel).subscribe({ - next: () => this.setStatus(ch.channel, true, 'Test sent ✓'), - error: (e) => this.setStatus(ch.channel, false, e?.error?.error?.message ?? 'Test failed'), + next: () => this.setStatus(ch.channel, true, this.translate.instant('notifications.testSent')), + error: (e) => this.setStatus(ch.channel, false, e?.error?.error?.message ?? this.translate.instant('notifications.testFailed')), }); } } diff --git a/apps/ideploy-web/src/app/modules/projects/import-config/import-config.ts b/apps/ideploy-web/src/app/modules/projects/import-config/import-config.ts index 3379ec1e8..9e6113cab 100644 --- a/apps/ideploy-web/src/app/modules/projects/import-config/import-config.ts +++ b/apps/ideploy-web/src/app/modules/projects/import-config/import-config.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { environment } from '../../../../environments/environment'; @@ -17,104 +18,104 @@ interface Preset { */ @Component({ selector: 'app-import-config', - imports: [FormsModule, RouterLink], + imports: [FormsModule, RouterLink, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `

- Back + {{ 'projects.common.back' | translate }} - New Project + {{ 'projects.common.newProject' | translate }}
-

New Project

+

{{ 'projects.common.newProject' | translate }}

-
Importing from Git
+
{{ 'projects.import.importingFromGit' | translate }}
{{ repo() }} {{ branch() }}
-

Configure your project parameters and deploy.

+

{{ 'projects.import.configureDeploy' | translate }}

- +
- +
- + -

Auto-detected from the repository — change if needed.

+

{{ 'projects.import.autoDetected' | translate }}

- Build method + {{ 'projects.import.buildMethod' | translate }} @if (hasDockerfile()) {
-

A Dockerfile was detected — choose how to deploy.

+

{{ 'projects.import.dockerfileDetected' | translate }}

} @else {
- No Dockerfile detected — the app will be deployed - without Docker (run directly in a base Node runtime). + {{ 'projects.import.noDockerfilePart1' | translate }} + {{ 'projects.import.withoutDockerStrong' | translate }} {{ 'projects.import.noDockerfilePart2' | translate }}
}
- + -

The directory where your package.json / build settings are located.

+

{{ 'projects.import.rootDirHint' | translate }}

@if (showBuild()) {
- - - + + +
} @if (showEnv()) {

- You can add environment variables after the first deploy, from the application's Environment tab. + {{ 'projects.import.envHint' | translate }}

} @@ -125,14 +126,14 @@ interface Preset {
@if (!isProd) { } - Add a server + {{ 'projects.import.addServer' | translate }}
@if (!isProd) {

- Runs the deployment on your local Docker — perfect for testing. + {{ 'projects.import.localDockerHint' | translate }}

} } @@ -140,7 +141,7 @@ interface Preset { }
@@ -152,33 +153,33 @@ interface Preset {
-

Docker Detected

+

{{ 'projects.import.dockerDetectedTitle' | translate }}

- +

- We detected a Dockerfile or Docker Compose configuration in your repository. Would you like to deploy using Docker containerization, or run the app directly in a base Node runtime? + {{ 'projects.import.dockerModalDesc' | translate }}

- - + +
@@ -189,6 +190,7 @@ export class ImportConfigComponent implements OnInit { private api = inject(ApiService); private route = inject(ActivatedRoute); private router = inject(Router); + private translate = inject(TranslateService); protected readonly repo = signal(''); protected readonly branch = signal('main'); @@ -271,14 +273,14 @@ export class ImportConfigComponent implements OnInit { next: (r) => { this.settingUpLocal.set(false); if (!r.dockerOk) { - this.error.set('Local server created, but Docker is not reachable. Is Docker Desktop running?'); + this.error.set(this.translate.instant('projects.import.errLocalDockerUnreachable')); return; } this.deploy(); }, error: (e) => { this.settingUpLocal.set(false); - this.error.set(e?.error?.error?.message ?? 'Failed to set up local server'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('projects.import.errLocalSetup')); }, }); } @@ -300,7 +302,7 @@ export class ImportConfigComponent implements OnInit { protected executeDeploy(): void { if (!this.projectName || !this.cloneUrl) { - this.error.set('Missing repository URL.'); + this.error.set(this.translate.instant('projects.import.errMissingRepo')); return; } this.deploying.set(true); @@ -325,7 +327,7 @@ export class ImportConfigComponent implements OnInit { }, error: (e) => { this.deploying.set(false); - this.error.set(e?.error?.error?.message ?? 'Deployment failed'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('projects.common.deploymentFailed')); }, }); } diff --git a/apps/ideploy-web/src/app/modules/projects/new-project/new-project.ts b/apps/ideploy-web/src/app/modules/projects/new-project/new-project.ts index 242af3dc0..d09b7217f 100644 --- a/apps/ideploy-web/src/app/modules/projects/new-project/new-project.ts +++ b/apps/ideploy-web/src/app/modules/projects/new-project/new-project.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, inject, signal, OnInit } import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { SlicePipe } from '@angular/common'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.models'; @@ -12,20 +13,20 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode */ @Component({ selector: 'app-new-project', - imports: [FormsModule, RouterLink, SlicePipe], + imports: [FormsModule, RouterLink, SlicePipe, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
- Back + {{ 'projects.common.back' | translate }} - New Project + {{ 'projects.common.newProject' | translate }}
-

Let's build something new

+

{{ 'projects.new.heading' | translate }}

- @if (gitUrl) { - + }

- Paste a public Git repository URL, pick one of your GitHub repos, or clone a template. + {{ 'projects.new.subheading' | translate }}

-

Import Git Repository

+

{{ 'projects.new.importGitRepo' | translate }}

@if (githubUser() === undefined) { -

Checking GitHub connection…

+

{{ 'projects.new.checkingGithub' | translate }}

} @else if (githubUser() === null) {

- Connect your GitHub account to import and deploy your repositories. + {{ 'projects.new.connectGithubDesc' | translate }}

} @else { @@ -69,11 +70,11 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode
- +
@if (filteredRepos().length === 0) { -
No repositories found.
+
{{ 'projects.new.noRepos' | translate }}
} @else {
@for (repo of filteredRepos(); track repo.fullName) { @@ -83,24 +84,24 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode
{{ repo.name }} - @if (repo.private) { } + @if (repo.private) { }
-
Updated: {{ repo.updatedAt | slice:0:10 }}
+
{{ 'projects.new.updated' | translate }} {{ repo.updatedAt | slice:0:10 }}
- +
}
} - + }
-

Clone Template

- Browse All +

{{ 'projects.new.cloneTemplate' | translate }}

+ {{ 'projects.new.browseAll' | translate }}
@for (t of templates(); track t.name) { @@ -111,8 +112,8 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode
{{ t.name }}
-

{{ t.slogan || 'One-click boilerplate project' }}

- +

{{ t.slogan || ('projects.new.oneClickBoilerplate' | translate) }}

+ } @@ -126,11 +127,11 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode
-
Create Empty Project
-

Skip Git connection and start a blank environment from scratch.

+
{{ 'projects.new.createEmpty' | translate }}
+

{{ 'projects.new.createEmptyDesc' | translate }}

- + @if (error()) { @@ -142,6 +143,7 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode export class NewProjectComponent implements OnInit { private api = inject(ApiService); private router = inject(Router); + private translate = inject(TranslateService); protected readonly githubUser = signal(undefined); protected readonly repos = signal([]); @@ -186,16 +188,13 @@ export class NewProjectComponent implements OnInit { // Guard: the global API returns client_id='' when GitHub OAuth isn't // configured → GitHub would 404. Surface a clear message instead. if (!url || /[?&]client_id=(&|$)/.test(url)) { - this.error.set( - 'GitHub integration is not configured on the Idem API (missing GITHUB_CLIENT_ID/SECRET). ' + - 'Ask an admin to set them, or paste a Git repository URL above instead.' - ); + this.error.set(this.translate.instant('projects.new.errGithubNotConfigured')); return; } window.location.href = url; }, error: () => - this.error.set('Could not reach the GitHub integration. Is the Idem API running?'), + this.error.set(this.translate.instant('projects.new.errGithubUnreachable')), }); } @@ -234,7 +233,7 @@ export class NewProjectComponent implements OnInit { next: () => this.router.navigate(['/services']), error: (e) => { this.busy.set(false); - this.error.set(e?.error?.error?.message ?? 'Deployment failed'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('projects.common.deploymentFailed')); }, }); } @@ -243,7 +242,7 @@ export class NewProjectComponent implements OnInit { const name = `project-${Date.now().toString(36)}`; this.api.createProject({ name }).subscribe({ next: (p) => this.router.navigate(['/projects', p.uuid]), - error: (e) => this.error.set(e?.error?.error?.message ?? 'Failed to create project'), + error: (e) => this.error.set(e?.error?.error?.message ?? this.translate.instant('projects.common.errCreateProject')), }); } } diff --git a/apps/ideploy-web/src/app/modules/projects/project-detail/project-detail.ts b/apps/ideploy-web/src/app/modules/projects/project-detail/project-detail.ts index 8d992969b..744d7b7fc 100644 --- a/apps/ideploy-web/src/app/modules/projects/project-detail/project-detail.ts +++ b/apps/ideploy-web/src/app/modules/projects/project-detail/project-detail.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { Application, Project } from '../../../shared/models/ideploy.models'; @@ -16,7 +17,7 @@ interface EnvRow { */ @Component({ selector: 'app-project-detail', - imports: [RouterLink, ReactiveFormsModule], + imports: [RouterLink, ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (project(); as p) { @@ -28,8 +29,8 @@ interface EnvRow { @for (env of environments(); track env.id) {
-

Environment: {{ env.name }}

- env #{{ env.id }} +

{{ 'projects.detail.environment' | translate }} {{ env.name }}

+ {{ 'projects.detail.envNumber' | translate }}{{ env.id }}
@@ -43,16 +44,16 @@ interface EnvRow {
- - - - - + + + + +
} } @else { -

Loading…

+

{{ 'projects.common.loading' | translate }}

} `, }) diff --git a/apps/ideploy-web/src/app/modules/projects/projects-list/projects-list.ts b/apps/ideploy-web/src/app/modules/projects/projects-list/projects-list.ts index 44316897c..be4b64b1b 100644 --- a/apps/ideploy-web/src/app/modules/projects/projects-list/projects-list.ts +++ b/apps/ideploy-web/src/app/modules/projects/projects-list/projects-list.ts @@ -1,58 +1,59 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { RouterLink } from '@angular/router'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { Project } from '../../../shared/models/ideploy.models'; @Component({ selector: 'app-projects-list', - imports: [RouterLink, ReactiveFormsModule], + imports: [RouterLink, ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-

Projects

+

{{ 'projects.list.title' | translate }}

@if (creating()) {
- - + +
- +
@if (error()) {

{{ error() }}

} } @if (loading()) { -

Loading…

+

{{ 'projects.common.loading' | translate }}

} @else if (projects().length === 0) { -
No projects yet.
+
{{ 'projects.list.noProjects' | translate }}
} @else {
@for (project of projects(); track project.uuid) {
{{ project.name }} - +
@if (project.description) {

{{ project.description }}

}
@@ -64,6 +65,7 @@ import { Project } from '../../../shared/models/ideploy.models'; export class ProjectsListComponent implements OnInit { private api = inject(ApiService); private fb = inject(FormBuilder); + private translate = inject(TranslateService); protected readonly projects = signal([]); protected readonly loading = signal(true); @@ -102,7 +104,7 @@ export class ProjectsListComponent implements OnInit { this.load(); }, error: (e) => { - this.error.set(e?.error?.error?.message ?? 'Failed to create project'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('projects.common.errCreateProject')); this.saving.set(false); }, }); diff --git a/apps/ideploy-web/src/app/modules/security/private-keys/private-keys.ts b/apps/ideploy-web/src/app/modules/security/private-keys/private-keys.ts index afa0283b4..2cb71c749 100644 --- a/apps/ideploy-web/src/app/modules/security/private-keys/private-keys.ts +++ b/apps/ideploy-web/src/app/modules/security/private-keys/private-keys.ts @@ -1,21 +1,22 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { PrivateKey } from '../../../shared/models/ideploy.models'; @Component({ selector: 'app-private-keys', - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Private keys

+

{{ 'security.privateKeys' | translate }}

@if (loading()) { -

Loading…

+

{{ 'security.loading' | translate }}

} @else if (keys().length === 0) { -
No private keys yet.
+
{{ 'security.noKeys' | translate }}
} @else {
@for (key of keys(); track key.uuid) { @@ -33,20 +34,20 @@ import { PrivateKey } from '../../../shared/models/ideploy.models';
-

Add a key

+

{{ 'security.addKeyTitle' | translate }}

- +
- +
@if (error()) {

{{ error() }}

}
@@ -55,6 +56,7 @@ import { PrivateKey } from '../../../shared/models/ideploy.models'; export class PrivateKeysComponent implements OnInit { private api = inject(ApiService); private fb = inject(FormBuilder); + private translate = inject(TranslateService); protected readonly keys = signal([]); protected readonly loading = signal(true); @@ -91,7 +93,7 @@ export class PrivateKeysComponent implements OnInit { this.load(); }, error: (e) => { - this.error.set(e?.error?.error?.message ?? 'Failed to add key'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('security.addKeyFailed')); this.saving.set(false); }, }); diff --git a/apps/ideploy-web/src/app/modules/servers/server-create/server-create.ts b/apps/ideploy-web/src/app/modules/servers/server-create/server-create.ts index 873c07d9b..92ae606b9 100644 --- a/apps/ideploy-web/src/app/modules/servers/server-create/server-create.ts +++ b/apps/ideploy-web/src/app/modules/servers/server-create/server-create.ts @@ -1,42 +1,43 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router } from '@angular/router'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; @Component({ selector: 'app-server-create', - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Add server

+

{{ 'servers.addServerTitle' | translate }}

- +
- +
- +
- +
- +
@if (error()) {

{{ error() }}

} `, @@ -45,6 +46,7 @@ export class ServerCreateComponent { private fb = inject(FormBuilder); private api = inject(ApiService); private router = inject(Router); + private translate = inject(TranslateService); protected readonly submitting = signal(false); protected readonly error = signal(null); @@ -64,7 +66,7 @@ export class ServerCreateComponent { this.api.createServer(this.form.getRawValue()).subscribe({ next: () => this.router.navigate(['/servers']), error: (e) => { - this.error.set(e?.error?.error?.message ?? 'Failed to create server'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('servers.createError')); this.submitting.set(false); }, }); diff --git a/apps/ideploy-web/src/app/modules/servers/servers-list/servers-list.ts b/apps/ideploy-web/src/app/modules/servers/servers-list/servers-list.ts index b380b7e10..0eb7e2814 100644 --- a/apps/ideploy-web/src/app/modules/servers/servers-list/servers-list.ts +++ b/apps/ideploy-web/src/app/modules/servers/servers-list/servers-list.ts @@ -1,22 +1,23 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { ProxyStatus, Server, ServerValidation } from '../../../shared/models/ideploy.models'; @Component({ selector: 'app-servers-list', - imports: [RouterLink], + imports: [RouterLink, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-

Servers

- + Add server +

{{ 'servers.title' | translate }}

+ {{ 'servers.addServerButton' | translate }}
@if (loading()) { -

Loading…

+

{{ 'servers.loading' | translate }}

} @else if (servers().length === 0) { -
No servers yet. Add one to get started.
+
{{ 'servers.empty' | translate }}
} @else {
@for (server of servers(); track server.uuid) { @@ -29,22 +30,22 @@ import { ProxyStatus, Server, ServerValidation } from '../../../shared/models/id @if (validations()[server.uuid]; as v) {
- {{ v.reachable ? 'reachable' : 'unreachable' }} + {{ (v.reachable ? 'servers.reachable' : 'servers.unreachable') | translate }} - · Docker: {{ v.dockerInstalled ? 'installed' : 'missing' }} + · Docker: {{ (v.dockerInstalled ? 'servers.installed' : 'servers.missing') | translate }}
} @if (proxies()[server.uuid]; as p) { -
Proxy: {{ p.status }}
+
{{ 'servers.proxyStatus' | translate:{ status: p.status } }}
}
- - - - - - + + + + + +
} diff --git a/apps/ideploy-web/src/app/modules/services/services-list/services-list.ts b/apps/ideploy-web/src/app/modules/services/services-list/services-list.ts index 3f7a8d342..0c7edbc52 100644 --- a/apps/ideploy-web/src/app/modules/services/services-list/services-list.ts +++ b/apps/ideploy-web/src/app/modules/services/services-list/services-list.ts @@ -1,21 +1,22 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { Service, ServiceTemplate } from '../../../shared/models/ideploy.models'; @Component({ selector: 'app-services-list', - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Services

+

{{ 'services.title' | translate }}

@if (loading()) { -

Loading…

+

{{ 'services.loading' | translate }}

} @else if (services().length === 0) { -
No services yet.
+
{{ 'services.empty' | translate }}
} @else {
@for (svc of services(); track svc.uuid) { @@ -27,8 +28,8 @@ import { Service, ServiceTemplate } from '../../../shared/models/ideploy.models'
- - + +
} @@ -37,39 +38,39 @@ import { Service, ServiceTemplate } from '../../../shared/models/ideploy.models'
-

Deploy a one-click service

+

{{ 'services.deployOneClick' | translate }}

- +
- +
- +
- +
- +
@if (error()) {

{{ error() }}

} @@ -78,6 +79,7 @@ import { Service, ServiceTemplate } from '../../../shared/models/ideploy.models' export class ServicesListComponent implements OnInit { private api = inject(ApiService); private fb = inject(FormBuilder); + private translate = inject(TranslateService); protected readonly services = signal([]); protected readonly templates = signal([]); @@ -120,7 +122,7 @@ export class ServicesListComponent implements OnInit { this.load(); }, error: (e: { error?: { error?: { message?: string } } }) => { - this.error.set(e?.error?.error?.message ?? 'Failed to create service'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('services.createError')); this.saving.set(false); }, }; diff --git a/apps/ideploy-web/src/app/modules/settings/settings-page/settings-page.ts b/apps/ideploy-web/src/app/modules/settings/settings-page/settings-page.ts index f5d88c833..6269bac89 100644 --- a/apps/ideploy-web/src/app/modules/settings/settings-page/settings-page.ts +++ b/apps/ideploy-web/src/app/modules/settings/settings-page/settings-page.ts @@ -1,40 +1,41 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; @Component({ selector: 'app-settings-page', - imports: [FormsModule], + imports: [FormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Settings

+

{{ 'settings.title' | translate }}

@if (version(); as v) {
-
iDeploy version: {{ v.version }}
+
{{ 'settings.versionLabel' | translate }} {{ v.version }}
- Auto-update: {{ v.autoUpdate ? 'enabled' : 'disabled' }} + {{ 'settings.autoUpdate' | translate }} {{ v.autoUpdate ? ('settings.enabled' | translate) : ('settings.disabled' | translate) }}
}
-

Instance settings

+

{{ 'settings.instanceSettings' | translate }}

- +
- +
-

Global search

- +

{{ 'settings.globalSearch' | translate }}

+ @for (hit of results(); track hit.uuid) {
{{ hit.type }} diff --git a/apps/ideploy-web/src/app/modules/shared-variables/shared-variables/shared-variables.ts b/apps/ideploy-web/src/app/modules/shared-variables/shared-variables/shared-variables.ts index 06ffffeb0..52dc23c91 100644 --- a/apps/ideploy-web/src/app/modules/shared-variables/shared-variables/shared-variables.ts +++ b/apps/ideploy-web/src/app/modules/shared-variables/shared-variables/shared-variables.ts @@ -1,15 +1,16 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; /** Team-scoped shared environment variables (Coolify Shared Variables). */ @Component({ selector: 'app-shared-variables', - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Shared Variables

-

Team-level variables reusable across all resources.

+

{{ 'sharedVariables.title' | translate }}

+

{{ 'sharedVariables.description' | translate }}

@for (v of vars(); track v.key) { @@ -19,9 +20,9 @@ import { ApiService } from '../../../shared/services/api.service';
}
- - - + + +
`, diff --git a/apps/ideploy-web/src/app/modules/sources/sources-list/sources-list.ts b/apps/ideploy-web/src/app/modules/sources/sources-list/sources-list.ts index bcdf15451..fd7cce910 100644 --- a/apps/ideploy-web/src/app/modules/sources/sources-list/sources-list.ts +++ b/apps/ideploy-web/src/app/modules/sources/sources-list/sources-list.ts @@ -1,13 +1,15 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; @Component({ selector: 'app-sources-list', + imports: [TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Git Sources

+

{{ 'sources.title' | translate }}

@if (sources().length === 0) { -
No Git sources configured (GitHub / GitLab apps).
+
{{ 'sources.empty' | translate }}
} @else {
@for (s of sources(); track s.uuid) { diff --git a/apps/ideploy-web/src/app/modules/storages/storages-list/storages-list.ts b/apps/ideploy-web/src/app/modules/storages/storages-list/storages-list.ts index be912481c..6d0d8ad12 100644 --- a/apps/ideploy-web/src/app/modules/storages/storages-list/storages-list.ts +++ b/apps/ideploy-web/src/app/modules/storages/storages-list/storages-list.ts @@ -1,13 +1,15 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; @Component({ selector: 'app-storages-list', + imports: [TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

S3 Storages

+

{{ 'storages.title' | translate }}

@if (storages().length === 0) { -
No S3 storages configured.
+
{{ 'storages.empty' | translate }}
} @else {
@for (s of storages(); track s.uuid) { diff --git a/apps/ideploy-web/src/app/modules/subscription/subscription-page/subscription-page.ts b/apps/ideploy-web/src/app/modules/subscription/subscription-page/subscription-page.ts index 025880afe..0954be3d2 100644 --- a/apps/ideploy-web/src/app/modules/subscription/subscription-page/subscription-page.ts +++ b/apps/ideploy-web/src/app/modules/subscription/subscription-page/subscription-page.ts @@ -1,42 +1,44 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; @Component({ selector: 'app-subscription-page', + imports: [TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Subscription

+

{{ 'subscription.title' | translate }}

@if (subscription(); as s) {
-
Current plan: {{ s.plan }}
+
{{ 'subscription.currentPlan' | translate }} {{ s.plan }}
@if (quota(); as q) {
- Apps: {{ q.apps.used }}/{{ q.apps.limit || '∞' }} - {{ q.apps.ok ? '' : '(limit reached)' }} + {{ 'subscription.apps' | translate }} {{ q.apps.used }}/{{ q.apps.limit || '∞' }} + {{ q.apps.ok ? '' : ('subscription.limitReached' | translate) }}
- Servers: {{ q.servers.used }}/{{ q.servers.limit || '∞' }} - {{ q.servers.ok ? '' : '(limit reached)' }} + {{ 'subscription.servers' | translate }} {{ q.servers.used }}/{{ q.servers.limit || '∞' }} + {{ q.servers.ok ? '' : ('subscription.limitReached' | translate) }}
}
} -

Plans

+

{{ 'subscription.plans' | translate }}

@for (plan of plans(); track plan['name']) {
{{ plan['display_name'] }}
{{ plan['price'] }} {{ plan['currency'] }}/{{ plan['billing_period'] }}
- Apps: {{ plan['app_limit'] || '∞' }} · Servers: {{ plan['server_limit'] || '∞' }} + {{ 'subscription.apps' | translate }} {{ plan['app_limit'] || '∞' }} · {{ 'subscription.servers' | translate }} {{ plan['server_limit'] || '∞' }}
@if (subscription()?.plan === plan['name']) { - CURRENT + {{ 'subscription.current' | translate }} } @else { - + }
diff --git a/apps/ideploy-web/src/app/modules/tags/tags-list/tags-list.ts b/apps/ideploy-web/src/app/modules/tags/tags-list/tags-list.ts index 98d50ef54..8760010a9 100644 --- a/apps/ideploy-web/src/app/modules/tags/tags-list/tags-list.ts +++ b/apps/ideploy-web/src/app/modules/tags/tags-list/tags-list.ts @@ -1,17 +1,18 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { Tag } from '../../../shared/models/ideploy.models'; @Component({ selector: 'app-tags-list', - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Tags

+

{{ 'tags.title' | translate }}

@if (loading()) { -

Loading…

+

{{ 'tags.loading' | translate }}

} @else {
@for (tag of tags(); track tag.uuid) { @@ -24,8 +25,8 @@ import { Tag } from '../../../shared/models/ideploy.models';
}
- - + +
`, diff --git a/apps/ideploy-web/src/app/modules/team/team-page/team-page.ts b/apps/ideploy-web/src/app/modules/team/team-page/team-page.ts index 82cb968fa..96e04773d 100644 --- a/apps/ideploy-web/src/app/modules/team/team-page/team-page.ts +++ b/apps/ideploy-web/src/app/modules/team/team-page/team-page.ts @@ -1,16 +1,17 @@ import { ChangeDetectionStrategy, Component, inject, signal, OnInit } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; @Component({ selector: 'app-team-page', - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

Team

+

{{ 'team.title' | translate }}

-

Members

+

{{ 'team.members' | translate }}

@for (m of members(); track m.user_id) {
{{ m.name }} @@ -21,21 +22,21 @@ import { ApiService } from '../../../shared/services/api.service';
-

Invitations

+

{{ 'team.invitations' | translate }}

@for (inv of invitations(); track inv.uuid) {
{{ inv.email }} ({{ inv.role }}) {{ inv.link }} - +
}
- + - +
`, diff --git a/apps/ideploy-web/src/app/modules/templates/templates-page/templates-page.ts b/apps/ideploy-web/src/app/modules/templates/templates-page/templates-page.ts index 527c7958f..31ed10514 100644 --- a/apps/ideploy-web/src/app/modules/templates/templates-page/templates-page.ts +++ b/apps/ideploy-web/src/app/modules/templates/templates-page/templates-page.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, inject, signal, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../../../shared/services/api.service'; import { ServiceTemplate } from '../../../shared/models/ideploy.models'; @@ -11,28 +12,28 @@ import { ServiceTemplate } from '../../../shared/models/ideploy.models'; */ @Component({ selector: 'app-templates-page', - imports: [FormsModule], + imports: [FormsModule, TranslateModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-

Templates

+

{{ 'templates.title' | translate }}

{{ filtered().length }} / {{ templates().length }}
- @if (error()) {
{{ error() }} @if (error()!.toLowerCase().includes('server')) { - · Add a server + · {{ 'templates.addServer' | translate }} }
} @if (templates().length === 0) { -
No templates available. (The service-templates catalog could not be loaded.)
+
{{ 'templates.noTemplates' | translate }}
} @else {
@for (t of filtered(); track t.name) { @@ -48,7 +49,7 @@ import { ServiceTemplate } from '../../../shared/models/ideploy.models';

{{ t.slogan }}

} @@ -59,6 +60,7 @@ import { ServiceTemplate } from '../../../shared/models/ideploy.models'; export class TemplatesPageComponent implements OnInit { private api = inject(ApiService); private router = inject(Router); + private translate = inject(TranslateService); protected readonly templates = signal([]); protected readonly query = signal(''); @@ -91,7 +93,7 @@ export class TemplatesPageComponent implements OnInit { }, error: (e) => { this.busy.set(null); - this.error.set(e?.error?.error?.message ?? 'Deployment failed'); + this.error.set(e?.error?.error?.message ?? this.translate.instant('templates.deploymentFailed')); }, }); } diff --git a/apps/ideploy-web/src/app/shared/components/language-selector/language-selector.ts b/apps/ideploy-web/src/app/shared/components/language-selector/language-selector.ts new file mode 100644 index 000000000..051d38f56 --- /dev/null +++ b/apps/ideploy-web/src/app/shared/components/language-selector/language-selector.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { LanguageService } from '../../services/language.service'; + +/** + * Compact EN/FR language switcher for the app shell. Changing the language writes + * the shared `idem_lang` cookie (via LanguageService), so the choice propagates to + * every other Idem app. + */ +@Component({ + selector: 'app-language-selector', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @for (lang of languages; track lang.code) { + + } +
+ `, +}) +export class LanguageSelectorComponent { + private readonly languageService = inject(LanguageService); + private readonly translate = inject(TranslateService); + + protected readonly languages = [ + { code: 'en', label: 'English' }, + { code: 'fr', label: 'Français' }, + ]; + + private readonly currentLang = signal(this.translate.currentLang || 'en'); + protected readonly current = computed(() => this.currentLang()); + + constructor() { + this.translate.onLangChange.subscribe((e) => this.currentLang.set(e.lang)); + } + + protected select(code: string): void { + this.languageService.setLanguage(code); + } +} diff --git a/apps/ideploy-web/src/app/shared/interceptors/auth.interceptor.ts b/apps/ideploy-web/src/app/shared/interceptors/auth.interceptor.ts index 8fc4c1ed4..90cf9650f 100644 --- a/apps/ideploy-web/src/app/shared/interceptors/auth.interceptor.ts +++ b/apps/ideploy-web/src/app/shared/interceptors/auth.interceptor.ts @@ -1,11 +1,21 @@ import { HttpInterceptorFn } from '@angular/common/http'; +import { readLocaleCookie } from '../utils/locale-cookie'; /** * Auth interceptor — sends the httpOnly `session` cookie with API requests * (`withCredentials`). iDeploy does NOT manage tokens client-side: the central * Idem API owns authentication and sets the session cookie; iDeploy's backend * verifies it. So we only need to forward credentials. + * + * It also advertises the user's UI language via `Accept-Language` so the backend + * can localize its responses (validation/error messages). */ export const authInterceptor: HttpInterceptorFn = (req, next) => { - return next(req.clone({ withCredentials: true })); + const lang = readLocaleCookie() ?? 'en'; + return next( + req.clone({ + withCredentials: true, + setHeaders: { 'Accept-Language': lang }, + }) + ); }; diff --git a/apps/ideploy-web/src/app/shared/services/language.service.ts b/apps/ideploy-web/src/app/shared/services/language.service.ts new file mode 100644 index 000000000..47621f46c --- /dev/null +++ b/apps/ideploy-web/src/app/shared/services/language.service.ts @@ -0,0 +1,94 @@ +import { Injectable, inject } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + SUPPORTED_LOCALES, + isSupportedLocale, + readLocaleCookie, + writeLocaleCookie, + type SupportedLocale, +} from '../utils/locale-cookie'; + +@Injectable({ + providedIn: 'root', +}) +export class LanguageService { + private readonly translateService = inject(TranslateService); + private readonly SUPPORTED_LANGUAGES = SUPPORTED_LOCALES; + + constructor() { + this.initializeLanguage(); + this.listenForCrossAppChanges(); + } + + /** + * Initialize language from URL parameter, the shared cross-app cookie or the browser. + */ + private initializeLanguage(): void { + const urlLanguage = this.getLanguageFromURL(); + const cookieLanguage = readLocaleCookie(); + const browserLanguage = this.getBrowserLanguage(); + const defaultLanguage: SupportedLocale = 'en'; + + this.translateService.addLangs([...this.SUPPORTED_LANGUAGES]); + this.translateService.setFallbackLang(defaultLanguage); + + // Priority: URL param > shared cookie > browser > default + const languageToUse = + urlLanguage || cookieLanguage || browserLanguage || defaultLanguage; + this.setLanguage(languageToUse); + } + + /** + * Re-apply the language when returning to the tab: another Idem app may have + * changed the shared cookie in the meantime (cross-app synchronization). + */ + private listenForCrossAppChanges(): void { + if (typeof document === 'undefined') { + return; + } + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') { + return; + } + const cookieLanguage = readLocaleCookie(); + if (cookieLanguage && cookieLanguage !== this.getCurrentLanguage()) { + this.setLanguage(cookieLanguage); + } + }); + } + + private getLanguageFromURL(): SupportedLocale | null { + if (typeof window !== 'undefined') { + const urlParams = new URLSearchParams(window.location.search); + const lang = urlParams.get('lang'); + return isSupportedLocale(lang) ? lang : null; + } + return null; + } + + private getBrowserLanguage(): SupportedLocale | null { + if (typeof window !== 'undefined' && window.navigator) { + const browserLang = window.navigator.language.split('-')[0]; + return isSupportedLocale(browserLang) ? browserLang : null; + } + return null; + } + + /** + * Set the current language and persist it to the shared cookie. + */ + setLanguage(lang: string): void { + const locale: SupportedLocale = isSupportedLocale(lang) ? lang : 'en'; + this.translateService.use(locale); + // Shared cookie = single source of truth across all Idem apps. + writeLocaleCookie(locale); + } + + getCurrentLanguage(): string { + return this.translateService.currentLang || 'en'; + } + + getSupportedLanguages(): string[] { + return [...this.SUPPORTED_LANGUAGES]; + } +} diff --git a/apps/ideploy-web/src/app/shared/utils/locale-cookie.ts b/apps/ideploy-web/src/app/shared/utils/locale-cookie.ts new file mode 100644 index 000000000..d49467e24 --- /dev/null +++ b/apps/ideploy-web/src/app/shared/utils/locale-cookie.ts @@ -0,0 +1,42 @@ +/** + * Cross-application locale cookie helper (shared contract across all Idem apps). + * + * Single source of truth for the UI language: the `idem_lang` cookie. In production + * every app lives under `*.idem.africa`, so the cookie is written with + * `domain=.idem.africa` (shared across sub-domains). In dev everything runs on + * `localhost` (different ports), where cookies are not isolated by port — a host-only + * cookie is therefore already shared across the dev apps. + * + * Keep this contract identical to the other Idem apps; any divergence breaks sync. + */ +export const LOCALE_COOKIE_NAME = 'idem_lang'; + +export const SUPPORTED_LOCALES = ['en', 'fr'] as const; +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +const ONE_YEAR_SECONDS = 31_536_000; + +export function isSupportedLocale(value: string | null | undefined): value is SupportedLocale { + return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +/** Read the shared locale cookie. Returns null when absent/unsupported or on the server. */ +export function readLocaleCookie(): SupportedLocale | null { + if (typeof document === 'undefined') { + return null; + } + const match = document.cookie.match(/(?:^|;\s*)idem_lang=([^;]+)/); + const value = match ? decodeURIComponent(match[1]) : null; + return isSupportedLocale(value) ? value : null; +} + +/** Write the shared locale cookie with the correct domain scope for the current host. */ +export function writeLocaleCookie(lang: SupportedLocale): void { + if (typeof document === 'undefined') { + return; + } + const host = typeof location !== 'undefined' ? location.hostname : ''; + const onIdem = host.endsWith('idem.africa'); + const scope = onIdem ? '; domain=.idem.africa; Secure' : ''; + document.cookie = `${LOCALE_COOKIE_NAME}=${lang}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax${scope}`; +} diff --git a/apps/landing/nginx.conf b/apps/landing/nginx.conf index 5e0900453..11f49218f 100644 --- a/apps/landing/nginx.conf +++ b/apps/landing/nginx.conf @@ -65,6 +65,28 @@ http { return 301 /fr/; } + # Redirection de la racine avec détection de la langue + location = / { + # 1. Vérifier le cookie de langue + if ($cookie_idem_lang = "fr") { + return 302 /fr/; + } + if ($cookie_idem_lang = "en") { + return 302 /en/; + } + + # 2. Détecter la langue préférée via l'en-tête Accept-Language + if ($http_accept_language ~* "^fr") { + return 302 /fr/; + } + if ($http_accept_language ~* "^en") { + return 302 /en/; + } + + # 3. Langue par défaut + return 302 /en/; + } + # Route par défaut FR - Fallback DIRECT (pas de $uri/) location / { try_files $uri /index.csr.html /index.html; diff --git a/apps/landing/src/app/components/footer/footer.html b/apps/landing/src/app/components/footer/footer.html index f140a6dcf..d1eadf83d 100644 --- a/apps/landing/src/app/components/footer/footer.html +++ b/apps/landing/src/app/components/footer/footer.html @@ -253,19 +253,41 @@

+
-

- © 2025 IDEM. All rights reserved. -

-

- African, you too can build +

+ © 2025 IDEM. All rights reserved. +

+

+ African, you too can build + • Made with ❤️ in Cameroon +

+
+ + +
+ + +

diff --git a/apps/landing/src/app/components/footer/footer.ts b/apps/landing/src/app/components/footer/footer.ts index b290874cf..dab8a3cc7 100644 --- a/apps/landing/src/app/components/footer/footer.ts +++ b/apps/landing/src/app/components/footer/footer.ts @@ -1,9 +1,43 @@ -import { Component } from '@angular/core'; +import { Component, inject, LOCALE_ID } from '@angular/core'; +import { CommonModule, DOCUMENT } from '@angular/common'; +import { isSupportedLocale, writeLocaleCookie } from '../../shared/utils/locale-cookie'; @Component({ selector: 'app-footer', - imports: [], + imports: [CommonModule], templateUrl: './footer.html', styleUrl: './footer.css', }) -export class Footer {} +export class Footer { + protected readonly locale = inject(LOCALE_ID); + private readonly document = inject(DOCUMENT); + + protected switchLanguage(targetLang: string): void { + if (this.locale === targetLang) { + return; + } + + // Persist to the shared cross-app cookie (source of truth) + localStorage. + if (isSupportedLocale(targetLang)) { + writeLocaleCookie(targetLang); + } + localStorage.setItem('idem_lang', targetLang); + + const pathname = this.document.location.pathname; + let newPath = pathname; + + // Check if the current URL has /fr/ or /fr or /en/ or /en prefix + const hasFr = pathname.startsWith('/fr/') || pathname === '/fr'; + const hasEn = pathname.startsWith('/en/') || pathname === '/en'; + + if (hasFr) { + newPath = pathname.replace(/^\/fr(\/|$)/, `/${targetLang}$1`); + } else if (hasEn) { + newPath = pathname.replace(/^\/en(\/|$)/, `/${targetLang}$1`); + } else { + newPath = `/${targetLang}${pathname.startsWith('/') ? pathname : '/' + pathname}`; + } + + this.document.location.href = `${this.document.location.origin}${newPath}`; + } +} diff --git a/apps/landing/src/app/components/header/header.html b/apps/landing/src/app/components/header/header.html index c28262984..8187eea3b 100644 --- a/apps/landing/src/app/components/header/header.html +++ b/apps/landing/src/app/components/header/header.html @@ -335,6 +335,31 @@ GitHub + + +
+
+ Language / Langue +
+
+ + +
+
diff --git a/apps/landing/src/app/components/header/header.ts b/apps/landing/src/app/components/header/header.ts index b8e539289..aa73820e1 100644 --- a/apps/landing/src/app/components/header/header.ts +++ b/apps/landing/src/app/components/header/header.ts @@ -7,6 +7,7 @@ import { ViewChild, signal, computed, + LOCALE_ID, } from '@angular/core'; import { trigger, transition, style, animate } from '@angular/animations'; import { Router, RouterLink, RouterLinkActive } from '@angular/router'; @@ -18,6 +19,7 @@ import { CommonModule } from '@angular/common'; import { ButtonModule } from 'primeng/button'; import { DOCUMENT } from '@angular/common'; import { environment } from '../../../environments/environment'; +import { isSupportedLocale, writeLocaleCookie } from '../../shared/utils/locale-cookie'; @Component({ selector: 'app-header', standalone: true, @@ -62,11 +64,13 @@ export class Header implements OnInit { private readonly auth = inject(AuthService); private readonly router = inject(Router); private readonly document = inject(DOCUMENT); + protected readonly locale = inject(LOCALE_ID); protected readonly dashboardUrl = environment.services.dashboard.url; // UI State Signals protected readonly isMenuOpen = signal(false); protected readonly isDropdownOpen = signal(false); + protected readonly isLangDropdownOpen = signal(false); protected readonly userRefresh = signal(0); protected readonly activeDropdown = signal(null); @@ -233,5 +237,53 @@ export class Header implements OnInit { if (this.isDropdownOpen() && !dropdownButton && !dropdownMenu) { this.isDropdownOpen.set(false); } + + const langButton = targetElement.closest('.lang-selector-btn'); + const langMenu = targetElement.closest('.lang-selector-menu'); + + if (this.isLangDropdownOpen() && !langButton && !langMenu) { + this.isLangDropdownOpen.set(false); + } + } + + /** + * Toggle language dropdown + */ + protected toggleLangDropdown(): void { + this.isLangDropdownOpen.update((open) => !open); + } + + /** + * Switch the application language + */ + protected switchLanguage(targetLang: string): void { + if (this.locale === targetLang) { + this.isLangDropdownOpen.set(false); + return; + } + + // Persist to the shared cross-app cookie (source of truth) + localStorage. + if (isSupportedLocale(targetLang)) { + writeLocaleCookie(targetLang); + } + localStorage.setItem('idem_lang', targetLang); + + const pathname = this.document.location.pathname; + let newPath = pathname; + + // Check if the current URL has /fr/ or /fr or /en/ or /en prefix + const hasFr = pathname.startsWith('/fr/') || pathname === '/fr'; + const hasEn = pathname.startsWith('/en/') || pathname === '/en'; + + if (hasFr) { + newPath = pathname.replace(/^\/fr(\/|$)/, `/${targetLang}$1`); + } else if (hasEn) { + newPath = pathname.replace(/^\/en(\/|$)/, `/${targetLang}$1`); + } else { + newPath = `/${targetLang}${pathname.startsWith('/') ? pathname : '/' + pathname}`; + } + + this.isLangDropdownOpen.set(false); + this.document.location.href = `${this.document.location.origin}${newPath}`; } } diff --git a/apps/landing/src/app/shared/utils/locale-cookie.ts b/apps/landing/src/app/shared/utils/locale-cookie.ts new file mode 100644 index 000000000..15baf2702 --- /dev/null +++ b/apps/landing/src/app/shared/utils/locale-cookie.ts @@ -0,0 +1,44 @@ +/** + * Cross-application locale cookie helper (shared contract across all Idem apps). + * + * Single source of truth for the UI language: the `idem_lang` cookie. In production + * every app lives under `*.idem.africa`, so the cookie is written with + * `domain=.idem.africa` (shared across sub-domains). In dev everything runs on + * `localhost` (different ports), where cookies are not isolated by port — a host-only + * cookie is therefore already shared across the dev apps. + * + * NOTE: landing uses Angular compile-time i18n (separate `/en` `/fr` builds), so the + * active locale still comes from `LOCALE_ID`; this cookie only drives cross-app sync + * and the nginx/root redirection. + */ +export const LOCALE_COOKIE_NAME = 'idem_lang'; + +export const SUPPORTED_LOCALES = ['en', 'fr'] as const; +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +const ONE_YEAR_SECONDS = 31536000; + +export function isSupportedLocale(value: string | null | undefined): value is SupportedLocale { + return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +/** Read the shared locale cookie. Returns null when absent/unsupported or on the server. */ +export function readLocaleCookie(): SupportedLocale | null { + if (typeof document === 'undefined') { + return null; + } + const match = document.cookie.match(/(?:^|;\s*)idem_lang=([^;]+)/); + const value = match ? decodeURIComponent(match[1]) : null; + return isSupportedLocale(value) ? value : null; +} + +/** Write the shared locale cookie with the correct domain scope for the current host. */ +export function writeLocaleCookie(lang: SupportedLocale): void { + if (typeof document === 'undefined') { + return; + } + const host = typeof location !== 'undefined' ? location.hostname : ''; + const onIdem = host.endsWith('idem.africa'); + const scope = onIdem ? '; domain=.idem.africa; Secure' : ''; + document.cookie = `${LOCALE_COOKIE_NAME}=${lang}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax${scope}`; +} diff --git a/apps/landing/src/index.html b/apps/landing/src/index.html index 81e0c845b..1b04788f4 100644 --- a/apps/landing/src/index.html +++ b/apps/landing/src/index.html @@ -2,6 +2,49 @@ + IDEM - The Open Source AI that brings African startups to life | AI-powered business creation tool diff --git a/apps/main-dashboard/src/app/layouts/empty-layout/empty-layout.html b/apps/main-dashboard/src/app/layouts/empty-layout/empty-layout.html index 0e0ea3c6b..dd1abe861 100644 --- a/apps/main-dashboard/src/app/layouts/empty-layout/empty-layout.html +++ b/apps/main-dashboard/src/app/layouts/empty-layout/empty-layout.html @@ -25,7 +25,7 @@ <!-- Right section --> <div class="flex items-center gap-4"> <!-- Language Selector --> - <app-language-selector /> + <app-language-selector direction="down" align="right" /> @if (user()) { <!-- Quota Display - Desktop only --> diff --git a/apps/main-dashboard/src/app/layouts/global-layout/global-layout.html b/apps/main-dashboard/src/app/layouts/global-layout/global-layout.html index abc341b05..db1c5f67e 100644 --- a/apps/main-dashboard/src/app/layouts/global-layout/global-layout.html +++ b/apps/main-dashboard/src/app/layouts/global-layout/global-layout.html @@ -5,7 +5,7 @@ <!-- Language Selector - Fixed Position --> <div class="fixed top-4 right-4 z-50"> - <app-language-selector /> + <app-language-selector direction="down" align="right" /> </div> <!-- Main content area --> diff --git a/apps/main-dashboard/src/app/shared/components/language-selector/language-selector.html b/apps/main-dashboard/src/app/shared/components/language-selector/language-selector.html index 8cd26dc66..5f5e27501 100644 --- a/apps/main-dashboard/src/app/shared/components/language-selector/language-selector.html +++ b/apps/main-dashboard/src/app/shared/components/language-selector/language-selector.html @@ -23,7 +23,11 @@ <!-- Dropdown Menu --> @if (isOpen()) { <div - class="absolute left-0 w-48 glass-card border border-white/10 rounded-lg shadow-2xl z-50 py-2 animate-fade-in bottom-full mb-2" + class="absolute w-48 glass-card border border-white/10 rounded-lg shadow-2xl z-50 py-2 animate-fade-in" + [ngClass]="[ + direction() === 'down' ? 'top-full mt-2' : 'bottom-full mb-2', + align() === 'right' ? 'right-0' : 'left-0' + ]" > @for (language of languages; track language.code) { <button diff --git a/apps/main-dashboard/src/app/shared/components/language-selector/language-selector.ts b/apps/main-dashboard/src/app/shared/components/language-selector/language-selector.ts index 4029bbcc4..832d00af2 100644 --- a/apps/main-dashboard/src/app/shared/components/language-selector/language-selector.ts +++ b/apps/main-dashboard/src/app/shared/components/language-selector/language-selector.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject, signal, input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { LanguageService } from '../../services/language.service'; import { TranslateModule } from '@ngx-translate/core'; @@ -13,6 +13,9 @@ import { TranslateModule } from '@ngx-translate/core'; export class LanguageSelectorComponent { private readonly languageService = inject(LanguageService); + readonly direction = input<'up' | 'down'>('up'); + readonly align = input<'left' | 'right'>('left'); + protected readonly isOpen = signal(false); protected readonly currentLanguage = signal(this.languageService.getCurrentLanguage()); diff --git a/apps/main-dashboard/src/app/shared/interceptors/auth.interceptor.ts b/apps/main-dashboard/src/app/shared/interceptors/auth.interceptor.ts index 4b3f0e9cb..c96ba40a6 100644 --- a/apps/main-dashboard/src/app/shared/interceptors/auth.interceptor.ts +++ b/apps/main-dashboard/src/app/shared/interceptors/auth.interceptor.ts @@ -4,6 +4,7 @@ import { TokenService } from '../services/token.service'; import { isPlatformServer } from '@angular/common'; import { Observable, from, of } from 'rxjs'; import { switchMap, catchError, timeout } from 'rxjs/operators'; +import { readLocaleCookie } from '../utils/locale-cookie'; /** * Interceptor function to add JWT to requests. @@ -42,6 +43,13 @@ export const authInterceptor: HttpInterceptorFn = ( return next(req); } + // Advertise the user's UI language on every real API request so backend AI + // generation replies in the right language. SSE streaming bypasses HttpClient + // (and thus this interceptor) — it carries the language as a `lang` query param + // instead (see SSEService callers). + const lang = readLocaleCookie() ?? 'en'; + req = req.clone({ headers: req.headers.set('Accept-Language', lang) }); + // Skip if the request already carries its own Authorization header (e.g. iDeploy API) if (req.headers.has('Authorization')) { return next(req); diff --git a/apps/main-dashboard/src/app/shared/services/language.service.ts b/apps/main-dashboard/src/app/shared/services/language.service.ts index fa75da73f..b2202809a 100644 --- a/apps/main-dashboard/src/app/shared/services/language.service.ts +++ b/apps/main-dashboard/src/app/shared/services/language.service.ts @@ -1,51 +1,81 @@ import { Injectable, inject } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; +import { + SUPPORTED_LOCALES, + isSupportedLocale, + readLocaleCookie, + writeLocaleCookie, + type SupportedLocale, +} from '../utils/locale-cookie'; @Injectable({ providedIn: 'root', }) export class LanguageService { private readonly translateService = inject(TranslateService); - private readonly STORAGE_KEY = 'idem_dashboard_language'; - private readonly SUPPORTED_LANGUAGES = ['en', 'fr']; + /** Legacy localStorage key, kept for backward-compat fallback only. */ + private readonly LEGACY_STORAGE_KEY = 'idem_dashboard_language'; + private readonly SUPPORTED_LANGUAGES = SUPPORTED_LOCALES; constructor() { this.initializeLanguage(); + this.listenForCrossAppChanges(); } /** - * Initialize language from URL parameter, localStorage or browser settings + * Initialize language from URL parameter, shared cookie, legacy storage or browser. */ private initializeLanguage(): void { const urlLanguage = this.getLanguageFromURL(); + const cookieLanguage = readLocaleCookie(); const savedLanguage = this.getSavedLanguage(); const browserLanguage = this.getBrowserLanguage(); - const defaultLanguage = 'en'; + const defaultLanguage: SupportedLocale = 'en'; - // Priority: URL param > localStorage > browser > default - const languageToUse = urlLanguage || savedLanguage || browserLanguage || defaultLanguage; + // Priority: URL param > shared cookie > legacy localStorage > browser > default + const languageToUse = + urlLanguage || cookieLanguage || savedLanguage || browserLanguage || defaultLanguage; this.setLanguage(languageToUse); } + /** + * Re-apply the language when returning to the tab: another Idem app may have + * changed the shared cookie in the meantime (cross-app synchronization). + */ + private listenForCrossAppChanges(): void { + if (typeof document === 'undefined') { + return; + } + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') { + return; + } + const cookieLanguage = readLocaleCookie(); + if (cookieLanguage && cookieLanguage !== this.getCurrentLanguage()) { + this.setLanguage(cookieLanguage); + } + }); + } + /** * Get language from URL query parameter */ - private getLanguageFromURL(): string | null { + private getLanguageFromURL(): SupportedLocale | null { if (typeof window !== 'undefined') { const urlParams = new URLSearchParams(window.location.search); const lang = urlParams.get('lang'); - return lang && this.SUPPORTED_LANGUAGES.includes(lang) ? lang : null; + return isSupportedLocale(lang) ? lang : null; } return null; } /** - * Get saved language from localStorage + * Get saved language from legacy localStorage (backward compatibility) */ - private getSavedLanguage(): string | null { + private getSavedLanguage(): SupportedLocale | null { if (typeof window !== 'undefined' && window.localStorage) { - const saved = localStorage.getItem(this.STORAGE_KEY); - return saved && this.SUPPORTED_LANGUAGES.includes(saved) ? saved : null; + const saved = localStorage.getItem(this.LEGACY_STORAGE_KEY); + return isSupportedLocale(saved) ? saved : null; } return null; } @@ -53,27 +83,31 @@ export class LanguageService { /** * Get browser language */ - private getBrowserLanguage(): string | null { + private getBrowserLanguage(): SupportedLocale | null { if (typeof window !== 'undefined' && window.navigator) { const browserLang = window.navigator.language.split('-')[0]; - return this.SUPPORTED_LANGUAGES.includes(browserLang) ? browserLang : null; + return isSupportedLocale(browserLang) ? browserLang : null; } return null; } /** - * Set the current language + * Set the current language and persist it to the shared cookie. */ setLanguage(lang: string): void { - if (!this.SUPPORTED_LANGUAGES.includes(lang)) { + const locale: SupportedLocale = isSupportedLocale(lang) ? lang : 'en'; + if (!isSupportedLocale(lang)) { console.warn(`Language ${lang} is not supported. Falling back to 'en'.`); - lang = 'en'; } - this.translateService.use(lang); + this.translateService.use(locale); + + // Shared cookie = single source of truth across all Idem apps. + writeLocaleCookie(locale); + // Keep the legacy key in sync so a rollback doesn't lose the user's choice. if (typeof window !== 'undefined' && window.localStorage) { - localStorage.setItem(this.STORAGE_KEY, lang); + localStorage.setItem(this.LEGACY_STORAGE_KEY, locale); } } diff --git a/apps/main-dashboard/src/app/shared/services/sse.service.ts b/apps/main-dashboard/src/app/shared/services/sse.service.ts index a6d7cc041..cf1ff4c28 100644 --- a/apps/main-dashboard/src/app/shared/services/sse.service.ts +++ b/apps/main-dashboard/src/app/shared/services/sse.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { SseClient } from 'ngx-sse-client'; import { SSEStepEvent, SSEServiceEventType, SSEConnectionConfig } from '../models/sse-step.model'; +import { readLocaleCookie } from '../utils/locale-cookie'; @Injectable({ providedIn: 'root', @@ -21,14 +22,18 @@ export class SSEService { config: SSEConnectionConfig, serviceType: SSEServiceEventType, ): Observable<SSEStepEvent> { - console.log(`Creating ${serviceType} SSE connection to:`, config.url); + // SSE bypasses the HttpClient auth interceptor, so the UI language cannot be + // sent as an Accept-Language header. Carry it as a `lang` query param instead + // so backend AI generation streams content in the user's language. + const url = this.withLanguageParam(config.url); + console.log(`Creating ${serviceType} SSE connection to:`, url); // Close existing connection if any this.closeConnection(serviceType); return new Observable<SSEStepEvent>((observer) => { const subscription = this.sseClient - .stream(config.url, { + .stream(url, { keepAlive: config.keepAlive || true, reconnectionDelay: config.reconnectionDelay || 1000, }) @@ -77,6 +82,16 @@ export class SSEService { }); } + /** + * Append the current UI language as a `lang` query param, preserving any + * existing query string. + */ + private withLanguageParam(url: string): string { + const lang = readLocaleCookie() ?? 'en'; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}lang=${lang}`; + } + /** * Close SSE connection for a specific service type * @param serviceType Type of service diff --git a/apps/main-dashboard/src/app/shared/utils/locale-cookie.ts b/apps/main-dashboard/src/app/shared/utils/locale-cookie.ts new file mode 100644 index 000000000..3fc07f908 --- /dev/null +++ b/apps/main-dashboard/src/app/shared/utils/locale-cookie.ts @@ -0,0 +1,43 @@ +/** + * Cross-application locale cookie helper. + * + * All Idem frontends share a single source of truth for the UI language: the + * `idem_lang` cookie. In production every app lives under `*.idem.africa`, so the + * cookie is written with `domain=.idem.africa` (shared across sub-domains). In dev + * everything runs on `localhost` (different ports), where cookies are NOT isolated + * by port — a host-only cookie is therefore already shared across the dev apps. + * + * Keep this contract (name, values, attributes, domain logic) identical in every + * app; any divergence breaks the cross-app synchronization. + */ +export const LOCALE_COOKIE_NAME = 'idem_lang'; + +export const SUPPORTED_LOCALES = ['en', 'fr'] as const; +export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +const ONE_YEAR_SECONDS = 31536000; + +export function isSupportedLocale(value: string | null | undefined): value is SupportedLocale { + return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +/** Read the shared locale cookie. Returns null when absent/unsupported or on the server. */ +export function readLocaleCookie(): SupportedLocale | null { + if (typeof document === 'undefined') { + return null; + } + const match = document.cookie.match(/(?:^|;\s*)idem_lang=([^;]+)/); + const value = match ? decodeURIComponent(match[1]) : null; + return isSupportedLocale(value) ? value : null; +} + +/** Write the shared locale cookie with the correct domain scope for the current host. */ +export function writeLocaleCookie(lang: SupportedLocale): void { + if (typeof document === 'undefined') { + return; + } + const host = typeof location !== 'undefined' ? location.hostname : ''; + const onIdem = host.endsWith('idem.africa'); + const scope = onIdem ? '; domain=.idem.africa; Secure' : ''; + document.cookie = `${LOCALE_COOKIE_NAME}=${lang}; path=/; max-age=${ONE_YEAR_SECONDS}; SameSite=Lax${scope}`; +}
Resource{{ 'pricing.colResource' | translate }} iDeploy Vercel Railway