Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m50s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 18s

Corrections fonctionnelles
- order initialisé à la création des pages et dossiers de lore (nextOrderFor) :
  un nouvel élément se place désormais en dernier de sa fratrie au lieu de 0
- storePortrait : type MIME canonique dérivé de l'extension du fichier
  (un data URL "image/jpg" n'est plus rejeté silencieusement)
- takeUntilDestroyed ajouté sur les abonnements paramMap de arc-view,
  folder-view et campaign-detail (fuites mémoire)

Factorisation (suppression de duplication)
- shared/folder-grouping.util.ts : groupByFolder + byOrder + byFolderName
  mutualisés entre npc-list, enemy-list, campaign-detail et la sidebar
  (folderChildren). Le tri des dossiers est désormais cohérent entre la
  sidebar et les vues cartes (insensible casse/accents partout)
- DataSyncService.onChange()/persist() remplacent le câblage
  changed$ + reorder recopié dans 5 vues
- campaign-detail : loadCampaignBundle()/applyCampaignBundle() éliminent
  le forkJoin dupliqué entre ngOnInit et reload
- ReorderSupport (domain/shared) : squelette générique remplaçant les
  8 boucles de réordonnancement copiées dans les services
- FoundryExportService : GameSystem résolu une seule fois dans buildBundle
- module Foundry : walkScalars mutualise la récursion de flattenStats et
  flattenStructure (comportements préservés) ; esc() de l'importer aligné
  sur foundry.utils.escapeHTML

Suppression de code mort
- characterService/characters retirés de loadCampaignTreeData et de ses
  16 appelants (arguments, injections, imports et littéraux CampaignTreeData)
- CSS orphelin : .tree-row.cdk-drop-list-receiving et .btn-back
- BottomPanel.initiallyOpen (jamais lu) retiré de l'interface et de l'appelant
- commentaire trompeur du PDF corrigé

Tests
- mise à jour de 3 tests obsolètes de campaign-tree.helper.spec qui
  vérifiaient encore l'ancien comportement (tri alphabétique, non-classés
  en enfants directs) → alignés sur le modèle actuel (tri par order +
  pseudo-dossier "Sans dossier")
This commit is contained in:
2026-06-26 09:10:20 +02:00
parent 3896c5b4cb
commit a9242ba1e1
94 changed files with 2234 additions and 546 deletions

View File

@@ -28,6 +28,10 @@
</div>
</div>
<div class="header-actions">
<button type="button" class="btn-secondary" (click)="exportPdf()" [disabled]="exportingPdf">
<lucide-icon [img]="FileText" [size]="14"></lucide-icon>
{{ (exportingPdf ? 'campaignDetail.pdfExporting' : 'campaignDetail.pdfExport') | translate }}
</button>
<button type="button" class="btn-secondary" (click)="exportFoundry()" [disabled]="exportingFoundry">
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
{{ (exportingFoundry ? 'campaignDetail.foundryExporting' : 'campaignDetail.foundryExport') | translate }}
@@ -136,17 +140,24 @@
</button>
</div>
@if (npcs.length > 0) {
<div class="characters-grid">
@for (npc of npcs; track npc) {
<div class="character-card" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div>
</div>
@for (g of npcGroups; track g.folder) {
@if (g.folder) {
<h4 class="persona-folder">{{ g.folder.split('/').join(' / ') }}</h4>
} @else if (npcGroups.length > 1) {
<h4 class="persona-folder persona-folder--none">{{ 'campaignTree.unclassified' | translate }}</h4>
}
</div>
<div class="characters-grid">
@for (npc of g.items; track npc.id) {
<div class="character-card" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div>
</div>
}
</div>
}
}
@if (npcs.length === 0) {
<div class="empty-state empty-state--compact">
@@ -172,9 +183,10 @@
</div>
</div>
@if (arcs.length > 0) {
<div class="arcs-grid">
@for (arc of arcs; track arc) {
<div class="arc-card" (click)="openArc(arc)">
<div class="arcs-grid" cdkDropList cdkDropListOrientation="mixed"
(cdkDropListDropped)="dropArc($event)">
@for (arc of arcs; track arc.id) {
<div class="arc-card" cdkDrag [cdkDragData]="arc" (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">{{ 'campaignDetail.chapters' | translate:{ n: chapterCountByArc[arc.id!] || 0 } }}</span>

View File

@@ -505,3 +505,15 @@
font-weight: 600;
}
}
// En-tête de dossier dans la grille des PNJ (regroupement par dossier).
.persona-folder {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #8a7bc8;
margin: 0.9rem 0 0.4rem;
&--none { color: #6b7280; }
}

View File

@@ -1,8 +1,12 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DataSyncService } from '../../../services/data-sync.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download } from 'lucide-angular';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
@@ -11,7 +15,6 @@ import { CampaignService } from '../../../services/campaign.service';
import { LoreService } from '../../../services/lore.service';
import { GameSystemService } from '../../../services/game-system.service';
import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
@@ -26,10 +29,11 @@ import { Campaign, Arc } from '../../../services/campaign.model';
import { Lore } from '../../../services/lore.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
import { FolderGroup, groupByFolder, byOrder } from '../../../shared/folder-grouping.util';
@Component({
selector: 'app-campaign-detail',
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe],
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe, CdkDropList, CdkDrag],
templateUrl: './campaign-detail.component.html',
styleUrls: ['./campaign-detail.component.scss']
})
@@ -46,9 +50,12 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Upload = Upload;
readonly Sparkles = Sparkles;
readonly Download = Download;
readonly FileText = FileText;
/** Export Foundry en cours (anti double-clic). */
exportingFoundry = false;
/** Export PDF en cours (anti double-clic). */
exportingPdf = false;
campaign: Campaign | null = null;
arcs: Arc[] = [];
@@ -64,6 +71,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
linkedGameSystem: GameSystem | null = null;
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
npcs: Npc[] = [];
/** PNJ groupés par dossier (dossiers alpha, ordre manuel dans chacun) — comme la sidebar. */
npcGroups: FolderGroup<Npc>[] = [];
/** Sessions de jeu (passées et en cours) liées à cette campagne. */
sessions: Session[] = [];
/**
@@ -98,7 +107,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private campaignService: CampaignService,
private loreService: LoreService,
private gameSystemService: GameSystemService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private enemyService: EnemyService,
@@ -107,37 +115,32 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
private translate: TranslateService,
private dataSync: DataSyncService,
private campaignSidebar: CampaignSidebarService,
private destroyRef: DestroyRef
) {}
ngOnInit(): void {
// Synchro temps réel : si l'ordre des arcs change ailleurs (arbre de la sidebar),
// on recharge les cartes pour rester cohérent sans rafraîchir la page.
this.dataSync.onChange(this.destroyRef, () => {
if (this.campaign?.id) {
this.campaignService.getArcs(this.campaign.id).subscribe(a => this.arcs = [...a].sort(byOrder));
this.loadNpcs(this.campaign.id); // PNJ regroupés/ordonnés
this.campaignSidebar.show(this.campaign.id); // recharge l'arbre aussi
}
});
// switchMap annule automatiquement le load précédent si l'utilisateur
// change de campagne avant que le forkJoin ne réponde — évite qu'une
// réponse en retard écrase des données plus récentes (race condition).
this.route.paramMap.pipe(
map(pm => pm.get('id')),
filter((id): id is string => !!id && id !== this.campaign?.id),
switchMap(id => forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}))
).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
});
switchMap(id => this.loadCampaignBundle(id)),
takeUntilDestroyed(this.destroyRef)
).subscribe(result => this.applyCampaignBundle(result));
}
private computeChapterCounts(data: CampaignTreeData): Record<string, number> {
@@ -154,28 +157,39 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
* veut rafraîchir même si l'ID n'a pas changé.
*/
private reload(id: string): void {
forkJoin({
this.loadCampaignBundle(id).subscribe(result => this.applyCampaignBundle(result));
}
/** Charge en un seul forkJoin : la campagne, ses voisines, l'arbre et les parties. */
private loadCampaignBundle(id: string) {
return forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.npcService, this.randomTableService, this.enemyService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
});
}
/** Applique le bundle chargé à l'état du composant (commun à ngOnInit et reload). */
private applyCampaignBundle(
{ campaign, allCampaigns, treeData, playthroughs }:
{ campaign: Campaign; allCampaigns: Campaign[]; treeData: CampaignTreeData; playthroughs: Playthrough[] }
): void {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = [...treeData.arcs].sort(byOrder);
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
}
/**
* Charge le Lore associé (si loreId présent). On swallow l'erreur :
* si le Lore a été supprimé entre-temps, on affiche simplement "Univers introuvable".
@@ -205,7 +219,10 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private loadNpcs(campaignId: string): void {
this.npcService.getByCampaign(campaignId).pipe(
catchError(() => of([] as Npc[]))
).subscribe(list => this.npcs = list);
).subscribe(list => {
this.npcs = list;
this.npcGroups = groupByFolder(list);
});
}
// Sessions retirées de cette vue (vivent dans Playthrough Detail).
@@ -282,6 +299,45 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
});
}
/** Génère et télécharge le livret PDF de la campagne. */
exportPdf(): void {
if (!this.campaign?.id || this.exportingPdf) return;
this.exportingPdf = true;
const name = this.campaign.name;
this.campaignService.exportPdf(this.campaign.id).subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${(name || 'campagne').replace(/[^a-zA-Z0-9-_]+/g, '_')}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
this.exportingPdf = false;
},
error: (err) => {
console.error('Échec de l\'export PDF', err);
this.exportingPdf = false;
}
});
}
/** Réordonne les arcs par glisser-déposer et persiste le nouvel ordre. */
dropArc(event: CdkDragDrop<Arc[]>): void {
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(this.arcs, event.previousIndex, event.currentIndex);
this.campaignService.reorderArcs(this.arcs.map(a => a.id!)).subscribe({
next: () => this.dataSync.notify(),
error: () => {
if (this.campaign?.id) {
this.campaignService.getArcs(this.campaign.id).subscribe(
a => this.arcs = [...a].sort((x, y) => (x.order ?? 0) - (y.order ?? 0)));
}
}
});
}
openArc(arc: Arc): void {
if (!this.campaign || !arc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);