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

@@ -4,7 +4,7 @@
[class.is-resizing]="isResizing"
[class.is-wide]="isWide"
[style.width.px]="effectiveWidth"
aria-label="Assistant IA">
[attr.aria-label]="'aiChatDrawer.title' | translate">
<!-- Poignee de redimensionnement (desactivee en mode wide) -->
@if (!isWide) {
@@ -13,23 +13,23 @@
(mousedown)="onResizeStart($event)"
role="separator"
aria-orientation="vertical"
aria-label="Redimensionner le panneau"
title="Glisser pour redimensionner"></div>
[attr.aria-label]="'aiChatDrawer.resizeAria' | translate"
[title]="'aiChatDrawer.resizeTitle' | translate"></div>
}
<!-- Sidebar conversations (mode persistent uniquement) -->
@if (persistent && sidebarOpen) {
<section class="conv-sidebar" aria-label="Conversations">
<section class="conv-sidebar" [attr.aria-label]="'aiChatDrawer.conversations' | translate">
<div class="conv-sidebar-header">
<span class="conv-sidebar-title">Conversations</span>
<button type="button" class="conv-new-btn" (click)="startNewConversation()" [disabled]="isStreaming" title="Nouvelle conversation">
<span class="conv-sidebar-title">{{ 'aiChatDrawer.conversations' | translate }}</span>
<button type="button" class="conv-new-btn" (click)="startNewConversation()" [disabled]="isStreaming" [title]="'aiChatDrawer.newConversation' | translate">
<lucide-icon [img]="MessageSquarePlus" [size]="16"></lucide-icon>
</button>
</div>
<ul class="conv-list">
@if (conversations.length === 0) {
<li class="conv-empty">Aucune conversation</li>
<li class="conv-empty">{{ 'aiChatDrawer.noConversation' | translate }}</li>
}
@for (c of conversations; track c) {
<li
@@ -42,7 +42,7 @@
class="conv-item-del"
(click)="deleteConversation(c, $event)"
[disabled]="isStreaming"
title="Supprimer">
[title]="'common.delete' | translate">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</li>
@@ -59,8 +59,8 @@
type="button"
class="sidebar-toggle"
(click)="toggleSidebar()"
[attr.aria-label]="sidebarOpen ? 'Masquer la liste' : 'Afficher la liste'"
[title]="sidebarOpen ? 'Masquer la liste' : 'Afficher la liste'">
[attr.aria-label]="(sidebarOpen ? 'aiChatDrawer.hideList' : 'aiChatDrawer.showList') | translate"
[title]="(sidebarOpen ? 'aiChatDrawer.hideList' : 'aiChatDrawer.showList') | translate">
<lucide-icon [img]="sidebarOpen ? PanelLeftClose : PanelLeftOpen" [size]="16"></lucide-icon>
</button>
}
@@ -68,8 +68,8 @@
<div class="header-title-wrap">
@if (persistent && currentConversationId) {
@if (!editingTitle) {
<h2 class="header-title" [title]="currentTitle">{{ currentTitle || 'Nouvelle conversation' }}</h2>
<button type="button" class="rename-btn" (click)="startRenameTitle()" title="Renommer" aria-label="Renommer">
<h2 class="header-title" [title]="currentTitle">{{ currentTitle || ('aiChatDrawer.newConversation' | translate) }}</h2>
<button type="button" class="rename-btn" (click)="startRenameTitle()" [title]="'common.rename' | translate" [attr.aria-label]="'common.rename' | translate">
<lucide-icon [img]="Pencil" [size]="12"></lucide-icon>
</button>
} @else {
@@ -83,7 +83,7 @@
autofocus />
}
} @else {
<h2 class="header-title">Assistant IA</h2>
<h2 class="header-title">{{ 'aiChatDrawer.title' | translate }}</h2>
}
</div>
@@ -91,19 +91,19 @@
type="button"
class="wide-toggle-btn"
(click)="toggleWide()"
[attr.aria-label]="isWide ? 'Reduire le panneau' : 'Agrandir le panneau'"
[title]="isWide ? 'Reduire' : 'Agrandir'">
[attr.aria-label]="(isWide ? 'aiChatDrawer.shrinkAria' : 'aiChatDrawer.expandAria') | translate"
[title]="(isWide ? 'aiChatDrawer.shrink' : 'aiChatDrawer.expand') | translate">
<lucide-icon [img]="isWide ? Minimize2 : Maximize2" [size]="16"></lucide-icon>
</button>
<button type="button" class="close-btn" (click)="onClose()" aria-label="Fermer">
<button type="button" class="close-btn" (click)="onClose()" [attr.aria-label]="'common.close' | translate">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</header>
@if (usage) {
<div class="context-gauge" [attr.data-level]="usageLevel"
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'">
[attr.title]="(usageHasMax ? 'aiChatDrawer.gaugeTitleMax' : 'aiChatDrawer.gaugeTitle') | translate:{ system: usage.system, history: usage.history, current: usage.current, max: usage.max }">
<!-- Barre seulement si on connaît le plafond (Ollama). Cloud : compteur seul. -->
@if (usageHasMax) {
<div class="gauge-bar">
@@ -111,9 +111,11 @@
</div>
}
<div class="gauge-label">
<span class="gauge-text">Contexte : {{ usageTotal }}@if (usageHasMax) {
/ {{ usage.max }}
} tokens</span>
<span class="gauge-text">@if (usageHasMax) {
{{ 'aiChatDrawer.gaugeLabelMax' | translate:{ total: usageTotal, max: usage.max } }}
} @else {
{{ 'aiChatDrawer.gaugeLabel' | translate:{ total: usageTotal } }}
}</span>
@if (usageHasMax) {
<span class="gauge-percent">{{ usagePercent }}%</span>
}
@@ -172,7 +174,7 @@
@if (quickSuggestions.length) {
<div class="quick-suggestions">
<p class="quick-label">Suggestions rapides :</p>
<p class="quick-label">{{ 'aiChatDrawer.quickSuggestions' | translate }}</p>
<div class="quick-list">
@for (s of quickSuggestions; track s) {
<button
@@ -193,10 +195,10 @@
type="text"
[(ngModel)]="input"
name="chatInput"
placeholder="Posez une question..."
[placeholder]="'aiChatDrawer.inputPlaceholder' | translate"
[disabled]="isStreaming"
autocomplete="off" />
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" aria-label="Envoyer">
<button type="submit" class="send-btn" [disabled]="!input.trim() || isStreaming" [attr.aria-label]="'aiChatDrawer.send' | translate">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</form>

View File

@@ -2,6 +2,7 @@
import { Component, ElementRef, EventEmitter, HostListener, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Lightbulb, Maximize2, MessageSquarePlus, Minimize2, PanelLeftClose, PanelLeftOpen, Pencil, Send, Sparkles, Trash2, Wand2, X } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { AiChatService, ChatMessage, ChatUsage, NarrativeEntityType } from '../../services/ai-chat.service';
import { Conversation, ConversationContext } from '../../services/conversation.model';
@@ -31,7 +32,7 @@ export interface ChatPrimaryAction {
*/
@Component({
selector: 'app-ai-chat-drawer',
imports: [FormsModule, LucideAngularModule, MarkdownPipe],
imports: [FormsModule, LucideAngularModule, MarkdownPipe, TranslatePipe],
templateUrl: './ai-chat-drawer.component.html',
styleUrls: ['./ai-chat-drawer.component.scss']
})
@@ -80,7 +81,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
@Input() entityType: NarrativeEntityType | null = null;
@Input() entityId: string | null = null;
@Input() isOpen = false;
@Input() welcomeMessage = 'Bonjour ! Je peux vous aider a developper cette page. Que souhaitez-vous creer ?';
@Input() welcomeMessage = '';
@Input() quickSuggestions: string[] = [];
@Input() primaryAction: ChatPrimaryAction | null = null;
@Input() systemPromptAddon: string | null = null;
@@ -120,7 +121,10 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
private readonly chatService: AiChatService,
private readonly conversationService: ConversationService,
private readonly confirmDialog: ConfirmDialogService,
) {}
private readonly translate: TranslateService,
) {
this.welcomeMessage = this.translate.instant('aiChatDrawer.welcome');
}
// --- Jauge de contexte --------------------------------------------------
@@ -310,7 +314,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
this.usage = null;
this.scrollToBottom();
},
error: () => (this.errorMessage = 'Impossible de charger la conversation.'),
error: () => (this.errorMessage = this.translate.instant('aiChatDrawer.loadConversationError')),
});
}
@@ -318,9 +322,9 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
event.stopPropagation();
if (this.isStreaming) return;
this.confirmDialog.confirm({
title: 'Supprimer la conversation',
message: `Supprimer la conversation "${conv.title}" ?`,
confirmLabel: 'Supprimer',
title: this.translate.instant('aiChatDrawer.deleteConversationTitle'),
message: this.translate.instant('aiChatDrawer.deleteConversationMessage', { title: conv.title }),
confirmLabel: this.translate.instant('common.delete'),
variant: 'danger'
}).then(ok => {
if (!ok) return;
@@ -410,7 +414,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
if (this.currentConversationId) return Promise.resolve(this.currentConversationId);
const ctx = this.buildContext();
if (!ctx.loreId && !ctx.campaignId) {
this.errorMessage = 'Contexte manquant pour creer une conversation.';
this.errorMessage = this.translate.instant('aiChatDrawer.missingContextError');
return Promise.resolve(null);
}
return new Promise((resolve) => {
@@ -422,7 +426,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
resolve(conv.id);
},
error: () => {
this.errorMessage = 'Impossible de creer la conversation.';
this.errorMessage = this.translate.instant('aiChatDrawer.createConversationError');
resolve(null);
},
});
@@ -460,7 +464,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
},
error: (err) => {
this.isStreaming = false;
this.errorMessage = err?.message ?? 'Erreur inconnue.';
this.errorMessage = err?.message ?? this.translate.instant('aiChatDrawer.unknownError');
this.currentAssistantText = '';
},
complete: () => {

View File

@@ -1,5 +1,5 @@
@if (items.length) {
<nav class="breadcrumb" aria-label="Fil d'Ariane">
<nav class="breadcrumb" [attr.aria-label]="'breadcrumb.ariaLabel' | translate">
<ol>
@for (item of items; track $index; let last = $last) {
<li

View File

@@ -1,6 +1,7 @@
import { Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
/**
* Un niveau dans le fil d'Ariane.
@@ -23,7 +24,7 @@ export interface BreadcrumbItem {
*/
@Component({
selector: 'app-breadcrumb',
imports: [RouterLink],
imports: [RouterLink, TranslatePipe],
templateUrl: './breadcrumb.component.html',
styleUrls: ['./breadcrumb.component.scss']
})

View File

@@ -2,7 +2,7 @@
@for (tag of value; track tag; let i = $index) {
<span class="chip">
{{ tag }}
<button type="button" class="chip-remove" (click)="remove(i)" title="Retirer">
<button type="button" class="chip-remove" (click)="remove(i)" [title]="'chipsInput.removeTag' | translate">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
</button>
</span>

View File

@@ -2,6 +2,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
/**
* Composant réutilisable de saisie de chips (étiquettes textuelles).
@@ -21,7 +22,7 @@ import { LucideAngularModule, X } from 'lucide-angular';
*/
@Component({
selector: 'app-chips-input',
imports: [FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './chips-input.component.html',
styleUrls: ['./chips-input.component.scss']
})
@@ -29,12 +30,16 @@ export class ChipsInputComponent {
readonly X = X;
@Input() value: string[] = [];
@Input() placeholder = 'Ajouter...';
@Input() placeholder = '';
@Output() valueChange = new EventEmitter<string[]>();
/** Texte de l'input en cours de saisie. */
current = '';
constructor(private translate: TranslateService) {
this.placeholder = this.translate.instant('chipsInput.placeholder');
}
/** Ajoute le texte courant s'il est non vide et non déjà présent. */
add(): void {
const raw = this.current.trim().replace(/,$/, '').trim();

View File

@@ -13,7 +13,7 @@
<lucide-icon [img]="TriangleAlert" [size]="22"></lucide-icon>
</div>
<h2>{{ title }}</h2>
<button type="button" class="btn-close" (click)="onCancel()" aria-label="Fermer">
<button type="button" class="btn-close" (click)="onCancel()" [attr.aria-label]="'common.close' | translate">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
</div>

View File

@@ -1,12 +1,13 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { LucideAngularModule, TriangleAlert, X } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
export type ConfirmDialogVariant = 'warning' | 'danger' | 'info';
@Component({
selector: 'app-confirm-dialog',
imports: [LucideAngularModule],
imports: [LucideAngularModule, TranslatePipe],
templateUrl: './confirm-dialog.component.html',
styleUrls: ['./confirm-dialog.component.scss']
})

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { ConfirmDialogVariant } from './confirm-dialog.component';
export interface ConfirmDialogOptions {
@@ -31,6 +32,8 @@ export class ConfirmDialogService {
readonly state$ = new BehaviorSubject<ConfirmDialogState>(CLOSED_STATE);
private resolver: ((value: boolean) => void) | null = null;
constructor(private translate: TranslateService) {}
confirm(opts: ConfirmDialogOptions): Promise<boolean> {
// Si un dialog precedent est encore ouvert, on le resout en "false"
// avant d'en ouvrir un nouveau pour eviter une fuite de Promise.
@@ -40,11 +43,11 @@ export class ConfirmDialogService {
}
this.state$.next({
open: true,
title: opts.title ?? 'Confirmation',
title: opts.title ?? this.translate.instant('confirmDialog.defaultTitle'),
message: opts.message,
details: opts.details ?? [],
confirmLabel: opts.confirmLabel ?? 'Confirmer',
cancelLabel: opts.cancelLabel ?? 'Annuler',
confirmLabel: opts.confirmLabel ?? this.translate.instant('common.confirm'),
cancelLabel: opts.cancelLabel ?? this.translate.instant('common.cancel'),
variant: opts.variant ?? 'warning'
});
return new Promise<boolean>((resolve) => { this.resolver = resolve; });

View File

@@ -10,7 +10,7 @@
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="Renseignez {{ f.name }}…">
[placeholder]="'dynamicFieldsForm.textPlaceholder' | translate:{ name: f.name }">
</textarea>
}
@case ('NUMBER') {
@@ -47,7 +47,7 @@
}
@if (!f.labels?.length) {
<div class="dff-kv-empty">
Aucun label defini dans le template pour ce champ.
{{ 'dynamicFieldsForm.noLabels' | translate }}
</div>
}
</div>
@@ -58,7 +58,7 @@
</div>
} @else {
<div class="dff-empty">
Aucun champ defini dans le template de ce systeme. Editez le GameSystem pour ajouter des champs.
{{ 'dynamicFieldsForm.noFields' | translate }}
</div>
}

View File

@@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '@ngx-translate/core';
import { TemplateField } from '../../services/template.model';
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
@@ -17,7 +18,7 @@ import { ImageGalleryComponent } from '../image-gallery/image-gallery.component'
*/
@Component({
selector: 'app-dynamic-fields-form',
imports: [FormsModule, ImageGalleryComponent],
imports: [FormsModule, ImageGalleryComponent, TranslatePipe],
templateUrl: './dynamic-fields-form.component.html',
styleUrls: ['./dynamic-fields-form.component.scss']
})

View File

@@ -10,11 +10,11 @@
[href]="enemyUrl(e.id!)"
target="_blank"
rel="noopener"
title="Ouvrir la fiche dans un nouvel onglet">
[title]="'enemyLinkPicker.openInNewTab' | translate">
<lucide-icon [img]="Skull" [size]="12"></lucide-icon>
{{ label(e) }}
</a>
<button type="button" class="chip-remove" (click)="remove(e.id!)" title="Retirer le lien">
<button type="button" class="chip-remove" (click)="remove(e.id!)" [title]="'enemyLinkPicker.removeLink' | translate">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
</button>
</span>
@@ -27,7 +27,7 @@
<input
type="text"
class="search-input"
placeholder="Rechercher un ennemi du bestiaire..."
[placeholder]="'enemyLinkPicker.searchPlaceholder' | translate"
[(ngModel)]="query"
(focus)="dropdownOpen = true"
(blur)="onBlur()" />
@@ -44,7 +44,7 @@
@if (dropdownOpen && query && suggestions.length === 0) {
<p class="empty-hint">
Aucun ennemi ne correspond
{{ 'enemyLinkPicker.noMatch' | translate }}
</p>
}
</div>

View File

@@ -2,6 +2,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X, Skull } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { Enemy } from '../../services/enemy.model';
/**
@@ -21,7 +22,7 @@ import { Enemy } from '../../services/enemy.model';
*/
@Component({
selector: 'app-enemy-link-picker',
imports: [FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './enemy-link-picker.component.html',
styleUrls: ['./enemy-link-picker.component.scss']
})

View File

@@ -7,7 +7,7 @@
#searchInput
type="text"
class="gs-input"
placeholder="Rechercher dans LoreMind..."
[placeholder]="'globalSearch.placeholder' | translate"
[ngModel]="query"
(ngModelChange)="onQueryChange($event)" />
<span class="gs-kbd">ESC</span>
@@ -34,23 +34,23 @@
}
@if (!loading && query.trim().length >= 2 && results.length === 0) {
<div class="gs-empty">
Aucun résultat
{{ 'globalSearch.noResults' | translate }}
</div>
}
@if (query.trim().length < 2) {
<div class="gs-empty">
Tape au moins 2 caractères
{{ 'globalSearch.minChars' | translate }}
</div>
}
<div class="gs-footer">
<div class="gs-hints">
<span><kbd></kbd><kbd></kbd> naviguer</span>
<span><kbd></kbd> sélectionner</span>
<span><kbd>ESC</kbd> fermer</span>
<span><kbd></kbd><kbd></kbd> {{ 'globalSearch.hintNavigate' | translate }}</span>
<span><kbd></kbd> {{ 'globalSearch.hintSelect' | translate }}</span>
<span><kbd>ESC</kbd> {{ 'globalSearch.hintClose' | translate }}</span>
</div>
@if (results.length > 0) {
<span class="gs-count">
{{ results.length }} résultat{{ results.length > 1 ? 's' : '' }}
{{ (results.length > 1 ? 'globalSearch.countPlural' : 'globalSearch.countSingular') | translate:{ count: results.length } }}
</span>
}
</div>

View File

@@ -2,6 +2,7 @@ import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild } fro
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, Subject, forkJoin, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { LucideAngularModule, Search, BookOpen, Folder, Users, FileText, Scroll, Drama, User, Dices, Package, Skull } from 'lucide-angular';
@@ -39,7 +40,7 @@ interface SearchResult {
*/
@Component({
selector: 'app-global-search',
imports: [FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './global-search.component.html',
styleUrls: ['./global-search.component.scss']
})
@@ -78,7 +79,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
private characterService: CharacterService,
private randomTableService: RandomTableService,
private itemCatalogService: ItemCatalogService,
private enemyService: EnemyService
private enemyService: EnemyService,
private translate: TranslateService
) {}
ngOnInit(): void {
@@ -147,7 +149,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
kind: 'page' as ResultKind,
title: p.title,
subtitle: p.notes ? this.firstLine(p.notes) : '',
tag: 'Page',
tag: this.translate.instant('globalSearch.tags.page'),
route: ['/lore', p.loreId, 'pages', p.id]
}));
const nodeResults: SearchResult[] = nodes.map(n => ({
@@ -155,7 +157,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
kind: 'node' as ResultKind,
title: n.name,
subtitle: '',
tag: 'Dossier',
tag: this.translate.instant('globalSearch.tags.node'),
route: ['/lore', n.loreId, 'folders', n.id]
}));
const templateResults: SearchResult[] = templates.map(t => ({
@@ -163,7 +165,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
kind: 'template' as ResultKind,
title: t.name,
subtitle: t.description ?? '',
tag: 'Template',
tag: this.translate.instant('globalSearch.tags.template'),
route: ['/lore', t.loreId, 'templates', t.id]
}));
const loreResults: SearchResult[] = lores.map(l => ({
@@ -171,7 +173,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
kind: 'lore' as ResultKind,
title: l.name,
subtitle: l.description ?? '',
tag: 'Lore',
tag: this.translate.instant('globalSearch.tags.lore'),
route: ['/lore', l.id]
}));
const campaignResults: SearchResult[] = campaigns.map(c => ({
@@ -179,7 +181,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
kind: 'campaign' as ResultKind,
title: c.name,
subtitle: c.description ?? '',
tag: 'Campagne',
tag: this.translate.instant('globalSearch.tags.campaign'),
route: ['/campaigns', c.id]
}));
const npcResults: SearchResult[] = npcs.map(n => ({
@@ -187,7 +189,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
kind: 'npc' as ResultKind,
title: n.name,
subtitle: (n.folder ?? '') as string,
tag: 'PNJ',
tag: this.translate.instant('globalSearch.tags.npc'),
route: ['/campaigns', n.campaignId, 'npcs', n.id]
}));
const characterResults: SearchResult[] = characters.map(c => ({
@@ -195,7 +197,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
kind: 'character' as ResultKind,
title: c.name,
subtitle: '',
tag: 'PJ',
tag: this.translate.instant('globalSearch.tags.character'),
route: ['/campaigns', c.campaignId, 'playthroughs', c.playthroughId, 'characters', c.id]
}));
const tableResults: SearchResult[] = tables.map(t => ({
@@ -203,7 +205,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
kind: 'random-table' as ResultKind,
title: t.name,
subtitle: t.description ?? '',
tag: 'Table aléatoire',
tag: this.translate.instant('globalSearch.tags.randomTable'),
route: ['/campaigns', t.campaignId, 'random-tables', t.id]
}));
const catalogResults: SearchResult[] = catalogs.map(c => ({
@@ -211,15 +213,15 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
kind: 'item-catalog' as ResultKind,
title: c.name,
subtitle: c.description ?? '',
tag: 'Catalogue d\'objets',
tag: this.translate.instant('globalSearch.tags.itemCatalog'),
route: ['/campaigns', c.campaignId, 'item-catalogs', c.id]
}));
const enemyResults: SearchResult[] = enemies.map(e => ({
id: e.id,
kind: 'enemy' as ResultKind,
title: e.name,
subtitle: [e.level ? `Niv. ${e.level}` : '', e.folder ?? ''].filter(Boolean).join(' · '),
tag: 'Ennemi',
subtitle: [e.level ? this.translate.instant('globalSearch.enemyLevel', { level: e.level }) : '', e.folder ?? ''].filter(Boolean).join(' · '),
tag: this.translate.instant('globalSearch.tags.enemy'),
route: ['/campaigns', e.campaignId, 'enemies', e.id]
}));
return [

View File

@@ -10,12 +10,12 @@
(click)="openLightbox(heroId)"
role="button"
tabindex="0">
<img [src]="urlFor(heroId)" [alt]="'Illustration principale'" loading="lazy" />
<img [src]="urlFor(heroId)" [alt]="'imageGallery.mainIllustrationAlt' | translate" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(heroId, $event)"
aria-label="Retirer cette image">
[attr.aria-label]="'imageGallery.removeImage' | translate">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
@@ -28,12 +28,12 @@
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<img [src]="urlFor(id)" [alt]="'imageGallery.illustrationAlt' | translate" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
[attr.aria-label]="'imageGallery.removeImage' | translate">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
@@ -54,7 +54,7 @@
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
<span>{{ 'imageGallery.empty' | translate }}</span>
</div>
}
}
@@ -67,12 +67,12 @@
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<img [src]="urlFor(id)" [alt]="'imageGallery.illustrationAlt' | translate" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
[attr.aria-label]="'imageGallery.removeImage' | translate">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
@@ -87,7 +87,7 @@
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
<span>{{ 'imageGallery.empty' | translate }}</span>
</div>
}
}
@@ -99,7 +99,7 @@
<button type="button"
class="carousel-nav carousel-prev"
(click)="scrollCarousel(-1)"
aria-label="Precedent">
[attr.aria-label]="'common.previous' | translate">
<lucide-icon [img]="ChevronLeft" [size]="20"></lucide-icon>
</button>
}
@@ -109,12 +109,12 @@
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<img [src]="urlFor(id)" [alt]="'imageGallery.illustrationAlt' | translate" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
[attr.aria-label]="'imageGallery.removeImage' | translate">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
@@ -130,7 +130,7 @@
<button type="button"
class="carousel-nav carousel-next"
(click)="scrollCarousel(1)"
aria-label="Suivant">
[attr.aria-label]="'common.next' | translate">
<lucide-icon [img]="ChevronRight" [size]="20"></lucide-icon>
</button>
}
@@ -138,7 +138,7 @@
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
<span>{{ 'imageGallery.empty' | translate }}</span>
</div>
}
}
@@ -153,12 +153,12 @@
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<img [src]="urlFor(id)" [alt]="'imageGallery.illustrationAlt' | translate" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
[attr.aria-label]="'imageGallery.removeImage' | translate">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
@@ -173,7 +173,7 @@
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
<span>{{ 'imageGallery.empty' | translate }}</span>
</div>
}
}
@@ -187,12 +187,12 @@
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Carte ' + id" loading="lazy" />
<img [src]="urlFor(id)" [alt]="'imageGallery.mapAlt' | translate" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette carte">
[attr.aria-label]="'imageGallery.removeMap' | translate">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
@@ -207,7 +207,7 @@
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
<span>{{ 'imageGallery.empty' | translate }}</span>
</div>
}
}
@@ -220,12 +220,12 @@
(click)="openLightbox(id)"
role="button"
tabindex="0">
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
<img [src]="urlFor(id)" [alt]="'imageGallery.illustrationAlt' | translate" loading="lazy" />
@if (editable) {
<button type="button"
class="gallery-remove"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
[attr.aria-label]="'imageGallery.removeImage' | translate">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
}
@@ -238,7 +238,7 @@
} @else {
<div class="gallery-empty">
<lucide-icon [img]="ImageIcon" [size]="20"></lucide-icon>
<span>Aucune illustration</span>
<span>{{ 'imageGallery.empty' | translate }}</span>
</div>
}
}
@@ -252,9 +252,9 @@
<div class="lightbox-backdrop"
(click)="closeLightbox()"
role="dialog"
aria-label="Image en plein ecran">
<img [src]="urlFor(lightboxId)" alt="Image agrandie" class="lightbox-image" />
<button type="button" class="lightbox-close" (click)="closeLightbox()" aria-label="Fermer">
[attr.aria-label]="'imageGallery.lightboxAria' | translate">
<img [src]="urlFor(lightboxId)" [alt]="'imageGallery.lightboxAlt' | translate" class="lightbox-image" />
<button type="button" class="lightbox-close" (click)="closeLightbox()" [attr.aria-label]="'common.close' | translate">
<lucide-icon [img]="X" [size]="24"></lucide-icon>
</button>
</div>

View File

@@ -1,6 +1,7 @@
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { LucideAngularModule, X, Image as ImageIcon, ChevronLeft, ChevronRight } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
import { ImageLayout } from '../../services/template.model';
@@ -27,7 +28,7 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
*/
@Component({
selector: 'app-image-gallery',
imports: [LucideAngularModule, ImageUploaderComponent],
imports: [LucideAngularModule, ImageUploaderComponent, TranslatePipe],
templateUrl: './image-gallery.component.html',
styleUrls: ['./image-gallery.component.scss']
})

View File

@@ -1,12 +1,12 @@
<!-- Mode compact : bouton "+ ajouter" carre, utilise dans les galeries. -->
@if (compact) {
<label class="upload-compact" [class.loading]="uploading" [title]="errorMessage || 'Ajouter une image'">
<label class="upload-compact" [class.loading]="uploading" [title]="errorMessage || ('imageUploader.addImage' | translate)">
@if (!uploading) {
<lucide-icon [img]="Upload" [size]="18"></lucide-icon>
<span>Ajouter</span>
<span>{{ 'common.add' | translate }}</span>
} @else {
<div class="upload-spinner" aria-label="Upload en cours"></div>
<p class="upload-zone-hint">Upload en cours...</p>
<div class="upload-spinner" [attr.aria-label]="'imageUploader.uploading' | translate"></div>
<p class="upload-zone-hint">{{ 'imageUploader.uploadingHint' | translate }}</p>
}
<input type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
@@ -29,11 +29,11 @@
(drop)="onDrop($event)">
@if (!uploading) {
<lucide-icon [img]="Upload" [size]="32"></lucide-icon>
<p class="upload-zone-title">Glisse une image ici</p>
<p class="upload-zone-hint">ou clique pour choisir un fichier (JPEG, PNG, WebP, GIF, max 10 Mo)</p>
<p class="upload-zone-title">{{ 'imageUploader.dropTitle' | translate }}</p>
<p class="upload-zone-hint">{{ 'imageUploader.dropHint' | translate }}</p>
} @else {
<div class="upload-spinner" aria-label="Upload en cours"></div>
<p class="upload-zone-hint">Upload en cours...</p>
<div class="upload-spinner" [attr.aria-label]="'imageUploader.uploading' | translate"></div>
<p class="upload-zone-hint">{{ 'imageUploader.uploadingHint' | translate }}</p>
}
<input type="file"
accept="image/jpeg,image/png,image/webp,image/gif"

View File

@@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { LucideAngularModule, Upload, AlertCircle } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
@@ -19,7 +20,7 @@ import { Image } from '../../services/image.model';
*/
@Component({
selector: 'app-image-uploader',
imports: [LucideAngularModule],
imports: [LucideAngularModule, TranslatePipe],
templateUrl: './image-uploader.component.html',
styleUrls: ['./image-uploader.component.scss']
})
@@ -41,7 +42,7 @@ export class ImageUploaderComponent {
private readonly ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
private readonly MAX_BYTES = 10 * 1024 * 1024;
constructor(private imageService: ImageService) {}
constructor(private imageService: ImageService, private translate: TranslateService) {}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
@@ -74,11 +75,11 @@ export class ImageUploaderComponent {
// Validation cote client (premier filet de securite).
if (!this.ALLOWED_MIMES.includes(file.type)) {
this.errorMessage = 'Format non supporte (JPEG, PNG, WebP, GIF uniquement).';
this.errorMessage = this.translate.instant('imageUploader.unsupportedFormat');
return;
}
if (file.size > this.MAX_BYTES) {
this.errorMessage = `Fichier trop volumineux (max ${this.MAX_BYTES / 1024 / 1024} Mo).`;
this.errorMessage = this.translate.instant('imageUploader.tooLarge', { max: this.MAX_BYTES / 1024 / 1024 });
return;
}
@@ -91,8 +92,8 @@ export class ImageUploaderComponent {
error: (err) => {
this.uploading = false;
this.errorMessage = err?.status === 413
? 'Fichier refuse par le serveur (trop volumineux).'
: 'Echec de l\'upload. Verifiez que le backend et MinIO tournent.';
? this.translate.instant('imageUploader.serverRejected')
: this.translate.instant('imageUploader.uploadFailed');
}
});
}

View File

@@ -0,0 +1,48 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TranslatePipe } from '@ngx-translate/core';
import { LanguageService } from '../../services/language.service';
/**
* Sélecteur de langue réutilisable (liste déroulante).
*
* Réutilisable partout : page Paramètres, en-tête, etc. Délègue tout au
* LanguageService — le changement est appliqué à chaud, sans rechargement.
*
* Usage : <app-language-switcher></app-language-switcher>
*/
@Component({
selector: 'app-language-switcher',
standalone: true,
imports: [TranslatePipe, FormsModule],
template: `
<label class="lang-switcher">
<span class="lang-switcher__label">{{ 'language.label' | translate }}</span>
<select
class="lang-switcher__select"
[ngModel]="lang.current"
(ngModelChange)="lang.use($event)">
@for (l of lang.languages; track l.code) {
<option [value]="l.code">{{ l.flag }} {{ l.labelKey | translate }}</option>
}
</select>
</label>
`,
styles: [`
.lang-switcher {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.lang-switcher__label {
font-weight: 500;
}
.lang-switcher__select {
padding: 0.35rem 0.5rem;
border-radius: 6px;
}
`],
})
export class LanguageSwitcherComponent {
readonly lang = inject(LanguageService);
}

View File

@@ -10,11 +10,11 @@
[href]="pageUrl(p.id!)"
target="_blank"
rel="noopener"
title="Ouvrir dans un nouvel onglet">
[title]="'loreLinkPicker.openInNewTab' | translate">
<lucide-icon [img]="Link2" [size]="12"></lucide-icon>
{{ p.title }}
</a>
<button type="button" class="chip-remove" (click)="remove(p.id!)" title="Retirer le lien">
<button type="button" class="chip-remove" (click)="remove(p.id!)" [title]="'loreLinkPicker.removeLink' | translate">
<lucide-icon [img]="X" [size]="12"></lucide-icon>
</button>
</span>
@@ -27,7 +27,7 @@
<input
type="text"
class="search-input"
placeholder="Rechercher une page à lier..."
[placeholder]="'loreLinkPicker.searchPlaceholder' | translate"
[(ngModel)]="query"
(focus)="dropdownOpen = true"
(blur)="onBlur()" />
@@ -44,7 +44,7 @@
@if (dropdownOpen && query && suggestions.length === 0) {
<p class="empty-hint">
Aucune page ne correspond
{{ 'loreLinkPicker.noMatch' | translate }}
</p>
}
</div>

View File

@@ -2,6 +2,7 @@ import { Component, Input, Output, EventEmitter } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, X, Link2 } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { Page } from '../../services/page.model';
/**
@@ -26,7 +27,7 @@ import { Page } from '../../services/page.model';
*/
@Component({
selector: 'app-lore-link-picker',
imports: [FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './lore-link-picker.component.html',
styleUrls: ['./lore-link-picker.component.scss']
})

View File

@@ -123,7 +123,7 @@
@if (orderedSections.length === 0) {
<div class="pv-empty">
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
<p>Cette fiche est encore vide.</p>
<p>{{ 'personaView.empty' | translate }}</p>
</div>
}
</div>

View File

@@ -1,6 +1,7 @@
import { Component, Input } from '@angular/core';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { TemplateField, ImageLayout } from '../../services/template.model';
/** Section rendue dans la vue, dans l'ordre du template. Discriminee par `kind`. */
@@ -34,7 +35,7 @@ export interface PersonaLike {
@Component({
selector: 'app-persona-view',
imports: [LucideAngularModule, ImageGalleryComponent],
imports: [LucideAngularModule, ImageGalleryComponent, TranslatePipe],
templateUrl: './persona-view.component.html',
styleUrls: ['./persona-view.component.scss']
})

View File

@@ -2,8 +2,7 @@
@if (!loading && rows.length === 0) {
<p class="flags-empty">
Aucun fait référencé. Ajoutez une condition « Quand un fait est vrai » sur
au moins une quête pour qu'il apparaisse ici.
{{ 'playthroughFlagsManager.empty' | translate }}
</p>
}
@@ -18,7 +17,7 @@
<div class="flag-info">
<span class="flag-name">{{ r.name }}</span>
</div>
<span class="flag-value">{{ r.value ? 'vrai' : 'faux' }}</span>
<span class="flag-value">{{ (r.value ? 'playthroughFlagsManager.true' : 'playthroughFlagsManager.false') | translate }}</span>
</li>
}
</ul>

View File

@@ -2,6 +2,7 @@ import { Component, Input, OnInit, OnChanges, SimpleChanges } from '@angular/cor
import { FormsModule } from '@angular/forms';
import { LucideAngularModule } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { CampaignFlagService } from '../../services/campaign-flag.service';
import { PlaythroughFlagService } from '../../services/playthrough-flag.service';
import { forkJoin } from 'rxjs';
@@ -15,7 +16,7 @@ import { forkJoin } from 'rxjs';
*/
@Component({
selector: 'app-playthrough-flags-manager',
imports: [FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './playthrough-flags-manager.component.html',
styleUrls: ['./playthrough-flags-manager.component.scss']
})

View File

@@ -2,7 +2,7 @@
@if (prerequisites.length === 0) {
<p class="prereq-editor-empty">
Aucune condition — la quête est immédiatement disponible.
{{ 'prerequisiteEditor.empty' | translate }}
</p>
}
@@ -13,14 +13,14 @@
@switch (p.kind) {
<!-- QUEST_COMPLETED -->
@case ('QUEST_COMPLETED') {
<span class="prereq-row-label">Quête terminée :</span>
<span class="prereq-row-label">{{ 'prerequisiteEditor.questCompletedLabel' | translate }}</span>
<select class="prereq-input"
[ngModel]="asQuestCompleted(p).questId"
(ngModelChange)="onQuestIdChange(i, $event)"
[ngModelOptions]="{ standalone: true }"
[disabled]="availableQuests.length === 0">
@if (availableQuests.length === 0) {
<option value="">(aucune autre quête)</option>
<option value="">{{ 'prerequisiteEditor.noOtherQuest' | translate }}</option>
}
@for (q of availableQuests; track q) {
<option [value]="q.id">{{ q.name }}</option>
@@ -29,7 +29,7 @@
}
<!-- SESSION_REACHED -->
@case ('SESSION_REACHED') {
<span class="prereq-row-label">À partir de la session :</span>
<span class="prereq-row-label">{{ 'prerequisiteEditor.sessionReachedLabel' | translate }}</span>
<input type="number"
class="prereq-input prereq-input-number"
min="1"
@@ -39,10 +39,10 @@
}
<!-- FLAG_SET -->
@case ('FLAG_SET') {
<span class="prereq-row-label">Quand le fait est vrai :</span>
<span class="prereq-row-label">{{ 'prerequisiteEditor.flagSetLabel' | translate }}</span>
<input type="text"
class="prereq-input"
placeholder="nom_du_fait"
[placeholder]="'prerequisiteEditor.flagNamePlaceholder' | translate"
[attr.list]="'prereq-flag-list-' + i"
[ngModel]="asFlagSet(p).flagName"
(ngModelChange)="onFlagNameChange(i, $event)"
@@ -54,7 +54,7 @@
</datalist>
}
}
<button type="button" class="prereq-remove" (click)="removeAt(i)" title="Retirer ce prérequis">
<button type="button" class="prereq-remove" (click)="removeAt(i)" [title]="'prerequisiteEditor.removePrerequisite' | translate">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</li>
@@ -65,20 +65,20 @@
<div class="prereq-add">
<button type="button" class="btn-secondary prereq-add-btn" (click)="toggleAddMenu()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Ajouter une condition
{{ 'prerequisiteEditor.addCondition' | translate }}
<lucide-icon [img]="ChevronDown" [size]="14"></lucide-icon>
</button>
@if (addMenuOpen) {
<div class="prereq-add-menu">
<button type="button" (click)="addPrerequisite('QUEST_COMPLETED')">
Après une autre quête
{{ 'prerequisiteEditor.menuAfterQuest' | translate }}
</button>
<button type="button" (click)="addPrerequisite('SESSION_REACHED')">
À partir d'une session
{{ 'prerequisiteEditor.menuFromSession' | translate }}
</button>
<button type="button" (click)="addPrerequisite('FLAG_SET')">
Quand un fait est vrai
{{ 'prerequisiteEditor.menuWhenFlag' | translate }}
</button>
</div>
}

View File

@@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ChevronDown } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { Chapter, Prerequisite } from '../../services/campaign.model';
/**
@@ -13,7 +14,7 @@ import { Chapter, Prerequisite } from '../../services/campaign.model';
*/
@Component({
selector: 'app-prerequisite-editor',
imports: [FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './prerequisite-editor.component.html',
styleUrls: ['./prerequisite-editor.component.scss']
})

View File

@@ -1,6 +1,7 @@
import { Component, Input } from '@angular/core';
import { LucideAngularModule, Lock, Circle, Play, CheckCircle2, LucideIconData } from 'lucide-angular';
import { TranslateService } from '@ngx-translate/core';
import { QuestStatus } from '../../services/campaign.model';
/**
@@ -19,6 +20,8 @@ export class QuestStatusBadgeComponent {
/** Variante visuelle compacte (sans label) — utile pour les listes denses. */
@Input() compact = false;
constructor(private translate: TranslateService) {}
get icon(): LucideIconData {
switch (this.status) {
case 'LOCKED': return Lock;
@@ -31,11 +34,11 @@ export class QuestStatusBadgeComponent {
get label(): string {
switch (this.status) {
case 'LOCKED': return 'Verrouillée';
case 'IN_PROGRESS': return 'En cours';
case 'COMPLETED': return 'Terminée';
case 'LOCKED': return this.translate.instant('questStatusBadge.locked');
case 'IN_PROGRESS': return this.translate.instant('questStatusBadge.inProgress');
case 'COMPLETED': return this.translate.instant('questStatusBadge.completed');
case 'AVAILABLE':
default: return 'Disponible';
default: return this.translate.instant('questStatusBadge.available');
}
}

View File

@@ -2,8 +2,7 @@
@if (rooms.length === 0) {
<p class="rooms-empty">
Aucune pièce. La scène se comporte comme un beat narratif classique.
Ajoutez une première pièce pour la convertir en lieu explorable (donjon, crypte…).
{{ 'roomsEditor.empty' | translate }}
</p>
}
@@ -21,16 +20,16 @@
[ngModel]="r.name"
(ngModelChange)="patch(r, { name: $event })"
(click)="$event.stopPropagation()"
placeholder="Nom de la pièce" />
[placeholder]="'roomsEditor.roomNamePlaceholder' | translate" />
<input type="number" class="room-floor-input"
[ngModel]="r.floor"
(ngModelChange)="patch(r, { floor: $event === null || $event === '' ? null : +$event })"
(click)="$event.stopPropagation()"
placeholder="Ét." title="Étage (0 = RdC)" />
[placeholder]="'roomsEditor.floorPlaceholder' | translate" [title]="'roomsEditor.floorTitle' | translate" />
<div class="room-actions" (click)="$event.stopPropagation()">
<button type="button" class="btn-icon" (click)="moveUp(r)" [disabled]="i === 0" title="Monter"></button>
<button type="button" class="btn-icon" (click)="moveDown(r)" [disabled]="i === rooms.length - 1" title="Descendre"></button>
<button type="button" class="btn-icon-danger" (click)="removeRoom(r)" title="Supprimer la pièce">
<button type="button" class="btn-icon" (click)="moveUp(r)" [disabled]="i === 0" [title]="'roomsEditor.moveUp' | translate"></button>
<button type="button" class="btn-icon" (click)="moveDown(r)" [disabled]="i === rooms.length - 1" [title]="'roomsEditor.moveDown' | translate"></button>
<button type="button" class="btn-icon-danger" (click)="removeRoom(r)" [title]="'roomsEditor.removeRoom' | translate">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
@@ -39,14 +38,14 @@
@if (isExpanded(r.id)) {
<div class="room-body">
<div class="field">
<label>Description (lue/résumée aux joueurs)</label>
<label>{{ 'roomsEditor.descriptionLabel' | translate }}</label>
<textarea rows="3"
[ngModel]="r.description"
(ngModelChange)="patch(r, { description: $event })"
placeholder="Atmosphère, ce que voient les PJ en entrant…"></textarea>
[placeholder]="'roomsEditor.descriptionPlaceholder' | translate"></textarea>
</div>
<div class="field">
<label>Ennemis du bestiaire</label>
<label>{{ 'roomsEditor.bestiaryLabel' | translate }}</label>
<app-enemy-link-picker
[value]="r.enemyIds ?? []"
[availableEnemies]="availableEnemies"
@@ -56,41 +55,41 @@
</div>
<div class="field-row">
<div class="field">
<label>Ennemis / créatures / boss (texte libre)</label>
<label>{{ 'roomsEditor.enemiesLabel' | translate }}</label>
<textarea rows="3"
[ngModel]="r.enemies"
(ngModelChange)="patch(r, { enemies: $event })"
placeholder="2 gobelins, 1 ogre boss (PV 60, AC 14)…"></textarea>
[placeholder]="'roomsEditor.enemiesPlaceholder' | translate"></textarea>
</div>
<div class="field">
<label>Loot / trésors</label>
<label>{{ 'roomsEditor.lootLabel' | translate }}</label>
<textarea rows="3"
[ngModel]="r.loot"
(ngModelChange)="patch(r, { loot: $event })"
placeholder="50 po, potion de soin, clé en argent…"></textarea>
[placeholder]="'roomsEditor.lootPlaceholder' | translate"></textarea>
</div>
</div>
<div class="field-row">
<div class="field">
<label>Pièges / dangers</label>
<label>{{ 'roomsEditor.trapsLabel' | translate }}</label>
<textarea rows="2"
[ngModel]="r.traps"
(ngModelChange)="patch(r, { traps: $event })"
placeholder="Trappe (DC 15 Perception), flèches empoisonnées…"></textarea>
[placeholder]="'roomsEditor.trapsPlaceholder' | translate"></textarea>
</div>
<div class="field">
<label>Notes MJ (privé)</label>
<label>{{ 'roomsEditor.gmNotesLabel' | translate }}</label>
<textarea rows="2"
[ngModel]="r.gmNotes"
(ngModelChange)="patch(r, { gmNotes: $event })"
placeholder="Le boss connaît les PJ par leur nom, indice…"></textarea>
[placeholder]="'roomsEditor.gmNotesPlaceholder' | translate"></textarea>
</div>
</div>
<!-- Branches inter-pièces -->
<div class="field">
<label>
<lucide-icon [img]="GitBranch" [size]="14"></lucide-icon>
Sorties / portes vers d'autres pièces
{{ 'roomsEditor.exitsLabel' | translate }}
</label>
@if ((r.branches?.length ?? 0) > 0) {
<ul class="branch-list">
@@ -99,14 +98,14 @@
<input type="text" class="branch-label"
[ngModel]="b.label"
(ngModelChange)="updateBranch(r, bi, { label: $event })"
placeholder="Porte nord" />
[placeholder]="'roomsEditor.branchLabelPlaceholder' | translate" />
<span class="branch-arrow"></span>
<select class="branch-target"
[ngModel]="b.targetRoomId"
(ngModelChange)="updateBranch(r, bi, { targetRoomId: $event })"
[disabled]="otherRooms(r).length === 0">
@if (otherRooms(r).length === 0) {
<option value="">(aucune autre pièce)</option>
<option value="">{{ 'roomsEditor.noOtherRoom' | translate }}</option>
}
@for (o of otherRooms(r); track o) {
<option [value]="o.id">{{ o.name }}</option>
@@ -115,7 +114,7 @@
<input type="text" class="branch-condition"
[ngModel]="b.condition"
(ngModelChange)="updateBranch(r, bi, { condition: $event })"
placeholder="Condition (optionnelle)" />
[placeholder]="'roomsEditor.branchConditionPlaceholder' | translate" />
<button type="button" class="btn-icon-danger" (click)="removeBranch(r, bi)">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
@@ -127,7 +126,7 @@
(click)="addBranch(r)"
[disabled]="otherRooms(r).length === 0">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
Ajouter une sortie
{{ 'roomsEditor.addExit' | translate }}
</button>
</div>
</div>
@@ -139,7 +138,7 @@
<button type="button" class="btn-primary" (click)="addRoom()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Ajouter une pièce
{{ 'roomsEditor.addRoom' | translate }}
</button>
</div>

View File

@@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ChevronDown, ChevronUp, GitBranch, X } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Room, RoomBranch } from '../../services/campaign.model';
import { Enemy } from '../../services/enemy.model';
import { EnemyLinkPickerComponent } from '../enemy-link-picker/enemy-link-picker.component';
@@ -17,7 +18,7 @@ import { EnemyLinkPickerComponent } from '../enemy-link-picker/enemy-link-picker
*/
@Component({
selector: 'app-rooms-editor',
imports: [FormsModule, LucideAngularModule, EnemyLinkPickerComponent],
imports: [FormsModule, LucideAngularModule, EnemyLinkPickerComponent, TranslatePipe],
templateUrl: './rooms-editor.component.html',
styleUrls: ['./rooms-editor.component.scss']
})
@@ -30,6 +31,8 @@ export class RoomsEditorComponent {
@Input() campaignId = '';
@Output() roomsChange = new EventEmitter<Room[]>();
constructor(private translate: TranslateService) {}
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ChevronDown = ChevronDown;
@@ -57,7 +60,7 @@ export class RoomsEditorComponent {
: 0;
const newRoom: Room = {
id: this.newId(),
name: `Salle ${this.rooms.length + 1}`,
name: this.translate.instant('roomsEditor.defaultRoomName', { n: this.rooms.length + 1 }),
order: nextOrder,
branches: []
};
@@ -89,7 +92,7 @@ export class RoomsEditorComponent {
addBranch(room: Room): void {
const others = this.rooms.filter(r => r.id !== room.id);
const branch: RoomBranch = {
label: 'Porte',
label: this.translate.instant('roomsEditor.defaultBranchLabel'),
targetRoomId: others[0]?.id ?? '',
condition: ''
};

View File

@@ -12,7 +12,7 @@
type="button"
class="sidebar-title sidebar-title--link"
[class.active]="isTitleActive()"
title="Retour à l'accueil de la campagne"
[title]="'secondarySidebar.backToCampaignHome' | translate"
(click)="clickTitle()">
<lucide-icon [img]="Home" [size]="14" class="sidebar-title-icon"></lucide-icon>
<span class="sidebar-title-text">{{ title }}</span>
@@ -149,6 +149,6 @@
@if (!isCollapsed) {
<div class="resize-handle"
(mousedown)="startResize($event)"
title="Glissez pour redimensionner"></div>
[title]="'secondarySidebar.resizeHandle' | translate"></div>
}
</aside>

View File

@@ -1,13 +1,14 @@
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, Home, LucideIconData } from 'lucide-angular';
import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService } from '../../services/layout.service';
import { resolveIcon } from '../../lore/lore-icons';
@Component({
selector: 'app-secondary-sidebar',
imports: [CommonModule, LucideAngularModule],
imports: [CommonModule, LucideAngularModule, TranslatePipe],
templateUrl: './secondary-sidebar.component.html',
styleUrls: ['./secondary-sidebar.component.scss']
})

View File

@@ -2,7 +2,7 @@
<div class="sip-frame" [style.aspectRatio]="aspectRatio">
@if (imageId) {
<img [src]="contentUrl(imageId)" alt="" />
<button type="button" class="sip-remove" (click)="remove()" title="Retirer l'image">
<button type="button" class="sip-remove" (click)="remove()" [title]="'singleImagePicker.removeImage' | translate">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
} @else {

View File

@@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
@@ -21,7 +22,7 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
*/
@Component({
selector: 'app-single-image-picker',
imports: [LucideAngularModule, ImageUploaderComponent],
imports: [LucideAngularModule, ImageUploaderComponent, TranslatePipe],
templateUrl: './single-image-picker.component.html',
styleUrls: ['./single-image-picker.component.scss']
})

View File

@@ -1,6 +1,6 @@
<div class="tfe">
<div class="tfe-header">
<h3 class="tfe-label">{{ label }}</h3>
<h3 class="tfe-label">{{ label || ('templateFieldsEditor.defaultLabel' | translate) }}</h3>
@if (hint) {
<p class="tfe-hint">{{ hint }}</p>
}
@@ -11,10 +11,10 @@
<div class="tfe-item">
<div class="tfe-row" [class.invalid]="isDuplicate(f, i) || !f.name.trim()">
<div class="tfe-row-controls">
<button type="button" class="btn-arrow" (click)="moveUp(i)" [disabled]="i === 0" title="Monter">
<button type="button" class="btn-arrow" (click)="moveUp(i)" [disabled]="i === 0" [title]="'templateFieldsEditor.moveUp' | translate">
<lucide-icon [img]="ArrowUp" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-arrow" (click)="moveDown(i)" [disabled]="i === fields.length - 1" title="Descendre">
<button type="button" class="btn-arrow" (click)="moveDown(i)" [disabled]="i === fields.length - 1" [title]="'templateFieldsEditor.moveDown' | translate">
<lucide-icon [img]="ArrowDown" [size]="14"></lucide-icon>
</button>
</div>
@@ -24,7 +24,7 @@
[(ngModel)]="f.name"
[name]="'name-' + i"
(ngModelChange)="onFieldChanged()"
placeholder="Nom du champ (ex: Histoire, PV...)"
[placeholder]="'templateFieldsEditor.fieldNamePlaceholder' | translate"
/>
<select
class="tfe-type"
@@ -46,7 +46,7 @@
}
</select>
}
<button type="button" class="btn-remove" (click)="remove(i)" title="Supprimer ce champ">
<button type="button" class="btn-remove" (click)="remove(i)" [title]="'templateFieldsEditor.removeField' | translate">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
@@ -55,15 +55,15 @@
<div class="tfe-labels">
<div class="tfe-labels-header">
<lucide-icon [img]="ListOrdered" [size]="12"></lucide-icon>
<span>Labels (cles fixes pour toutes les fiches)</span>
<span>{{ 'templateFieldsEditor.labelsHeader' | translate }}</span>
</div>
<div class="tfe-labels-list">
@for (lbl of f.labels; track $index; let li = $index) {
<div class="tfe-label-row">
<button type="button" class="btn-arrow-mini" (click)="moveLabelUp(f, li)" [disabled]="li === 0" title="Monter">
<button type="button" class="btn-arrow-mini" (click)="moveLabelUp(f, li)" [disabled]="li === 0" [title]="'templateFieldsEditor.moveUp' | translate">
<lucide-icon [img]="ArrowUp" [size]="11"></lucide-icon>
</button>
<button type="button" class="btn-arrow-mini" (click)="moveLabelDown(f, li)" [disabled]="li === (f.labels?.length || 0) - 1" title="Descendre">
<button type="button" class="btn-arrow-mini" (click)="moveLabelDown(f, li)" [disabled]="li === (f.labels?.length || 0) - 1" [title]="'templateFieldsEditor.moveDown' | translate">
<lucide-icon [img]="ArrowDown" [size]="11"></lucide-icon>
</button>
<input
@@ -72,9 +72,9 @@
[ngModel]="lbl"
(ngModelChange)="updateLabelAt(f, li, $event)"
[name]="'lbl-' + i + '-' + li"
placeholder="Ex: FOR, DEX..."
[placeholder]="'templateFieldsEditor.labelPlaceholder' | translate"
/>
<button type="button" class="btn-remove-mini" (click)="removeLabel(f, li)" title="Retirer ce label">
<button type="button" class="btn-remove-mini" (click)="removeLabel(f, li)" [title]="'templateFieldsEditor.removeLabel' | translate">
<lucide-icon [img]="X" [size]="11"></lucide-icon>
</button>
</div>
@@ -82,7 +82,7 @@
</div>
<button type="button" class="chip chip-mini" (click)="addLabel(f)">
<lucide-icon [img]="Plus" [size]="11"></lucide-icon>
Ajouter un label
{{ 'templateFieldsEditor.addLabel' | translate }}
</button>
</div>
}
@@ -91,13 +91,13 @@
@if (fields.length === 0) {
<div class="tfe-empty">
Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.
{{ 'templateFieldsEditor.empty' | translate }}
</div>
}
</div>
<div class="tfe-add">
<span class="tfe-add-label">Ajouter :</span>
<span class="tfe-add-label">{{ 'templateFieldsEditor.addPrefix' | translate }}</span>
@for (s of suggestions; track s) {
<button
type="button"
@@ -111,19 +111,19 @@
}
<button type="button" class="chip chip-custom" (click)="addBlank('TEXT')">
<lucide-icon [img]="Type" [size]="12"></lucide-icon>
Texte
{{ 'templateFieldsEditor.typeText' | translate }}
</button>
<button type="button" class="chip chip-custom" (click)="addBlank('NUMBER')">
<lucide-icon [img]="Hash" [size]="12"></lucide-icon>
Nombre
{{ 'templateFieldsEditor.typeNumber' | translate }}
</button>
<button type="button" class="chip chip-custom" (click)="addBlank('IMAGE')">
<lucide-icon [img]="ImageIcon" [size]="12"></lucide-icon>
Image(s)
{{ 'templateFieldsEditor.typeImage' | translate }}
</button>
<button type="button" class="chip chip-custom" (click)="addBlank('KEY_VALUE_LIST')">
<lucide-icon [img]="ListOrdered" [size]="12"></lucide-icon>
Liste cle/valeur
{{ 'templateFieldsEditor.typeKeyValue' | translate }}
</button>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash, ListOrdered, X } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
/**
@@ -16,7 +17,7 @@ import { TemplateField, FieldType, ImageLayout } from '../../services/template.m
*/
@Component({
selector: 'app-template-fields-editor',
imports: [FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './template-fields-editor.component.html',
styleUrls: ['./template-fields-editor.component.scss']
})
@@ -37,27 +38,33 @@ export class TemplateFieldsEditorComponent {
/** Suggestions de noms de champs (chips ajout rapide). */
@Input() suggestions: string[] = [];
/** Label de la section (ex: "Champs de la fiche PJ"). */
@Input() label = 'Champs du template';
/** Label de la section (ex: "Champs de la fiche PJ"). Si vide, un libellé par défaut traduit est affiché. */
@Input() label = '';
/** Hint affichee sous le label. */
@Input() hint?: string;
@Output() fieldsChange = new EventEmitter<TemplateField[]>();
readonly typeOptions: { value: FieldType; label: string }[] = [
{ value: 'TEXT', label: 'Texte' },
{ value: 'NUMBER', label: 'Nombre' },
{ value: 'IMAGE', label: 'Image(s)' },
{ value: 'KEY_VALUE_LIST', label: 'Liste cle/valeur' }
];
constructor(private translate: TranslateService) {}
readonly layoutOptions: { value: ImageLayout; label: string }[] = [
{ value: 'GALLERY', label: 'Galerie' },
{ value: 'HERO', label: 'Bandeau' },
{ value: 'MASONRY', label: 'Mosaique' },
{ value: 'CAROUSEL', label: 'Carrousel' }
];
get typeOptions(): { value: FieldType; label: string }[] {
return [
{ value: 'TEXT', label: this.translate.instant('templateFieldsEditor.typeText') },
{ value: 'NUMBER', label: this.translate.instant('templateFieldsEditor.typeNumber') },
{ value: 'IMAGE', label: this.translate.instant('templateFieldsEditor.typeImage') },
{ value: 'KEY_VALUE_LIST', label: this.translate.instant('templateFieldsEditor.typeKeyValue') }
];
}
get layoutOptions(): { value: ImageLayout; label: string }[] {
return [
{ value: 'GALLERY', label: this.translate.instant('templateFieldsEditor.layoutGallery') },
{ value: 'HERO', label: this.translate.instant('templateFieldsEditor.layoutHero') },
{ value: 'MASONRY', label: this.translate.instant('templateFieldsEditor.layoutMasonry') },
{ value: 'CAROUSEL', label: this.translate.instant('templateFieldsEditor.layoutCarousel') }
];
}
isDuplicate(field: TemplateField, index: number): boolean {
if (!field.name?.trim()) return false;

View File

@@ -6,22 +6,18 @@
>
<div class="banner-content">
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
<span>
Une nouvelle version de LoreMind est disponible
(<strong>{{ versionChecker.remoteVersion() }}</strong>).
Recharge pour profiter des dernieres ameliorations.
</span>
<span [innerHTML]="'updateBanner.message' | translate:{ version: versionChecker.remoteVersion() }"></span>
</div>
<div class="banner-actions">
<button type="button" class="btn-reload" (click)="reload()">
Recharger
{{ 'updateBanner.reload' | translate }}
</button>
<button
type="button"
class="btn-dismiss"
(click)="dismiss()"
title="Fermer (sera reaffiche au prochain demarrage)"
aria-label="Fermer"
[title]="'updateBanner.dismissTitle' | translate"
[attr.aria-label]="'updateBanner.dismissAria' | translate"
>
<lucide-icon [img]="X" [size]="16"></lucide-icon>
</button>

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { LucideAngularModule, RefreshCw, X } from 'lucide-angular';
import { VersionCheckerService } from '../../services/version-checker.service';
@@ -10,7 +11,7 @@ import { VersionCheckerService } from '../../services/version-checker.service';
*/
@Component({
selector: 'app-update-banner',
imports: [LucideAngularModule],
imports: [LucideAngularModule, TranslatePipe],
templateUrl: './update-banner.component.html',
styleUrls: ['./update-banner.component.scss']
})