Files
LoreMind/web/src/app/sessions/session-detail/session-detail.component.ts
IETM_FIXE\ietm6 42c4b53ced
Some checks failed
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m9s
Build & Push Images / build (brain) (push) Successful in 1m10s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 2m36s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (core) (push) Successful in 3m9s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 38s
Stack qualité CI (MegaLinter, Trivy, ESLint) et purge du backlog lint web
CI (Gitea Actions) :
- Nouveau workflow quality.yml (séparé de ci.yml, non bloquant pour la release) :
  MegaLinter v9 (PMD, Ruff, Bandit, gitleaks, hadolint), ng lint (web) et
  Trivy (CVE des dépendances Maven/pip/npm), sur main + beta + PR
- Trivy : préchargement du cache Maven avant le scan — sans ça Maven Central
  rate-limite l'IP du runner (429) en résolvant les BOMs du parent Spring Boot
- upload-artifact v4 → v3 : l'API artifacts v4 n'est pas supportée par Gitea
  (corrige aussi l'upload du rapport Playwright de e2e.yml, cassé depuis toujours)
- Ruleset PMD projet (java-pmd-ruleset.xml, auto-détecté par MegaLinter) orienté
  bugs réels, sans le style Lombok-hostile du défaut : 11 337 → 65 findings ;
  PMD en mode rapport (JAVA_PMD_DISABLE_ERRORS) le temps de purger ce backlog

Web :
- angular-eslint 21 + eslint-plugin-sonarjs : les règles Sonar vivent désormais
  dans ESLint (config web/eslint.config.js, réglages justifiés en commentaire)
- tsconfig : skipLibCheck + types:[] + node_modules ré-exclu — répare les builds
  desktop Windows/Linux cassés par le conflit @types/eslint-scope vs ESLint 9
- Purge du backlog lint : 114 erreurs → 0 sur 66 fichiers (ngOnDestroy vides
  supprimés, any typés avec les vrais DTOs, outputs (close) → (closed), regex
  super-linéaires désamorcées, ternaires/fonctions imbriqués dépliés, intention
  documentée sur les catch/error volontairement vides)
- Bug latent corrigé au passage : license.service.disconnect() émettait
  undefined au lieu de true en cas de succès (masqué par un double cast)
2026-07-07 01:05:00 +02:00

429 lines
16 KiB
TypeScript

import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
LucideAngularModule, LucideIconData,
Dices, ArrowLeft, Square, Trash2, Pencil, Check,
StickyNote, Sparkles, UserCheck, Plus, X,
Pin, PinOff, ExternalLink, ScrollText, RefreshCw
} from 'lucide-angular';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
import { of } from 'rxjs';
import { SessionService } from '../../services/session.service';
import { Session } from '../../services/session.model';
import { PlaythroughService } from '../../services/playthrough.service';
import { CampaignService } from '../../services/campaign.service';
import { Scene } from '../../services/campaign.model';
import {
SessionEntry, SessionEntryInput, EntryType, ENTRY_TYPE_META
} from '../../services/session-entry.model';
import { SessionEntryService } from '../../services/session-entry.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
import { SessionReferencePanelComponent } from '../session-reference-panel/session-reference-panel.component';
import { DiceRollResult } from '../session-dice-panel/session-dice-panel.component';
/**
* Vue détail d'une Session avec journal horodaté.
* Form de saisie en haut, timeline en dessous (plus récent en premier).
* Le layout dédié "mode jeu" sera ajouté en Phase 4.
*/
@Component({
selector: 'app-session-detail',
imports: [CommonModule, FormsModule, LucideAngularModule, TranslatePipe, RouterLink, SessionReferencePanelComponent],
templateUrl: './session-detail.component.html',
styleUrls: ['./session-detail.component.scss']
})
export class SessionDetailComponent implements OnInit, OnDestroy {
readonly Dices = Dices;
readonly ArrowLeft = ArrowLeft;
readonly Square = Square;
readonly Trash2 = Trash2;
readonly Pencil = Pencil;
readonly Check = Check;
readonly Plus = Plus;
readonly X = X;
readonly Pin = Pin;
readonly PinOff = PinOff;
readonly ExternalLink = ExternalLink;
readonly ScrollText = ScrollText;
readonly RefreshCw = RefreshCw;
/** Mapping enum → composant Lucide pour le rendu des icônes par type. */
readonly typeIcons: Record<EntryType, LucideIconData> = {
NOTE: StickyNote,
EVENT: Sparkles,
DICE_ROLL: Dices,
PLAYER_ACTION: UserCheck,
};
readonly entryTypes: EntryType[] = ['NOTE', 'EVENT', 'DICE_ROLL', 'PLAYER_ACTION'];
readonly entryTypeMeta = ENTRY_TYPE_META;
session: Session | null = null;
/** Résolu via Playthrough.campaignId (Session → Playthrough → Campaign). */
campaignId: string | null = null;
/** Timeline triée du plus récent au plus ancien (DESC) pour l'UX en partie. */
entries: SessionEntry[] = [];
editingName = false;
editName = '';
/** State de la zone "Ajouter une entrée". */
newEntryType: EntryType = 'NOTE';
newEntryContent = '';
submittingEntry = false;
/** Id de l'entrée en cours d'édition (null si aucune). */
editingEntryId: string | null = null;
editEntryType: EntryType = 'NOTE';
editEntryContent = '';
// ─────────────── Mode cockpit : scène courante épinglée ───────────────
pinnedScene: Scene | null = null;
/** Arc parent de la scène épinglée (résolu via son chapitre) — pour le lien d'ouverture. */
pinnedArcId: string | null = null;
/** Narration joueur dépliée dans le bandeau. */
narrationOpen = false;
// ─────────────── Récap « précédemment… » ───────────────
recapLoading = false;
recapText: string | null = null;
recapFrom: string | null = null;
recapError: string | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private sessionService: SessionService,
private playthroughService: PlaythroughService,
private campaignService: CampaignService,
private entryService: SessionEntryService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService,
private translate: TranslateService
) {}
/** Libellé traduit d'un type d'entrée (le modèle partagé reste en FR pour la donnée). */
typeLabel(type: EntryType): string {
return this.translate.instant('sessionDetail.entryType.' + type);
}
/** Placeholder de la zone de saisie, dépendant du type sélectionné. */
newEntryPlaceholder(): string {
return this.translate.instant('sessionDetail.addEntryPlaceholder', {
type: this.typeLabel(this.newEntryType).toLowerCase()
});
}
ngOnInit(): void {
this.layoutService.hide();
this.route.paramMap.pipe(
map(pm => pm.get('id')),
filter((id): id is string => !!id),
switchMap(id => this.sessionService.getSessionById(id).pipe(
catchError(() => of(null))
))
).subscribe(session => {
this.session = session;
if (session) {
this.pageTitleService.set(session.name);
this.loadEntries(session.id);
this.loadPinnedScene(session.currentSceneId ?? null);
if (session.playthroughId) {
this.playthroughService.getById(session.playthroughId).pipe(
catchError(() => of(null))
).subscribe(pt => { this.campaignId = pt ? pt.campaignId : null; });
}
}
});
}
private loadEntries(sessionId: string): void {
this.entryService.getEntries(sessionId).pipe(
catchError(() => of([] as SessionEntry[]))
).subscribe(list => {
this.entries = list.slice().sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
});
}
// ─────────────── Mode cockpit : scène courante ───────────────
/** Charge (ou vide) le bandeau de scène épinglée. Épingle caduque (scène supprimée) → ignorée. */
private loadPinnedScene(sceneId: string | null): void {
if (!sceneId) { this.pinnedScene = null; this.pinnedArcId = null; return; }
this.campaignService.getSceneById(sceneId).pipe(catchError(() => of(null))).subscribe(scene => {
this.pinnedScene = scene;
this.narrationOpen = false;
this.pinnedArcId = null;
if (scene?.chapterId) {
this.campaignService.getChapterById(scene.chapterId).pipe(catchError(() => of(null)))
.subscribe(ch => this.pinnedArcId = ch?.arcId ?? null);
}
});
}
/** Épingle une scène depuis le panneau de référence (onglet Scènes). */
onPinScene(sceneId: string): void {
if (!this.session) return;
this.sessionService.setCurrentScene(this.session.id, sceneId).subscribe({
next: updated => { this.session = updated; this.loadPinnedScene(updated.currentSceneId ?? null); },
error: () => console.error('Erreur lors de l\'épinglage de la scène')
});
}
unpinScene(): void {
if (!this.session) return;
this.sessionService.setCurrentScene(this.session.id, null).subscribe({
next: updated => { this.session = updated; this.pinnedScene = null; this.pinnedArcId = null; },
error: () => console.error('Erreur lors du retrait de l\'épingle')
});
}
/** Ouvre la scène épinglée dans un nouvel onglet (préserve l'écran de session). */
openPinnedScene(): void {
if (!this.pinnedScene || !this.campaignId || !this.pinnedArcId) return;
const url = ['/campaigns', this.campaignId, 'arcs', this.pinnedArcId,
'chapters', this.pinnedScene.chapterId, 'scenes', this.pinnedScene.id].join('/');
window.open(url, '_blank', 'noopener');
}
// ─────────────── Récap « précédemment… » ───────────────
generateRecap(): void {
if (!this.session || this.recapLoading) return;
this.recapLoading = true;
this.recapError = null;
this.sessionService.recap(this.session.id).subscribe({
next: r => {
this.recapLoading = false;
this.recapText = r.recap;
this.recapFrom = r.previousSessionName;
},
error: err => {
this.recapLoading = false;
// Message backend (pas de séance précédente, journal vide, Brain KO…) si disponible.
this.recapError = err?.error?.error
?? this.translate.instant('sessionDetail.recap.error');
}
});
}
/** Consigne le récap au journal (entrée NOTE 📜) puis referme l'encart. */
addRecapToJournal(): void {
if (!this.session || !this.recapText) return;
const input: SessionEntryInput = { type: 'NOTE', content: '📜 ' + this.recapText };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => {
this.entries = [created, ...this.entries];
this.recapText = null;
this.recapFrom = null;
},
error: () => console.error('Erreur lors de l\'ajout du récap au journal')
});
}
closeRecap(): void {
this.recapText = null;
this.recapFrom = null;
this.recapError = null;
}
// ─────────────── Renommage de la Session ───────────────
startRename(): void {
if (!this.session) return;
this.editName = this.session.name;
this.editingName = true;
}
cancelRename(): void {
this.editingName = false;
this.editName = '';
}
saveRename(): void {
if (!this.session || !this.editName.trim()) return;
this.sessionService.renameSession(this.session.id, this.editName.trim()).subscribe({
next: updated => {
this.session = updated;
this.editingName = false;
this.pageTitleService.set(updated.name);
},
error: () => console.error('Erreur lors du renommage de la session')
});
}
// ─────────────── Fin / suppression de Session ───────────────
endSession(): void {
if (!this.session || !this.session.active) return;
const session = this.session;
this.confirmDialog.confirm({
title: this.translate.instant('sessionDetail.endConfirm.title'),
message: this.translate.instant('sessionDetail.endConfirm.message', { name: session.name }),
details: [this.translate.instant('sessionDetail.endConfirm.detail')],
confirmLabel: this.translate.instant('sessionDetail.endConfirm.confirmLabel'),
variant: 'warning'
}).then(ok => {
if (!ok) return;
this.sessionService.endSession(session.id).subscribe({
next: updated => this.session = updated,
error: () => console.error('Erreur lors de la fin de session')
});
});
}
deleteSession(): void {
if (!this.session) return;
const session = this.session;
const entryCount = this.entries.length;
let entriesDetail: string;
if (entryCount === 0) {
entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.noEntries');
} else if (entryCount === 1) {
entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.entriesOne');
} else {
entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.entriesMany', { n: entryCount });
}
const details = [
entriesDetail,
this.translate.instant('sessionDetail.deleteConfirm.irreversible')
];
this.confirmDialog.confirm({
title: this.translate.instant('sessionDetail.deleteConfirm.title'),
message: this.translate.instant('sessionDetail.deleteConfirm.message', { name: session.name }),
details,
confirmLabel: this.translate.instant('common.delete'),
variant: 'danger'
}).then(ok => {
if (!ok) return;
const cid = this.campaignId;
this.sessionService.deleteSession(session.id).subscribe({
next: () => {
if (cid) this.router.navigate(['/campaigns', cid]);
else this.router.navigate(['/campaigns']);
},
error: () => console.error('Erreur lors de la suppression de la session')
});
});
}
// ─────────────── Ajout d'entrée ───────────────
submitNewEntry(): void {
if (!this.session || this.submittingEntry) return;
const content = this.newEntryContent.trim();
if (!content) return;
this.submittingEntry = true;
const input: SessionEntryInput = { type: this.newEntryType, content };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => {
this.submittingEntry = false;
this.entries = [created, ...this.entries];
this.newEntryContent = '';
},
error: () => {
this.submittingEntry = false;
console.error('Erreur lors de l\'ajout de l\'entrée');
}
});
}
// ─────────────── Édition d'entrée ───────────────
startEditEntry(entry: SessionEntry): void {
this.editingEntryId = entry.id;
this.editEntryType = entry.type;
this.editEntryContent = entry.content;
}
cancelEditEntry(): void {
this.editingEntryId = null;
this.editEntryContent = '';
}
saveEditEntry(entry: SessionEntry): void {
if (!this.session) return;
const content = this.editEntryContent.trim();
if (!content) return;
const input: SessionEntryInput = { type: this.editEntryType, content };
this.entryService.updateEntry(this.session.id, entry.id, input).subscribe({
next: updated => {
this.entries = this.entries.map(e => e.id === updated.id ? updated : e);
this.editingEntryId = null;
},
error: () => console.error('Erreur lors de la mise à jour de l\'entrée')
});
}
/**
* Réception d'un jet de dés depuis le panneau latéral.
* On crée une entrée DICE_ROLL dans le journal avec le résumé formaté.
*/
onDiceRolled(result: DiceRollResult): void {
if (!this.session || !this.session.active) return;
const input: SessionEntryInput = { type: 'DICE_ROLL', content: result.summary };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => this.entries = [created, ...this.entries],
error: () => console.error('Erreur lors de l\'ajout du jet au journal')
});
}
/**
* Réception d'une réponse IA à sauvegarder dans le journal.
* Type NOTE par défaut car c'est le MJ qui choisit de capter une suggestion
* comme repère — pas un évènement de partie en lui-même.
*/
onAiReplyToJournal(content: string): void {
if (!this.session || !this.session.active) return;
const trimmed = content.trim();
if (!trimmed) return;
const input: SessionEntryInput = { type: 'NOTE', content: '💡 ' + trimmed };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => this.entries = [created, ...this.entries],
error: () => console.error('Erreur lors de l\'ajout de la suggestion IA au journal')
});
}
/**
* Réception d'un objet de catalogue à consigner dans le journal.
* Le panneau fournit déjà une chaîne formatée (🛒 …) : on la sauvegarde
* telle quelle comme entrée NOTE.
*/
onItemNoteToJournal(content: string): void {
if (!this.session || !this.session.active) return;
const trimmed = content.trim();
if (!trimmed) return;
const input: SessionEntryInput = { type: 'NOTE', content: trimmed };
this.entryService.createEntry(this.session.id, input).subscribe({
next: created => this.entries = [created, ...this.entries],
error: () => console.error('Erreur lors de l\'ajout de l\'objet au journal')
});
}
deleteEntry(entry: SessionEntry): void {
if (!this.session) return;
const session = this.session;
this.confirmDialog.confirm({
title: this.translate.instant('sessionDetail.deleteEntryConfirm.title'),
message: this.translate.instant('sessionDetail.deleteEntryConfirm.message'),
confirmLabel: this.translate.instant('common.delete'),
variant: 'danger'
}).then(ok => {
if (!ok) return;
this.entryService.deleteEntry(session.id, entry.id).subscribe({
next: () => this.entries = this.entries.filter(e => e.id !== entry.id),
error: () => console.error('Erreur lors de la suppression de l\'entrée')
});
});
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}