Mise en place de l'anglais comme deuxième langue pour l'application
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled

This commit is contained in:
2026-06-14 16:24:05 +02:00
parent 6e75326779
commit af3a6d443c
198 changed files with 7801 additions and 1720 deletions

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
/**
* Un message d'une conversation IA (vue front).
@@ -45,6 +46,7 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character' | 'n
@Injectable({ providedIn: 'root' })
export class AiChatService {
private readonly translate = inject(TranslateService);
private readonly loreEndpoint = '/api/ai/chat/stream';
private readonly campaignEndpoint = '/api/ai/chat/stream-campaign';
private readonly sessionEndpoint = '/api/ai/chat/stream-session';
@@ -237,9 +239,9 @@ export class AiChatService {
private safeParseMessage(json: string): string {
try {
const obj = JSON.parse(json) as { message?: string };
return obj.message ?? 'Erreur inconnue côté serveur.';
return obj.message ?? this.translate.instant('services.unknownServerError');
} catch {
return json || 'Erreur inconnue côté serveur.';
return json || this.translate.instant('services.unknownServerError');
}
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import {
CampaignImportApplyResult,
CampaignImportProposal,
@@ -16,7 +17,7 @@ import {
*/
@Injectable({ providedIn: 'root' })
export class CampaignImportService {
constructor(private http: HttpClient) {}
constructor(private http: HttpClient, private translate: TranslateService) {}
importStructureStream(campaignId: string, file: File): Observable<CampaignImportStreamEvent> {
return new Observable<CampaignImportStreamEvent>((subscriber) => {
@@ -70,7 +71,7 @@ export class CampaignImportService {
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'error') {
let message = 'Échec de l\'import.';
let message = this.translate.instant('services.importFailed');
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
terminated = true;
subscriber.error(new Error(message));
@@ -120,7 +121,7 @@ export class CampaignImportService {
if (currentEvent !== null || currentData !== '') dispatch();
if (!terminated) {
subscriber.error(new Error(
'L\'import s\'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.'));
this.translate.instant('services.importInterrupted')));
}
} catch (err) {
if (!terminated) subscriber.error(err);

View File

@@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { forkJoin, Subscription } from 'rxjs';
import { CampaignService } from './campaign.service';
import { CharacterService } from './character.service';
@@ -30,7 +31,8 @@ export class CampaignSidebarService {
private npcService: NpcService,
private randomTableService: RandomTableService,
private enemyService: EnemyService,
private layoutService: LayoutService
private layoutService: LayoutService,
private translate: TranslateService
) {}
/**
@@ -51,7 +53,7 @@ export class CampaignSidebarService {
this.enemyService
)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId));
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId, this.translate));
});
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model';
/**
@@ -10,7 +11,7 @@ import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEve
export class GameSystemService {
private apiUrl = '/api/game-systems';
constructor(private http: HttpClient) {}
constructor(private http: HttpClient, private translate: TranslateService) {}
getAll(): Observable<GameSystem[]> {
return this.http.get<GameSystem[]>(this.apiUrl);
@@ -101,7 +102,7 @@ export class GameSystemService {
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'error') {
let message = 'Échec de l\'import.';
let message = this.translate.instant('services.importFailed');
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
terminated = true;
subscriber.error(new Error(message));
@@ -151,7 +152,7 @@ export class GameSystemService {
if (currentEvent !== null || currentData !== '') dispatch();
if (!terminated) {
subscriber.error(new Error(
'L\'import s\'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.'));
this.translate.instant('services.importInterrupted')));
}
} catch (err) {
if (!terminated) subscriber.error(err);

View File

@@ -0,0 +1,101 @@
import { Injectable, inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
/**
* Langue affichée par l'interface.
*
* Source de vérité = ngx-translate (TranslateService). Ce service ajoute :
* - la liste des langues proposées dans le sélecteur (LANGUAGES) ;
* - la persistance du choix dans localStorage (par appareil) ;
* - la résolution de la langue initiale au démarrage (localStorage → langue
* du navigateur → repli français).
*
* Le changement est appliqué à chaud : translate.use() recharge le bon JSON et
* rafraîchit tous les pipes `translate` — aucun rechargement de page.
*/
export interface AppLanguage {
/** Code ISO court utilisé par les fichiers i18n (fr.json, en.json). */
code: string;
/** Clé i18n du libellé natif affiché dans le sélecteur. */
labelKey: string;
/** Drapeau emoji pour un repère visuel léger. */
flag: string;
}
const STORAGE_KEY = 'loremind.lang';
@Injectable({ providedIn: 'root' })
export class LanguageService {
private readonly translate = inject(TranslateService);
/** Langues proposées dans l'application. Ajouter une langue = une ligne ici + un JSON. */
readonly languages: readonly AppLanguage[] = [
{ code: 'fr', labelKey: 'language.fr', flag: '🇫🇷' },
{ code: 'en', labelKey: 'language.en', flag: '🇬🇧' },
];
readonly defaultLang = 'fr';
/**
* Appelé une fois au démarrage (APP_INITIALIZER) : enregistre les langues
* connues, fixe le repli et applique la langue résolue avant le premier rendu.
*/
init(): Promise<unknown> {
const codes = this.languages.map((l) => l.code);
this.translate.addLangs(codes);
this.translate.setFallbackLang(this.defaultLang);
const lang = this.resolveInitialLang();
// use() renvoie un Observable qui émet quand le JSON est chargé : on
// l'attend pour éviter un flash de clés non traduites au boot.
return new Promise((resolve) => {
this.translate.use(lang).subscribe({
next: () => resolve(true),
error: () => resolve(false),
});
});
}
/** Code de la langue active. */
get current(): string {
return this.translate.getCurrentLang() || this.defaultLang;
}
/** Change la langue à chaud et mémorise le choix. */
use(code: string): void {
if (!this.languages.some((l) => l.code === code)) {
return;
}
this.translate.use(code);
this.persist(code);
}
private resolveInitialLang(): string {
const stored = this.read();
if (stored && this.languages.some((l) => l.code === stored)) {
return stored;
}
const browser = this.translate.getBrowserLang();
if (browser && this.languages.some((l) => l.code === browser)) {
return browser;
}
return this.defaultLang;
}
private read(): string | null {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
private persist(code: string): void {
try {
localStorage.setItem(STORAGE_KEY, code);
} catch {
// localStorage indisponible (mode privé strict) : la langue reste valable
// pour la session courante, simplement non mémorisée.
}
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
/**
@@ -11,7 +12,7 @@ import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChat
export class NotebookService {
private readonly apiUrl = '/api/notebooks';
constructor(private http: HttpClient, private zone: NgZone) {}
constructor(private http: HttpClient, private zone: NgZone, private translate: TranslateService) {}
listByCampaign(campaignId: string): Observable<Notebook[]> {
return this.http.get<Notebook[]>(`${this.apiUrl}/campaign/${campaignId}`);
@@ -143,7 +144,7 @@ export class NotebookService {
this.zone.run(() => subscriber.complete());
} catch (err) {
if ((err as Error).name !== 'AbortError') {
emit({ type: 'error', message: (err as Error).message || 'Erreur réseau' });
emit({ type: 'error', message: (err as Error).message || this.translate.instant('services.networkError') });
}
this.zone.run(() => subscriber.complete());
}