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

@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { forkJoin, Subscription } from 'rxjs';
import { CampaignService } from './campaign.service';
import { CharacterService } from './character.service';
import { NpcService } from './npc.service';
import { RandomTableService } from './random-table.service';
import { EnemyService } from './enemy.service';
@@ -27,7 +26,6 @@ import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/c
export class CampaignSidebarService {
constructor(
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private randomTableService: RandomTableService,
private enemyService: EnemyService,
@@ -47,7 +45,6 @@ export class CampaignSidebarService {
treeData: loadCampaignTreeData(
this.campaignService,
campaignId,
this.characterService,
this.npcService,
this.randomTableService,
this.enemyService

View File

@@ -47,6 +47,11 @@ export class CampaignService {
return this.http.get(`${this.apiUrl}/${id}/foundry-export`, { responseType: 'blob' });
}
/** Génère et télécharge le livret PDF de la campagne. */
exportPdf(id: string): Observable<Blob> {
return this.http.get(`${this.apiUrl}/${id}/pdf-export`, { responseType: 'blob' });
}
createCampaign(campaign: CampaignCreate): Observable<Campaign> {
return this.http.post<Campaign>(this.apiUrl, campaign);
}
@@ -89,6 +94,11 @@ export class CampaignService {
return this.http.get<ArcDeletionImpact>(`/api/arcs/${id}/deletion-impact`);
}
/** Réordonne les arcs d'une campagne (glisser-déposer) : order = position. */
reorderArcs(orderedIds: string[]): Observable<void> {
return this.http.put<void>('/api/arcs/reorder', { orderedIds });
}
// ========== CHAPTER ==========
/**
* Liste les chapitres d'un arc. Si {@code playthroughId} est fourni, le backend
@@ -122,6 +132,11 @@ export class CampaignService {
return this.http.get<ChapterDeletionImpact>(`/api/chapters/${id}/deletion-impact`);
}
/** Réordonne (et déplace) les chapitres d'un arc : order = position, arcId = arc cible. */
reorderChapters(arcId: string, orderedIds: string[]): Observable<void> {
return this.http.put<void>('/api/chapters/reorder', { arcId, orderedIds });
}
// ========== SCENE ==========
getScenes(chapterId: string): Observable<Scene[]> {
const params = new HttpParams().set('chapterId', chapterId);
@@ -144,6 +159,11 @@ export class CampaignService {
return this.http.delete<void>(`/api/scenes/${id}`);
}
/** Réordonne (et déplace) les scènes d'un chapitre : order = position, chapterId = cible. */
reorderScenes(chapterId: string, orderedIds: string[]): Observable<void> {
return this.http.put<void>('/api/scenes/reorder', { chapterId, orderedIds });
}
search(q: string): Observable<Campaign[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Campaign[]>(`${this.apiUrl}/search`, { params });

View File

@@ -0,0 +1,38 @@
import { Injectable, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable, Subject } from 'rxjs';
/**
* Bus d'événement minimal pour synchroniser les vues qui affichent les MÊMES données
* (arbre de la sidebar ↔ vues en cartes). Après un réordonnancement/déplacement
* (depuis n'importe quelle vue), on émet `changed$` ; chaque vue concernée se recharge
* → tout reste cohérent SANS rafraîchir la page (F5).
*/
@Injectable({ providedIn: 'root' })
export class DataSyncService {
private readonly _changed = new Subject<void>();
/** Émis quand une donnée ordonnable a changé (reorder/move). */
readonly changed$ = this._changed.asObservable();
notify(): void {
this._changed.next();
}
/**
* Abonne `handler` aux changements de données, avec auto-désabonnement lié au
* cycle de vie du composant (`destroyRef`). Évite de recopier le couple
* `changed$.pipe(takeUntilDestroyed(...)).subscribe(...)` dans chaque vue.
*/
onChange(destroyRef: DestroyRef, handler: () => void): void {
this.changed$.pipe(takeUntilDestroyed(destroyRef)).subscribe(handler);
}
/**
* Persiste un réordonnancement : au succès, notifie les autres vues
* (`notify()`) ; en cas d'échec, exécute `rollback` (typiquement recharger pour
* annuler le déplacement optimiste à l'écran).
*/
persist<T>(reorder$: Observable<T>, rollback: () => void): void {
reorder$.subscribe({ next: () => this.notify(), error: () => rollback() });
}
}

View File

@@ -32,6 +32,11 @@ export class EnemyService {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/** Réordonne (et reclasse) les ennemis d'un dossier : order = position, folder = cible. */
reorder(folder: string | null, orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/reorder`, { folder, orderedIds });
}
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
search(q: string): Observable<Enemy[]> {
return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } });

View File

@@ -1,6 +1,15 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
/** Type d'entité déplaçable (réordonnancement par glisser-déposer dans l'arbre). */
export type ReorderKind = 'arc' | 'chapter' | 'scene' | 'npc' | 'enemy' | 'table' | 'folder' | 'page';
/** Contexte servant à recharger la bonne sidebar après un réordonnancement. */
export interface SidebarReorderContext {
scope: 'campaign' | 'lore';
id: string;
}
export interface TreeItem {
id: string;
label: string;
@@ -9,6 +18,14 @@ export interface TreeItem {
isAction?: boolean; // style "action" (ex: "+ Nouveau chapitre")
/** Clé d'icône optionnelle (ex: "users"). Résolue par le composant via `resolveIcon`. */
iconKey?: string;
/** Type d'entité déplaçable (active le drag sur ce nœud). Absent = non déplaçable. */
dragKind?: ReorderKind;
/** Id BRUT de l'entité (sans préfixe) — utilisé pour l'ordre persisté. */
dragId?: string;
/** Types acceptés en dépôt dans la liste des enfants de ce nœud (drop list). */
dropKinds?: ReorderKind[];
/** Parent transmis au réordonnancement (arcId, chapterId, nodeId, dossier…). */
dropParentId?: string | null;
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
meta?: string;
/**
@@ -66,7 +83,6 @@ export interface BottomPanel {
id: string; // identifiant pour mémoriser l'état ouvert/fermé
title: string;
items: BottomPanelItem[];
initiallyOpen?: boolean;
/** Action "+" inline dans le header — créer un item sans déplier le panneau. */
headerAction?: { label: string; route: string };
}
@@ -87,6 +103,12 @@ export interface SecondarySidebarConfig {
bottomPanel?: BottomPanel; // optionnel : présent côté Lore (Templates)
/** @deprecated Remplacé par bottomPanel. Gardé pour compat des callers campagne. */
footerLabel?: string;
/** Types déplaçables acceptés à la RACINE de l'arbre (ex: arcs côté campagne). */
rootDropKinds?: ReorderKind[];
/** Parent transmis au reorder pour les éléments racine (campaignId, ou null = racine lore). */
rootDropParentId?: string | null;
/** Contexte de rechargement après un réordonnancement (présent = DnD activé). */
reorderContext?: SidebarReorderContext;
}
/**

View File

@@ -21,6 +21,8 @@ export interface LoreNode {
/** ID du dossier parent (null = racine). */
parentId?: string | null;
loreId: string;
/** Position parmi les dossiers frères (glisser-déposer). */
order?: number;
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
type?: string;
description?: string;

View File

@@ -117,6 +117,15 @@ export class LoreService {
return this.http.delete<void>(`${this.nodesUrl}/${id}`).pipe(tap(() => this.invalidate()));
}
/**
* Réordonne (et déplace) des dossiers : `order` = position dans `orderedIds`, et
* chaque dossier reçoit `parentId` (null = racine). Anti-cycle géré côté backend.
*/
reorderNodes(parentId: string | null, orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.nodesUrl}/reorder`, { parentId, orderedIds })
.pipe(tap(() => this.invalidate()));
}
getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> {
return this.http.get<LoreNodeDeletionImpact>(`${this.nodesUrl}/${id}/deletion-impact`);
}

View File

@@ -37,6 +37,11 @@ export class NpcService {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/** Réordonne (et reclasse) les PNJ d'un dossier : order = position, folder = dossier cible. */
reorder(folder: string | null, orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/reorder`, { folder, orderedIds });
}
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
search(q: string): Observable<Npc[]> {
return this.http.get<Npc[]>(`${this.apiUrl}/search`, { params: { q } });

View File

@@ -6,6 +6,8 @@ export interface Page {
nodeId: string;
templateId?: string | null;
title: string;
/** Position de la page dans son dossier (glisser-déposer). */
order?: number;
values?: Record<string, string>;
/**
* Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images

View File

@@ -59,6 +59,15 @@ export class PageService {
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
}
/**
* Réordonne (et déplace) les pages d'un dossier : `order` = position dans
* `orderedIds`, et chaque page reçoit `nodeId` (déplacement par glisser-déposer).
*/
reorder(nodeId: string, orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/reorder`, { nodeId, orderedIds })
.pipe(tap(() => this.invalidate()));
}
search(q: string): Observable<Page[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });

View File

@@ -32,6 +32,11 @@ export class RandomTableService {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/** Réordonne les tables aléatoires d'une campagne : order = position. */
reorder(orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/reorder`, { orderedIds });
}
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
search(q: string): Observable<RandomTable[]> {
return this.http.get<RandomTable[]>(`${this.apiUrl}/search`, { params: { q } });

View File

@@ -0,0 +1,67 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { CampaignService } from './campaign.service';
import { NpcService } from './npc.service';
import { EnemyService } from './enemy.service';
import { RandomTableService } from './random-table.service';
import { PageService } from './page.service';
import { LoreService } from './lore.service';
import { TemplateService } from './template.service';
import { LayoutService, ReorderKind, SidebarReorderContext } from './layout.service';
import { CampaignSidebarService } from './campaign-sidebar.service';
import { DataSyncService } from './data-sync.service';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore/lore-sidebar.helper';
/**
* Persiste un réordonnancement déclenché par glisser-déposer DANS L'ARBRE de la
* sidebar, puis recharge la sidebar depuis le backend (source de vérité).
*
* Le `kind` détermine l'endpoint (et le sens du `parentId` : arcId / chapterId /
* nodeId / dossier…). On recharge systématiquement après la réponse — succès comme
* erreur — pour resynchroniser l'affichage sur l'état persisté (zéro dérive).
*/
@Injectable({ providedIn: 'root' })
export class SidebarReorderService {
constructor(
private campaignService: CampaignService,
private npcService: NpcService,
private enemyService: EnemyService,
private randomTableService: RandomTableService,
private pageService: PageService,
private loreService: LoreService,
private templateService: TemplateService,
private layoutService: LayoutService,
private campaignSidebar: CampaignSidebarService,
private dataSync: DataSyncService
) {}
reorder(context: SidebarReorderContext, kind: ReorderKind, parentId: string | null, orderedIds: string[]): void {
this.persist(kind, parentId, orderedIds).subscribe({
next: () => { this.reload(context); this.dataSync.notify(); },
error: () => { this.reload(context); this.dataSync.notify(); }
});
}
private persist(kind: ReorderKind, parentId: string | null, orderedIds: string[]): Observable<void> {
switch (kind) {
case 'arc': return this.campaignService.reorderArcs(orderedIds);
case 'chapter': return this.campaignService.reorderChapters(parentId ?? '', orderedIds);
case 'scene': return this.campaignService.reorderScenes(parentId ?? '', orderedIds);
case 'npc': return this.npcService.reorder(parentId || null, orderedIds);
case 'enemy': return this.enemyService.reorder(parentId || null, orderedIds);
case 'table': return this.randomTableService.reorder(orderedIds);
case 'folder': return this.loreService.reorderNodes(parentId || null, orderedIds);
case 'page': return this.pageService.reorder(parentId ?? '', orderedIds);
default: return of(void 0);
}
}
private reload(context: SidebarReorderContext): void {
if (context.scope === 'campaign') {
this.campaignSidebar.show(context.id);
} else {
loadLoreSidebarData(context.id, this.loreService, this.templateService, this.pageService)
.subscribe(data => this.layoutService.show(buildLoreSidebarConfig(data)));
}
}
}