Files
LoreMind/web/src/app/sessions/session-reference-panel/session-reference-panel.component.ts
IETM_FIXE\ietm6 a9242ba1e1
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
Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
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")
2026-06-26 09:10:20 +02:00

157 lines
6.2 KiB
TypeScript

import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package } from 'lucide-angular';
import { catchError, of } from 'rxjs';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { NpcService } from '../../services/npc.service';
import { EnemyService } from '../../services/enemy.service';
import { Character } from '../../services/character.model';
import { Npc } from '../../services/npc.model';
import { Arc, Chapter, Scene } from '../../services/campaign.model';
import { loadCampaignTreeData, CampaignTreeData } from '../../campaigns/campaign-tree.helper';
import {
SessionDicePanelComponent, DiceRollResult
} from '../session-dice-panel/session-dice-panel.component';
import { SessionAiChatPanelComponent } from '../session-ai-chat-panel/session-ai-chat-panel.component';
import { SessionRandomTablesPanelComponent } from '../session-random-tables-panel/session-random-tables-panel.component';
import { SessionItemCatalogsPanelComponent } from '../session-item-catalogs-panel/session-item-catalogs-panel.component';
type TabId = 'dice' | 'tables' | 'objects' | 'characters' | 'scenes' | 'ai';
/**
* Panneau latéral du mode jeu : référence rapide en lecture seule.
*
* <p>Charge à la volée les PJ/PNJ et l'arbre de scènes de la campagne associée
* à la session. La navigation vers les fiches s'ouvre dans un nouvel onglet
* pour ne pas casser le flux de la session en cours.</p>
*
* <p>Le sous-composant {@link SessionDicePanelComponent} émet un événement
* de jet qui remonte ici puis vers le parent via {@link rolled}.</p>
*/
@Component({
selector: 'app-session-reference-panel',
imports: [LucideAngularModule, TranslatePipe, SessionDicePanelComponent, SessionAiChatPanelComponent, SessionRandomTablesPanelComponent, SessionItemCatalogsPanelComponent],
templateUrl: './session-reference-panel.component.html',
styleUrls: ['./session-reference-panel.component.scss']
})
export class SessionReferencePanelComponent implements OnChanges {
readonly User = User;
readonly Drama = Drama;
readonly Swords = Swords;
readonly Dices = Dices;
readonly ExternalLink = ExternalLink;
readonly Sparkles = Sparkles;
readonly Table2 = Table2;
readonly Package = Package;
@Input() campaignId!: string;
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
@Input() playthroughId: string | null = null;
@Input() sessionId!: string;
@Input() canAddToJournal = true;
@Output() rolled = new EventEmitter<DiceRollResult>();
/** Émis quand l'IA répond et que le MJ veut sauvegarder la réponse comme entrée. */
@Output() aiReplyToJournal = new EventEmitter<string>();
/** Émis pour consigner un objet de catalogue au journal (entrée NOTE). */
@Output() noteToJournal = new EventEmitter<string>();
activeTab: TabId = 'dice';
characters: Character[] = [];
npcs: Npc[] = [];
treeData: CampaignTreeData | null = null;
loadingChars = false;
loadingTree = false;
/** True dès qu'un tab "lourd" a été chargé pour éviter de rappeler l'API en boucle. */
private charsLoaded = false;
private treeLoaded = false;
constructor(
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private enemyService: EnemyService
) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes['campaignId']) {
this.charsLoaded = false;
this.treeLoaded = false;
this.characters = [];
this.npcs = [];
this.treeData = null;
}
}
selectTab(tab: TabId): void {
this.activeTab = tab;
if (tab === 'characters') this.ensureCharactersLoaded();
if (tab === 'scenes') this.ensureTreeLoaded();
}
private ensureCharactersLoaded(): void {
if (this.charsLoaded || this.loadingChars || !this.campaignId) return;
this.loadingChars = true;
// PJ : propres à la Partie. PNJ : campagne-scope.
const chars$ = this.playthroughId
? this.characterService.getByPlaythrough(this.playthroughId)
: of([] as Character[]);
chars$.pipe(catchError(() => of([] as Character[])))
.subscribe(list => { this.characters = list; this.tryFinishCharsLoad(); });
this.npcService.getByCampaign(this.campaignId).pipe(catchError(() => of([] as Npc[])))
.subscribe(list => { this.npcs = list; this.tryFinishCharsLoad(); });
}
private tryFinishCharsLoad(): void {
// On considère que le chargement est fini quand au moins une des deux listes
// a été assignée (vide ou pleine). Le double subscribe ci-dessus garantit
// qu'on tombe ici deux fois ; idempotent.
this.loadingChars = false;
this.charsLoaded = true;
}
private ensureTreeLoaded(): void {
if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
this.loadingTree = true;
loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, undefined, this.enemyService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
).subscribe(data => {
this.treeData = data;
this.loadingTree = false;
this.treeLoaded = true;
});
}
/**
* Ouvre une fiche dans un nouvel onglet pour préserver l'écran de session.
* Le MJ peut consulter sans perdre son journal ni son historique de dés.
*/
openInNewTab(path: (string | number)[]): void {
const url = path.map(p => String(p)).join('/');
window.open('/' + url, '_blank', 'noopener');
}
/** Helpers de typage pour le template (Angular n'infère pas bien sans). */
chaptersOf(arc: Arc): Chapter[] {
return this.treeData?.chaptersByArc[arc.id!] ?? [];
}
scenesOf(chapter: Chapter): Scene[] {
return this.treeData?.scenesByChapter[chapter.id!] ?? [];
}
onDiceRolled(result: DiceRollResult): void {
this.rolled.emit(result);
}
onAiSaveToJournal(content: string): void {
this.aiReplyToJournal.emit(content);
}
onItemNote(content: string): void {
this.noteToJournal.emit(content);
}
}