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

@@ -2,7 +2,7 @@
<div class="modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Créer une nouvelle Campagne</h2>
<h2>{{ 'campaignCreate.title' | translate }}</h2>
<button class="btn-close" (click)="onCancel()">
<lucide-icon [img]="X" [size]="18"></lucide-icon>
</button>
@@ -11,54 +11,51 @@
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="field">
<label for="campaign-name">Nom de la campagne *</label>
<label for="campaign-name">{{ 'campaignCreate.nameLabel' | translate }}</label>
<input
id="campaign-name"
type="text"
formControlName="name"
placeholder="Ex: L'Ombre du Nord, Les Héritiers Oubliés..."
[placeholder]="'campaignCreate.namePlaceholder' | translate"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
<div class="field">
<label for="campaign-description">Description / Pitch</label>
<label for="campaign-description">{{ 'campaignCreate.descriptionLabel' | translate }}</label>
<textarea
id="campaign-description"
formControlName="description"
placeholder="Résumez l'intrigue principale de votre campagne..."
[placeholder]="'campaignCreate.descriptionPlaceholder' | translate"
rows="5"
></textarea>
</div>
<div class="field">
<label for="campaign-player-count">Nombre de joueurs</label>
<label for="campaign-player-count">{{ 'campaignCreate.playerCountLabel' | translate }}</label>
<input id="campaign-player-count" type="number" formControlName="playerCount" min="1" />
</div>
<div class="field">
<label for="campaign-lore">Univers associé</label>
<label for="campaign-lore">{{ 'campaignCreate.loreLabel' | translate }}</label>
<select id="campaign-lore" formControlName="loreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option value="">{{ 'campaignCreate.noLoreOption' | translate }}</option>
@for (lore of availableLores; track lore) {
<option [value]="lore.id">{{ lore.name }}</option>
}
</select>
<p class="hint">
Optionnel. Si associée, vous pourrez lier arcs, chapitres et scènes aux pages du Lore.
Laissez vide pour un one-shot ou si vous créerez le Lore plus tard.
</p>
<p class="hint">{{ 'campaignCreate.loreHint' | translate }}</p>
</div>
<div class="field">
<label for="campaign-game-system">Système de JDR</label>
<label for="campaign-game-system">{{ 'campaignCreate.gameSystemLabel' | translate }}</label>
@if (!creatingGameSystem) {
<select id="campaign-game-system" formControlName="gameSystemId">
<option value="">— Aucun (campagne générique) —</option>
<option value="">{{ 'campaignCreate.noGameSystemOption' | translate }}</option>
@for (gs of availableGameSystems; track gs) {
<option [value]="gs.id">{{ gs.name }}</option>
}
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
<option [value]="CREATE_GAMESYSTEM_SENTINEL">{{ 'campaignCreate.createGameSystemOption' | translate }}</option>
</select>
}
@@ -69,7 +66,7 @@
type="text"
[(ngModel)]="newGameSystemName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
[placeholder]="'campaignCreate.gameSystemNamePlaceholder' | translate"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
@@ -79,50 +76,39 @@
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
{{ 'common.create' | translate }}
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
{{ 'common.cancel' | translate }}
</button>
</div>
<p class="hint">
Création rapide — vous pourrez ajouter les règles, les templates de fiches PJ/PNJ
et le reste depuis la section "Systèmes" plus tard.
</p>
<p class="hint">{{ 'campaignCreate.gameSystemCreateHint' | translate }}</p>
</div>
}
@if (!creatingGameSystem) {
<p class="hint">
Optionnel. Si défini, l'IA injectera les règles du système (classes, combat, lore...)
dans ses suggestions pour respecter les mécaniques du JDR.
</p>
<p class="hint">{{ 'campaignCreate.gameSystemHint' | translate }}</p>
}
@if (!creatingGameSystem) {
<p class="hint hint-warning">
⚠️ Le système de jeu choisi détermine aussi le <strong>template des fiches de PJ et PNJ</strong>.
Le changer plus tard rendra les champs des fiches existantes invisibles
(les données restent stockées mais ne s'afficheront qu'en revenant à l'ancien système).
Choisissez bien dès le départ si possible.
</p>
<p class="hint hint-warning" [innerHTML]="'campaignCreate.gameSystemWarning' | translate"></p>
}
</div>
<div class="info-box">
<p><strong>💡 Organisation :</strong> Votre campagne sera structurée en :</p>
<p [innerHTML]="'campaignCreate.orgIntro' | translate"></p>
<ul>
<li><strong>Arcs</strong> - Les grandes phases narratives</li>
<li><strong>Chapitres</strong> - Les segments d'un arc</li>
<li><strong>Scènes</strong> - Les moments de jeu individuels</li>
<li [innerHTML]="'campaignCreate.orgArcs' | translate"></li>
<li [innerHTML]="'campaignCreate.orgChapters' | translate"></li>
<li [innerHTML]="'campaignCreate.orgScenes' | translate"></li>
</ul>
</div>
<div class="modal-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="BookCopy" [size]="16"></lucide-icon>
Créer la campagne
{{ 'campaignCreate.submit' | translate }}
</button>
<button type="button" class="btn-secondary" (click)="onCancel()">Annuler</button>
<button type="button" class="btn-secondary" (click)="onCancel()">{{ 'common.cancel' | translate }}</button>
</div>
</form>

View File

@@ -3,6 +3,7 @@ import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, BookCopy, X, Plus, Check } from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { LoreService } from '../../../services/lore.service';
import { Lore } from '../../../services/lore.model';
import { GameSystemService } from '../../../services/game-system.service';
@@ -22,7 +23,7 @@ export interface CampaignCreatePayload {
@Component({
selector: 'app-campaign-create',
imports: [ReactiveFormsModule, FormsModule, LucideAngularModule],
imports: [ReactiveFormsModule, FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './campaign-create.component.html',
styleUrls: ['./campaign-create.component.scss']
})

View File

@@ -7,22 +7,22 @@
<h1>{{ campaign.name }}</h1>
<p class="description">{{ campaign.description }}</p>
<div class="meta">
<span class="badge">{{ campaign.playerCount || 0 }} joueurs</span>
<span class="badge">{{ 'campaignDetail.players' | translate:{ n: campaign.playerCount || 0 } }}</span>
<!-- Badge "Univers" : lien vers le Lore associé si présent -->
@if (linkedLore) {
<a
class="badge badge-lore"
[routerLink]="['/lore', linkedLore.id]"
title="Ouvrir l'univers associé">
[title]="'campaignDetail.openLinkedLore' | translate">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
{{ linkedLore.name }}
</a>
}
<!-- Campagne liée à un Lore qui n'existe plus (supprimé ailleurs) -->
@if (campaign.loreId && !linkedLore) {
<span class="badge badge-lore-missing" title="L'univers associé est introuvable">
<span class="badge badge-lore-missing" [title]="'campaignDetail.loreMissingTitle' | translate">
<lucide-icon [img]="Globe" [size]="12"></lucide-icon>
Univers introuvable
{{ 'campaignDetail.loreMissing' | translate }}
</span>
}
</div>
@@ -30,11 +30,11 @@
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="startEdit()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
{{ 'common.edit' | translate }}
</button>
<button type="button" class="btn-danger" (click)="deleteCampaign()">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
{{ 'common.delete' | translate }}
</button>
</div>
</div>
@@ -43,34 +43,34 @@
@if (editing) {
<div class="detail-header edit-mode">
<div class="field">
<label>Nom</label>
<label>{{ 'common.name' | translate }}</label>
<input type="text" [(ngModel)]="editName" name="editName" required />
</div>
<div class="field">
<label>Description</label>
<label>{{ 'common.description' | translate }}</label>
<textarea [(ngModel)]="editDescription" name="editDescription" rows="3"></textarea>
</div>
<div class="field">
<label>Univers associé</label>
<label>{{ 'campaignDetail.loreLabel' | translate }}</label>
<select [(ngModel)]="editLoreId" name="editLoreId">
<option value="">— Aucun univers (campagne libre) —</option>
<option value="">{{ 'campaignDetail.noLoreOption' | translate }}</option>
@for (lore of availableLores; track lore) {
<option [value]="lore.id">{{ lore.name }}</option>
}
</select>
</div>
<div class="field">
<label>Système de JDR</label>
<label>{{ 'campaignDetail.gameSystemLabel' | translate }}</label>
@if (!creatingGameSystem) {
<select
[(ngModel)]="editGameSystemId"
name="editGameSystemId"
(ngModelChange)="onEditGameSystemChange($event)">
<option value="">— Aucun (générique) —</option>
<option value="">{{ 'campaignDetail.noGameSystemOption' | translate }}</option>
@for (gs of availableGameSystems; track gs) {
<option [value]="gs.id">{{ gs.name }}</option>
}
<option [value]="CREATE_GAMESYSTEM_SENTINEL">+ Créer un nouveau système…</option>
<option [value]="CREATE_GAMESYSTEM_SENTINEL">{{ 'campaignDetail.createGameSystemOption' | translate }}</option>
</select>
}
@if (creatingGameSystem) {
@@ -79,7 +79,7 @@
type="text"
[(ngModel)]="newGameSystemName"
name="newGameSystemName"
placeholder="Nom du nouveau système (ex: D&D 5e, Nimble, Maison)"
[placeholder]="'campaignDetail.gameSystemNamePlaceholder' | translate"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()"
autofocus
@@ -89,10 +89,10 @@
[disabled]="!newGameSystemName.trim() || creatingGameSystemInFlight"
(click)="submitCreateGameSystem()">
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
Créer
{{ 'common.create' | translate }}
</button>
<button type="button" class="btn-inline-secondary" (click)="cancelCreateGameSystem()">
Annuler
{{ 'common.cancel' | translate }}
</button>
</div>
</div>
@@ -100,10 +100,10 @@
</div>
<div class="header-actions">
<button type="button" class="btn-primary" (click)="saveEdit()" [disabled]="!editName.trim()">
Sauvegarder
{{ 'common.save' | translate }}
</button>
<button type="button" class="btn-secondary" (click)="cancelEdit()">
Annuler
{{ 'common.cancel' | translate }}
</button>
</div>
</div>
@@ -111,7 +111,7 @@
@if (!editing) {
<section class="detail-section personas-section">
<div class="section-header">
<h2>Personnages</h2>
<h2>{{ 'campaignDetail.charactersTitle' | translate }}</h2>
</div>
<!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) :
ils se gèrent depuis la page d'une Partie. Ici on ne liste que les PNJ
@@ -121,14 +121,14 @@
<div class="subsection-header">
<h3>
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
Personnages non-joueurs
{{ 'campaignDetail.npcTitle' | translate }}
@if (npcs.length > 0) {
<span class="count-badge">{{ npcs.length }}</span>
}
</h3>
<button class="btn-add" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PNJ
{{ 'campaignDetail.newNpc' | translate }}
</button>
</div>
@if (npcs.length > 0) {
@@ -146,10 +146,10 @@
}
@if (npcs.length === 0) {
<div class="empty-state empty-state--compact">
<p>Aucun PNJ pour le moment.</p>
<p>{{ 'campaignDetail.noNpc' | translate }}</p>
<button class="btn-add-first" (click)="createNpc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PNJ
{{ 'campaignDetail.createFirstNpc' | translate }}
</button>
</div>
}
@@ -159,11 +159,11 @@
@if (!editing) {
<section class="detail-section arcs-section">
<div class="section-header">
<h2>Arcs narratifs</h2>
<h2>{{ 'campaignDetail.arcsTitle' | translate }}</h2>
<div class="section-header-actions">
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
{{ 'campaignDetail.newArc' | translate }}
</button>
</div>
</div>
@@ -173,7 +173,7 @@
<div class="arc-card" (click)="openArc(arc)">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ chapterCountByArc[arc.id!] || 0 }} chapitres</span>
<span class="arc-meta">{{ 'campaignDetail.chapters' | translate:{ n: chapterCountByArc[arc.id!] || 0 } }}</span>
</div>
}
</div>
@@ -181,10 +181,10 @@
@if (arcs.length === 0) {
<div class="empty-state">
<lucide-icon [img]="Swords" [size]="40" class="empty-icon"></lucide-icon>
<p>Aucun arc narratif pour le moment.</p>
<p>{{ 'campaignDetail.noArc' | translate }}</p>
<button class="btn-add-first" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier arc
{{ 'campaignDetail.createFirstArc' | translate }}
</button>
</div>
}
@@ -196,11 +196,11 @@
<div class="section-header">
<h2>
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
Mes parties
{{ 'campaignDetail.playthroughsTitle' | translate }}
</h2>
<button class="btn-add" [disabled]="newPlaythroughInFlight" (click)="createPlaythrough()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvelle partie
{{ 'campaignDetail.newPlaythrough' | translate }}
</button>
</div>
@if (playthroughs.length > 0) {
@@ -221,7 +221,7 @@
}
@if (playthroughs.length === 0) {
<div class="empty-state empty-state--compact">
<p>Aucune partie. Créez-en une pour démarrer une session avec une table.</p>
<p>{{ 'campaignDetail.noPlaythrough' | translate }}</p>
</div>
}
</section>

View File

@@ -3,6 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
@@ -28,7 +29,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
@Component({
selector: 'app-campaign-detail',
imports: [FormsModule, LucideAngularModule, RouterLink],
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe],
templateUrl: './campaign-detail.component.html',
styleUrls: ['./campaign-detail.component.scss']
})
@@ -101,7 +102,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private playthroughService: PlaythroughService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
) {}
ngOnInit(): void {
@@ -216,7 +218,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
createPlaythrough(): void {
if (!this.campaign || this.newPlaythroughInFlight) return;
this.newPlaythroughInFlight = true;
const defaultName = `Nouvelle partie ${this.playthroughs.length + 1}`;
const defaultName = this.translate.instant('campaignDetail.defaultPlaythroughName', { n: this.playthroughs.length + 1 });
this.playthroughService.create({
campaignId: this.campaign.id!,
name: defaultName
@@ -275,7 +277,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
}
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!));
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!, this.translate));
}
// ─────────────── Édition / suppression de la Campagne ───────────────
@@ -352,16 +354,14 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
if (gameSystemChanged && hasSheets) {
const count = this.npcs.length;
this.confirmDialog.confirm({
title: 'Changer le systeme de jeu ?',
message:
`Vous etes sur le point de changer le systeme de jeu de cette campagne. ` +
`Cela change egalement le template des fiches de PJ et PNJ.`,
title: this.translate.instant('campaignDetail.gameSystemChange.title'),
message: this.translate.instant('campaignDetail.gameSystemChange.message'),
details: [
`${count} fiche(s) existante(s) sont liees au template du systeme actuel.`,
`Leurs champs ne s'afficheront plus avec le nouveau systeme.`,
`Les donnees restent stockees : revenir a l'ancien systeme les rendra a nouveau visibles.`
this.translate.instant('campaignDetail.gameSystemChange.detailSheets', { n: count }),
this.translate.instant('campaignDetail.gameSystemChange.detailFields'),
this.translate.instant('campaignDetail.gameSystemChange.detailStored')
],
confirmLabel: 'Changer quand meme',
confirmLabel: this.translate.instant('campaignDetail.gameSystemChange.confirm'),
variant: 'warning'
}).then(ok => { if (ok) this.persistEdit(newGameSystemId); });
return;
@@ -400,20 +400,20 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.campaignService.getCampaignDeletionImpact(campaign.id!).subscribe({
next: impact => {
const parts: string[] = [];
if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 1 ? 's' : ''}`);
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
if (impact.playthroughs > 0) parts.push(`${impact.playthroughs} partie${impact.playthroughs > 1 ? 's' : ''} (avec ses PJ, sessions, faits)`);
if (impact.arcs > 0) parts.push(this.translate.instant(impact.arcs > 1 ? 'campaignDetail.delete.arcsPlural' : 'campaignDetail.delete.arcs', { n: impact.arcs }));
if (impact.chapters > 0) parts.push(this.translate.instant(impact.chapters > 1 ? 'campaignDetail.delete.chaptersPlural' : 'campaignDetail.delete.chapters', { n: impact.chapters }));
if (impact.scenes > 0) parts.push(this.translate.instant(impact.scenes > 1 ? 'campaignDetail.delete.scenesPlural' : 'campaignDetail.delete.scenes', { n: impact.scenes }));
if (impact.playthroughs > 0) parts.push(this.translate.instant(impact.playthroughs > 1 ? 'campaignDetail.delete.playthroughsPlural' : 'campaignDetail.delete.playthroughs', { n: impact.playthroughs }));
const details: string[] = [];
if (parts.length) details.push(`Sera aussi supprime : ${parts.join(', ')}.`);
details.push('Cette action est irreversible.');
if (parts.length) details.push(this.translate.instant('campaignDetail.delete.alsoDeletes', { items: parts.join(', ') }));
details.push(this.translate.instant('campaignDetail.delete.irreversible'));
this.confirmDialog.confirm({
title: 'Supprimer la campagne ?',
message: `Supprimer definitivement la campagne "${campaign.name}" ?`,
title: this.translate.instant('campaignDetail.delete.title'),
message: this.translate.instant('campaignDetail.delete.message', { name: campaign.name }),
confirmLabel: this.translate.instant('common.delete'),
details,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok) return;

View File

@@ -3,13 +3,10 @@
<div class="page-header">
<button type="button" class="btn-back" (click)="cancel()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne
{{ 'campaignImport.back' | translate }}
</button>
<h1>Importer un PDF de campagne</h1>
<p class="subtitle">
L'IA propose une arborescence arc → chapitre → scène. Vous la révisez et l'ajustez
avant de créer quoi que ce soit.
</p>
<h1>{{ 'campaignImport.title' | translate }}</h1>
<p class="subtitle">{{ 'campaignImport.subtitle' | translate }}</p>
</div>
<!-- Étape 1 : upload (masqué une fois en revue) -->
@@ -18,7 +15,7 @@
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
{{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }}
{{ (importing ? 'campaignImport.importing' : 'campaignImport.choosePdf') | translate }}
</button>
<!-- Progression live -->
@if (importing) {
@@ -36,7 +33,7 @@
}
@if (importCounts) {
<p class="import-counts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ
{{ 'campaignImport.foundSoFar' | translate:{ arcs: importCounts.arcs, chapters: importCounts.chapters, scenes: importCounts.scenes, npcs: importCounts.npcs } }}
</p>
}
</div>
@@ -52,12 +49,7 @@
<section class="review-area">
<div class="review-header">
<p class="review-summary">
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
<strong>{{ chapterCount }}</strong> chapitre(s),
<strong>{{ sceneCount }}</strong> scène(s)@if (npcs.length > 0) {<span>,
<strong>{{ npcCount }}</strong> PNJ</span>}.
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
<span [innerHTML]="'campaignImport.summaryToCreate' | translate:{ arcs: arcCount, chapters: chapterCount, scenes: sceneCount }"></span>@if (npcs.length > 0) {<span [innerHTML]="'campaignImport.summaryNpcs' | translate:{ npcs: npcCount }"></span>}<span [innerHTML]="'campaignImport.summaryHint' | translate"></span>
</p>
</div>
<div class="tree">
@@ -70,12 +62,12 @@
</button>
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
[readonly]="arc.existing" placeholder="Nom de l'arc" />
[readonly]="arc.existing" [placeholder]="'campaignImport.arcNamePlaceholder' | translate" />
@if (arc.existing) {
<span class="badge-existing">déjà présent</span>
<span class="badge-existing">{{ 'campaignImport.alreadyPresent' | translate }}</span>
}
@if (!arc.existing) {
<button type="button" class="btn-remove" (click)="removeArc(ai)" title="Supprimer l'arc">
<button type="button" class="btn-remove" (click)="removeArc(ai)" [title]="'campaignImport.removeArc' | translate">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
}
@@ -84,16 +76,16 @@
<div class="node-body">
@if (!arc.existing) {
<div class="arc-type-toggle">
<span class="toggle-label">Type :</span>
<span class="toggle-label">{{ 'campaignImport.typeLabel' | translate }}</span>
<button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
(click)="setArcType(arc, 'LINEAR')">Linéaire</button>
(click)="setArcType(arc, 'LINEAR')">{{ 'campaignImport.typeLinear' | translate }}</button>
<button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
(click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
(click)="setArcType(arc, 'HUB')">{{ 'campaignImport.typeHub' | translate }}</button>
</div>
}
@if (!arc.existing) {
<textarea class="node-desc" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
rows="2" [placeholder]="'campaignImport.arcSynopsisPlaceholder' | translate"></textarea>
}
<!-- Chapitres -->
@for (chapter of arc.chapters; track chapter; let ci = $index) {
@@ -104,12 +96,12 @@
</button>
<lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
[readonly]="chapter.existing" placeholder="Nom du chapitre" />
[readonly]="chapter.existing" [placeholder]="'campaignImport.chapterNamePlaceholder' | translate" />
@if (chapter.existing) {
<span class="badge-existing">déjà présent</span>
<span class="badge-existing">{{ 'campaignImport.alreadyPresent' | translate }}</span>
}
@if (!chapter.existing) {
<button type="button" class="btn-remove" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
<button type="button" class="btn-remove" (click)="removeChapter(arc, ci)" [title]="'campaignImport.removeChapter' | translate">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
}
@@ -118,7 +110,7 @@
<div class="node-body">
@if (!chapter.existing) {
<textarea class="node-desc" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci"
rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea>
rows="2" [placeholder]="'campaignImport.chapterSynopsisPlaceholder' | translate"></textarea>
}
<!-- Scènes -->
@for (scene of chapter.scenes; track scene; let si = $index) {
@@ -129,60 +121,60 @@
<div class="scene-fields">
<input type="text" class="node-name" [(ngModel)]="scene.name"
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
placeholder="Nom de la scène" />
[placeholder]="'campaignImport.sceneNamePlaceholder' | translate" />
@if (!scene.existing) {
<input type="text" class="node-desc-inline" [(ngModel)]="scene.description"
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" [placeholder]="'campaignImport.synopsisOptionalPlaceholder' | translate" />
}
</div>
@if (scene.existing) {
<span class="badge-existing">déjà présent</span>
<span class="badge-existing">{{ 'campaignImport.alreadyPresent' | translate }}</span>
}
@if (!scene.existing) {
<button type="button" class="btn-details" (click)="toggleDetails(scene)"
title="Narration joueurs, notes MJ, pièces">
[title]="'campaignImport.detailsTitle' | translate">
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
Détails@if (scene.rooms.length) {
<span> · {{ scene.rooms.length }} pièce(s)</span>
{{ 'campaignImport.details' | translate }}@if (scene.rooms.length) {
<span> {{ 'campaignImport.roomsCount' | translate:{ n: scene.rooms.length } }}</span>
}
</button>
}
@if (!scene.existing) {
<button type="button" class="btn-remove" (click)="removeScene(chapter, si)" title="Supprimer la scène">
<button type="button" class="btn-remove" (click)="removeScene(chapter, si)" [title]="'campaignImport.removeScene' | translate">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
}
</div>
@if (scene.detailsOpen && !scene.existing) {
<div class="scene-details">
<label class="field-label">À lire aux joueurs</label>
<label class="field-label">{{ 'campaignImport.playerNarrationLabel' | translate }}</label>
<textarea class="node-desc" [(ngModel)]="scene.playerNarration"
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea>
<label class="field-label">Notes MJ</label>
[placeholder]="'campaignImport.playerNarrationPlaceholder' | translate"></textarea>
<label class="field-label">{{ 'campaignImport.gmNotesLabel' | translate }}</label>
<textarea class="node-desc" [(ngModel)]="scene.gmNotes"
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Secrets, développement, conséquences (optionnel)"></textarea>
[placeholder]="'campaignImport.gmNotesPlaceholder' | translate"></textarea>
<!-- Pièces (donjon) : vide = scène narrative classique -->
<span class="field-label">Pièces (lieu explorable)</span>
<span class="field-label">{{ 'campaignImport.roomsLabel' | translate }}</span>
<div class="rooms">
@for (room of scene.rooms; track room; let ri = $index) {
<div class="room-row">
<input type="text" class="room-name" [(ngModel)]="room.name"
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" />
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'campaignImport.roomPlaceholder' | translate" />
<input type="text" class="room-field" [(ngModel)]="room.description"
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Description" />
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'common.description' | translate" />
<input type="text" class="room-field" [(ngModel)]="room.enemies"
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Ennemis" />
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'campaignImport.enemiesPlaceholder' | translate" />
<input type="text" class="room-field" [(ngModel)]="room.loot"
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Trésor" />
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" title="Supprimer la pièce">
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" [placeholder]="'campaignImport.lootPlaceholder' | translate" />
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" [title]="'campaignImport.removeRoom' | translate">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</div>
}
<button type="button" class="btn-add-inline" (click)="addRoom(scene)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> {{ 'campaignImport.room' | translate }}
</button>
</div>
</div>
@@ -190,32 +182,29 @@
</div>
}
<button type="button" class="btn-add-inline" (click)="addScene(chapter)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> {{ 'campaignImport.scene' | translate }}
</button>
</div>
}
</div>
}
<button type="button" class="btn-add-inline" (click)="addChapter(arc)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> {{ 'campaignImport.chapter' | translate }}
</button>
</div>
}
</div>
}
<button type="button" class="btn-add" (click)="addArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> {{ 'campaignImport.addArc' | translate }}
</button>
</div>
<!-- PNJ détectés dans le PDF : revue par cases à cocher -->
@if (npcs.length > 0) {
<div class="npc-review">
<h3>PNJ et créatures détectés ({{ npcs.length }})</h3>
<p class="hint">
Cochez ceux à créer dans la campagne (description issue du livre).
Ceux déjà présents dans la campagne sont grisés et ne seront pas recréés.
</p>
<h3>{{ 'campaignImport.npcReviewTitle' | translate:{ n: npcs.length } }}</h3>
<p class="hint">{{ 'campaignImport.npcReviewHint' | translate }}</p>
<ul class="npc-list">
@for (npc of npcs; track npc.name) {
<li class="npc-row" [class.npc-existing]="npc.existing">
@@ -223,7 +212,7 @@
<input type="checkbox" [(ngModel)]="npc.selected" [disabled]="npc.existing">
<strong>{{ npc.name }}</strong>
@if (npc.existing) {
<em class="npc-tag">déjà présent</em>
<em class="npc-tag">{{ 'campaignImport.alreadyPresent' | translate }}</em>
}
</label>
@if (npc.description) {
@@ -241,9 +230,9 @@
<div class="review-actions">
<button type="button" class="btn-primary" [disabled]="applying || !hasNewContent" (click)="apply()">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
{{ applying ? 'Création…' : 'Créer dans la campagne' }}
{{ (applying ? 'campaignImport.creating' : 'campaignImport.createInCampaign') | translate }}
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
<button type="button" class="btn-secondary" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
</div>
</section>
}

View File

@@ -6,6 +6,7 @@ import {
LucideAngularModule, ArrowLeft, Upload, Plus, Trash2,
ChevronDown, ChevronRight, Swords, BookOpen, MapPin, Check
} from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignImportService } from '../../../services/campaign-import.service';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
@@ -53,7 +54,7 @@ interface NpcNode { name: string; description: string; selected: boolean; existi
*/
@Component({
selector: 'app-campaign-import',
imports: [FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule, TranslatePipe],
templateUrl: './campaign-import.component.html',
styleUrls: ['./campaign-import.component.scss']
})
@@ -109,12 +110,13 @@ export class CampaignImportComponent implements OnInit {
private randomTableService: RandomTableService,
private enemyService: EnemyService,
private campaignSidebar: CampaignSidebarService,
private pageTitle: PageTitleService
private pageTitle: PageTitleService,
private translate: TranslateService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
this.pageTitle.set('Importer une campagne');
this.pageTitle.set(this.translate.instant('campaignImport.pageTitle'));
this.campaignSidebar.show(this.campaignId);
// Pré-chargement de l'arborescence existante (pour fusionner à la revue).
@@ -138,7 +140,7 @@ export class CampaignImportComponent implements OnInit {
this.reviewing = false;
this.importError = null;
this.applyError = null;
this.importPhase = 'Extraction du texte…';
this.importPhase = this.translate.instant('campaignImport.phaseExtracting');
this.importProgress = null;
this.importStatus = null;
this.importCounts = null;
@@ -150,10 +152,10 @@ export class CampaignImportComponent implements OnInit {
// Un morceau vient d'aboutir : le message d'attente est obsolète.
this.importStatus = null;
if (ev.total === 0) {
this.importPhase = 'Extraction du texte…';
this.importPhase = this.translate.instant('campaignImport.phaseExtracting');
this.importProgress = null;
} else {
this.importPhase = `Analyse de la campagne… (${ev.current}/${ev.total})`;
this.importPhase = this.translate.instant('campaignImport.phaseAnalyzing', { current: ev.current, total: ev.total });
this.importProgress = { current: ev.current, total: ev.total };
this.importCounts = {
arcs: ev.arcCount, chapters: ev.chapterCount,
@@ -168,7 +170,7 @@ export class CampaignImportComponent implements OnInit {
this.importProgress = null;
this.importStatus = null;
if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) {
this.importError = "Aucune structure narrative détectée dans ce PDF.";
this.importError = this.translate.instant('campaignImport.errorNoStructure');
this.reviewing = false;
} else {
this.tree = this.buildMergedTree(ev.arcs ?? []);
@@ -181,7 +183,9 @@ export class CampaignImportComponent implements OnInit {
this.importing = false;
this.importPhase = '';
this.importProgress = null;
this.importError = err?.message ? `Échec de l'import : ${err.message}` : "Échec de l'import du PDF.";
this.importError = err?.message
? this.translate.instant('campaignImport.errorImportDetail', { message: err.message })
: this.translate.instant('campaignImport.errorImport');
}
});
}
@@ -413,7 +417,7 @@ export class CampaignImportComponent implements OnInit {
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => {
this.applying = false;
this.applyError = "Échec de la création. La campagne existe-t-elle toujours ?";
this.applyError = this.translate.instant('campaignImport.errorApply');
}
});
}