From 823dee0a6d3526e92ca00394f64c561cd2f6d703 Mon Sep 17 00:00:00 2001 From: arolleaguekeng Date: Sun, 12 Jul 2026 20:20:57 +0100 Subject: [PATCH 1/4] feat: implement multi-language support with automatic redirection, cookie-based persistence, and a new header locale switcher --- apps/landing/nginx.conf | 22 ++++++ .../src/app/components/header/header.html | 75 +++++++++++++++++++ .../src/app/components/header/header.ts | 49 ++++++++++++ apps/landing/src/index.html | 29 +++++++ 4 files changed, 175 insertions(+) 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/header/header.html b/apps/landing/src/app/components/header/header.html index c28262984..9abda7e3f 100644 --- a/apps/landing/src/app/components/header/header.html +++ b/apps/landing/src/app/components/header/header.html @@ -100,6 +100,56 @@ + +
+ + + @if (isLangDropdownOpen()) { +
+ + +
+ } +
+ @if (user()) { + + + diff --git a/apps/landing/src/app/components/header/header.ts b/apps/landing/src/app/components/header/header.ts index b8e539289..d708c0933 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'; @@ -62,11 +63,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 +236,51 @@ 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; + } + + // Save language preference in localStorage and cookie + localStorage.setItem('idem_lang', targetLang); + document.cookie = `idem_lang=${targetLang}; path=/; max-age=31536000; SameSite=Lax`; + + 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/index.html b/apps/landing/src/index.html index 81e0c845b..e06d05337 100644 --- a/apps/landing/src/index.html +++ b/apps/landing/src/index.html @@ -2,6 +2,35 @@ + IDEM - The Open Source AI that brings African startups to life | AI-powered business creation tool From 59fbc337c7f8db5b7a45fa25351ae8d515435e48 Mon Sep 17 00:00:00 2001 From: arolleaguekeng <arolleaguekeng@gmail.com> Date: Sun, 12 Jul 2026 20:36:07 +0100 Subject: [PATCH 2/4] feat: migrate language selector from header to footer component --- .../src/app/components/footer/footer.html | 42 ++++++++++++---- .../src/app/components/footer/footer.ts | 37 ++++++++++++-- .../src/app/components/header/header.html | 50 ------------------- 3 files changed, 66 insertions(+), 63 deletions(-) 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 @@ <h3 class="text-lg font-semibold text-white mb-4 flex items-center" i18n="@@foot </div> <!-- Copyright Section --> - <div class="mt-12 pt-8 text-center relative"> + <div class="mt-12 pt-8 text-center relative flex flex-col items-center justify-center gap-4"> <div - class="h-px bg-gradient-to-r from-transparent via-white/10 to-transparent my-6 -rotate-1" + class="w-full h-px bg-gradient-to-r from-transparent via-white/10 to-transparent my-6 -rotate-1" ></div> - <p class="text-gray-200 mb-2" i18n="@@footer.2025_idem_all"> - © 2025 IDEM. All rights reserved. - </p> - <p class="text-gray-400 text-sm"> - <span class="text-primary font-semibold" i18n="@@footer.african_you_too" - >African, you too can build</span + <div> + <p class="text-gray-200 mb-2" i18n="@@footer.2025_idem_all"> + © 2025 IDEM. All rights reserved. + </p> + <p class="text-gray-400 text-sm"> + <span class="text-primary font-semibold" i18n="@@footer.african_you_too" + >African, you too can build</span + > + <ng-container i18n="@@footer.madeInCameroon">• Made with ❤️ in Cameroon </ng-container> + </p> + </div> + + <!-- Language Selector (Footer) --> + <div class="flex items-center gap-3 mt-2"> + <button + (click)="switchLanguage('en')" + [ngClass]="{ 'bg-white/15 text-white border-white/20 font-semibold': locale === 'en', 'bg-white/5 text-gray-400 border-white/5 hover:text-white hover:bg-white/10': locale !== 'en' }" + class="py-1 px-3 rounded-lg border text-xs font-medium transition-all flex items-center gap-1.5 cursor-pointer" + > + <span>🇬🇧</span> + <span>English</span> + </button> + <button + (click)="switchLanguage('fr')" + [ngClass]="{ 'bg-white/15 text-white border-white/20 font-semibold': locale === 'fr', 'bg-white/5 text-gray-400 border-white/5 hover:text-white hover:bg-white/10': locale !== 'fr' }" + class="py-1 px-3 rounded-lg border text-xs font-medium transition-all flex items-center gap-1.5 cursor-pointer" > - <ng-container i18n="@@footer.madeInCameroon">• Made with ❤️ in Cameroon </ng-container> - </p> + <span>🇫🇷</span> + <span>Français</span> + </button> + </div> </div> </div> </footer> diff --git a/apps/landing/src/app/components/footer/footer.ts b/apps/landing/src/app/components/footer/footer.ts index b290874cf..cff592916 100644 --- a/apps/landing/src/app/components/footer/footer.ts +++ b/apps/landing/src/app/components/footer/footer.ts @@ -1,9 +1,40 @@ -import { Component } from '@angular/core'; +import { Component, inject, LOCALE_ID } from '@angular/core'; +import { CommonModule, DOCUMENT } from '@angular/common'; @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; + } + + // Save language preference in localStorage and cookie + localStorage.setItem('idem_lang', targetLang); + document.cookie = `idem_lang=${targetLang}; path=/; max-age=31536000; SameSite=Lax`; + + 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 9abda7e3f..8187eea3b 100644 --- a/apps/landing/src/app/components/header/header.html +++ b/apps/landing/src/app/components/header/header.html @@ -100,56 +100,6 @@ </svg> </a> - <!-- Language Selector (Desktop) --> - <div class="relative"> - <button - (click)="toggleLangDropdown()" - class="lang-selector-btn text-white/80 hover:text-white transition-all py-1.5 px-2.5 text-xs font-semibold rounded-lg flex items-center gap-1.5 glass-button cursor-pointer" - aria-label="Select language" - i18n-aria-label="@@header.aria.selectLanguage" - > - <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> - <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-.778.099-1.533.284-2.253"></path> - </svg> - <span class="uppercase font-semibold tracking-wider text-[11px]">{{ locale }}</span> - <svg - class="w-3 h-3 transition-transform duration-200" - [ngClass]="{ 'rotate-180': isLangDropdownOpen() }" - fill="none" - stroke="currentColor" - stroke-width="2.5" - viewBox="0 0 24 24" - xmlns="http://www.w3.org/2000/svg" - > - <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path> - </svg> - </button> - - @if (isLangDropdownOpen()) { - <div - [@fadeIn] - class="lang-selector-menu absolute right-0 mt-2 w-32 glass-dropdown rounded-xl shadow-2xl z-50 py-1" - > - <button - (click)="switchLanguage('en')" - [ngClass]="{ 'bg-white/10 text-white font-semibold': locale === 'en', 'text-white/80 hover:bg-white/5': locale !== 'en' }" - class="w-full text-left px-4 py-2 text-xs transition-all flex items-center gap-2 cursor-pointer" - > - <span>🇬🇧</span> - <span>English</span> - </button> - <button - (click)="switchLanguage('fr')" - [ngClass]="{ 'bg-white/10 text-white font-semibold': locale === 'fr', 'text-white/80 hover:bg-white/5': locale !== 'fr' }" - class="w-full text-left px-4 py-2 text-xs transition-all flex items-center gap-2 cursor-pointer" - > - <span>🇫🇷</span> - <span>Français</span> - </button> - </div> - } - </div> - @if (user()) { <button (click)="navigateToDashboard()" From c84ae23111b8977a944d830f3619caa8a5c0b591 Mon Sep 17 00:00:00 2001 From: arolleaguekeng <arolleaguekeng@gmail.com> Date: Wed, 15 Jul 2026 23:14:04 +0100 Subject: [PATCH 3/4] feat: implement cross-application UI language synchronization using shared cookies and propagate language headers to backend services --- apps/api/api/index.ts | 5 + apps/api/api/interfaces/express.interface.ts | 2 + .../api/api/middleware/language.middleware.ts | 24 + .../BusinessPlan/businessPlan.service.ts | 5 +- apps/api/api/services/prompt.service.ts | 49 +++ apps/api/api/utils/request-language.ts | 32 ++ .../apps/we-dev-client/src/api/appInfo.ts | 20 +- .../src/components/AiChat/chat/index.tsx | 5 +- .../components/Settings/GeneralSettings.tsx | 4 +- .../apps/we-dev-client/src/hooks/useInit.ts | 14 +- .../apps/we-dev-client/src/locale/fr.json | 411 ++++++++++++++++++ .../apps/we-dev-client/src/utils/i18.ts | 37 +- .../we-dev-client/src/utils/localeCookie.ts | 58 +++ .../apps/we-dev-next/src/config/prompts.ts | 27 +- .../src/handlers/builderHandler.ts | 11 +- .../we-dev-next/src/handlers/chatHandler.ts | 12 +- .../apps/we-dev-next/src/routes/chat.ts | 22 +- .../apps/we-dev-next/src/types/project.ts | 2 + .../src/app/components/footer/footer.ts | 7 +- .../src/app/components/header/header.ts | 7 +- .../src/app/shared/utils/locale-cookie.ts | 44 ++ apps/landing/src/index.html | 36 +- .../shared/interceptors/auth.interceptor.ts | 8 + .../app/shared/services/language.service.ts | 72 ++- .../src/app/shared/services/sse.service.ts | 19 +- .../src/app/shared/utils/locale-cookie.ts | 43 ++ 26 files changed, 896 insertions(+), 80 deletions(-) create mode 100644 apps/api/api/middleware/language.middleware.ts create mode 100644 apps/api/api/utils/request-language.ts create mode 100644 apps/appgen/apps/we-dev-client/src/locale/fr.json create mode 100644 apps/appgen/apps/we-dev-client/src/utils/localeCookie.ts create mode 100644 apps/landing/src/app/shared/utils/locale-cookie.ts create mode 100644 apps/main-dashboard/src/app/shared/utils/locale-cookie.ts 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<string> { 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<LanguageStore>(); + +export function runWithLanguage<T>(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<HTMLTextAreaElement>(null); const messagesEndRef = useRef<HTMLDivElement>(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<IModelOption>({ @@ -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" }, ]} /> </div> @@ -643,7 +643,7 @@ export function GeneralSettings() { onChange={(value) => handleLanguageChange(value)} options={[ { value: "en", label: "English" }, - { value: "zh", label: "中文" }, + { value: "fr", label: "Français" }, ]} /> </div> 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 <div> <span> <img> <a> <input>, évitez <h1><h2> 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<string, any>; } -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<string, string>, - 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/landing/src/app/components/footer/footer.ts b/apps/landing/src/app/components/footer/footer.ts index cff592916..dab8a3cc7 100644 --- a/apps/landing/src/app/components/footer/footer.ts +++ b/apps/landing/src/app/components/footer/footer.ts @@ -1,5 +1,6 @@ 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', @@ -16,9 +17,11 @@ export class Footer { return; } - // Save language preference in localStorage and cookie + // Persist to the shared cross-app cookie (source of truth) + localStorage. + if (isSupportedLocale(targetLang)) { + writeLocaleCookie(targetLang); + } localStorage.setItem('idem_lang', targetLang); - document.cookie = `idem_lang=${targetLang}; path=/; max-age=31536000; SameSite=Lax`; const pathname = this.document.location.pathname; let newPath = pathname; diff --git a/apps/landing/src/app/components/header/header.ts b/apps/landing/src/app/components/header/header.ts index d708c0933..aa73820e1 100644 --- a/apps/landing/src/app/components/header/header.ts +++ b/apps/landing/src/app/components/header/header.ts @@ -19,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, @@ -261,9 +262,11 @@ export class Header implements OnInit { return; } - // Save language preference in localStorage and cookie + // Persist to the shared cross-app cookie (source of truth) + localStorage. + if (isSupportedLocale(targetLang)) { + writeLocaleCookie(targetLang); + } localStorage.setItem('idem_lang', targetLang); - document.cookie = `idem_lang=${targetLang}; path=/; max-age=31536000; SameSite=Lax`; const pathname = this.document.location.pathname; let newPath = pathname; 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 e06d05337..1b04788f4 100644 --- a/apps/landing/src/index.html +++ b/apps/landing/src/index.html @@ -4,27 +4,41 @@ <meta charset="utf-8" /> <script> (function() { + // Shared cross-app locale cookie: `idem_lang` on `.idem.africa` in prod + // (shared across sub-domains), host-only on localhost. Keep this contract + // identical to shared/utils/locale-cookie.ts and the other Idem apps. + var host = window.location.hostname; + var onIdem = host.indexOf('idem.africa') !== -1 && + host.lastIndexOf('idem.africa') === host.length - 'idem.africa'.length; + function getCookie(name) { + var m = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]+)')); + return m ? decodeURIComponent(m[1]) : null; + } + function saveLang(lang) { + localStorage.setItem('idem_lang', lang); + var scope = onIdem ? '; domain=.idem.africa; Secure' : ''; + document.cookie = 'idem_lang=' + lang + '; path=/; max-age=31536000; SameSite=Lax' + scope; + } + var pathname = window.location.pathname; var hasFr = pathname.startsWith('/fr/') || pathname === '/fr'; var hasEn = pathname.startsWith('/en/') || pathname === '/en'; - + if (hasFr) { - localStorage.setItem('idem_lang', 'fr'); - document.cookie = "idem_lang=fr; path=/; max-age=31536000; SameSite=Lax"; + saveLang('fr'); } else if (hasEn) { - localStorage.setItem('idem_lang', 'en'); - document.cookie = "idem_lang=en; path=/; max-age=31536000; SameSite=Lax"; + saveLang('en'); } else { // Skip redirection on local development servers - if (window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') { - var pref = localStorage.getItem('idem_lang'); - if (!pref) { + if (host !== 'localhost' && host !== '127.0.0.1') { + // Priority: shared cookie (set by any Idem app) > localStorage > browser. + var pref = getCookie('idem_lang') || localStorage.getItem('idem_lang'); + if (pref !== 'fr' && pref !== 'en') { var userLang = navigator.language || navigator.userLanguage || ''; pref = userLang.toLowerCase().startsWith('fr') ? 'fr' : 'en'; } - localStorage.setItem('idem_lang', pref); - document.cookie = "idem_lang=" + pref + "; path=/; max-age=31536000; SameSite=Lax"; - + saveLang(pref); + var newPath = '/' + pref + (pathname.startsWith('/') ? pathname : '/' + pathname); window.location.replace(newPath); } 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}`; +} From db7ee5d7df6a7fe062cc8b0b6cd59004dc7d6fd5 Mon Sep 17 00:00:00 2001 From: arolleaguekeng <arolleaguekeng@gmail.com> Date: Wed, 15 Jul 2026 23:30:38 +0100 Subject: [PATCH 4/4] feat: implement internationalization support using ngx-translate across the application --- apps/ideploy-web/package.json | 2 + apps/ideploy-web/public/assets/i18n/en.json | 506 ++++++++++++++++++ apps/ideploy-web/public/assets/i18n/fr.json | 506 ++++++++++++++++++ apps/ideploy-web/src/app/app.config.ts | 10 + apps/ideploy-web/src/app/app.ts | 9 +- .../src/app/layouts/shell/shell.ts | 63 +-- .../application-detail/application-detail.ts | 93 ++-- .../applications-list/applications-list.ts | 32 +- .../modules/auth/sso-callback/sso-callback.ts | 4 +- .../modules/dashboard/dashboard/dashboard.ts | 66 +-- .../databases-list/databases-list.ts | 32 +- .../deploy/deployment-logs/deployment-logs.ts | 27 +- .../destinations-list/destinations-list.ts | 15 +- .../app/modules/landing/landing/landing.ts | 113 ++-- .../app/modules/landing/pricing/pricing.ts | 80 ++- .../notifications/notifications.ts | 20 +- .../projects/import-config/import-config.ts | 84 +-- .../projects/new-project/new-project.ts | 63 ++- .../projects/project-detail/project-detail.ts | 19 +- .../projects/projects-list/projects-list.ts | 26 +- .../security/private-keys/private-keys.ts | 20 +- .../servers/server-create/server-create.ts | 20 +- .../servers/servers-list/servers-list.ts | 29 +- .../services/services-list/services-list.ts | 32 +- .../settings/settings-page/settings-page.ts | 21 +- .../shared-variables/shared-variables.ts | 13 +- .../sources/sources-list/sources-list.ts | 6 +- .../storages/storages-list/storages-list.ts | 6 +- .../subscription-page/subscription-page.ts | 22 +- .../app/modules/tags/tags-list/tags-list.ts | 11 +- .../app/modules/team/team-page/team-page.ts | 19 +- .../templates-page/templates-page.ts | 16 +- .../language-selector/language-selector.ts | 52 ++ .../shared/interceptors/auth.interceptor.ts | 12 +- .../app/shared/services/language.service.ts | 94 ++++ .../src/app/shared/utils/locale-cookie.ts | 42 ++ .../layouts/empty-layout/empty-layout.html | 2 +- .../layouts/global-layout/global-layout.html | 2 +- .../language-selector/language-selector.html | 6 +- .../language-selector/language-selector.ts | 5 +- 40 files changed, 1735 insertions(+), 465 deletions(-) create mode 100644 apps/ideploy-web/public/assets/i18n/en.json create mode 100644 apps/ideploy-web/public/assets/i18n/fr.json create mode 100644 apps/ideploy-web/src/app/shared/components/language-selector/language-selector.ts create mode 100644 apps/ideploy-web/src/app/shared/services/language.service.ts create mode 100644 apps/ideploy-web/src/app/shared/utils/locale-cookie.ts 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: `<router-outlet />`, }) -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: ` <div class="fixed top-0 left-0 right-0 z-50 h-16 topbar-shell"> @@ -36,7 +38,7 @@ interface NavSection { <div class="flex items-center gap-1.5 px-3 py-1.5 rounded-md" style="background:rgba(255,180,171,0.12);color:#ffb4ab;border:1px solid rgba(255,180,171,0.28);"> <i class="fa-solid fa-shield-halved text-xs"></i> - <span style="font-size:11px;font-weight:700;letter-spacing:.05em;">ADMIN</span> + <span style="font-size:11px;font-weight:700;letter-spacing:.05em;">{{ 'shell.admin' | translate }}</span> </div> } <a routerLink="/subscription" @@ -49,7 +51,7 @@ interface NavSection { <i class="fa-solid fa-cube text-[10px]" style="color:#60a5fa;"></i> <div class="flex flex-col gap-0.5"> <div class="flex items-center justify-between gap-2"> - <span style="font-size:9px;color:#8d919a;text-transform:uppercase;">Apps</span> + <span style="font-size:9px;color:#8d919a;text-transform:uppercase;">{{ 'shell.apps' | translate }}</span> <span style="font-size:9px;font-weight:700;color:#e3e1e6;">{{ appsUsed() }}/{{ appsLimit() }}</span> </div> <div class="w-14 h-0.5 rounded-full overflow-hidden" style="background:rgba(255,255,255,0.1);"> @@ -61,7 +63,7 @@ interface NavSection { <i class="fa-solid fa-server text-[10px]" style="color:#4ade80;"></i> <div class="flex flex-col gap-0.5"> <div class="flex items-center justify-between gap-2"> - <span style="font-size:9px;color:#8d919a;text-transform:uppercase;">Srv</span> + <span style="font-size:9px;color:#8d919a;text-transform:uppercase;">{{ 'shell.srv' | translate }}</span> <span style="font-size:9px;font-weight:700;color:#e3e1e6;">{{ serversUsed() }}/{{ serversLimit() }}</span> </div> <div class="w-14 h-0.5 rounded-full overflow-hidden" style="background:rgba(255,255,255,0.1);"> @@ -69,8 +71,9 @@ interface NavSection { </div> </div> </div> + <app-language-selector /> <div class="flex items-center gap-2"> - <a routerLink="/settings" class="flex items-center gap-2 p-1.5 rounded-lg" [title]="authUser()?.email ?? ''"> + <a routerLink="/settings" class="flex items-center gap-2 p-1.5 rounded-lg" [title]="authUser()?.email ?? ''" [attr.aria-label]="'shell.userMenu' | translate"> @if (photoUrl()) { <img [src]="photoUrl()!" class="w-8 h-8 rounded-full object-cover" alt="" /> } @else { @@ -79,7 +82,7 @@ interface NavSection { </div> } </a> - <button class="p-1.5 rounded-lg" title="Log out" (click)="logout()"> + <button class="p-1.5 rounded-lg" [title]="'shell.logout' | translate" (click)="logout()"> <i class="fa-solid fa-arrow-right-from-bracket text-xs" style="color:#8d919a;"></i> </button> </div> @@ -92,20 +95,20 @@ interface NavSection { <div style="padding:16px 12px; border-bottom:1px solid rgba(255,255,255,0.06);"> <div class="flex items-center gap-2 px-1"> <i class="fa-solid fa-users-rectangle" style="color:#60a5fa;"></i> - <span class="text-sm font-semibold text-white">{{ me()?.team?.name ?? 'My Team' }}</span> + <span class="text-sm font-semibold text-white">{{ me()?.team?.name ?? ('shell.myTeam' | translate) }}</span> </div> </div> <ul role="list" class="flex flex-col flex-1 px-3 py-5 gap-y-0.5"> @for (section of nav; track section.title || 'main') { @if (section.title) { - <li style="padding-top:20px; padding-bottom:5px;"><span class="sbi-section">{{ section.title }}</span></li> + <li style="padding-top:20px; padding-bottom:5px;"><span class="sbi-section">{{ section.title! | translate }}</span></li> } @for (item of section.items; track item.path) { <li> <a class="sbi" [routerLink]="item.path" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: item.path === '/dashboard' }"> <i class="sbi-icon" [class]="item.icon"></i> - <span>{{ item.label }}</span> + <span>{{ item.label | translate }}</span> </a> </li> } @@ -114,9 +117,9 @@ interface NavSection { <div class="sbi-disabled" style="justify-content:space-between;"> <div class="flex items-center gap-3"> <i class="fa-solid fa-wand-magic-sparkles" style="width:18px;text-align:center;"></i> - <span>AI Smart Deploy</span> + <span>{{ 'shell.aiSmartDeploy' | translate }}</span> </div> - <span class="sbi-badge-soon">SOON</span> + <span class="sbi-badge-soon">{{ 'shell.soon' | translate }}</span> </div> </li> </ul> @@ -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 { <div class="flex items-center gap-2"> @if (a.link) { <a class="button-secondary" [href]="a.link" target="_blank" rel="noopener"> - <i class="fa-solid fa-arrow-up-right-from-square mr-2"></i>Open + <i class="fa-solid fa-arrow-up-right-from-square mr-2"></i>{{ 'applications.open' | translate }} </a> } - <button class="button-secondary" (click)="lifecycle('restart')">Restart</button> - <button class="button-secondary" (click)="lifecycle('stop')">Stop</button> - <button class="button" (click)="deploy()">Deploy</button> + <button class="button-secondary" (click)="lifecycle('restart')">{{ 'applications.detail.restart' | translate }}</button> + <button class="button-secondary" (click)="lifecycle('stop')">{{ 'applications.detail.stop' | translate }}</button> + <button class="button" (click)="deploy()">{{ 'applications.deploy' | translate }}</button> </div> </div> <!-- Config --> <section class="box mb-6"> - <h2 class="mb-3 font-semibold">Configuration</h2> + <h2 class="mb-3 font-semibold">{{ 'applications.detail.configuration' | translate }}</h2> <form class="space-y-3" [formGroup]="configForm" (ngSubmit)="saveConfig()"> <div class="grid grid-cols-2 gap-3"> <div> - <label class="mb-1 block text-sm">Git repository</label> + <label class="mb-1 block text-sm">{{ 'applications.detail.gitRepository' | translate }}</label> <input class="input" formControlName="git_repository" /> </div> <div> - <label class="mb-1 block text-sm">Branch</label> + <label class="mb-1 block text-sm">{{ 'applications.branch' | translate }}</label> <input class="input" formControlName="git_branch" /> </div> <div> - <label class="mb-1 block text-sm">Build pack</label> + <label class="mb-1 block text-sm">{{ 'applications.detail.buildPack' | translate }}</label> <input class="input" formControlName="build_pack" /> </div> <div> - <label class="mb-1 block text-sm">FQDN</label> + <label class="mb-1 block text-sm">{{ 'applications.detail.fqdn' | translate }}</label> <input class="input" formControlName="fqdn" /> </div> </div> - <button class="button" type="submit" [disabled]="savingConfig()">Save</button> + <button class="button" type="submit" [disabled]="savingConfig()">{{ 'applications.detail.save' | translate }}</button> </form> </section> <!-- Env vars --> <section class="box mb-6"> - <h2 class="mb-3 font-semibold">Environment variables</h2> + <h2 class="mb-3 font-semibold">{{ 'applications.detail.environmentVariables' | translate }}</h2> @for (env of envVars(); track env.key) { <div class="mb-2 flex items-center gap-2"> <code class="text-sm">{{ env.key }}</code> <span class="text-sm" style="color: var(--color-text-secondary)">= {{ env.value }}</span> - <button class="ml-auto text-xs text-red-400" (click)="removeEnv(env)">remove</button> + <button class="ml-auto text-xs text-red-400" (click)="removeEnv(env)">{{ 'applications.detail.remove' | translate }}</button> </div> } <form class="mt-3 flex gap-2" [formGroup]="envForm" (ngSubmit)="addEnv()"> - <input class="input flex-1" placeholder="KEY" formControlName="key" /> - <input class="input flex-1" placeholder="value" formControlName="value" /> - <button class="button" type="submit" [disabled]="envForm.invalid">Add</button> + <input class="input flex-1" [placeholder]="'applications.detail.keyPlaceholder' | translate" formControlName="key" /> + <input class="input flex-1" [placeholder]="'applications.detail.valuePlaceholder' | translate" formControlName="value" /> + <button class="button" type="submit" [disabled]="envForm.invalid">{{ 'applications.detail.add' | translate }}</button> </form> </section> <!-- Scheduled tasks --> <section class="box mb-6"> - <h2 class="mb-3 font-semibold">Scheduled tasks</h2> + <h2 class="mb-3 font-semibold">{{ 'applications.detail.scheduledTasks' | translate }}</h2> @for (task of tasks(); track task.uuid) { <div class="mb-2 flex items-center gap-3 text-sm"> <span class="font-semibold">{{ task.name }}</span> <code>{{ task.command }}</code> <span style="color: var(--color-text-secondary)">{{ task.frequency }}</span> - <button class="ml-auto text-xs" (click)="runTask(task)">run now</button> - <button class="text-xs text-red-400" (click)="removeTask(task)">delete</button> + <button class="ml-auto text-xs" (click)="runTask(task)">{{ 'applications.detail.runNow' | translate }}</button> + <button class="text-xs text-red-400" (click)="removeTask(task)">{{ 'applications.detail.delete' | translate }}</button> </div> } <form class="mt-3 flex flex-wrap gap-2" [formGroup]="taskForm" (ngSubmit)="addTask()"> - <input class="input flex-1" placeholder="name" formControlName="name" /> - <input class="input flex-1" placeholder="command" formControlName="command" /> - <input class="input w-40" placeholder="cron (e.g. 0 * * * *)" formControlName="frequency" /> - <button class="button" type="submit" [disabled]="taskForm.invalid">Add task</button> + <input class="input flex-1" [placeholder]="'applications.detail.namePlaceholder' | translate" formControlName="name" /> + <input class="input flex-1" [placeholder]="'applications.detail.commandPlaceholder' | translate" formControlName="command" /> + <input class="input w-40" [placeholder]="'applications.detail.cronPlaceholder' | translate" formControlName="frequency" /> + <button class="button" type="submit" [disabled]="taskForm.invalid">{{ 'applications.detail.addTask' | translate }}</button> </form> </section> <!-- Volumes --> <section class="box mb-6"> - <h2 class="mb-3 font-semibold">Persistent volumes</h2> + <h2 class="mb-3 font-semibold">{{ 'applications.detail.persistentVolumes' | translate }}</h2> @for (vol of volumes()?.persistent ?? []; track vol.id) { <div class="mb-1 text-sm"> <code>{{ vol.name }}</code> → {{ vol.mount_path }} </div> } <form class="mt-3 flex flex-wrap gap-2" [formGroup]="volumeForm" (ngSubmit)="addVolume()"> - <input class="input flex-1" placeholder="name" formControlName="name" /> - <input class="input flex-1" placeholder="/mount/path" formControlName="mount_path" /> - <button class="button" type="submit" [disabled]="volumeForm.invalid">Add volume</button> + <input class="input flex-1" [placeholder]="'applications.detail.namePlaceholder' | translate" formControlName="name" /> + <input class="input flex-1" [placeholder]="'applications.detail.mountPathPlaceholder' | translate" formControlName="mount_path" /> + <button class="button" type="submit" [disabled]="volumeForm.invalid">{{ 'applications.detail.addVolume' | translate }}</button> </form> </section> <!-- Ops --> <section class="box mb-6"> - <h2 class="mb-3 font-semibold">Operations</h2> + <h2 class="mb-3 font-semibold">{{ 'applications.detail.operations' | translate }}</h2> <div class="mb-2 flex gap-2"> - <button class="button-secondary" (click)="refreshStatus()">Status</button> - <button class="button-secondary" (click)="refreshMetrics()">Metrics</button> + <button class="button-secondary" (click)="refreshStatus()">{{ 'applications.detail.status' | translate }}</button> + <button class="button-secondary" (click)="refreshMetrics()">{{ 'applications.detail.metrics' | translate }}</button> </div> @if (opsOutput()) { <pre class="overflow-auto whitespace-pre-wrap font-mono text-xs">{{ opsOutput() }}</pre> } <form class="mt-3 flex gap-2" [formGroup]="execForm" (ngSubmit)="runExec()"> - <input class="input flex-1" placeholder="command to run in container" formControlName="command" /> - <button class="button" type="submit" [disabled]="execForm.invalid">Exec</button> + <input class="input flex-1" [placeholder]="'applications.detail.execPlaceholder' | translate" formControlName="command" /> + <button class="button" type="submit" [disabled]="execForm.invalid">{{ 'applications.detail.exec' | translate }}</button> </form> </section> <!-- Firewall (WAF) --> <section class="box mb-6"> - <h2 class="mb-3 font-semibold">Firewall (WAF)</h2> + <h2 class="mb-3 font-semibold">{{ 'applications.detail.firewall' | translate }}</h2> @if (firewall(); as fw) { <label class="mb-3 flex items-center gap-2 text-sm"> <input type="checkbox" [checked]="fw.enabled" (change)="toggleFirewall(fw)" /> - Enabled · {{ fw.total_blocked }} blocked / {{ fw.total_requests }} requests + {{ 'applications.detail.firewallStats' | translate: { blocked: fw.total_blocked, requests: fw.total_requests } }} </label> @for (rule of firewallRules(); track rule.id) { <div class="mb-1 flex items-center gap-3 text-sm"> <span class="font-semibold">{{ rule.name }}</span> <span style="color: var(--color-text-secondary)">{{ rule.action }} (p{{ rule.priority }})</span> - <button class="ml-auto text-xs text-red-400" (click)="removeRule(rule)">delete</button> + <button class="ml-auto text-xs text-red-400" (click)="removeRule(rule)">{{ 'applications.detail.delete' | translate }}</button> </div> } <form class="mt-3 flex flex-wrap gap-2" [formGroup]="ruleForm" (ngSubmit)="addRule()"> - <input class="input flex-1" placeholder="rule name" formControlName="name" /> - <input class="input flex-1" placeholder='conditions JSON e.g. [{"field":"uri","operator":"contains","value":"/admin"}]' formControlName="conditions" /> - <button class="button" type="submit" [disabled]="ruleForm.invalid">Add rule</button> + <input class="input flex-1" [placeholder]="'applications.detail.ruleNamePlaceholder' | translate" formControlName="name" /> + <input class="input flex-1" [placeholder]="'applications.detail.conditionsPlaceholder' | translate" formControlName="conditions" /> + <button class="button" type="submit" [disabled]="ruleForm.invalid">{{ 'applications.detail.addRule' | translate }}</button> </form> - <button class="button-secondary mt-3" (click)="deployFirewall()">Deploy firewall</button> + <button class="button-secondary mt-3" (click)="deployFirewall()">{{ 'applications.detail.deployFirewall' | translate }}</button> } </section> <!-- Pipeline (CI/CD) --> <section class="box mb-6"> - <h2 class="mb-3 font-semibold">CI/CD Pipeline</h2> + <h2 class="mb-3 font-semibold">{{ 'applications.detail.cicdPipeline' | translate }}</h2> @if (pipeline(); as p) { <div class="mb-2 text-sm" style="color: var(--color-text-secondary)"> - Stages: {{ p.stages.join(' → ') }} · {{ p.enabled ? 'enabled' : 'disabled' }} + {{ 'applications.detail.stagesLabel' | translate }} {{ p.stages.join(' → ') }} · {{ (p.enabled ? 'applications.detail.enabled' : 'applications.detail.disabled') | translate }} </div> } - <button class="button" (click)="runPipeline()">Run pipeline</button> + <button class="button" (click)="runPipeline()">{{ 'applications.detail.runPipeline' | translate }}</button> <div class="mt-3 space-y-1"> @for (ex of pipelineExecutions(); track $index) { <div class="text-sm"> @@ -175,23 +176,23 @@ import { <!-- Deployments / rollback --> <section class="box"> - <h2 class="mb-3 font-semibold">Deployment history</h2> + <h2 class="mb-3 font-semibold">{{ 'applications.detail.deploymentHistory' | translate }}</h2> @if (deployments().length === 0) { - <p class="text-sm" style="color: var(--color-text-secondary)">No deployments yet.</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'applications.detail.noDeployments' | translate }}</p> } @else { <div class="space-y-2"> @for (dep of deployments(); track dep.deployment_uuid) { <div class="flex items-center gap-3 text-sm"> <span class="font-mono">{{ dep.commit }}</span> <span>{{ dep.status }}</span> - <button class="ml-auto text-xs" (click)="rollback(dep)">Redeploy this commit</button> + <button class="ml-auto text-xs" (click)="rollback(dep)">{{ 'applications.detail.redeployCommit' | translate }}</button> </div> } </div> } </section> } @else { - <p class="text-sm" style="color: var(--color-text-secondary)">Loading…</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'applications.loading' | translate }}</p> } `, }) 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: ` <div class="mb-6 flex items-center justify-between"> - <h1 class="text-2xl font-bold">Applications</h1> - <button class="button" (click)="creating.set(!creating())">{{ creating() ? 'Cancel' : '+ New application' }}</button> + <h1 class="text-2xl font-bold">{{ 'applications.list.title' | translate }}</h1> + <button class="button" (click)="creating.set(!creating())">{{ (creating() ? 'applications.list.cancel' : 'applications.list.newApplication') | translate }}</button> </div> @if (creating()) { <form class="box mb-6 max-w-2xl space-y-3" [formGroup]="form" (ngSubmit)="create()"> <div class="flex gap-3"> <div class="flex-1"> - <label class="mb-1 block text-sm">Name</label> + <label class="mb-1 block text-sm">{{ 'applications.list.name' | translate }}</label> <input class="input" formControlName="name" /> </div> <div class="w-40"> - <label class="mb-1 block text-sm">Environment ID</label> + <label class="mb-1 block text-sm">{{ 'applications.list.environmentId' | translate }}</label> <input class="input" type="number" formControlName="environment_id" /> </div> </div> <div> - <label class="mb-1 block text-sm">Git repository URL</label> + <label class="mb-1 block text-sm">{{ 'applications.list.gitRepositoryUrl' | translate }}</label> <input class="input" formControlName="git_repository" placeholder="https://github.com/org/repo" /> </div> <div class="flex gap-3"> <div class="flex-1"> - <label class="mb-1 block text-sm">Branch</label> + <label class="mb-1 block text-sm">{{ 'applications.branch' | translate }}</label> <input class="input" formControlName="git_branch" /> </div> <div class="w-40"> - <label class="mb-1 block text-sm">Destination ID</label> + <label class="mb-1 block text-sm">{{ 'applications.list.destinationId' | translate }}</label> <input class="input" type="number" formControlName="destination_id" /> </div> </div> @@ -44,15 +45,15 @@ import { Application } from '../../../shared/models/ideploy.models'; <p class="text-sm text-red-400">{{ error() }}</p> } <button class="button" type="submit" [disabled]="form.invalid || saving()"> - {{ saving() ? 'Creating…' : 'Create application' }} + {{ (saving() ? 'applications.list.creating' : 'applications.list.createApplication') | translate }} </button> </form> } @if (loading()) { - <p class="text-sm" style="color: var(--color-text-secondary)">Loading…</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'applications.loading' | translate }}</p> } @else if (applications().length === 0) { - <div class="box">No applications yet.</div> + <div class="box">{{ 'applications.list.empty' | translate }}</div> } @else { <div class="space-y-3"> @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 }} </div> <div class="text-xs" style="color: var(--color-text-tertiary)"> - status: {{ app.status }} + {{ 'applications.list.statusLabel' | translate }} {{ app.status }} </div> </div> <div class="flex items-center gap-2"> @if (app.link) { <a class="button-secondary" [href]="app.link" target="_blank" rel="noopener"> - <i class="fa-solid fa-arrow-up-right-from-square mr-2"></i>Open + <i class="fa-solid fa-arrow-up-right-from-square mr-2"></i>{{ 'applications.open' | translate }} </a> } <button class="button" [disabled]="deploying() === app.uuid" (click)="deploy(app)"> - {{ deploying() === app.uuid ? 'Queuing…' : 'Deploy' }} + {{ (deploying() === app.uuid ? 'applications.list.queuing' : 'applications.deploy') | translate }} </button> </div> </div> @@ -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<Application[]>([]); 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: ` <div class="flex min-h-screen items-center justify-center" style="background: var(--color-bg-dark);"> <div class="text-center"> <i class="fa-solid fa-circle-notch fa-spin text-2xl" style="color:#2563eb;"></i> - <p class="mt-4 text-sm" style="color:#8d919a;">Signing you in…</p> + <p class="mt-4 text-sm" style="color:#8d919a;">{{ 'auth.signingIn' | translate }}</p> </div> </div> `, 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: ` <!-- Top toolbar --> @@ -32,17 +33,17 @@ interface DeployRow { [class.focus-within:ring-2]="true" [class.focus-within:ring-blue-500/20]="true"> <i class="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-xs text-white/40"></i> - <input class="input" style="padding-left:36px;" placeholder="Search Projects…" + <input class="input" style="padding-left:36px;" [placeholder]="'dashboard.searchProjects' | translate" [ngModel]="query()" (ngModelChange)="query.set($event)" /> </div> <div class="flex items-center gap-1 bg-white/5 p-1 rounded-xl border border-white/10"> - <button class="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-white/5 text-white/60 transition-colors cursor-pointer" title="Grid view" (click)="view.set('grid')" + <button class="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-white/5 text-white/60 transition-colors cursor-pointer" [title]="'dashboard.gridView' | translate" (click)="view.set('grid')" [class.bg-white/10]="view() === 'grid'" [class.!text-white]="view() === 'grid'"><i class="fa-solid fa-table-cells-large"></i></button> - <button class="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-white/5 text-white/60 transition-colors cursor-pointer" title="List view" (click)="view.set('list')" + <button class="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-white/5 text-white/60 transition-colors cursor-pointer" [title]="'dashboard.listView' | translate" (click)="view.set('list')" [class.bg-white/10]="view() === 'list'" [class.!text-white]="view() === 'list'"><i class="fa-solid fa-list"></i></button> </div> <button class="button flex items-center gap-2 cursor-pointer transition-transform hover:scale-[1.02]" (click)="goNewProject()"> - <i class="fa-solid fa-plus text-xs"></i> New Project + <i class="fa-solid fa-plus text-xs"></i> {{ 'dashboard.newProject' | translate }} </button> </div> @@ -50,12 +51,12 @@ interface DeployRow { <!-- ===== Left column ===== --> <div class="lg:col-span-1 space-y-6"> <div> - <h2 class="mb-3 text-sm font-semibold" style="color:var(--color-text-secondary);">Usage</h2> + <h2 class="mb-3 text-sm font-semibold" style="color:var(--color-text-secondary);">{{ 'dashboard.usage' | translate }}</h2> <div class="box p-5"> <div class="mb-4 flex items-center justify-between"> - <span class="text-sm font-semibold text-white/90">Current Limit</span> + <span class="text-sm font-semibold text-white/90">{{ 'dashboard.currentLimit' | translate }}</span> <a routerLink="/subscription" class="rounded-md px-2.5 py-1 text-xs font-semibold hover:bg-white/15 transition-colors" - style="background:var(--color-surface-2);color:var(--color-text-primary);">Upgrade</a> + style="background:var(--color-surface-2);color:var(--color-text-primary);">{{ 'dashboard.upgrade' | translate }}</a> </div> <div class="space-y-4"> @if (loading()) { @@ -80,21 +81,21 @@ interface DeployRow { </div> <div> - <h2 class="mb-3 text-sm font-semibold" style="color:var(--color-text-secondary);">Alerts</h2> + <h2 class="mb-3 text-sm font-semibold" style="color:var(--color-text-secondary);">{{ 'dashboard.alerts' | translate }}</h2> <div class="box text-center p-6"> - <p class="font-semibold text-white/90">Get notified about your deployments</p> + <p class="font-semibold text-white/90">{{ 'dashboard.getNotified' | translate }}</p> <p class="mt-1.5 text-xs leading-relaxed" style="color:var(--color-text-secondary);"> - Slack, Discord, Telegram, email — be alerted when a deploy fails or a server goes down. + {{ 'dashboard.alertsDescription' | translate }} </p> - <a routerLink="/notifications" class="button-secondary mt-4 inline-flex text-xs px-4 py-2 cursor-pointer rounded-xl hover:bg-white/10 transition-colors">Configure notifications</a> + <a routerLink="/notifications" class="button-secondary mt-4 inline-flex text-xs px-4 py-2 cursor-pointer rounded-xl hover:bg-white/10 transition-colors">{{ 'dashboard.configureNotifications' | translate }}</a> </div> </div> <div> - <h2 class="mb-3 text-sm font-semibold" style="color:var(--color-text-secondary);">Recent Deployments</h2> + <h2 class="mb-3 text-sm font-semibold" style="color:var(--color-text-secondary);">{{ 'dashboard.recentDeployments' | translate }}</h2> <div class="box text-center p-6" style="color:var(--color-text-tertiary);"> <i class="fa-solid fa-clock-rotate-left mb-2 text-lg text-white/30"></i> - <p class="text-xs">Deployments you trigger will appear here.</p> + <p class="text-xs">{{ 'dashboard.recentDeploymentsEmpty' | translate }}</p> </div> </div> </div> @@ -102,7 +103,7 @@ interface DeployRow { <!-- ===== Right column ===== --> <div class="lg:col-span-2 space-y-6"> <div> - <h2 class="mb-3 text-sm font-semibold" style="color:var(--color-text-secondary);">Project History</h2> + <h2 class="mb-3 text-sm font-semibold" style="color:var(--color-text-secondary);">{{ 'dashboard.projectHistory' | translate }}</h2> @if (loading()) { <!-- Skeleton Projects Grid --> @@ -164,7 +165,7 @@ interface DeployRow { } @else { <!-- Empty State --> <div class="db-glass p-8 text-center text-sm rounded-2xl" style="color:var(--color-text-secondary);"> - No projects in history. Use the import block below to deploy your first project. + {{ 'dashboard.noProjects' | translate }} </div> } </div> @@ -178,12 +179,12 @@ interface DeployRow { <i class="fa-brands fa-github text-xl animate-pulse"></i> </div> <div> - <h4 class="font-bold text-white/95 text-base">Import Project</h4> - <p class="text-xs mt-0.5" style="color:var(--color-text-secondary);">Deploy your app directly from a GitHub repository or Git URL.</p> + <h4 class="font-bold text-white/95 text-base">{{ 'dashboard.importProject' | translate }}</h4> + <p class="text-xs mt-0.5" style="color:var(--color-text-secondary);">{{ 'dashboard.importProjectDescription' | translate }}</p> </div> </div> <button class="button cursor-pointer text-xs font-semibold py-2.5 px-5 shadow-lg shadow-blue-500/15 hover:scale-[1.02] transition-transform" (click)="goNewProject()"> - Import Repository + {{ 'dashboard.importRepository' | translate }} </button> </div> @@ -191,18 +192,18 @@ interface DeployRow { <div class="mb-4 rounded-md p-3 text-sm text-red-400" style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);"> {{ error() }} @if (error()!.toLowerCase().includes('server')) { - · <a routerLink="/servers/new" style="color:#60a5fa;">Add a server</a> + · <a routerLink="/servers/new" style="color:#60a5fa;">{{ 'dashboard.addServer' | translate }}</a> } </div> } <!-- Templates title --> <div class="mb-3 flex items-center justify-between"> - <h4 class="text-sm font-semibold font-mono text-white/80">Start with a template</h4> + <h4 class="text-sm font-semibold font-mono text-white/80">{{ 'dashboard.startWithTemplate' | translate }}</h4> @if (loading()) { <div class="h-3 w-16 rounded bg-white/10 dbpulse"></div> } @else { - <span class="text-[10px] font-mono" style="color:var(--color-text-secondary);">{{ templateRows().length }} available</span> + <span class="text-[10px] font-mono" style="color:var(--color-text-secondary);">{{ 'dashboard.available' | translate: { count: templateRows().length } }}</span> } </div> @@ -230,7 +231,7 @@ interface DeployRow { <div class="font-semibold capitalize text-white/90">{{ row.title }}</div> <div class="text-xs" style="color:var(--color-text-secondary);">{{ row.description }}</div> </div> - <button class="button-secondary cursor-pointer text-xs font-semibold px-3 py-1.5 rounded-lg hover:bg-white/10 transition-colors" [disabled]="busy()" (click)="deployTemplate(row)">Deploy</button> + <button class="button-secondary cursor-pointer text-xs font-semibold px-3 py-1.5 rounded-lg hover:bg-white/10 transition-colors" [disabled]="busy()" (click)="deployTemplate(row)">{{ 'dashboard.deploy' | translate }}</button> </div> } } @@ -241,8 +242,8 @@ interface DeployRow { <i class="fa-solid fa-compass"></i> </div> <div class="flex-1"> - <div class="font-semibold text-white/90 group-hover:text-blue-400 transition-colors">Browse Templates</div> - <div class="text-xs" style="color:var(--color-text-secondary);">Databases, stacks and one-click apps</div> + <div class="font-semibold text-white/90 group-hover:text-blue-400 transition-colors">{{ 'dashboard.browseTemplates' | translate }}</div> + <div class="text-xs" style="color:var(--color-text-secondary);">{{ 'dashboard.browseTemplatesDescription' | translate }}</div> </div> <i class="fa-solid fa-arrow-up-right-from-square text-xs text-white/40 group-hover:text-blue-400 transition-colors"></i> </a> @@ -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<Project[]>([]); protected readonly servers = signal<Server[]>([]); @@ -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: ` - <h1 class="mb-6 text-2xl font-bold">Databases</h1> + <h1 class="mb-6 text-2xl font-bold">{{ 'databases.title' | translate }}</h1> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div> @if (loading()) { - <p class="text-sm" style="color: var(--color-text-secondary)">Loading…</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'databases.loading' | translate }}</p> } @else if (databases().length === 0) { - <div class="box">No databases yet.</div> + <div class="box">{{ 'databases.empty' | translate }}</div> } @else { <div class="space-y-3"> @for (db of databases(); track db.uuid) { @@ -27,10 +28,10 @@ import { Database, DatabaseType } from '../../../shared/models/ideploy.models'; </div> </div> <div class="flex gap-2"> - <button class="button-secondary" (click)="action(db, 'start')">Start</button> - <button class="button-secondary" (click)="action(db, 'stop')">Stop</button> - <button class="button-secondary" (click)="backup(db)">Backup now</button> - <button class="text-xs text-red-400" (click)="remove(db)">Delete</button> + <button class="button-secondary" (click)="action(db, 'start')">{{ 'databases.start' | translate }}</button> + <button class="button-secondary" (click)="action(db, 'stop')">{{ 'databases.stop' | translate }}</button> + <button class="button-secondary" (click)="backup(db)">{{ 'databases.backupNow' | translate }}</button> + <button class="text-xs text-red-400" (click)="remove(db)">{{ 'databases.delete' | translate }}</button> </div> </div> } @@ -39,9 +40,9 @@ import { Database, DatabaseType } from '../../../shared/models/ideploy.models'; </div> <form class="box space-y-3" [formGroup]="form" (ngSubmit)="create()"> - <h2 class="font-semibold">New database</h2> + <h2 class="font-semibold">{{ 'databases.newDatabase' | translate }}</h2> <div> - <label class="mb-1 block text-sm">Type</label> + <label class="mb-1 block text-sm">{{ 'databases.type' | translate }}</label> <select class="input" formControlName="type"> @for (t of types; track t) { <option [value]="t">{{ t }}</option> @@ -49,16 +50,16 @@ import { Database, DatabaseType } from '../../../shared/models/ideploy.models'; </select> </div> <div> - <label class="mb-1 block text-sm">Name</label> + <label class="mb-1 block text-sm">{{ 'databases.name' | translate }}</label> <input class="input" formControlName="name" /> </div> <div class="flex gap-3"> <div class="flex-1"> - <label class="mb-1 block text-sm">Environment ID</label> + <label class="mb-1 block text-sm">{{ 'databases.environmentId' | translate }}</label> <input class="input" type="number" formControlName="environment_id" /> </div> <div class="flex-1"> - <label class="mb-1 block text-sm">Destination ID</label> + <label class="mb-1 block text-sm">{{ 'databases.destinationId' | translate }}</label> <input class="input" type="number" formControlName="destination_id" /> </div> </div> @@ -66,7 +67,7 @@ import { Database, DatabaseType } from '../../../shared/models/ideploy.models'; <p class="text-sm text-red-400">{{ error() }}</p> } <button class="button" type="submit" [disabled]="form.invalid || saving()"> - {{ saving() ? 'Creating…' : 'Create database' }} + {{ (saving() ? 'databases.creating' : 'databases.createDatabase') | translate }} </button> </form> </div> @@ -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: ` <!-- Header --> <div class="flex h-16 items-center justify-between border-b px-6 mb-8" style="border-color:var(--color-surface-2);"> <a routerLink="/dashboard" class="flex items-center gap-2 text-sm transition-colors hover:text-white" style="color:var(--color-text-secondary);"> - <i class="fa-solid fa-arrow-left"></i> Back to Dashboard + <i class="fa-solid fa-arrow-left"></i> {{ 'deploy.backToDashboard' | translate }} </a> - <span class="text-sm font-semibold font-mono text-white/90">Deployment Logs</span> + <span class="text-sm font-semibold font-mono text-white/90">{{ 'deploy.deploymentLogs' | translate }}</span> <span class="w-12"></span> </div> @@ -41,7 +42,7 @@ import { startWith, switchMap, takeWhile } from 'rxjs/operators'; {{ deployment().application_name }} </h1> <p class="text-sm mt-1" style="color:var(--color-text-secondary);"> - Branch: <span class="font-mono text-xs px-1.5 py-0.5 rounded bg-white/5 text-white/80"><i class="fa-solid fa-code-branch mr-1"></i>{{ deployment().application_git_branch || 'main' }}</span> + {{ 'deploy.branch' | translate }} <span class="font-mono text-xs px-1.5 py-0.5 rounded bg-white/5 text-white/80"><i class="fa-solid fa-code-branch mr-1"></i>{{ deployment().application_git_branch || 'main' }}</span> </p> </div> @@ -50,31 +51,31 @@ import { startWith, switchMap, takeWhile } from 'rxjs/operators'; @switch (deployment().status) { @case ('queued') { <span class="status-badge bg-white/5 text-white/70 border border-white/10"> - <i class="fa-solid fa-circle-notch fa-spin text-xs"></i> Queued + <i class="fa-solid fa-circle-notch fa-spin text-xs"></i> {{ 'deploy.statusQueued' | translate }} </span> } @case ('in_progress') { <span class="status-badge bg-blue-500/10 text-blue-400 border border-blue-500/20"> - <i class="fa-solid fa-circle-notch fa-spin text-xs"></i> In Progress + <i class="fa-solid fa-circle-notch fa-spin text-xs"></i> {{ 'deploy.statusInProgress' | translate }} </span> } @case ('finished') { <span class="status-badge bg-green-500/10 text-green-400 border border-green-500/20"> - ✓ Success + ✓ {{ 'deploy.statusSuccess' | translate }} </span> } @case ('failed') { <span class="status-badge bg-red-500/10 text-red-400 border border-red-500/20"> - ✗ Failed + ✗ {{ 'deploy.statusFailed' | translate }} </span> } } <!-- Live URL Button --> @if (deployment().status === 'finished' && deployment().application_url) { - <a [href]="deployment().application_url" target="_blank" rel="noopener noreferrer" + <a [href]="deployment().application_url" target="_blank" rel="noopener noreferrer" class="button cursor-pointer text-xs px-3 py-1.5 inline-flex items-center gap-1.5 shadow-lg shadow-blue-500/10 transition-transform hover:scale-[1.02]"> - Visit App <i class="fa-solid fa-arrow-up-right-from-square text-[10px]"></i> + {{ 'deploy.visitApp' | translate }} <i class="fa-solid fa-arrow-up-right-from-square text-[10px]"></i> </a> } </div> @@ -99,8 +100,8 @@ import { startWith, switchMap, takeWhile } from 'rxjs/operators'; <span class="text-xs font-mono ml-2 text-white/40">build-console</span> </div> - <button (click)="copyLogs()" class="text-xs px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-white/80 transition-colors cursor-pointer inline-flex items-center gap-1" title="Copy logs to clipboard"> - <i class="fa-solid fa-copy"></i> Copy logs + <button (click)="copyLogs()" class="text-xs px-2.5 py-1 rounded bg-white/5 hover:bg-white/10 text-white/80 transition-colors cursor-pointer inline-flex items-center gap-1" [title]="'deploy.copyLogsTitle' | translate"> + <i class="fa-solid fa-copy"></i> {{ 'deploy.copyLogs' | translate }} </button> </div> @@ -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) {<span>{{ line }}</span> -}@if (lines().length === 0) {<span style="color: var(--color-text-tertiary)">Waiting for logs…</span>}</pre> +}@if (lines().length === 0) {<span style="color: var(--color-text-tertiary)">{{ 'deploy.waitingForLogs' | translate }}</span>}</pre> </div> </div> `, 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: ` - <h1 class="heading-serif mb-6" style="font-size:32px;font-weight:700;color:#fff;">Destinations</h1> + <h1 class="heading-serif mb-6" style="font-size:32px;font-weight:700;color:#fff;">{{ 'destinations.title' | translate }}</h1> @if (rows().length === 0) { - <div class="box">No servers yet — add a server to create Docker destinations.</div> + <div class="box">{{ 'destinations.empty' | translate }}</div> } @else { <div class="space-y-4"> @for (row of rows(); track row.server.uuid) { @@ -26,18 +27,18 @@ interface ServerDestinations { <span class="font-semibold">{{ row.server.name }}</span> </div> @if (row.destinations.length === 0) { - <p class="text-sm" style="color: var(--color-text-secondary)">No destinations.</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'destinations.noDestinations' | translate }}</p> } @else { @for (d of row.destinations; track d.uuid) { <div class="text-sm"> <i class="fa-solid fa-network-wired mr-2" style="color:#8d919a;"></i> - {{ d.name }} · network <code>{{ d.network }}</code> + {{ d.name }} · {{ 'destinations.network' | translate }} <code>{{ d.network }}</code> </div> } } <form class="mt-3 flex gap-2" [formGroup]="formFor(row.server.uuid)" (ngSubmit)="create(row.server.uuid)"> - <input class="input flex-1" placeholder="docker network (e.g. ideploy)" [formControl]="formFor(row.server.uuid).controls.network" /> - <button class="button" type="submit" [disabled]="formFor(row.server.uuid).invalid">Add destination</button> + <input class="input flex-1" [placeholder]="'destinations.networkPlaceholder' | translate" [formControl]="formFor(row.server.uuid).controls.network" /> + <button class="button" type="submit" [disabled]="formFor(row.server.uuid).invalid">{{ 'destinations.addDestination' | translate }}</button> </form> </div> } 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: ` <div class="relative min-h-screen text-white overflow-hidden" style="font-family: 'Jura', sans-serif;"> @@ -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);"> <div class="max-w-7xl mx-auto flex items-center justify-between"> <div class="flex items-center gap-3"> - <img src="/ideploy-logo.png" alt="EPLOY Logo" class="w-[150px] h-auto object-cover" + <img src="/ideploy-logo.png" [alt]="'landing.logoAlt' | translate" class="w-[150px] h-auto object-cover" style="filter: drop-shadow(0 0 15px var(--color-primary-500));" /> </div> <div class="hidden md:flex items-center gap-8"> - <a href="#showcase" class="text-sm font-semibold text-white/70 hover:text-white transition-colors">Showcase</a> - <a href="#features" class="text-sm font-semibold text-white/70 hover:text-white transition-colors">Platform</a> - <a routerLink="/pricing" class="text-sm font-semibold text-white/70 hover:text-white transition-colors">Pricing</a> + <a href="#showcase" class="text-sm font-semibold text-white/70 hover:text-white transition-colors">{{ 'landing.navShowcase' | translate }}</a> + <a href="#features" class="text-sm font-semibold text-white/70 hover:text-white transition-colors">{{ 'landing.navPlatform' | translate }}</a> + <a routerLink="/pricing" class="text-sm font-semibold text-white/70 hover:text-white transition-colors">{{ 'landing.navPricing' | translate }}</a> </div> @if (user(); as u) { <div class="relative"> @@ -49,16 +50,16 @@ import { environment } from '../../../../environments/environment'; @if (menuOpen()) { <div class="absolute right-0 mt-2 w-56 rounded-xl glass-card overflow-hidden"> <div class="py-2"> - <a routerLink="/dashboard" class="block px-4 py-2 text-sm text-gray-300 hover:bg-white/5 hover:text-white">Dashboard</a> - <button (click)="logout()" class="w-full text-left px-4 py-2 text-sm text-red-500 hover:bg-white/5">Logout</button> + <a routerLink="/dashboard" class="block px-4 py-2 text-sm text-gray-300 hover:bg-white/5 hover:text-white">{{ 'landing.navDashboard' | translate }}</a> + <button (click)="logout()" class="w-full text-left px-4 py-2 text-sm text-red-500 hover:bg-white/5">{{ 'landing.logout' | translate }}</button> </div> </div> } </div> } @else { <div class="flex items-center gap-4"> - <a [href]="loginUrl" class="hidden sm:block text-sm font-semibold text-white/70 hover:text-white">Log in</a> - <a [href]="loginUrl" class="inner-button text-sm px-5 py-2.5">Get started</a> + <a [href]="loginUrl" class="hidden sm:block text-sm font-semibold text-white/70 hover:text-white">{{ 'landing.login' | translate }}</a> + <a [href]="loginUrl" class="inner-button text-sm px-5 py-2.5">{{ 'landing.getStarted' | translate }}</a> </div> } </div> @@ -69,20 +70,20 @@ import { environment } from '../../../../environments/environment'; <div class="absolute inset-0 z-0"> <div class="absolute inset-0 z-10" style="background: linear-gradient(to bottom, rgba(6,8,13,0.5), rgba(6,8,13,0.7), #06080d);"></div> <img src="https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=2000&auto=format&fit=crop" - alt="Server Room" class="w-full h-full object-cover opacity-40 mix-blend-lighten" style="filter: grayscale(50%);" /> + [alt]="'landing.heroImageAlt' | translate" class="w-full h-full object-cover opacity-40 mix-blend-lighten" style="filter: grayscale(50%);" /> </div> <div class="relative z-10 max-w-5xl mx-auto w-full flex flex-col items-center text-center px-4"> <div class="max-w-4xl mx-auto"> <h1 class="font-black mb-8 text-white break-words" style="font-size: clamp(3.8rem, 8vw, 7.5rem); line-height:1.05; letter-spacing: -0.04em; text-shadow: 0 0 40px rgba(0,0,0,0.5);"> - Deploy apps,<br /><span class="i-underline text-secondary">Not servers</span> + {{ 'landing.heroTitleLine1' | translate }}<br /><span class="i-underline text-secondary">{{ 'landing.heroTitleAccent' | translate }}</span> </h1> <p class="text-[20px] md:text-[24px] text-white/70 mb-12 max-w-2xl mx-auto font-medium leading-relaxed"> - The leading platform to deploy, scale, and secure your applications without leaving your workspace. Powered by Idem. + {{ 'landing.heroSubtitle' | translate }} </p> <div class="flex flex-col sm:flex-row gap-6 justify-center"> - <a [href]="loginUrl" class="inner-button px-12 py-5 text-xl">Get started for free</a> - <a href="https://github.com/coollabsio/coolify" target="_blank" class="outer-button px-12 py-5 text-xl">Talk to sales</a> + <a [href]="loginUrl" class="inner-button px-12 py-5 text-xl">{{ 'landing.getStartedFree' | translate }}</a> + <a href="https://github.com/coollabsio/coolify" target="_blank" class="outer-button px-12 py-5 text-xl">{{ 'landing.talkToSales' | translate }}</a> </div> </div> </div> @@ -91,7 +92,7 @@ import { environment } from '../../../../environments/environment'; <!-- ===== MARQUEE ===== --> <section class="py-12 px-6 border-y border-white/5 bg-transparent overflow-hidden"> <div class="max-w-7xl mx-auto flex items-center mb-8"> - <p class="text-white/40 text-sm font-bold uppercase tracking-widest px-4">Supported Stacks & Partners</p> + <p class="text-white/40 text-sm font-bold uppercase tracking-widest px-4">{{ 'landing.supportedStacks' | translate }}</p> <div class="flex-1 h-px bg-white/5 ml-4"></div> </div> <div class="marquee-wrapper mx-auto max-w-7xl"> @@ -110,9 +111,9 @@ import { environment } from '../../../../environments/environment'; <div class="max-w-7xl mx-auto"> <div class="mb-16"> <h1 class="text-4xl md:text-5xl font-black text-white mb-6" style="letter-spacing:-0.04em;"> - Explore what <span class="i-underline">people are building</span> + {{ 'landing.showcaseTitle' | translate }} <span class="i-underline">{{ 'landing.showcaseTitleAccent' | translate }}</span> </h1> - <p class="text-xl text-white/60 max-w-2xl font-medium">From complex microservices architectures to simple static portfolios, see how Eploy powers the independent web.</p> + <p class="text-xl text-white/60 max-w-2xl font-medium">{{ 'landing.showcaseSubtitle' | translate }}</p> </div> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> @for (p of showcase; track p.title) { @@ -128,7 +129,7 @@ import { environment } from '../../../../environments/environment'; } </div> <div class="mt-12 text-center"> - <a [href]="loginUrl" class="outer-button px-8 py-3 text-sm inline-flex">Join the community</a> + <a [href]="loginUrl" class="outer-button px-8 py-3 text-sm inline-flex">{{ 'landing.joinCommunity' | translate }}</a> </div> </div> </section> @@ -141,30 +142,30 @@ import { environment } from '../../../../environments/environment'; <div class="w-16 h-16 rounded-[1rem] bg-white/5 flex items-center justify-center mb-8 border border-white/10 backdrop-blur-md"> <span class="text-2xl" style="color: var(--color-primary-500)">1</span> </div> - <h1 class="text-5xl font-black text-white mb-6 leading-tight" style="letter-spacing:-0.04em;">Push to <span class="i-underline">deploy</span>.<br />It's that simple.</h1> - <p class="text-xl text-white/60 mb-8 font-medium leading-relaxed">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.</p> + <h1 class="text-5xl font-black text-white mb-6 leading-tight" style="letter-spacing:-0.04em;">{{ 'landing.pillar1Title' | translate }} <span class="i-underline">{{ 'landing.pillar1TitleAccent' | translate }}</span>.<br />{{ 'landing.pillar1TitleRest' | translate }}</h1> + <p class="text-xl text-white/60 mb-8 font-medium leading-relaxed">{{ 'landing.pillar1Body' | translate }}</p> <div class="flex flex-col gap-5 font-bold text-white/80"> <div class="glass-card flex items-center gap-4 p-4 rounded-xl border border-white/5 group"> <div class="w-3 h-3 rounded-full group-hover:scale-150 transition-transform" style="background: var(--color-primary-500)"></div> - <span>Automatic builds based on Nixpacks</span> + <span>{{ 'landing.pillar1Feature1' | translate }}</span> </div> <div class="glass-card flex items-center gap-4 p-4 rounded-xl border border-white/5 group"> <div class="w-3 h-3 rounded-full group-hover:scale-150 transition-transform" style="background: var(--color-primary-500)"></div> - <span>Pre-configured Let's Encrypt SSL</span> + <span>{{ 'landing.pillar1Feature2' | translate }}</span> </div> </div> </div> <div class="relative w-full aspect-square rounded-[2rem] p-4 md:p-8 flex items-center justify-center"> <div class="absolute w-[80%] h-[80%] rounded-full blur-[100px]" style="background: linear-gradient(to top right, rgba(37,99,235,0.3), transparent);"></div> <div class="glass-card relative w-full h-full rounded-[1.5rem] border py-2 border-white/10 overflow-hidden group"> - <img src="https://images.unsplash.com/photo-1555066931-4365d14bab8c?q=80&w=1200&auto=format&fit=crop" class="w-full h-full object-cover" alt="Code deployment log" /> + <img src="https://images.unsplash.com/photo-1555066931-4365d14bab8c?q=80&w=1200&auto=format&fit=crop" class="w-full h-full object-cover" [alt]="'landing.pillar1ImageAlt' | translate" /> <div class="absolute bottom-8 left-8 right-8 z-20 glass-card border border-white/10 rounded-2xl p-6 flex items-center gap-6" style="background: rgba(0,0,0,0.6);"> <div class="w-16 h-16 rounded-full flex items-center justify-center" style="background: rgba(34,197,94,0.2);"> <i class="fa-solid fa-check text-2xl text-green-400"></i> </div> <div> - <div class="text-xl font-bold text-white mb-1">Production Ready</div> - <div class="text-sm text-green-400 font-mono">deployment-39ffa2z completed in 12.4s</div> + <div class="text-xl font-bold text-white mb-1">{{ 'landing.productionReady' | translate }}</div> + <div class="text-sm text-green-400 font-mono">{{ 'landing.deploymentLog' | translate }}</div> </div> </div> </div> @@ -174,23 +175,23 @@ import { environment } from '../../../../environments/environment'; <div class="order-2 lg:order-1 relative w-full aspect-[4/3] md:aspect-square rounded-[2rem] p-4 md:p-8 flex items-center justify-center"> <div class="absolute w-[80%] h-[80%] rounded-full blur-[100px]" style="background: linear-gradient(to top right, rgba(34,211,238,0.3), transparent);"></div> <div class="glass-card relative w-full h-full rounded-[1.5rem] border border-white/10 overflow-hidden group"> - <img src="https://images.unsplash.com/photo-1618401471353-b98afee0b2eb?q=80&w=1200&auto=format&fit=crop" class="w-full h-full object-cover" alt="Server clusters data" /> + <img src="https://images.unsplash.com/photo-1618401471353-b98afee0b2eb?q=80&w=1200&auto=format&fit=crop" class="w-full h-full object-cover" [alt]="'landing.pillar2ImageAlt' | translate" /> </div> </div> <div class="order-1 lg:order-2 max-w-xl lg:pl-10"> <div class="w-16 h-16 rounded-[1rem] bg-white/5 flex items-center justify-center mb-8 border border-white/10 backdrop-blur-md"> <span class="text-2xl" style="color: var(--color-accent-500)">2</span> </div> - <h1 class="text-5xl font-black text-white mb-6 leading-tight" style="letter-spacing:-0.04em;">Instantiate DBs<br />in <span class="i-underline">1-Click</span>.</h1> - <p class="text-xl text-white/60 mb-8 font-medium leading-relaxed">Stop manually configuring databases. Provision Postgres, Redis, MongoDB, or MySQL instances in one click. Completely managed, inherently secure, with native scheduled backups.</p> + <h1 class="text-5xl font-black text-white mb-6 leading-tight" style="letter-spacing:-0.04em;">{{ 'landing.pillar2Title' | translate }}<br />{{ 'landing.pillar2TitleMid' | translate }} <span class="i-underline">{{ 'landing.pillar2TitleAccent' | translate }}</span>.</h1> + <p class="text-xl text-white/60 mb-8 font-medium leading-relaxed">{{ 'landing.pillar2Body' | translate }}</p> <div class="flex flex-col gap-5 font-bold text-white/80"> <div class="glass-card flex items-center gap-4 p-4 rounded-xl border border-white/5 group"> <div class="w-3 h-3 rounded-full" style="background: var(--color-accent-500)"></div> - <span>Scheduled S3 backups natively supported</span> + <span>{{ 'landing.pillar2Feature1' | translate }}</span> </div> <div class="glass-card flex items-center gap-4 p-4 rounded-xl border border-white/5 group"> <div class="w-3 h-3 rounded-full" style="background: var(--color-accent-500)"></div> - <span>Automatic environment variable tunneling</span> + <span>{{ 'landing.pillar2Feature2' | translate }}</span> </div> </div> </div> @@ -201,26 +202,26 @@ import { environment } from '../../../../environments/environment'; <!-- ===== FEATURES ===== --> <section id="features" class="py-32 px-6 relative z-10"> <div class="max-w-7xl mx-auto"> - <h1 class="text-4xl md:text-5xl font-black text-white text-center mb-16" style="letter-spacing:-0.04em;">Everything you <span class="i-underline">need</span>. <span class="text-white/40">Nothing you don't.</span></h1> + <h1 class="text-4xl md:text-5xl font-black text-white text-center mb-16" style="letter-spacing:-0.04em;">{{ 'landing.featuresTitle' | translate }} <span class="i-underline">{{ 'landing.featuresTitleAccent' | translate }}</span>. <span class="text-white/40">{{ 'landing.featuresTitleRest' | translate }}</span></h1> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div class="glass-card hover:-translate-y-2 transition-all p-8 rounded-[2rem] border border-white/10 group"> <div class="w-12 h-12 rounded-[1rem] bg-white/5 border border-white/10 flex items-center justify-center mb-6"><i class="fa-solid fa-cube text-xl text-primary-400"></i></div> - <h3 class="text-xl font-bold text-white mb-3">Any Language</h3> - <p class="text-white/60 text-sm font-medium leading-relaxed">Nixpacks integration means if it builds on your machine, it builds on Eploy. No Dockerfile required.</p> + <h3 class="text-xl font-bold text-white mb-3">{{ 'landing.feature1Title' | translate }}</h3> + <p class="text-white/60 text-sm font-medium leading-relaxed">{{ 'landing.feature1Body' | translate }}</p> </div> <div class="glass-card hover:-translate-y-2 transition-all p-8 rounded-[2rem] border border-white/10 group"> <div class="w-12 h-12 rounded-[1rem] bg-white/5 border border-white/10 flex items-center justify-center mb-6"><i class="fa-solid fa-lock text-xl text-accent-400"></i></div> - <h3 class="text-xl font-bold text-white mb-3">Auto SSL</h3> - <p class="text-white/60 text-sm font-medium leading-relaxed">We automatically generate and renew Let's Encrypt certificates for all your connected custom domains.</p> + <h3 class="text-xl font-bold text-white mb-3">{{ 'landing.feature2Title' | translate }}</h3> + <p class="text-white/60 text-sm font-medium leading-relaxed">{{ 'landing.feature2Body' | translate }}</p> </div> <div class="glass-card hover:-translate-y-2 transition-all p-8 rounded-[2rem] border border-white/10 group lg:col-span-2 relative overflow-hidden"> <div class="absolute right-0 bottom-0 w-64 h-64 rounded-tl-full blur-3xl pointer-events-none" style="background: rgba(37,99,235,0.1);"></div> <div class="w-12 h-12 rounded-[1rem] bg-white/5 border border-white/10 flex items-center justify-center mb-6"><i class="fa-solid fa-code-branch text-xl text-white"></i></div> - <h3 class="text-xl font-bold text-white mb-3">PR Previews</h3> - <p class="text-white/60 text-sm font-medium leading-relaxed max-w-sm">Every pull request gets its own isolated deployment environment. Review changes live before merging to production. Automatically destroyed when merged, keeping costs at zero.</p> + <h3 class="text-xl font-bold text-white mb-3">{{ 'landing.feature3Title' | translate }}</h3> + <p class="text-white/60 text-sm font-medium leading-relaxed max-w-sm">{{ 'landing.feature3Body' | translate }}</p> <div class="mt-6 flex items-center gap-4"> <div class="h-2 w-32 bg-white/10 rounded-full overflow-hidden"><div class="h-full w-1/2" style="background: var(--color-primary-500);"></div></div> - <span class="text-xs font-mono text-white/40">Building...</span> + <span class="text-xs font-mono text-white/40">{{ 'landing.building' | translate }}</span> </div> </div> </div> @@ -232,13 +233,13 @@ import { environment } from '../../../../environments/environment'; <div class="max-w-4xl mx-auto text-center"> <div class="text-[8rem] font-serif leading-none mt-4" style="color:#222;">"</div> <h2 class="text-3xl md:text-5xl font-black text-white -mt-16 mb-12 leading-tight" style="letter-spacing:-0.03em;"> - 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 }} </h2> <div class="flex items-center justify-center gap-4"> <div class="w-12 h-12 rounded-full glass-card border border-white/10"></div> <div class="text-left"> <p class="font-bold text-lg text-white">David J.</p> - <p class="text-sm text-white/50 font-medium">Lead DevOps, Systema</p> + <p class="text-sm text-white/50 font-medium">{{ 'landing.testimonialRole' | translate }}</p> </div> </div> </div> @@ -247,7 +248,7 @@ import { environment } from '../../../../environments/environment'; <!-- ===== ROLES ===== --> <section class="py-32 px-6 relative z-10"> <div class="max-w-7xl mx-auto"> - <h1 class="text-4xl md:text-5xl font-black text-white mb-16 text-center" style="letter-spacing:-0.04em;">Built for how you <span class="i-underline">work</span></h1> + <h1 class="text-4xl md:text-5xl font-black text-white mb-16 text-center" style="letter-spacing:-0.04em;">{{ 'landing.rolesTitle' | translate }} <span class="i-underline">{{ 'landing.rolesTitleAccent' | translate }}</span></h1> <div class="grid grid-cols-1 md:grid-cols-3 gap-8"> @for (role of roles; track role.title) { <div class="glass-card hover:-translate-y-2 transition-transform p-10 rounded-[2rem] border border-white/10 text-center group relative overflow-hidden"> @@ -267,13 +268,13 @@ import { environment } from '../../../../environments/environment'; <!-- ===== HOW IT WORKS ===== --> <section class="py-32 px-6 relative z-10 border-t border-white/5"> <div class="max-w-4xl mx-auto"> - <h1 class="text-4xl md:text-5xl font-black text-white text-center mb-16" style="letter-spacing:-0.04em;"><span class="i-underline">How</span> it works</h1> + <h1 class="text-4xl md:text-5xl font-black text-white text-center mb-16" style="letter-spacing:-0.04em;"><span class="i-underline">{{ 'landing.howItWorksTitleAccent' | translate }}</span> {{ 'landing.howItWorksTitle' | translate }}</h1> <div class="glass-card relative p-8 md:p-16 rounded-[3rem] border border-white/10"> <div class="space-y-16"> <div class="flex flex-col md:flex-row items-center justify-between gap-8 group"> <div class="md:w-5/12 text-center md:text-right"> - <h3 class="text-2xl font-bold text-white mb-2">1. Connect Provider</h3> - <p class="text-white/60 font-medium">Link your GitHub, GitLab, or Bitbucket account. We only ask for the permissions we absolutely need.</p> + <h3 class="text-2xl font-bold text-white mb-2">{{ 'landing.step1Title' | translate }}</h3> + <p class="text-white/60 font-medium">{{ 'landing.step1Body' | translate }}</p> </div> <div class="w-16 h-16 rounded-full glass-card flex items-center justify-center border-2 relative z-10" style="border-color: var(--color-primary-500);"><span class="text-xl font-bold text-white">1</span></div> <div class="md:w-5/12"></div> @@ -282,14 +283,14 @@ import { environment } from '../../../../environments/environment'; <div class="md:w-5/12 hidden md:block"></div> <div class="w-16 h-16 rounded-full glass-card flex items-center justify-center border-2 border-white/20 relative z-10"><span class="text-xl font-bold text-white">2</span></div> <div class="md:w-5/12 text-center md:text-left"> - <h3 class="text-2xl font-bold text-white mb-2">2. Define Destination</h3> - <p class="text-white/60 font-medium">Add any Linux server using an SSH key. A $4/mo Hetzner VPS works just fine.</p> + <h3 class="text-2xl font-bold text-white mb-2">{{ 'landing.step2Title' | translate }}</h3> + <p class="text-white/60 font-medium">{{ 'landing.step2Body' | translate }}</p> </div> </div> <div class="flex flex-col md:flex-row items-center justify-between gap-8 group"> <div class="md:w-5/12 text-center md:text-right"> - <h3 class="text-2xl font-bold text-white mb-2">3. Deploy</h3> - <p class="text-white/60 font-medium">Hit deploy. We automatically build the container and route the traffic. It just works.</p> + <h3 class="text-2xl font-bold text-white mb-2">{{ 'landing.step3Title' | translate }}</h3> + <p class="text-white/60 font-medium">{{ 'landing.step3Body' | translate }}</p> </div> <div class="w-16 h-16 rounded-full glass-card flex items-center justify-center border-2 relative z-10" style="border-color: var(--color-accent-500);"><span class="text-xl font-bold text-white">3</span></div> <div class="md:w-5/12"></div> @@ -304,13 +305,13 @@ import { environment } from '../../../../environments/environment'; <div class="max-w-4xl mx-auto rounded-[3rem] overflow-hidden relative"> <div class="absolute inset-0 z-0" style="background: linear-gradient(to bottom right, rgba(37,99,235,0.2), black, rgba(34,211,238,0.2));"></div> <div class="glass-card relative z-10 p-16 md:p-24 border border-white/10 text-center" style="backdrop-filter: blur(48px);"> - <h1 class="text-5xl md:text-6xl font-black text-white mb-6" style="letter-spacing:-0.04em;">Ready to <span class="i-underline">host?</span></h1> - <p class="text-xl text-white/70 mb-12 max-w-xl mx-auto font-medium">5 free deployments, a free domain with automatic SSL and commercial use allowed from day one. Paid plans start at 2 999 F/month.</p> + <h1 class="text-5xl md:text-6xl font-black text-white mb-6" style="letter-spacing:-0.04em;">{{ 'landing.ctaTitle' | translate }} <span class="i-underline">{{ 'landing.ctaTitleAccent' | translate }}</span></h1> + <p class="text-xl text-white/70 mb-12 max-w-xl mx-auto font-medium">{{ 'landing.ctaSubtitle' | translate }}</p> <div class="flex flex-col sm:flex-row items-center justify-center gap-4"> - <a [href]="loginUrl" class="inner-button px-8 py-4 text-lg w-full sm:w-auto">Get started for free</a> - <a routerLink="/pricing" class="outer-button px-8 py-4 text-lg w-full sm:w-auto">View pricing</a> + <a [href]="loginUrl" class="inner-button px-8 py-4 text-lg w-full sm:w-auto">{{ 'landing.getStartedFree' | translate }}</a> + <a routerLink="/pricing" class="outer-button px-8 py-4 text-lg w-full sm:w-auto">{{ 'landing.viewPricing' | translate }}</a> </div> - <p class="mt-8 text-sm text-white/40 font-mono">MIT Licensed. Open Source forever.</p> + <p class="mt-8 text-sm text-white/40 font-mono">{{ 'landing.ctaLicense' | translate }}</p> </div> </div> </section> @@ -324,10 +325,10 @@ import { environment } from '../../../../environments/environment'; </div> <span class="text-base font-black text-white tracking-tight">EPLOY</span> </div> - <p class="text-sm text-white/50 font-medium">© {{ year }} EPLOY · Powered seamlessly by Idem Frameworks</p> + <p class="text-sm text-white/50 font-medium">{{ 'landing.footerCopyright' | translate: { year: year } }}</p> <div class="flex items-center gap-8"> - <a [href]="loginUrl" class="text-sm font-bold text-white/50 hover:text-white transition-colors">Sign In</a> - <a href="https://github.com/coollabsio/coolify" target="_blank" class="text-sm font-bold text-white/50 hover:text-white transition-colors">GitHub Repository</a> + <a [href]="loginUrl" class="text-sm font-bold text-white/50 hover:text-white transition-colors">{{ 'landing.signIn' | translate }}</a> + <a href="https://github.com/coollabsio/coolify" target="_blank" class="text-sm font-bold text-white/50 hover:text-white transition-colors">{{ 'landing.githubRepo' | translate }}</a> </div> </div> </footer> 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: ` <div class="relative min-h-screen text-white overflow-hidden" style="font-family: 'Jura', sans-serif;"> @@ -53,20 +54,20 @@ interface OverageRow { style="background: rgba(6,8,13,0.6); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);"> <div class="max-w-7xl mx-auto flex items-center justify-between"> <a routerLink="/" class="flex items-center gap-3"> - <img src="/ideploy-logo.png" alt="EPLOY Logo" class="w-[150px] h-auto object-cover" + <img src="/ideploy-logo.png" [alt]="'pricing.logoAlt' | translate" class="w-[150px] h-auto object-cover" style="filter: drop-shadow(0 0 15px var(--color-primary-500));" /> </a> <div class="hidden md:flex items-center gap-8"> - <a routerLink="/" fragment="showcase" class="text-sm font-semibold text-white/70 hover:text-white transition-colors">Showcase</a> - <a routerLink="/" fragment="features" class="text-sm font-semibold text-white/70 hover:text-white transition-colors">Platform</a> - <a routerLink="/pricing" class="text-sm font-semibold text-white transition-colors">Pricing</a> + <a routerLink="/" fragment="showcase" class="text-sm font-semibold text-white/70 hover:text-white transition-colors">{{ 'pricing.navShowcase' | translate }}</a> + <a routerLink="/" fragment="features" class="text-sm font-semibold text-white/70 hover:text-white transition-colors">{{ 'pricing.navPlatform' | translate }}</a> + <a routerLink="/pricing" class="text-sm font-semibold text-white transition-colors">{{ 'pricing.navPricing' | translate }}</a> </div> @if (user(); as u) { - <a routerLink="/dashboard" class="inner-button text-sm px-5 py-2.5">Dashboard</a> + <a routerLink="/dashboard" class="inner-button text-sm px-5 py-2.5">{{ 'pricing.navDashboard' | translate }}</a> } @else { <div class="flex items-center gap-4"> - <a [href]="loginUrl" class="hidden sm:block text-sm font-semibold text-white/70 hover:text-white">Log in</a> - <a [href]="loginUrl" class="inner-button text-sm px-5 py-2.5">Get started</a> + <a [href]="loginUrl" class="hidden sm:block text-sm font-semibold text-white/70 hover:text-white">{{ 'pricing.login' | translate }}</a> + <a [href]="loginUrl" class="inner-button text-sm px-5 py-2.5">{{ 'pricing.getStarted' | translate }}</a> </div> } </div> @@ -76,13 +77,12 @@ interface OverageRow { <section class="pt-44 pb-16 px-6 text-center"> <div class="max-w-4xl mx-auto"> <h1 class="font-black text-white mb-6" style="font-size: clamp(2.8rem, 6vw, 5rem); line-height:1.05; letter-spacing:-0.04em;"> - Generous at entry.<br /><span class="i-underline">Priced for growth.</span> + {{ 'pricing.heroTitle' | translate }}<br /><span class="i-underline">{{ 'pricing.heroTitleAccent' | translate }}</span> </h1> <p class="text-xl text-white/60 max-w-2xl mx-auto font-medium leading-relaxed mb-6"> - 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 }} </p> - <p class="text-sm text-white/40 font-mono">Prices in FCFA · Mobile Money & card accepted · Annual = 2 months free</p> + <p class="text-sm text-white/40 font-mono">{{ 'pricing.heroNote' | translate }}</p> </div> </section> @@ -95,12 +95,12 @@ interface OverageRow { [style.border-color]="plan.popular ? 'var(--color-primary-500)' : null"> @if (plan.popular) { <div class="absolute -top-3 left-1/2 -translate-x-1/2 px-4 py-1 rounded-full text-xs font-bold text-white" - style="background: var(--color-primary-500)">Most popular</div> + style="background: var(--color-primary-500)">{{ 'pricing.mostPopular' | translate }}</div> } <h3 class="text-xl font-black text-white mb-2">{{ plan.name }}</h3> <div class="mb-2"> <span class="text-4xl font-black text-white">{{ plan.price }}</span> - <span class="text-white/50 text-sm font-medium">/month</span> + <span class="text-white/50 text-sm font-medium">{{ 'pricing.perMonth' | translate }}</span> </div> @if (plan.usd) { <div class="text-xs text-white/40 font-mono mb-3">{{ plan.usd }}</div> @@ -126,24 +126,23 @@ interface OverageRow { <section class="py-16 px-6"> <div class="max-w-5xl mx-auto glass-card rounded-[2rem] border border-white/10 p-10 md:p-14 text-center"> <h2 class="text-3xl md:text-4xl font-black text-white mb-4" style="letter-spacing:-0.03em;"> - No subscription? <span class="i-underline">Pay per deployment.</span> + {{ 'pricing.payTitle' | translate }} <span class="i-underline">{{ 'pricing.payTitleAccent' | translate }}</span> </h2> <p class="text-white/60 font-medium max-w-2xl mx-auto mb-10"> - 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 }} </p> <div class="grid grid-cols-1 sm:grid-cols-3 gap-6"> <div class="glass-card rounded-2xl border border-white/10 p-6"> <div class="text-3xl font-black text-white mb-1">100 F</div> - <div class="text-sm text-white/50 font-medium">per deployment</div> + <div class="text-sm text-white/50 font-medium">{{ 'pricing.payCard1Label' | translate }}</div> </div> <div class="glass-card rounded-2xl border border-white/10 p-6"> <div class="text-3xl font-black text-white mb-1">900 F</div> - <div class="text-sm text-white/50 font-medium">pack of 10 deployments</div> + <div class="text-sm text-white/50 font-medium">{{ 'pricing.payCard2Label' | translate }}</div> </div> <div class="glass-card rounded-2xl border border-white/10 p-6"> - <div class="text-3xl font-black text-white mb-1">Unlimited</div> - <div class="text-sm text-white/50 font-medium">on any paid plan</div> + <div class="text-3xl font-black text-white mb-1">{{ 'pricing.payCard3Value' | translate }}</div> + <div class="text-sm text-white/50 font-medium">{{ 'pricing.payCard3Label' | translate }}</div> </div> </div> </div> @@ -153,17 +152,16 @@ interface OverageRow { <section class="py-16 px-6"> <div class="max-w-7xl mx-auto"> <h2 class="text-3xl md:text-4xl font-black text-white text-center mb-4" style="letter-spacing:-0.03em;"> - Managed services <span class="i-underline">à la carte</span> + {{ 'pricing.managedTitle' | translate }} <span class="i-underline">{{ 'pricing.managedTitleAccent' | translate }}</span> </h2> <p class="text-white/60 font-medium text-center max-w-2xl mx-auto mb-12"> - Like a cloud provider: activate each service in one click from the dashboard, - billed monthly, no commitment. + {{ 'pricing.managedSubtitle' | translate }} </p> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5"> @for (svc of managedServices; track svc.name) { <div class="glass-card p-6 rounded-2xl border border-white/10 hover:-translate-y-1 transition-transform"> <div class="text-2xl font-black mb-2" style="color: var(--color-primary-500)"> - {{ svc.price }}<span class="text-xs text-white/40 font-medium">/month</span> + {{ svc.price }}<span class="text-xs text-white/40 font-medium">{{ 'pricing.perMonth' | translate }}</span> </div> <div class="text-sm font-bold text-white mb-1">{{ svc.name }}</div> <div class="text-xs text-white/50 font-medium">{{ svc.note }}</div> @@ -177,17 +175,17 @@ interface OverageRow { <section class="py-16 px-6"> <div class="max-w-5xl mx-auto"> <h2 class="text-3xl md:text-4xl font-black text-white text-center mb-4" style="letter-spacing:-0.03em;"> - Overages, <span class="i-underline">head to head</span> + {{ 'pricing.overagesTitle' | translate }} <span class="i-underline">{{ 'pricing.overagesTitleAccent' | translate }}</span> </h2> <p class="text-white/60 font-medium text-center max-w-2xl mx-auto mb-12"> - When your app grows past its plan, the bill grows with it — not against it. + {{ 'pricing.overagesSubtitle' | translate }} </p> <div class="glass-card rounded-[2rem] border border-white/10 overflow-hidden"> <div class="overflow-x-auto"> <table class="w-full text-sm"> <thead class="bg-white/5"> <tr> - <th class="text-left p-5 font-bold text-white">Resource</th> + <th class="text-left p-5 font-bold text-white">{{ 'pricing.colResource' | translate }}</th> <th class="text-center p-5 font-bold" style="color: var(--color-primary-500)">iDeploy</th> <th class="text-center p-5 font-bold text-white/70">Vercel</th> <th class="text-center p-5 font-bold text-white/70">Railway</th> @@ -214,26 +212,24 @@ interface OverageRow { <div class="max-w-5xl mx-auto glass-card rounded-[2rem] border border-white/10 p-10 md:p-14 grid grid-cols-1 md:grid-cols-2 gap-10 items-center"> <div> <h2 class="text-3xl md:text-4xl font-black text-white mb-4" style="letter-spacing:-0.03em;"> - Bring your <span class="i-underline">own server</span> + {{ 'pricing.byosTitle' | translate }} <span class="i-underline">{{ 'pricing.byosTitleAccent' | translate }}</span> </h2> <p class="text-white/60 font-medium leading-relaxed"> - 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 }} </p> </div> <div class="flex flex-col gap-4"> <div class="glass-card flex items-center gap-4 p-4 rounded-xl border border-white/5"> <div class="w-3 h-3 rounded-full" style="background: var(--color-accent-500)"></div> - <span class="font-bold text-white/80">Your hardware, our automation</span> + <span class="font-bold text-white/80">{{ 'pricing.byosFeature1' | translate }}</span> </div> <div class="glass-card flex items-center gap-4 p-4 rounded-xl border border-white/5"> <div class="w-3 h-3 rounded-full" style="background: var(--color-accent-500)"></div> - <span class="font-bold text-white/80">Extra BYOS server: 1 500 F/month</span> + <span class="font-bold text-white/80">{{ 'pricing.byosFeature2' | translate }}</span> </div> <div class="glass-card flex items-center gap-4 p-4 rounded-xl border border-white/5"> <div class="w-3 h-3 rounded-full" style="background: var(--color-accent-500)"></div> - <span class="font-bold text-white/80">Code and data exportable at any time</span> + <span class="font-bold text-white/80">{{ 'pricing.byosFeature3' | translate }}</span> </div> </div> </div> @@ -245,14 +241,14 @@ interface OverageRow { <div class="absolute inset-0 z-0" style="background: linear-gradient(to bottom right, rgba(37,99,235,0.2), black, rgba(34,211,238,0.2));"></div> <div class="glass-card relative z-10 p-16 md:p-20 border border-white/10 text-center" style="backdrop-filter: blur(48px);"> <h2 class="text-4xl md:text-5xl font-black text-white mb-6" style="letter-spacing:-0.04em;"> - Your first app is <span class="i-underline">on us</span> + {{ 'pricing.ctaTitle' | translate }} <span class="i-underline">{{ 'pricing.ctaTitleAccent' | translate }}</span> </h2> <p class="text-xl text-white/70 mb-10 max-w-xl mx-auto font-medium"> - Free domain, free SSL, 5 free deployments, commercial use allowed. Start now, upgrade when your app grows. + {{ 'pricing.ctaSubtitle' | translate }} </p> <div class="flex flex-col sm:flex-row items-center justify-center gap-4"> - <a [href]="loginUrl" class="inner-button px-8 py-4 text-lg w-full sm:w-auto">Get started for free</a> - <a routerLink="/" class="outer-button px-8 py-4 text-lg w-full sm:w-auto">Back to home</a> + <a [href]="loginUrl" class="inner-button px-8 py-4 text-lg w-full sm:w-auto">{{ 'pricing.getStartedFree' | translate }}</a> + <a routerLink="/" class="outer-button px-8 py-4 text-lg w-full sm:w-auto">{{ 'pricing.backToHome' | translate }}</a> </div> </div> </div> @@ -267,10 +263,10 @@ interface OverageRow { </div> <span class="text-base font-black text-white tracking-tight">EPLOY</span> </div> - <p class="text-sm text-white/50 font-medium">© {{ year }} EPLOY · Powered seamlessly by Idem Frameworks</p> + <p class="text-sm text-white/50 font-medium">{{ 'pricing.footerCopyright' | translate: { year: year } }}</p> <div class="flex items-center gap-8"> - <a routerLink="/pricing" class="text-sm font-bold text-white/50 hover:text-white transition-colors">Pricing</a> - <a [href]="loginUrl" class="text-sm font-bold text-white/50 hover:text-white transition-colors">Sign In</a> + <a routerLink="/pricing" class="text-sm font-bold text-white/50 hover:text-white transition-colors">{{ 'pricing.navPricing' | translate }}</a> + <a [href]="loginUrl" class="text-sm font-bold text-white/50 hover:text-white transition-colors">{{ 'pricing.signIn' | translate }}</a> </div> </div> </footer> 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: ` - <h1 class="mb-6 text-2xl font-bold">Notifications</h1> + <h1 class="mb-6 text-2xl font-bold">{{ 'notifications.title' | translate }}</h1> <div class="space-y-4"> @for (ch of channels(); track ch.channel) { <div class="box"> <h2 class="mb-2 font-semibold">{{ ch.label }}</h2> <label class="mb-2 flex items-center gap-2 text-sm"> <input type="checkbox" [(ngModel)]="ch.enabled" /> - Enabled + {{ 'notifications.enabled' | translate }} </label> <input class="input mb-2" - placeholder="Webhook URL / token" + [placeholder]="'notifications.webhookPlaceholder' | translate" [(ngModel)]="ch.webhook" /> <div class="flex gap-2"> - <button class="button" (click)="save(ch)">Save</button> - <button class="button-secondary" (click)="test(ch)">Send test</button> + <button class="button" (click)="save(ch)">{{ 'notifications.save' | translate }}</button> + <button class="button-secondary" (click)="test(ch)">{{ 'notifications.sendTest' | translate }}</button> </div> @if (status()[ch.channel]; as st) { <p class="mt-2 text-xs" [class.text-green-400]="st.ok" [class.text-red-400]="!st.ok"> @@ -44,6 +45,7 @@ interface ChannelState { }) export class NotificationsComponent implements OnInit { private api = inject(ApiService); + private translate = inject(TranslateService); protected readonly channels = signal<ChannelState[]>([ { 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: ` <div class="flex h-16 items-center justify-between border-b px-6" style="border-color:var(--color-surface-2);"> <a routerLink="/new-project" class="flex items-center gap-2 text-sm transition-colors hover:text-white" style="color:var(--color-text-secondary);"> - <i class="fa-solid fa-arrow-left"></i> Back + <i class="fa-solid fa-arrow-left"></i> {{ 'projects.common.back' | translate }} </a> - <span class="text-sm font-semibold font-mono">New Project</span> + <span class="text-sm font-semibold font-mono">{{ 'projects.common.newProject' | translate }}</span> <span class="w-12"></span> </div> <div class="mx-auto max-w-2xl px-6 py-12"> <div class="db-glass"> - <h1 class="mb-4 text-2xl font-bold font-mono text-white/95">New Project</h1> + <h1 class="mb-4 text-2xl font-bold font-mono text-white/95">{{ 'projects.common.newProject' | translate }}</h1> <!-- Imported source --> <div class="mb-6 rounded-xl p-4 border" style="background:var(--color-surface-1);border-color:var(--color-surface-2);"> - <div class="text-xs font-semibold uppercase tracking-wider" style="color:var(--color-text-tertiary);">Importing from Git</div> + <div class="text-xs font-semibold uppercase tracking-wider" style="color:var(--color-text-tertiary);">{{ 'projects.import.importingFromGit' | translate }}</div> <div class="mt-2 flex items-center gap-2 text-sm font-semibold text-white/90"> <i class="fa-brands fa-github text-lg"></i> {{ repo() }} <span class="font-mono text-xs px-2 py-0.5 rounded" style="background:var(--color-surface-2);color:var(--color-text-secondary);"><i class="fa-solid fa-code-branch mr-1"></i>{{ branch() }}</span> </div> </div> - <p class="mb-4 text-sm" style="color:var(--color-text-secondary);">Configure your project parameters and deploy.</p> + <p class="mb-4 text-sm" style="color:var(--color-text-secondary);">{{ 'projects.import.configureDeploy' | translate }}</p> <div class="mb-5 grid grid-cols-1 gap-4 sm:grid-cols-2"> <div> - <label class="mb-1 block text-sm font-semibold text-white/80" for="teamName">Team</label> + <label class="mb-1 block text-sm font-semibold text-white/80" for="teamName">{{ 'projects.import.team' | translate }}</label> <input id="teamName" name="teamName" class="input bg-opacity-50 cursor-not-allowed" [value]="teamName()" disabled /> </div> <div> - <label class="mb-1 block text-sm font-semibold text-white/80" for="projectName">Project Name</label> + <label class="mb-1 block text-sm font-semibold text-white/80" for="projectName">{{ 'projects.import.projectName' | translate }}</label> <input id="projectName" name="projectName" class="input" [(ngModel)]="projectName" autocomplete="off" /> </div> </div> <div class="mb-4"> - <label class="mb-1 block text-sm font-semibold text-white/80" for="appPreset">Application Preset</label> + <label class="mb-1 block text-sm font-semibold text-white/80" for="appPreset">{{ 'projects.import.appPreset' | translate }}</label> <select id="appPreset" name="appPreset" class="input cursor-pointer" [ngModel]="presetIndex()" (ngModelChange)="presetIndex.set(+$event)"> @for (p of presets; track p.label; let i = $index) { <option [value]="i">{{ p.label }}</option> } </select> - <p class="mt-1 text-xs" style="color:var(--color-text-tertiary);">Auto-detected from the repository — change if needed.</p> + <p class="mt-1 text-xs" style="color:var(--color-text-tertiary);">{{ 'projects.import.autoDetected' | translate }}</p> </div> <!-- Build method --> <div class="mb-4"> - <span class="mb-1.5 block text-sm font-semibold text-white/80">Build method</span> + <span class="mb-1.5 block text-sm font-semibold text-white/80">{{ 'projects.import.buildMethod' | translate }}</span> @if (hasDockerfile()) { <div class="space-y-2 rounded-xl p-3 border" style="background:var(--color-surface-1);border-color:var(--color-surface-2);"> <label class="flex items-center gap-2 text-sm cursor-pointer text-white/80 hover:text-white"> <input type="radio" name="buildMethod" class="cursor-pointer" [checked]="buildMethod() === 'docker'" (change)="buildMethod.set('docker')" /> - <span><i class="fa-brands fa-docker mr-1 text-blue-400"></i> Use Docker — build the repo's Dockerfile</span> + <span><i class="fa-brands fa-docker mr-1 text-blue-400"></i> {{ 'projects.import.useDocker' | translate }}</span> </label> <label class="flex items-center gap-2 text-sm cursor-pointer text-white/80 hover:text-white"> <input type="radio" name="buildMethod" class="cursor-pointer" [checked]="buildMethod() === 'buildless'" (change)="buildMethod.set('buildless')" /> - <span><i class="fa-brands fa-node-js mr-1 text-green-400"></i> Without Docker — run the app directly (no containerization)</span> + <span><i class="fa-brands fa-node-js mr-1 text-green-400"></i> {{ 'projects.import.withoutDocker' | translate }}</span> </label> </div> - <p class="mt-1 text-xs" style="color:var(--color-text-tertiary);">A Dockerfile was detected — choose how to deploy.</p> + <p class="mt-1 text-xs" style="color:var(--color-text-tertiary);">{{ 'projects.import.dockerfileDetected' | translate }}</p> } @else { <div class="rounded-xl p-3 text-sm border" style="background:var(--color-surface-1);border-color:var(--color-surface-2);color:var(--color-text-secondary);"> - <i class="fa-brands fa-node-js mr-1 text-green-400"></i> No Dockerfile detected — the app will be deployed - <strong>without Docker</strong> (run directly in a base Node runtime). + <i class="fa-brands fa-node-js mr-1 text-green-400"></i> {{ 'projects.import.noDockerfilePart1' | translate }} + <strong>{{ 'projects.import.withoutDockerStrong' | translate }}</strong> {{ 'projects.import.noDockerfilePart2' | translate }} </div> } </div> <div class="mb-5"> - <label class="mb-1 block text-sm font-semibold text-white/80" for="rootDir">Root Directory</label> + <label class="mb-1 block text-sm font-semibold text-white/80" for="rootDir">{{ 'projects.import.rootDirectory' | translate }}</label> <input id="rootDir" name="rootDir" class="input font-mono" [(ngModel)]="rootDir" placeholder="./" autocomplete="off" /> - <p class="mt-1 text-xs" style="color:var(--color-text-tertiary);">The directory where your package.json / build settings are located.</p> + <p class="mt-1 text-xs" style="color:var(--color-text-tertiary);">{{ 'projects.import.rootDirHint' | translate }}</p> </div> <!-- Collapsibles --> <button class="mb-3 flex w-full items-center gap-2 rounded-lg p-3 text-left text-sm font-semibold cursor-pointer hover:bg-white/[0.02] transition-colors" style="border:1px solid var(--color-surface-2);" (click)="showBuild.set(!showBuild())"> <i class="fa-solid" [class.fa-chevron-right]="!showBuild()" [class.fa-chevron-down]="showBuild()"></i> - Build and Output Settings + {{ 'projects.import.buildOutputSettings' | translate }} </button> @if (showBuild()) { <div class="mb-3 space-y-3 px-1"> - <input class="input font-mono" [(ngModel)]="buildCommand" placeholder="Build command (optional, e.g. npm run build)" aria-label="Build command" autocomplete="off" /> - <input class="input font-mono" [(ngModel)]="startCommand" placeholder="Start command (optional, e.g. npm run start)" aria-label="Start command" autocomplete="off" /> - <input class="input font-mono" [(ngModel)]="portsExposes" placeholder="Exposed port (e.g. 3000)" aria-label="Exposed port" autocomplete="off" /> + <input class="input font-mono" [(ngModel)]="buildCommand" [placeholder]="'projects.import.buildCommandPlaceholder' | translate" [attr.aria-label]="'projects.import.buildCommandLabel' | translate" autocomplete="off" /> + <input class="input font-mono" [(ngModel)]="startCommand" [placeholder]="'projects.import.startCommandPlaceholder' | translate" [attr.aria-label]="'projects.import.startCommandLabel' | translate" autocomplete="off" /> + <input class="input font-mono" [(ngModel)]="portsExposes" [placeholder]="'projects.import.portPlaceholder' | translate" [attr.aria-label]="'projects.import.portLabel' | translate" autocomplete="off" /> </div> } <button class="mb-5 flex w-full items-center gap-2 rounded-lg p-3 text-left text-sm font-semibold cursor-pointer hover:bg-white/[0.02] transition-colors" style="border:1px solid var(--color-surface-2);" (click)="showEnv.set(!showEnv())"> <i class="fa-solid" [class.fa-chevron-right]="!showEnv()" [class.fa-chevron-down]="showEnv()"></i> - Environment Variables + {{ 'projects.import.envVariables' | translate }} </button> @if (showEnv()) { <p class="mb-4 px-1 text-xs" style="color:var(--color-text-tertiary);"> - You can add environment variables after the first deploy, from the application's Environment tab. + {{ 'projects.import.envHint' | translate }} </p> } @@ -125,14 +126,14 @@ interface Preset { <div class="flex items-center gap-3"> @if (!isProd) { <button class="button cursor-pointer" [disabled]="settingUpLocal()" (click)="useLocalServer()"> - {{ settingUpLocal() ? 'Setting up…' : 'Use this machine (local Docker)' }} + {{ (settingUpLocal() ? 'projects.import.settingUp' : 'projects.import.useLocalMachine') | translate }} </button> } - <a routerLink="/servers/new" class="text-xs font-semibold hover:underline" style="color:#60a5fa;">Add a server</a> + <a routerLink="/servers/new" class="text-xs font-semibold hover:underline" style="color:#60a5fa;">{{ 'projects.import.addServer' | translate }}</a> </div> @if (!isProd) { <p class="mt-2 text-xs" style="color:var(--color-text-tertiary);"> - Runs the deployment on your local Docker — perfect for testing. + {{ 'projects.import.localDockerHint' | translate }} </p> } } @@ -140,7 +141,7 @@ interface Preset { } <button class="button w-full cursor-pointer py-2.5 text-base" [disabled]="deploying() || !projectName" (click)="deploy()"> - {{ deploying() ? 'Deploying…' : 'Deploy' }} + {{ (deploying() ? 'projects.import.deploying' : 'projects.common.deploy') | translate }} </button> </div> </div> @@ -152,33 +153,33 @@ interface Preset { <div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500/10 text-blue-400"> <i class="fa-solid fa-cube text-lg"></i> </div> - <h2 class="text-xl font-bold font-mono text-white/95">Docker Detected</h2> + <h2 class="text-xl font-bold font-mono text-white/95">{{ 'projects.import.dockerDetectedTitle' | translate }}</h2> </div> - + <p class="text-sm mb-6" style="color:var(--color-text-secondary);"> - 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 }} </p> <div class="space-y-3 mb-6"> <label class="flex items-center gap-3 p-3 rounded-xl border border-white/5 bg-white/[0.01] hover:bg-white/[0.02] cursor-pointer transition-colors group"> <input type="radio" name="modalBuildMethod" [checked]="modalBuildMethod() === 'docker'" (change)="modalBuildMethod.set('docker')" class="cursor-pointer" /> <div> - <div class="text-sm font-semibold text-white/90 group-hover:text-blue-400 transition-colors">Deploy with Docker</div> - <div class="text-xs text-white/40 mt-0.5">Use your custom Docker configuration.</div> + <div class="text-sm font-semibold text-white/90 group-hover:text-blue-400 transition-colors">{{ 'projects.import.deployWithDocker' | translate }}</div> + <div class="text-xs text-white/40 mt-0.5">{{ 'projects.import.deployWithDockerDesc' | translate }}</div> </div> </label> <label class="flex items-center gap-3 p-3 rounded-xl border border-white/5 bg-white/[0.01] hover:bg-white/[0.02] cursor-pointer transition-colors group"> <input type="radio" name="modalBuildMethod" [checked]="modalBuildMethod() === 'buildless'" (change)="modalBuildMethod.set('buildless')" class="cursor-pointer" /> <div> - <div class="text-sm font-semibold text-white/90 group-hover:text-blue-400 transition-colors">Deploy without Docker</div> - <div class="text-xs text-white/40 mt-0.5">Run directly in our optimized Node runtime.</div> + <div class="text-sm font-semibold text-white/90 group-hover:text-blue-400 transition-colors">{{ 'projects.import.deployWithoutDocker' | translate }}</div> + <div class="text-xs text-white/40 mt-0.5">{{ 'projects.import.deployWithoutDockerDesc' | translate }}</div> </div> </label> </div> <div class="flex gap-3 justify-end"> - <button class="button-secondary cursor-pointer text-xs px-4 py-2" (click)="showDockerModal.set(false)">Cancel</button> - <button class="button cursor-pointer text-xs px-4 py-2" (click)="confirmDockerDeploy()">Confirm & Deploy</button> + <button class="button-secondary cursor-pointer text-xs px-4 py-2" (click)="showDockerModal.set(false)">{{ 'projects.common.cancel' | translate }}</button> + <button class="button cursor-pointer text-xs px-4 py-2" (click)="confirmDockerDeploy()">{{ 'projects.import.confirmDeploy' | translate }}</button> </div> </div> </div> @@ -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: ` <!-- Top bar --> <div class="flex h-16 items-center justify-between border-b px-6" style="border-color:var(--color-surface-2);"> <a routerLink="/dashboard" class="flex items-center gap-2 text-sm transition-colors hover:text-white" style="color:var(--color-text-secondary);"> - <i class="fa-solid fa-arrow-left"></i> Back + <i class="fa-solid fa-arrow-left"></i> {{ 'projects.common.back' | translate }} </a> - <span class="text-sm font-semibold font-mono text-white/90">New Project</span> + <span class="text-sm font-semibold font-mono text-white/90">{{ 'projects.common.newProject' | translate }}</span> <span class="w-12"></span> </div> <div class="mx-auto max-w-5xl px-6 py-12"> - <h1 class="heading-serif mb-8 text-center" style="font-size:40px;font-weight:700;color:#fff;">Let's build something new</h1> + <h1 class="heading-serif mb-8 text-center" style="font-size:40px;font-weight:700;color:#fff;">{{ 'projects.new.heading' | translate }}</h1> <!-- Git URL prompt --> <div class="mb-2 flex items-center gap-3 rounded-xl px-4 py-3 transition-all duration-200 border" @@ -34,32 +35,32 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode [class.focus-within:ring-2]="true" [class.focus-within:ring-blue-500/20]="true"> <i class="fa-solid fa-link text-blue-400"></i> - <input class="flex-1 bg-transparent outline-none text-sm" placeholder="Enter a Git repository URL…" - aria-label="Git repository URL" + <input class="flex-1 bg-transparent outline-none text-sm" [placeholder]="'projects.new.gitUrlPlaceholder' | translate" + [attr.aria-label]="'projects.new.gitUrlLabel' | translate" [(ngModel)]="gitUrl" (keyup.enter)="importUrl()" style="color:var(--color-text-primary);" /> @if (gitUrl) { - <button class="button cursor-pointer text-xs font-semibold py-1.5 px-3" (click)="importUrl()">Continue</button> + <button class="button cursor-pointer text-xs font-semibold py-1.5 px-3" (click)="importUrl()">{{ 'projects.new.continue' | translate }}</button> } </div> <p class="mb-10 text-center text-sm" style="color:var(--color-text-tertiary);"> - Paste a public Git repository URL, pick one of your GitHub repos, or clone a template. + {{ 'projects.new.subheading' | translate }} </p> <div class="grid grid-cols-1 gap-10 lg:grid-cols-2"> <!-- ===== Import Git Repository ===== --> <div> - <h2 class="mb-4 text-xl font-semibold font-mono text-white/95">Import Git Repository</h2> + <h2 class="mb-4 text-xl font-semibold font-mono text-white/95">{{ 'projects.new.importGitRepo' | translate }}</h2> @if (githubUser() === undefined) { - <p class="text-sm" style="color:var(--color-text-secondary);">Checking GitHub connection…</p> + <p class="text-sm" style="color:var(--color-text-secondary);">{{ 'projects.new.checkingGithub' | translate }}</p> } @else if (githubUser() === null) { <div class="db-glass text-center p-8 rounded-2xl"> <i class="fa-brands fa-github mb-3 text-4xl text-white/80"></i> <p class="mb-4 text-sm" style="color:var(--color-text-secondary);"> - Connect your GitHub account to import and deploy your repositories. + {{ 'projects.new.connectGithubDesc' | translate }} </p> <button class="button cursor-pointer" (click)="connectGithub()"> - <i class="fa-brands fa-github mr-2"></i> Connect GitHub + <i class="fa-brands fa-github mr-2"></i> {{ 'projects.new.connectGithub' | translate }} </button> </div> } @else { @@ -69,11 +70,11 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode </span> <div class="relative flex-1"> <i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-[10px]" style="color:#8d919a;"></i> - <input class="input font-mono text-xs" style="padding-left:30px;height:36px;" placeholder="Search repositories…" aria-label="Search repositories" [ngModel]="repoQuery()" (ngModelChange)="repoQuery.set($event)" /> + <input class="input font-mono text-xs" style="padding-left:30px;height:36px;" [placeholder]="'projects.new.searchReposPlaceholder' | translate" [attr.aria-label]="'projects.new.searchReposLabel' | translate" [ngModel]="repoQuery()" (ngModelChange)="repoQuery.set($event)" /> </div> </div> @if (filteredRepos().length === 0) { - <div class="db-glass p-8 text-center text-sm" style="color:var(--color-text-secondary);">No repositories found.</div> + <div class="db-glass p-8 text-center text-sm" style="color:var(--color-text-secondary);">{{ 'projects.new.noRepos' | translate }}</div> } @else { <div class="overflow-y-auto rounded-xl db-glass p-0" style="max-height: 400px;"> @for (repo of filteredRepos(); track repo.fullName) { @@ -83,24 +84,24 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode </div> <div class="min-w-0 flex-1"> <div class="truncate text-sm font-semibold text-white/90 font-mono">{{ repo.name }} - @if (repo.private) { <i class="fa-solid fa-lock ml-1.5 text-[10px]" style="color:var(--color-text-tertiary);" title="Private repository"></i> } + @if (repo.private) { <i class="fa-solid fa-lock ml-1.5 text-[10px]" style="color:var(--color-text-tertiary);" [title]="'projects.new.privateRepo' | translate"></i> } </div> - <div class="truncate text-[10px] font-mono mt-0.5" style="color:var(--color-text-tertiary);">Updated: {{ repo.updatedAt | slice:0:10 }}</div> + <div class="truncate text-[10px] font-mono mt-0.5" style="color:var(--color-text-tertiary);">{{ 'projects.new.updated' | translate }} {{ repo.updatedAt | slice:0:10 }}</div> </div> - <button class="button-secondary cursor-pointer text-xs font-semibold px-3 py-1.5 rounded-lg hover:bg-white/10 transition-colors" (click)="importRepo(repo)">Import</button> + <button class="button-secondary cursor-pointer text-xs font-semibold px-3 py-1.5 rounded-lg hover:bg-white/10 transition-colors" (click)="importRepo(repo)">{{ 'projects.new.import' | translate }}</button> </div> } </div> } - <button class="mt-3 text-xs hover:text-white transition-colors cursor-pointer" style="color:var(--color-text-tertiary);" (click)="disconnectGithub()">Disconnect GitHub</button> + <button class="mt-3 text-xs hover:text-white transition-colors cursor-pointer" style="color:var(--color-text-tertiary);" (click)="disconnectGithub()">{{ 'projects.new.disconnectGithub' | translate }}</button> } </div> <!-- ===== Clone Template ===== --> <div> <div class="mb-4 flex items-center justify-between"> - <h2 class="text-xl font-semibold font-mono text-white/95">Clone Template</h2> - <a routerLink="/templates" class="text-sm font-semibold hover:underline" style="color:#60a5fa;">Browse All</a> + <h2 class="text-xl font-semibold font-mono text-white/95">{{ 'projects.new.cloneTemplate' | translate }}</h2> + <a routerLink="/templates" class="text-sm font-semibold hover:underline" style="color:#60a5fa;">{{ 'projects.new.browseAll' | translate }}</a> </div> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> @for (t of templates(); track t.name) { @@ -111,8 +112,8 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode </div> <div class="font-semibold capitalize font-mono text-white/90 group-hover:text-blue-400 transition-colors">{{ t.name }}</div> </div> - <p class="mb-4 flex-1 text-xs leading-relaxed" style="color:var(--color-text-secondary);">{{ t.slogan || 'One-click boilerplate project' }}</p> - <button class="button-secondary w-full cursor-pointer hover:bg-blue-500 hover:text-white transition-all text-xs font-semibold py-1.5 rounded-lg border border-transparent hover:border-blue-600/30" [disabled]="busy()" (click)="cloneTemplate(t)">Deploy</button> + <p class="mb-4 flex-1 text-xs leading-relaxed" style="color:var(--color-text-secondary);">{{ t.slogan || ('projects.new.oneClickBoilerplate' | translate) }}</p> + <button class="button-secondary w-full cursor-pointer hover:bg-blue-500 hover:text-white transition-all text-xs font-semibold py-1.5 rounded-lg border border-transparent hover:border-blue-600/30" [disabled]="busy()" (click)="cloneTemplate(t)">{{ 'projects.common.deploy' | translate }}</button> </div> } </div> @@ -126,11 +127,11 @@ import { GithubRepo, ServiceTemplate } from '../../../shared/models/ideploy.mode <i class="fa-solid fa-cube text-lg"></i> </div> <div> - <div class="font-semibold font-mono text-white/90">Create Empty Project</div> - <p class="text-xs mt-0.5" style="color:var(--color-text-secondary);">Skip Git connection and start a blank environment from scratch.</p> + <div class="font-semibold font-mono text-white/90">{{ 'projects.new.createEmpty' | translate }}</div> + <p class="text-xs mt-0.5" style="color:var(--color-text-secondary);">{{ 'projects.new.createEmptyDesc' | translate }}</p> </div> </div> - <button class="button-secondary cursor-pointer text-xs font-semibold py-2 px-4 rounded-xl hover:bg-white/10 hover:text-white transition-all" (click)="createEmpty()">Create Empty Project</button> + <button class="button-secondary cursor-pointer text-xs font-semibold py-2 px-4 rounded-xl hover:bg-white/10 hover:text-white transition-all" (click)="createEmpty()">{{ 'projects.new.createEmpty' | translate }}</button> </div> @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<string | null | undefined>(undefined); protected readonly repos = signal<GithubRepo[]>([]); @@ -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) { <section class="box mb-6"> <div class="mb-3 flex items-center justify-between"> - <h2 class="font-semibold">Environment: {{ env.name }}</h2> - <span class="text-xs" style="color: var(--color-text-secondary)">env #{{ env.id }}</span> + <h2 class="font-semibold">{{ 'projects.detail.environment' | translate }} {{ env.name }}</h2> + <span class="text-xs" style="color: var(--color-text-secondary)">{{ 'projects.detail.envNumber' | translate }}{{ env.id }}</span> </div> <!-- Applications in this environment --> @@ -43,16 +44,16 @@ interface EnvRow { <!-- New application --> <form class="mt-3 flex flex-wrap gap-2" [formGroup]="newAppForm(env.id)" (ngSubmit)="createApp(env.id)"> - <input class="input flex-1" placeholder="app name" [formControl]="newAppForm(env.id).controls.name" /> - <input class="input flex-1" placeholder="git repository URL" [formControl]="newAppForm(env.id).controls.git_repository" /> - <input class="input w-28" placeholder="branch" [formControl]="newAppForm(env.id).controls.git_branch" /> - <input class="input w-36" type="number" placeholder="destination id" [formControl]="newAppForm(env.id).controls.destination_id" /> - <button class="button" type="submit" [disabled]="newAppForm(env.id).invalid">New application</button> + <input class="input flex-1" [placeholder]="'projects.detail.appNamePlaceholder' | translate" [formControl]="newAppForm(env.id).controls.name" /> + <input class="input flex-1" [placeholder]="'projects.detail.gitRepoPlaceholder' | translate" [formControl]="newAppForm(env.id).controls.git_repository" /> + <input class="input w-28" [placeholder]="'projects.detail.branchPlaceholder' | translate" [formControl]="newAppForm(env.id).controls.git_branch" /> + <input class="input w-36" type="number" [placeholder]="'projects.detail.destinationIdPlaceholder' | translate" [formControl]="newAppForm(env.id).controls.destination_id" /> + <button class="button" type="submit" [disabled]="newAppForm(env.id).invalid">{{ 'projects.detail.newApplication' | translate }}</button> </form> </section> } } @else { - <p class="text-sm" style="color: var(--color-text-secondary)">Loading…</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'projects.common.loading' | translate }}</p> } `, }) 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: ` <div class="mb-6 flex items-center justify-between"> - <h1 class="heading-serif" style="font-size:32px;font-weight:700;color:#fff;">Projects</h1> + <h1 class="heading-serif" style="font-size:32px;font-weight:700;color:#fff;">{{ 'projects.list.title' | translate }}</h1> <button class="button" (click)="creating.set(!creating())"> - {{ creating() ? 'Cancel' : '+ New project' }} + {{ (creating() ? 'projects.common.cancel' : 'projects.list.newProject') | translate }} </button> </div> @if (creating()) { <form class="box mb-6 max-w-lg space-y-3" [formGroup]="form" (ngSubmit)="create()"> <div> - <label class="mb-1 block text-sm">Name</label> - <input class="input" formControlName="name" placeholder="My project" /> + <label class="mb-1 block text-sm">{{ 'projects.list.name' | translate }}</label> + <input class="input" formControlName="name" [placeholder]="'projects.list.namePlaceholder' | translate" /> </div> <div> - <label class="mb-1 block text-sm">Description</label> + <label class="mb-1 block text-sm">{{ 'projects.list.description' | translate }}</label> <input class="input" formControlName="description" /> </div> @if (error()) { <p class="text-sm text-red-400">{{ error() }}</p> } <button class="button" type="submit" [disabled]="form.invalid || saving()"> - {{ saving() ? 'Creating…' : 'Create project' }} + {{ (saving() ? 'projects.list.creating' : 'projects.list.createProject') | translate }} </button> </form> } @if (loading()) { - <p class="text-sm" style="color: var(--color-text-secondary)">Loading…</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'projects.common.loading' | translate }}</p> } @else if (projects().length === 0) { - <div class="box">No projects yet.</div> + <div class="box">{{ 'projects.list.noProjects' | translate }}</div> } @else { <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> @for (project of projects(); track project.uuid) { <div class="db-glass p-5"> <div class="mb-2 flex items-center justify-between"> <a class="font-semibold hover:underline" style="color:#fff;" [routerLink]="['/projects', project.uuid]">{{ project.name }}</a> - <button class="text-xs text-red-400" (click)="remove(project)">delete</button> + <button class="text-xs text-red-400" (click)="remove(project)">{{ 'projects.list.delete' | translate }}</button> </div> @if (project.description) { <p class="text-sm" style="color: var(--color-text-secondary)">{{ project.description }}</p> } <div class="mt-3 flex justify-end"> <a [routerLink]="['/projects', project.uuid]" style="display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:700;color:#b4c5ff;"> - View details <i class="fa-solid fa-chevron-right text-[10px]"></i> + {{ 'projects.list.viewDetails' | translate }} <i class="fa-solid fa-chevron-right text-[10px]"></i> </a> </div> </div> @@ -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<Project[]>([]); 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: ` - <h1 class="mb-6 text-2xl font-bold">Private keys</h1> + <h1 class="mb-6 text-2xl font-bold">{{ 'security.privateKeys' | translate }}</h1> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div> @if (loading()) { - <p class="text-sm" style="color: var(--color-text-secondary)">Loading…</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'security.loading' | translate }}</p> } @else if (keys().length === 0) { - <div class="box">No private keys yet.</div> + <div class="box">{{ 'security.noKeys' | translate }}</div> } @else { <div class="space-y-3"> @for (key of keys(); track key.uuid) { @@ -33,20 +34,20 @@ import { PrivateKey } from '../../../shared/models/ideploy.models'; </div> <form class="box space-y-4" [formGroup]="form" (ngSubmit)="submit()"> - <h2 class="font-semibold">Add a key</h2> + <h2 class="font-semibold">{{ 'security.addKeyTitle' | translate }}</h2> <div> - <label class="mb-1 block text-sm">Name</label> + <label class="mb-1 block text-sm">{{ 'security.name' | translate }}</label> <input class="input" formControlName="name" /> </div> <div> - <label class="mb-1 block text-sm">Private key (PEM)</label> + <label class="mb-1 block text-sm">{{ 'security.privateKeyPem' | translate }}</label> <textarea class="input font-mono" rows="6" formControlName="private_key"></textarea> </div> @if (error()) { <p class="text-sm text-red-400">{{ error() }}</p> } <button class="button" type="submit" [disabled]="form.invalid || saving()"> - {{ saving() ? 'Saving…' : 'Add key' }} + {{ saving() ? ('security.saving' | translate) : ('security.addKey' | translate) }} </button> </form> </div> @@ -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<PrivateKey[]>([]); 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: ` - <h1 class="mb-6 text-2xl font-bold">Add server</h1> + <h1 class="mb-6 text-2xl font-bold">{{ 'servers.addServerTitle' | translate }}</h1> <form class="box max-w-lg space-y-4" [formGroup]="form" (ngSubmit)="submit()"> <div> - <label class="mb-1 block text-sm">Name</label> + <label class="mb-1 block text-sm">{{ 'servers.name' | translate }}</label> <input class="input" formControlName="name" /> </div> <div> - <label class="mb-1 block text-sm">IP address</label> + <label class="mb-1 block text-sm">{{ 'servers.ipAddress' | translate }}</label> <input class="input" formControlName="ip" /> </div> <div class="flex gap-4"> <div class="flex-1"> - <label class="mb-1 block text-sm">SSH user</label> + <label class="mb-1 block text-sm">{{ 'servers.sshUser' | translate }}</label> <input class="input" formControlName="user" /> </div> <div class="w-28"> - <label class="mb-1 block text-sm">Port</label> + <label class="mb-1 block text-sm">{{ 'servers.port' | translate }}</label> <input class="input" type="number" formControlName="port" /> </div> </div> <div> - <label class="mb-1 block text-sm">Private key ID</label> + <label class="mb-1 block text-sm">{{ 'servers.privateKeyId' | translate }}</label> <input class="input" type="number" formControlName="private_key_id" /> </div> @if (error()) { <p class="text-sm text-red-400">{{ error() }}</p> } <button class="button" type="submit" [disabled]="form.invalid || submitting()"> - {{ submitting() ? 'Creating…' : 'Create server' }} + {{ (submitting() ? 'servers.creating' : 'servers.createServer') | translate }} </button> </form> `, @@ -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<string | null>(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: ` <div class="mb-6 flex items-center justify-between"> - <h1 class="text-2xl font-bold">Servers</h1> - <a class="button" routerLink="/servers/new">+ Add server</a> + <h1 class="text-2xl font-bold">{{ 'servers.title' | translate }}</h1> + <a class="button" routerLink="/servers/new">{{ 'servers.addServerButton' | translate }}</a> </div> @if (loading()) { - <p class="text-sm" style="color: var(--color-text-secondary)">Loading…</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'servers.loading' | translate }}</p> } @else if (servers().length === 0) { - <div class="box">No servers yet. Add one to get started.</div> + <div class="box">{{ 'servers.empty' | translate }}</div> } @else { <div class="space-y-3"> @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) { <div class="mt-1 text-xs"> <span [class.text-green-400]="v.reachable" [class.text-red-400]="!v.reachable"> - {{ v.reachable ? 'reachable' : 'unreachable' }} + {{ (v.reachable ? 'servers.reachable' : 'servers.unreachable') | translate }} </span> - · Docker: {{ v.dockerInstalled ? 'installed' : 'missing' }} + · Docker: {{ (v.dockerInstalled ? 'servers.installed' : 'servers.missing') | translate }} </div> } @if (proxies()[server.uuid]; as p) { - <div class="mt-1 text-xs">Proxy: {{ p.status }}</div> + <div class="mt-1 text-xs">{{ 'servers.proxyStatus' | translate:{ status: p.status } }}</div> } </div> <div class="flex flex-wrap gap-2"> - <button class="button-secondary" (click)="validate(server)">Validate</button> - <button class="button-secondary" (click)="install(server)">Install Docker</button> - <button class="button-secondary" (click)="proxyStatus(server)">Proxy status</button> - <button class="button-secondary" (click)="startProxy(server)">Start proxy</button> - <button class="button-secondary" (click)="installCrowdSec(server)">Install CrowdSec</button> - <button class="text-xs text-red-400" (click)="remove(server)">Delete</button> + <button class="button-secondary" (click)="validate(server)">{{ 'servers.validate' | translate }}</button> + <button class="button-secondary" (click)="install(server)">{{ 'servers.installDocker' | translate }}</button> + <button class="button-secondary" (click)="proxyStatus(server)">{{ 'servers.proxyStatusButton' | translate }}</button> + <button class="button-secondary" (click)="startProxy(server)">{{ 'servers.startProxy' | translate }}</button> + <button class="button-secondary" (click)="installCrowdSec(server)">{{ 'servers.installCrowdSec' | translate }}</button> + <button class="text-xs text-red-400" (click)="remove(server)">{{ 'servers.delete' | translate }}</button> </div> </div> } 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: ` - <h1 class="mb-6 text-2xl font-bold">Services</h1> + <h1 class="mb-6 text-2xl font-bold">{{ 'services.title' | translate }}</h1> <div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div> @if (loading()) { - <p class="text-sm" style="color: var(--color-text-secondary)">Loading…</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'services.loading' | translate }}</p> } @else if (services().length === 0) { - <div class="box">No services yet.</div> + <div class="box">{{ 'services.empty' | translate }}</div> } @else { <div class="space-y-3"> @for (svc of services(); track svc.uuid) { @@ -27,8 +28,8 @@ import { Service, ServiceTemplate } from '../../../shared/models/ideploy.models' </div> </div> <div class="flex gap-2"> - <button class="button-secondary" (click)="action(svc, 'stop')">Stop</button> - <button class="button" (click)="action(svc, 'start')">Start</button> + <button class="button-secondary" (click)="action(svc, 'stop')">{{ 'services.stop' | translate }}</button> + <button class="button" (click)="action(svc, 'start')">{{ 'services.start' | translate }}</button> </div> </div> } @@ -37,39 +38,39 @@ import { Service, ServiceTemplate } from '../../../shared/models/ideploy.models' </div> <form class="box space-y-3" [formGroup]="form" (ngSubmit)="create()"> - <h2 class="font-semibold">Deploy a one-click service</h2> + <h2 class="font-semibold">{{ 'services.deployOneClick' | translate }}</h2> <div> - <label class="mb-1 block text-sm">Template</label> + <label class="mb-1 block text-sm">{{ 'services.template' | translate }}</label> <select class="input" formControlName="template"> - <option value="">— custom (none) —</option> + <option value="">{{ 'services.customNone' | translate }}</option> @for (t of templates(); track t.name) { <option [value]="t.name">{{ t.name }} — {{ t.slogan }}</option> } </select> </div> <div> - <label class="mb-1 block text-sm">Name</label> + <label class="mb-1 block text-sm">{{ 'services.name' | translate }}</label> <input class="input" formControlName="name" /> </div> <div class="flex gap-3"> <div class="flex-1"> - <label class="mb-1 block text-sm">Environment ID</label> + <label class="mb-1 block text-sm">{{ 'services.environmentId' | translate }}</label> <input class="input" type="number" formControlName="environment_id" /> </div> <div class="flex-1"> - <label class="mb-1 block text-sm">Destination ID</label> + <label class="mb-1 block text-sm">{{ 'services.destinationId' | translate }}</label> <input class="input" type="number" formControlName="destination_id" /> </div> </div> <div> - <label class="mb-1 block text-sm">docker-compose.yml (ignored when a template is selected)</label> + <label class="mb-1 block text-sm">{{ 'services.dockerComposeLabel' | translate }}</label> <textarea class="input font-mono" rows="6" formControlName="docker_compose_raw"></textarea> </div> @if (error()) { <p class="text-sm text-red-400">{{ error() }}</p> } <button class="button" type="submit" [disabled]="saving()"> - {{ saving() ? 'Creating…' : 'Create service' }} + {{ (saving() ? 'services.creating' : 'services.createService') | translate }} </button> </form> </div> @@ -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<Service[]>([]); protected readonly templates = signal<ServiceTemplate[]>([]); @@ -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: ` - <h1 class="mb-6 text-2xl font-bold">Settings</h1> + <h1 class="mb-6 text-2xl font-bold">{{ 'settings.title' | translate }}</h1> @if (version(); as v) { <div class="box mb-6"> - <div class="text-sm">iDeploy version: <strong>{{ v.version }}</strong></div> + <div class="text-sm">{{ 'settings.versionLabel' | translate }} <strong>{{ v.version }}</strong></div> <div class="text-sm" style="color: var(--color-text-secondary)"> - Auto-update: {{ v.autoUpdate ? 'enabled' : 'disabled' }} + {{ 'settings.autoUpdate' | translate }} {{ v.autoUpdate ? ('settings.enabled' | translate) : ('settings.disabled' | translate) }} </div> </div> } <section class="box mb-6"> - <h2 class="mb-3 font-semibold">Instance settings</h2> + <h2 class="mb-3 font-semibold">{{ 'settings.instanceSettings' | translate }}</h2> <div class="space-y-3"> <div> - <label class="mb-1 block text-sm">Wildcard domain</label> + <label class="mb-1 block text-sm">{{ 'settings.wildcardDomain' | translate }}</label> <input class="input" [(ngModel)]="wildcardDomain" /> </div> <label class="flex items-center gap-2 text-sm"> - <input type="checkbox" [(ngModel)]="registrationEnabled" /> Registration enabled + <input type="checkbox" [(ngModel)]="registrationEnabled" /> {{ 'settings.registrationEnabled' | translate }} </label> - <button class="button" (click)="save()">Save</button> + <button class="button" (click)="save()">{{ 'settings.save' | translate }}</button> </div> </section> <section class="box"> - <h2 class="mb-3 font-semibold">Global search</h2> - <input class="input mb-3" placeholder="Search projects, servers, apps, services…" [(ngModel)]="query" (ngModelChange)="onSearch()" /> + <h2 class="mb-3 font-semibold">{{ 'settings.globalSearch' | translate }}</h2> + <input class="input mb-3" [placeholder]="'settings.searchPlaceholder' | translate" [(ngModel)]="query" (ngModelChange)="onSearch()" /> @for (hit of results(); track hit.uuid) { <div class="text-sm"> <span class="rounded px-2 py-0.5 text-xs" style="background-color: var(--color-surface-2)">{{ hit.type }}</span> 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: ` - <h1 class="heading-serif mb-6" style="font-size:32px;font-weight:700;color:#fff;">Shared Variables</h1> - <p class="mb-4 text-sm" style="color: var(--color-text-secondary)">Team-level variables reusable across all resources.</p> + <h1 class="heading-serif mb-6" style="font-size:32px;font-weight:700;color:#fff;">{{ 'sharedVariables.title' | translate }}</h1> + <p class="mb-4 text-sm" style="color: var(--color-text-secondary)">{{ 'sharedVariables.description' | translate }}</p> <div class="box max-w-2xl"> @for (v of vars(); track v.key) { @@ -19,9 +20,9 @@ import { ApiService } from '../../../shared/services/api.service'; </div> } <form class="mt-3 flex gap-2" [formGroup]="form" (ngSubmit)="add()"> - <input class="input flex-1" placeholder="KEY" formControlName="key" /> - <input class="input flex-1" placeholder="value" formControlName="value" /> - <button class="button" type="submit" [disabled]="form.invalid || teamId() === null">Add</button> + <input class="input flex-1" [placeholder]="'sharedVariables.keyPlaceholder' | translate" formControlName="key" /> + <input class="input flex-1" [placeholder]="'sharedVariables.valuePlaceholder' | translate" formControlName="value" /> + <button class="button" type="submit" [disabled]="form.invalid || teamId() === null">{{ 'sharedVariables.add' | translate }}</button> </form> </div> `, 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: ` - <h1 class="heading-serif mb-6" style="font-size:32px;font-weight:700;color:#fff;">Git Sources</h1> + <h1 class="heading-serif mb-6" style="font-size:32px;font-weight:700;color:#fff;">{{ 'sources.title' | translate }}</h1> @if (sources().length === 0) { - <div class="box">No Git sources configured (GitHub / GitLab apps).</div> + <div class="box">{{ 'sources.empty' | translate }}</div> } @else { <div class="space-y-3"> @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: ` - <h1 class="heading-serif mb-6" style="font-size:32px;font-weight:700;color:#fff;">S3 Storages</h1> + <h1 class="heading-serif mb-6" style="font-size:32px;font-weight:700;color:#fff;">{{ 'storages.title' | translate }}</h1> @if (storages().length === 0) { - <div class="box">No S3 storages configured.</div> + <div class="box">{{ 'storages.empty' | translate }}</div> } @else { <div class="space-y-3"> @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: ` - <h1 class="mb-6 text-2xl font-bold">Subscription</h1> + <h1 class="mb-6 text-2xl font-bold">{{ 'subscription.title' | translate }}</h1> @if (subscription(); as s) { <div class="box mb-6"> - <div class="text-lg font-semibold">Current plan: {{ s.plan }}</div> + <div class="text-lg font-semibold">{{ 'subscription.currentPlan' | translate }} {{ s.plan }}</div> @if (quota(); as q) { <div class="mt-2 text-sm"> - Apps: {{ q.apps.used }}/{{ q.apps.limit || '∞' }} - <span [class.text-red-400]="!q.apps.ok">{{ q.apps.ok ? '' : '(limit reached)' }}</span> + {{ 'subscription.apps' | translate }} {{ q.apps.used }}/{{ q.apps.limit || '∞' }} + <span [class.text-red-400]="!q.apps.ok">{{ q.apps.ok ? '' : ('subscription.limitReached' | translate) }}</span> </div> <div class="text-sm"> - Servers: {{ q.servers.used }}/{{ q.servers.limit || '∞' }} - <span [class.text-red-400]="!q.servers.ok">{{ q.servers.ok ? '' : '(limit reached)' }}</span> + {{ 'subscription.servers' | translate }} {{ q.servers.used }}/{{ q.servers.limit || '∞' }} + <span [class.text-red-400]="!q.servers.ok">{{ q.servers.ok ? '' : ('subscription.limitReached' | translate) }}</span> </div> } </div> } - <h2 class="mb-3 font-semibold">Plans</h2> + <h2 class="mb-3 font-semibold">{{ 'subscription.plans' | translate }}</h2> <div class="grid grid-cols-1 gap-4 md:grid-cols-3"> @for (plan of plans(); track plan['name']) { <div class="box flex flex-col"> <div class="text-lg font-semibold">{{ plan['display_name'] }}</div> <div class="my-2 text-2xl">{{ plan['price'] }} {{ plan['currency'] }}<span class="text-sm">/{{ plan['billing_period'] }}</span></div> <div class="text-sm" style="color: var(--color-text-secondary)"> - Apps: {{ plan['app_limit'] || '∞' }} · Servers: {{ plan['server_limit'] || '∞' }} + {{ 'subscription.apps' | translate }} {{ plan['app_limit'] || '∞' }} · {{ 'subscription.servers' | translate }} {{ plan['server_limit'] || '∞' }} </div> <div class="mt-3"> @if (subscription()?.plan === plan['name']) { - <span class="status-badge" style="background:rgba(74,222,128,.12);color:#4ade80;border:1px solid rgba(74,222,128,.28);">CURRENT</span> + <span class="status-badge" style="background:rgba(74,222,128,.12);color:#4ade80;border:1px solid rgba(74,222,128,.28);">{{ 'subscription.current' | translate }}</span> } @else { - <button class="button w-full" (click)="select(plan)">{{ plan['price'] && +(plan['price'] || 0) > 0 ? 'Subscribe' : 'Switch' }}</button> + <button class="button w-full" (click)="select(plan)">{{ plan['price'] && +(plan['price'] || 0) > 0 ? ('subscription.subscribe' | translate) : ('subscription.switch' | translate) }}</button> } </div> </div> 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: ` - <h1 class="mb-6 text-2xl font-bold">Tags</h1> + <h1 class="mb-6 text-2xl font-bold">{{ 'tags.title' | translate }}</h1> <div class="box max-w-lg"> @if (loading()) { - <p class="text-sm" style="color: var(--color-text-secondary)">Loading…</p> + <p class="text-sm" style="color: var(--color-text-secondary)">{{ 'tags.loading' | translate }}</p> } @else { <div class="mb-4 flex flex-wrap gap-2"> @for (tag of tags(); track tag.uuid) { @@ -24,8 +25,8 @@ import { Tag } from '../../../shared/models/ideploy.models'; </div> } <form class="flex gap-2" [formGroup]="form" (ngSubmit)="add()"> - <input class="input flex-1" placeholder="New tag name" formControlName="name" /> - <button class="button" type="submit" [disabled]="form.invalid">Add</button> + <input class="input flex-1" [placeholder]="'tags.newTagPlaceholder' | translate" formControlName="name" /> + <button class="button" type="submit" [disabled]="form.invalid">{{ 'tags.add' | translate }}</button> </form> </div> `, 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: ` - <h1 class="mb-6 text-2xl font-bold">Team</h1> + <h1 class="mb-6 text-2xl font-bold">{{ 'team.title' | translate }}</h1> <section class="box mb-6"> - <h2 class="mb-3 font-semibold">Members</h2> + <h2 class="mb-3 font-semibold">{{ 'team.members' | translate }}</h2> @for (m of members(); track m.user_id) { <div class="mb-1 flex items-center gap-3 text-sm"> <span class="font-semibold">{{ m.name }}</span> @@ -21,21 +22,21 @@ import { ApiService } from '../../../shared/services/api.service'; </section> <section class="box"> - <h2 class="mb-3 font-semibold">Invitations</h2> + <h2 class="mb-3 font-semibold">{{ 'team.invitations' | translate }}</h2> @for (inv of invitations(); track inv.uuid) { <div class="mb-1 flex items-center gap-3 text-sm"> <span>{{ inv.email }} ({{ inv.role }})</span> <code class="text-xs">{{ inv.link }}</code> - <button class="ml-auto text-xs text-red-400" (click)="revoke(inv.uuid)">revoke</button> + <button class="ml-auto text-xs text-red-400" (click)="revoke(inv.uuid)">{{ 'team.revoke' | translate }}</button> </div> } <form class="mt-3 flex gap-2" [formGroup]="form" (ngSubmit)="invite()"> - <input class="input flex-1" placeholder="email" formControlName="email" /> + <input class="input flex-1" [placeholder]="'team.emailPlaceholder' | translate" formControlName="email" /> <select class="input w-32" formControlName="role"> - <option value="member">member</option> - <option value="admin">admin</option> + <option value="member">{{ 'team.roleMember' | translate }}</option> + <option value="admin">{{ 'team.roleAdmin' | translate }}</option> </select> - <button class="button" type="submit" [disabled]="form.invalid">Invite</button> + <button class="button" type="submit" [disabled]="form.invalid">{{ 'team.invite' | translate }}</button> </form> </section> `, 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: ` <div class="mb-6 flex items-center justify-between gap-4"> - <h1 class="heading-serif" style="font-size:32px;font-weight:700;color:#fff;">Templates</h1> + <h1 class="heading-serif" style="font-size:32px;font-weight:700;color:#fff;">{{ 'templates.title' | translate }}</h1> <span class="text-sm" style="color:var(--color-text-secondary);">{{ filtered().length }} / {{ templates().length }}</span> </div> - <input class="input mb-6" placeholder="Search templates (name, category, tag)…" + <input class="input mb-6" [placeholder]="'templates.searchPlaceholder' | translate" [ngModel]="query()" (ngModelChange)="query.set($event)" /> @if (error()) { <div class="mb-4 rounded-md p-3 text-sm text-red-400" style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);"> {{ error() }} @if (error()!.toLowerCase().includes('server')) { - · <a href="/servers/new" style="color:#60a5fa;">Add a server</a> + · <a href="/servers/new" style="color:#60a5fa;">{{ 'templates.addServer' | translate }}</a> } </div> } @if (templates().length === 0) { - <div class="box">No templates available. (The service-templates catalog could not be loaded.)</div> + <div class="box">{{ 'templates.noTemplates' | translate }}</div> } @else { <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> @for (t of filtered(); track t.name) { @@ -48,7 +49,7 @@ import { ServiceTemplate } from '../../../shared/models/ideploy.models'; </div> <p class="mb-4 flex-1 text-sm" style="color:var(--color-text-secondary);">{{ t.slogan }}</p> <button class="button w-full" [disabled]="busy() === t.name" (click)="deploy(t)"> - {{ busy() === t.name ? 'Deploying…' : 'Deploy' }} + {{ busy() === t.name ? ('templates.deploying' | translate) : ('templates.deploy' | translate) }} </button> </div> } @@ -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<ServiceTemplate[]>([]); 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: ` + <div + class="flex items-center gap-0.5 rounded-md p-0.5" + style="background:rgba(255,255,255,0.04);" + role="group" + aria-label="Language"> + @for (lang of languages; track lang.code) { + <button + type="button" + class="px-2 py-1 rounded text-[11px] font-bold uppercase transition-opacity" + [style.background]="current() === lang.code ? 'rgba(37,99,235,0.20)' : 'transparent'" + [style.color]="current() === lang.code ? '#60a5fa' : '#8d919a'" + [attr.aria-pressed]="current() === lang.code" + (click)="select(lang.code)"> + {{ lang.code }} + </button> + } + </div> + `, +}) +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/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());