Stack qualité CI (MegaLinter, Trivy, ESLint) et purge du backlog lint web
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

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)
This commit is contained in:
2026-07-07 01:05:00 +02:00
parent 3e9e828225
commit 42c4b53ced
71 changed files with 353 additions and 276 deletions

View File

@@ -82,9 +82,11 @@ jobs:
if: failure() if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
# v3 obligatoire : l'API artifacts v4 n'est pas supportée par Gitea
# (GHESNotSupportedError — constaté sur le job MegaLinter).
- name: Upload Playwright report - name: Upload Playwright report
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: playwright-report name: playwright-report
path: web/playwright-report/ path: web/playwright-report/

View File

@@ -32,9 +32,11 @@ jobs:
# main → scan complet du dépôt ; beta et PR → seulement les fichiers # main → scan complet du dépôt ; beta et PR → seulement les fichiers
# modifiés par rapport à main (rapide, feedback ciblé). # modifiés par rapport à main (rapide, feedback ciblé).
VALIDATE_ALL_CODEBASE: ${{ github.ref == 'refs/heads/main' }} VALIDATE_ALL_CODEBASE: ${{ github.ref == 'refs/heads/main' }}
# v3 obligatoire : l'API artifacts v4 (@actions/artifact 2.x) n'est pas
# supportée par Gitea (GHESNotSupportedError constaté avec v4).
- name: Publier les rapports - name: Publier les rapports
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: megalinter-reports name: megalinter-reports
path: megalinter-reports/ path: megalinter-reports/
@@ -64,11 +66,29 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Pour résoudre pom.xml (versions héritées du parent Spring Boot), Trivy
# télécharge les BOMs depuis Maven Central — qui finit par rate-limiter
# l'IP du runner (429). Parade officielle : peupler ~/.m2 AVANT le scan,
# Trivy lit le cache local en priorité. Le cache setup-java (clé pom.xml)
# rend l'étape quasi gratuite d'un run à l'autre.
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Précharger le cache Maven (~/.m2)
working-directory: core
run: |
chmod +x ./mvnw
./mvnw -B -q dependency:go-offline
- name: Installer Trivy - name: Installer Trivy
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Scanne les manifestes du dépôt (pom.xml, requirements*.txt, # Scanne les manifestes du dépôt (pom.xml, requirements*.txt,
# package-lock.json) contre les bases CVE. --ignore-unfixed : on ne # package-lock.json) contre les bases CVE. --ignore-unfixed : on ne
# bloque que sur les vulnérabilités qui ONT un correctif publié. # bloque que sur les vulnérabilités qui ONT un correctif publié.
# Si un 429 Maven Central réapparaît malgré le cache : ajouter
# --offline-scan (aucun accès réseau pendant la résolution, au prix
# d'ignorer silencieusement ce qui manquerait dans ~/.m2).
- name: Scan des dépendances (HIGH/CRITICAL bloquants) - name: Scan des dépendances (HIGH/CRITICAL bloquants)
run: | run: |
trivy fs . \ trivy fs . \

View File

@@ -35,3 +35,9 @@ REPORT_OUTPUT_FOLDER: megalinter-reports
# Phase de rodage : passer temporairement à true pour rendre le job # Phase de rodage : passer temporairement à true pour rendre le job
# informatif (rapport sans échec CI) le temps de purger l'existant. # informatif (rapport sans échec CI) le temps de purger l'existant.
DISABLE_ERRORS: false DISABLE_ERRORS: false
# PMD : ruleset projet à la racine (java-pmd-ruleset.xml, détecté
# automatiquement) — orienté bugs réels, sans style. Non-bloquant le temps
# de purger le backlog (~65 findings : PreserveStackTrace, EmptyCatchBlock,
# CheckResultSet, RelianceOnDefaultCharset…). Repasser à false ensuite.
JAVA_PMD_DISABLE_ERRORS: true

73
java-pmd-ruleset.xml Normal file
View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
name="LoreMind"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
<description>
Ruleset PMD LoreMind — détecté automatiquement par MegaLinter (JAVA_PMD).
Philosophie : bugs et pièges réels uniquement. Les catégories de STYLE
(codestyle/design/documentation) sont exclues : le défaut MegaLinter y
générait ~11 000 violations hors sujet (conventions incompatibles avec
l'idiome Lombok/slf4j/Spring du projet : champ `log`, DI constructeur…).
</description>
<!-- Bugs probables : null, equals/hashCode, ressources, casts douteux… -->
<rule ref="category/java/errorprone.xml">
<!-- Chaque littéral dupliqué = une violation : du bruit, pas un bug. -->
<exclude name="AvoidDuplicateLiterals"/>
<exclude name="AvoidLiteralsInIfCondition"/>
<!-- Entités JPA/DTO : pas de sérialisation Java native ici. -->
<exclude name="MissingSerialVersionUID"/>
<!-- `this.name = name` en DI/setters : idiome standard. -->
<exclude name="AvoidFieldNameMatchingMethodName"/>
<exclude name="AvoidFieldNameMatchingTypeName"/>
<!-- `while ((x = read()) != null)` : idiome de lecture standard. -->
<exclude name="AssignmentInOperand"/>
<!-- catch(Exception) best-effort : choix assumé du projet (imports,
exports, best-effort serveur) — pas un gate. -->
<exclude name="AvoidCatchingGenericException"/>
<!-- TODO backlog : réactiver et passer les toLowerCase/UpperCase en
Locale.ROOT (27 sites) — bug potentiel (locale turque) mais
changement de comportement à valider posément. -->
<exclude name="UseLocaleWithCaseConversions"/>
</rule>
<!-- Concurrence -->
<rule ref="category/java/multithreading.xml">
<!-- Règle d'époque J2EE : les threads sont légitimes (sidecar, SSE…). -->
<exclude name="DoNotUseThreads"/>
<!-- Flaguerait CHAQUE HashMap, y compris locales à une méthode (×135). -->
<exclude name="UseConcurrentHashMap"/>
</rule>
<!-- Performance (sans les micro-optimisations StringBuilder : le rendu
PDF/HTML fait des milliers d'appends, aucun n'est un point chaud). -->
<rule ref="category/java/performance.xml">
<exclude name="AvoidInstantiatingObjectsInLoops"/>
<exclude name="AppendCharacterWithChar"/>
<exclude name="ConsecutiveLiteralAppends"/>
<exclude name="ConsecutiveAppendsShouldReuse"/>
<exclude name="InsufficientStringBufferDeclaration"/>
</rule>
<!-- Sécurité -->
<rule ref="category/java/security.xml"/>
<!-- Bonnes pratiques (sous-ensemble non-style) -->
<rule ref="category/java/bestpractices.xml">
<!-- slf4j paramétré ({} + args) rend le guard inutile. -->
<exclude name="GuardLogStatement"/>
<!-- Les ports hexagonaux à méthode unique ne sont pas des lambdas. -->
<exclude name="ImplicitFunctionalInterface"/>
<!-- Déclarer List vs ArrayList : style, pas un bug. -->
<exclude name="LooseCoupling"/>
<!-- StringBuilder en champ : pattern builder assumé (export PDF). -->
<exclude name="AvoidStringBufferField"/>
<!-- Style de tests : à l'appréciation du projet, pas un gate. -->
<exclude name="UnitTestAssertionsShouldIncludeMessage"/>
<exclude name="UnitTestContainsTooManyAsserts"/>
<exclude name="UnitTestShouldIncludeAssert"/>
</rule>
</ruleset>

View File

@@ -42,6 +42,23 @@ module.exports = defineConfig([
// Math.random() est légitime ici : tables aléatoires / jets de dés = le // Math.random() est légitime ici : tables aléatoires / jets de dés = le
// domaine métier. Aucun usage cryptographique côté front. // domaine métier. Aucun usage cryptographique côté front.
"sonarjs/pseudo-random": "off", "sonarjs/pseudo-random": "off",
// 9 fonctions historiques dépassent le seuil de 15 (jusqu'à 39 sur les
// graphes campagne/chapitre). Les refactorer "pour le lint" sans filet de
// tests serait plus risqué qu'utile → warn (visible, non bloquant).
// Backlog : campaign-graph (×3), chapter-graph (×2), campaign-import,
// settings, persona-view, sse.util. Repasser en "error" une fois résorbé.
"sonarjs/cognitive-complexity": "warn",
// Convention TS standard : un préfixe `_` marque un paramètre/variable
// volontairement inutilisé (ex. paramètres conservés pour ne pas casser
// les appelants — cf. campaign-tree.helper.ts).
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
}, },
}, },
{ {

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -25,7 +25,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
templateUrl: './arc-create.component.html', templateUrl: './arc-create.component.html',
styleUrls: ['./arc-create.component.scss'] styleUrls: ['./arc-create.component.scss']
}) })
export class ArcCreateComponent implements OnInit, OnDestroy { export class ArcCreateComponent implements OnInit {
readonly BookOpen = BookOpen; readonly BookOpen = BookOpen;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
@@ -88,11 +88,4 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
cancel(): void { cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]); this.router.navigate(['/campaigns', this.campaignId]);
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -183,5 +183,5 @@
[isOpen]="chatOpen" [isOpen]="chatOpen"
[welcomeMessage]="'arcEdit.chatWelcome' | translate" [welcomeMessage]="'arcEdit.chatWelcome' | translate"
[quickSuggestions]="chatQuickSuggestions" [quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false"> (closed)="chatOpen = false">
</app-ai-chat-drawer> </app-ai-chat-drawer>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
@@ -42,7 +42,7 @@ import { FieldProposal } from '../../../services/entity-assist.model';
templateUrl: './arc-edit.component.html', templateUrl: './arc-edit.component.html',
styleUrls: ['./arc-edit.component.scss'] styleUrls: ['./arc-edit.component.scss']
}) })
export class ArcEditComponent implements OnInit, OnDestroy { export class ArcEditComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
@@ -221,11 +221,4 @@ export class ArcEditComponent implements OnInit, OnDestroy {
cancel(): void { cancel(): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]); this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]);
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -35,7 +35,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
templateUrl: './arc-view.component.html', templateUrl: './arc-view.component.html',
styleUrls: ['./arc-view.component.scss'] styleUrls: ['./arc-view.component.scss']
}) })
export class ArcViewComponent implements OnInit, OnDestroy { export class ArcViewComponent implements OnInit {
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Plus = Plus; readonly Plus = Plus;
@@ -162,8 +162,4 @@ export class ArcViewComponent implements OnInit, OnDestroy {
error: () => console.error('Impossible de récupérer les dépendances de l\'arc') error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
}); });
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
}
} }

View File

@@ -92,7 +92,7 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
private readonly DRAG_THRESHOLD = 4; private readonly DRAG_THRESHOLD = 4;
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag. // Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
private adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = []; private adjacency: { key: string; kind: NodeKind; a: string; b: string }[] = [];
// Disposition personnalisée : positions sauvegardées sur la campagne, sauvegarde // Disposition personnalisée : positions sauvegardées sur la campagne, sauvegarde
// différée après un drag (évite un PUT par pixel déplacé). // différée après un drag (évite un PUT par pixel déplacé).
@@ -174,7 +174,10 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
if (this.draggingId) return; // pas de changement de focus en plein drag if (this.draggingId) return; // pas de changement de focus en plein drag
this.hoveredId = node.id; this.hoveredId = node.id;
this.hoverNeighbors = new Set( this.hoverNeighbors = new Set(
this.adjacency.flatMap(e => e.a === node.id ? [e.b] : e.b === node.id ? [e.a] : [])); this.adjacency.flatMap(e => {
if (e.a === node.id) return [e.b];
return e.b === node.id ? [e.a] : [];
}));
} }
onNodeLeave(): void { onNodeLeave(): void {
@@ -230,7 +233,7 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
for (const [arcId, chapters] of Object.entries(treeData.chaptersByArc)) { for (const [arcId, chapters] of Object.entries(treeData.chaptersByArc)) {
for (const ch of chapters) arcOfChapter.set(ch.id!, arcId); for (const ch of chapters) arcOfChapter.set(ch.id!, arcId);
} }
const linkedScenes: Array<{ scene: Scene; chapterId: string }> = []; const linkedScenes: { scene: Scene; chapterId: string }[] = [];
if (!this.hiddenKinds.has('scene')) { if (!this.hiddenKinds.has('scene')) {
for (const [chapterId, scenes] of Object.entries(treeData.scenesByChapter)) { for (const [chapterId, scenes] of Object.entries(treeData.scenesByChapter)) {
if (!arcOfChapter.has(chapterId)) continue; if (!arcOfChapter.has(chapterId)) continue;
@@ -268,7 +271,7 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée // Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
// (A→B et B→A = un seul trait) ; les liens entité→page portent le type de l'entité. // (A→B et B→A = un seul trait) ; les liens entité→page portent le type de l'entité.
const adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = []; const adjacency: { key: string; kind: NodeKind; a: string; b: string }[] = [];
const seenPairs = new Set<string>(); const seenPairs = new Set<string>();
for (const p of pages) { for (const p of pages) {
for (const targetId of p.relatedPageIds ?? []) { for (const targetId of p.relatedPageIds ?? []) {
@@ -530,14 +533,16 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
if (Math.abs(dx) >= needX || Math.abs(dy) >= needY) continue; if (Math.abs(dx) >= needX || Math.abs(dy) >= needY) continue;
const overlapX = needX - Math.abs(dx); const overlapX = needX - Math.abs(dx);
const overlapY = needY - Math.abs(dy); const overlapY = needY - Math.abs(dy);
// Départage déterministe quand les centres coïncident (dx/dy = 0).
const tieBreak = i % 2 === 0 ? 1 : -1;
if (overlapX / needX < overlapY / needY) { if (overlapX / needX < overlapY / needY) {
const shift = overlapX / 2 + 0.5; const shift = overlapX / 2 + 0.5;
const sign = dx !== 0 ? Math.sign(dx) : (i % 2 === 0 ? 1 : -1); const sign = dx !== 0 ? Math.sign(dx) : tieBreak;
a.x -= sign * shift; a.x -= sign * shift;
b.x += sign * shift; b.x += sign * shift;
} else { } else {
const shift = overlapY / 2 + 0.5; const shift = overlapY / 2 + 0.5;
const sign = dy !== 0 ? Math.sign(dy) : (i % 2 === 0 ? 1 : -1); const sign = dy !== 0 ? Math.sign(dy) : tieBreak;
a.y -= sign * shift; a.y -= sign * shift;
b.y += sign * shift; b.y += sign * shift;
} }

View File

@@ -82,7 +82,10 @@ export function buildCampaignTree(
// Clé TYPE|ID obligatoire : chaque table a sa propre séquence IDENTITY (une quête // Clé TYPE|ID obligatoire : chaque table a sa propre séquence IDENTITY (une quête
// id 6 et une scène id 6 coexistent) — sans le type, un manque de quête allumerait // id 6 et une scène id 6 coexistent) — sans le type, un manque de quête allumerait
// à tort la pastille d'une scène/PNJ portant le même numéro. // à tort la pastille d'une scène/PNJ portant le même numéro.
const sevRank = (s: ReadinessSeverity): number => (s === 'BLOCKING' ? 2 : s === 'RECOMMENDED' ? 1 : 0); const sevRank = (s: ReadinessSeverity): number => {
if (s === 'BLOCKING') return 2;
return s === 'RECOMMENDED' ? 1 : 0;
};
const gapsByKey = new Map<string, ReadinessGap[]>(); const gapsByKey = new Map<string, ReadinessGap[]>();
for (const g of readinessGaps) { for (const g of readinessGaps) {
const key = `${g.entityType}|${g.entityId}`; const key = `${g.entityType}|${g.entityId}`;

View File

@@ -69,7 +69,7 @@
[placeholder]="'campaignCreate.gameSystemNamePlaceholder' | translate" [placeholder]="'campaignCreate.gameSystemNamePlaceholder' | translate"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()" (keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()" (keydown.escape)="cancelCreateGameSystem()"
autofocus
/> />
<div class="inline-create-actions"> <div class="inline-create-actions">
<button type="button" class="btn-inline-primary" <button type="button" class="btn-inline-primary"

View File

@@ -29,7 +29,7 @@ export interface CampaignCreatePayload {
styleUrls: ['./campaign-create.component.scss'] styleUrls: ['./campaign-create.component.scss']
}) })
export class CampaignCreateComponent implements OnInit { export class CampaignCreateComponent implements OnInit {
@Output() close = new EventEmitter<void>(); @Output() closed = new EventEmitter<void>();
@Output() created = new EventEmitter<CampaignCreatePayload>(); @Output() created = new EventEmitter<CampaignCreatePayload>();
readonly BookCopy = BookCopy; readonly BookCopy = BookCopy;
@@ -131,6 +131,6 @@ export class CampaignCreateComponent implements OnInit {
} }
onCancel(): void { onCancel(): void {
this.close.emit(); this.closed.emit();
} }
} }

View File

@@ -106,7 +106,7 @@
[placeholder]="'campaignDetail.gameSystemNamePlaceholder' | translate" [placeholder]="'campaignDetail.gameSystemNamePlaceholder' | translate"
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()" (keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
(keydown.escape)="cancelCreateGameSystem()" (keydown.escape)="cancelCreateGameSystem()"
autofocus
/> />
<div class="inline-create-actions"> <div class="inline-create-actions">
<button type="button" class="btn-inline-primary" <button type="button" class="btn-inline-primary"

View File

@@ -52,6 +52,19 @@ interface NpcNode { name: string; description: string; selected: boolean; existi
* Flux : upload → progression streamée → arbre éditable (revue) → création. * Flux : upload → progression streamée → arbre éditable (revue) → création.
* Rien n'est créé tant que l'utilisateur n'a pas validé « Créer dans la campagne ». * Rien n'est créé tant que l'utilisateur n'a pas validé « Créer dans la campagne ».
*/ */
/** Nettoie les pièces (rooms) d'une scène pour le payload de création. */
function cleanRooms(rooms: { name: string; description: string; enemies: string; loot: string }[]) {
return rooms
.filter(r => r.name.trim())
.map(r => ({
name: r.name.trim(),
description: r.description.trim(),
enemies: r.enemies.trim(),
loot: r.loot.trim()
}));
}
@Component({ @Component({
selector: 'app-campaign-import', selector: 'app-campaign-import',
imports: [FormsModule, LucideAngularModule, TranslatePipe, FileDropDirective], imports: [FormsModule, LucideAngularModule, TranslatePipe, FileDropDirective],
@@ -401,14 +414,7 @@ export class CampaignImportComponent implements OnInit {
playerNarration: s.playerNarration.trim(), playerNarration: s.playerNarration.trim(),
gmNotes: s.gmNotes.trim(), gmNotes: s.gmNotes.trim(),
existingId: s.existingId ?? null, existingId: s.existingId ?? null,
rooms: s.rooms rooms: cleanRooms(s.rooms)
.filter(r => r.name.trim())
.map(r => ({
name: r.name.trim(),
description: r.description.trim(),
enemies: r.enemies.trim(),
loot: r.loot.trim()
}))
})) }))
})) }))
})), })),

View File

@@ -39,7 +39,7 @@
@if (showCreateModal) { @if (showCreateModal) {
<app-campaign-create <app-campaign-create
(close)="onModalClose()" (closed)="onModalClose()"
(created)="onCampaignCreated($event)"> (created)="onCampaignCreated($event)">
</app-campaign-create> </app-campaign-create>
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -24,7 +24,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
templateUrl: './chapter-create.component.html', templateUrl: './chapter-create.component.html',
styleUrls: ['./chapter-create.component.scss'] styleUrls: ['./chapter-create.component.scss']
}) })
export class ChapterCreateComponent implements OnInit, OnDestroy { export class ChapterCreateComponent implements OnInit {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null; selectedIcon: string | null = null;
@@ -88,11 +88,4 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
cancel(): void { cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]); this.router.navigate(['/campaigns', this.campaignId]);
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -151,5 +151,5 @@
[isOpen]="chatOpen" [isOpen]="chatOpen"
[welcomeMessage]="'chapterEdit.chatWelcome' | translate" [welcomeMessage]="'chapterEdit.chatWelcome' | translate"
[quickSuggestions]="chatQuickSuggestions" [quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false"> (closed)="chatOpen = false">
</app-ai-chat-drawer> </app-ai-chat-drawer>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
@@ -52,7 +52,7 @@ import { SceneDraftPanelComponent } from '../../../shared/scene-draft-panel/scen
templateUrl: './chapter-edit.component.html', templateUrl: './chapter-edit.component.html',
styleUrls: ['./chapter-edit.component.scss'] styleUrls: ['./chapter-edit.component.scss']
}) })
export class ChapterEditComponent implements OnInit, OnDestroy { export class ChapterEditComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
@@ -220,6 +220,4 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
cancel(): void { cancel(): void {
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]); this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
} }
ngOnDestroy(): void {}
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener, DestroyRef } from '@angular/core'; import { Component, OnInit, ElementRef, ViewChild, HostListener, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -29,7 +29,7 @@ interface GraphEdge { key: string; label: string; kind: LinkType; x1: number; y1
templateUrl: './chapter-graph.component.html', templateUrl: './chapter-graph.component.html',
styleUrls: ['./chapter-graph.component.scss'] styleUrls: ['./chapter-graph.component.scss']
}) })
export class ChapterGraphComponent implements OnInit, OnDestroy { export class ChapterGraphComponent implements OnInit {
readonly ArrowLeft = ArrowLeft; readonly ArrowLeft = ArrowLeft;
readonly Plus = Plus; readonly Plus = Plus;
readonly ZoomIn = ZoomIn; readonly ZoomIn = ZoomIn;
@@ -507,7 +507,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
scene.graphX = node.x; scene.graphX = node.x;
scene.graphY = node.y; scene.graphY = node.y;
this.campaignService.updateScene(nodeId, { ...scene, order: scene.order ?? 0 }) this.campaignService.updateScene(nodeId, { ...scene, order: scene.order ?? 0 })
.subscribe({ error: () => {} }); .subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
} }
private truncate(text: string): string { private truncate(text: string): string {
@@ -548,7 +548,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
}; };
this.campaignService.createScene(payload).subscribe({ this.campaignService.createScene(payload).subscribe({
next: () => this.load(), next: () => this.load(),
error: () => {} error: () => { /* best-effort : erreur ignorée volontairement */ }
}); });
} }
@@ -592,7 +592,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
node.name = name; node.name = name;
node.displayName = this.truncate(name); node.displayName = this.truncate(name);
scene.name = name; scene.name = name;
this.campaignService.updateScene(id, { ...scene, order: scene.order ?? 0 }).subscribe({ error: () => {} }); this.campaignService.updateScene(id, { ...scene, order: scene.order ?? 0 }).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
} }
cancelRename(): void { cancelRename(): void {
@@ -635,7 +635,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
if (existing.some(b => b.targetSceneId === toId)) return; if (existing.some(b => b.targetSceneId === toId)) return;
const next: SceneBranch[] = [...existing, { label: '', targetSceneId: toId, condition: '', kind: 'EXIT' }]; const next: SceneBranch[] = [...existing, { label: '', targetSceneId: toId, condition: '', kind: 'EXIT' }];
this.campaignService.updateScene(fromId, { ...scene, order: scene.order ?? 0, branches: next }) this.campaignService.updateScene(fromId, { ...scene, order: scene.order ?? 0, branches: next })
.subscribe({ next: () => this.load(), error: () => {} }); .subscribe({ next: () => this.load(), error: () => { /* best-effort : erreur ignorée volontairement */ } });
} }
// ─────────────── Sélection / édition / suppression d'un lien ─────────────── // ─────────────── Sélection / édition / suppression d'un lien ───────────────
@@ -698,7 +698,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
? { ...b, label: this.editEdgeLabel.trim(), condition: this.editEdgeCondition.trim(), kind: this.editEdgeKind } ? { ...b, label: this.editEdgeLabel.trim(), condition: this.editEdgeCondition.trim(), kind: this.editEdgeKind }
: b); : b);
this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches }) this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches })
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => {} }); .subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => { /* best-effort : erreur ignorée volontairement */ } });
} }
/** Supprime (sépare) le lien sélectionné. */ /** Supprime (sépare) le lien sélectionné. */
@@ -709,10 +709,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
const { scene, branchIndex } = found; const { scene, branchIndex } = found;
const branches = (scene.branches ?? []).filter((_, i) => i !== branchIndex); const branches = (scene.branches ?? []).filter((_, i) => i !== branchIndex);
this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches }) this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches })
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => {} }); .subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => { /* best-effort : erreur ignorée volontairement */ } });
}
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
} }
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -30,7 +30,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
templateUrl: './chapter-view.component.html', templateUrl: './chapter-view.component.html',
styleUrls: ['./chapter-view.component.scss'] styleUrls: ['./chapter-view.component.scss']
}) })
export class ChapterViewComponent implements OnInit, OnDestroy { export class ChapterViewComponent implements OnInit {
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Network = Network; readonly Network = Network;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
@@ -147,11 +147,4 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
error: () => console.error('Impossible de récupérer les dépendances du chapitre') error: () => console.error('Impossible de récupérer les dépendances du chapitre')
}); });
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -100,6 +100,6 @@
[isOpen]="chatOpen" [isOpen]="chatOpen"
[welcomeMessage]="'characterEdit.chatWelcome' | translate" [welcomeMessage]="'characterEdit.chatWelcome' | translate"
[quickSuggestions]="chatQuickSuggestions" [quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false"> (closed)="chatOpen = false">
</app-ai-chat-drawer> </app-ai-chat-drawer>
} }

View File

@@ -26,6 +26,6 @@
entityType="character" entityType="character"
[entityId]="characterId" [entityId]="characterId"
[isOpen]="chatOpen" [isOpen]="chatOpen"
(close)="chatOpen = false"> (closed)="chatOpen = false">
</app-ai-chat-drawer> </app-ai-chat-drawer>
} }

View File

@@ -57,7 +57,11 @@ export class ItemCatalogViewComponent implements OnInit {
map.get(cat)!.push(it); map.get(cat)!.push(it);
} }
return [...map.entries()] return [...map.entries()]
.sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr'))) .sort(([a], [b]) => {
if (a === '—') return 1; // catégorie "sans catégorie" toujours en dernier
if (b === '—') return -1;
return a.localeCompare(b, 'fr');
})
.map(([category, items]) => ({ category, items })); .map(([category, items]) => ({ category, items }));
} }

View File

@@ -131,6 +131,6 @@
[isOpen]="chatOpen" [isOpen]="chatOpen"
[welcomeMessage]="'npcEdit.chatWelcome' | translate" [welcomeMessage]="'npcEdit.chatWelcome' | translate"
[quickSuggestions]="chatQuickSuggestions" [quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false"> (closed)="chatOpen = false">
</app-ai-chat-drawer> </app-ai-chat-drawer>
} }

View File

@@ -43,6 +43,6 @@
entityType="npc" entityType="npc"
[entityId]="npcId" [entityId]="npcId"
[isOpen]="chatOpen" [isOpen]="chatOpen"
(close)="chatOpen = false"> (closed)="chatOpen = false">
</app-ai-chat-drawer> </app-ai-chat-drawer>
} }

View File

@@ -15,6 +15,11 @@ import { Page } from '../../../services/page.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component'; import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/** Indexe des pages par id (les pages venant du serveur ont toujours un id). */
function indexPagesById(pages: Page[]): Map<string, Page> {
return new Map(pages.map(p => [p.id!, p]));
}
/** /**
* Vue lecture seule "WorldAnvil" d'une fiche PNJ. * Vue lecture seule "WorldAnvil" d'une fiche PNJ.
* Route : /campaigns/:campaignId/npcs/:npcId * Route : /campaigns/:campaignId/npcs/:npcId
@@ -89,7 +94,7 @@ export class NpcViewComponent implements OnInit, OnDestroy {
if (camp.loreId) { if (camp.loreId) {
this.loreId = camp.loreId; this.loreId = camp.loreId;
this.pageService.getByLoreId(camp.loreId).subscribe(pages => { this.pageService.getByLoreId(camp.loreId).subscribe(pages => {
this.lorePagesById = new Map(pages.map(p => [p.id!, p])); this.lorePagesById = indexPagesById(pages);
}); });
} }
}); });

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -34,7 +34,7 @@ import { SessionPrepPanelComponent } from '../../../shared/session-prep-panel/se
templateUrl: './playthrough-detail.component.html', templateUrl: './playthrough-detail.component.html',
styleUrls: ['./playthrough-detail.component.scss'] styleUrls: ['./playthrough-detail.component.scss']
}) })
export class PlaythroughDetailComponent implements OnInit, OnDestroy { export class PlaythroughDetailComponent implements OnInit {
readonly ArrowLeft = ArrowLeft; readonly ArrowLeft = ArrowLeft;
readonly Play = Play; readonly Play = Play;
readonly Flag = Flag; readonly Flag = Flag;
@@ -155,6 +155,4 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
} }
}); });
} }
ngOnDestroy(): void {}
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -26,7 +26,7 @@ import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-fl
templateUrl: './playthrough-flags-page.component.html', templateUrl: './playthrough-flags-page.component.html',
styleUrls: ['./playthrough-flags-page.component.scss'] styleUrls: ['./playthrough-flags-page.component.scss']
}) })
export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy { export class PlaythroughFlagsPageComponent implements OnInit {
readonly ArrowLeft = ArrowLeft; readonly ArrowLeft = ArrowLeft;
campaignId = ''; campaignId = '';
@@ -75,6 +75,4 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
back(): void { back(): void {
this.router.navigate(['/campaigns', this.campaignId]); this.router.navigate(['/campaigns', this.campaignId]);
} }
ngOnDestroy(): void {}
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -41,7 +41,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
templateUrl: './quest-edit.component.html', templateUrl: './quest-edit.component.html',
styleUrls: ['./quest-edit.component.scss'] styleUrls: ['./quest-edit.component.scss']
}) })
export class QuestEditComponent implements OnInit, OnDestroy { export class QuestEditComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null; selectedIcon: string | null = null;
@@ -232,6 +232,4 @@ export class QuestEditComponent implements OnInit, OnDestroy {
cancel(): void { cancel(): void {
this.router.navigate(['/campaigns', this.campaignId, 'quests']); this.router.navigate(['/campaigns', this.campaignId, 'quests']);
} }
ngOnDestroy(): void {}
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -33,7 +33,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
templateUrl: './quest-view.component.html', templateUrl: './quest-view.component.html',
styleUrls: ['./quest-view.component.scss'] styleUrls: ['./quest-view.component.scss']
}) })
export class QuestViewComponent implements OnInit, OnDestroy { export class QuestViewComponent implements OnInit {
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly BookOpen = BookOpen; readonly BookOpen = BookOpen;
@@ -199,8 +199,4 @@ export class QuestViewComponent implements OnInit, OnDestroy {
}); });
}); });
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
}
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -24,7 +24,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
templateUrl: './scene-create.component.html', templateUrl: './scene-create.component.html',
styleUrls: ['./scene-create.component.scss'] styleUrls: ['./scene-create.component.scss']
}) })
export class SceneCreateComponent implements OnInit, OnDestroy { export class SceneCreateComponent implements OnInit {
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
selectedIcon: string | null = null; selectedIcon: string | null = null;
@@ -90,11 +90,4 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
cancel(): void { cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]); this.router.navigate(['/campaigns', this.campaignId]);
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -385,5 +385,5 @@
[isOpen]="chatOpen" [isOpen]="chatOpen"
[welcomeMessage]="'sceneEdit.chatWelcome' | translate" [welcomeMessage]="'sceneEdit.chatWelcome' | translate"
[quickSuggestions]="chatQuickSuggestions" [quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false"> (closed)="chatOpen = false">
</app-ai-chat-drawer> </app-ai-chat-drawer>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
@@ -67,7 +67,7 @@ interface BattlemapEdit {
templateUrl: './scene-edit.component.html', templateUrl: './scene-edit.component.html',
styleUrls: ['./scene-edit.component.scss'] styleUrls: ['./scene-edit.component.scss']
}) })
export class SceneEditComponent implements OnInit, OnDestroy { export class SceneEditComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS; readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
@@ -251,7 +251,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
}; };
if (entry.mediaFileId) { if (entry.mediaFileId) {
this.storedFileService.getById(entry.mediaFileId) this.storedFileService.getById(entry.mediaFileId)
.subscribe({ next: f => entry.mediaName = f.filename, error: () => {} }); .subscribe({ next: f => entry.mediaName = f.filename, error: () => { /* best-effort : erreur ignorée volontairement */ } });
} }
if (entry.dataFileId) { if (entry.dataFileId) {
this.storedFileService.getById(entry.dataFileId).subscribe({ this.storedFileService.getById(entry.dataFileId).subscribe({
@@ -260,7 +260,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
// Deduit la source : un .dd2vtt/.uvtt => DungeonDraft. // Deduit la source : un .dd2vtt/.uvtt => DungeonDraft.
if (/\.(dd2vtt|uvtt)$/i.test(f.filename)) entry.source = 'DUNGEONDRAFT'; if (/\.(dd2vtt|uvtt)$/i.test(f.filename)) entry.source = 'DUNGEONDRAFT';
}, },
error: () => {} error: () => { /* best-effort : erreur ignorée volontairement */ }
}); });
} }
return entry; return entry;
@@ -480,12 +480,14 @@ export class SceneEditComponent implements OnInit, OnDestroy {
onDd2vttSelected(bm: BattlemapEdit, event: Event): void { onDd2vttSelected(bm: BattlemapEdit, event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
if (file) void this.uploadDd2vtt(bm, file); // Fire-and-forget : uploadDd2vtt gère lui-même ses erreurs.
if (file) this.uploadDd2vtt(bm, file);
input.value = ''; input.value = '';
} }
onDd2vttDropped(bm: BattlemapEdit, files: File[]): void { onDd2vttDropped(bm: BattlemapEdit, files: File[]): void {
if (files[0]) void this.uploadDd2vtt(bm, files[0]); // Fire-and-forget : uploadDd2vtt gère lui-même ses erreurs.
if (files[0]) this.uploadDd2vtt(bm, files[0]);
} }
/** /**
@@ -552,13 +554,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
} }
private extForType(type: string): string { private extForType(type: string): string {
return type === 'image/jpeg' ? '.jpg' : type === 'image/webp' ? '.webp' : '.png'; if (type === 'image/jpeg') return '.jpg';
} return type === 'image/webp' ? '.webp' : '.png';
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
} }
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -31,7 +31,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
templateUrl: './scene-view.component.html', templateUrl: './scene-view.component.html',
styleUrls: ['./scene-view.component.scss'] styleUrls: ['./scene-view.component.scss']
}) })
export class SceneViewComponent implements OnInit, OnDestroy { export class SceneViewComponent implements OnInit {
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly resolveCampaignIcon = resolveCampaignIcon; readonly resolveCampaignIcon = resolveCampaignIcon;
@@ -160,11 +160,4 @@ export class SceneViewComponent implements OnInit, OnDestroy {
}); });
}); });
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -361,7 +361,10 @@ export class GameSystemEditComponent implements OnInit {
}; };
for (const line of lines) { for (const line of lines) {
const match = line.match(/^##\s+(.+?)\s*$/); // `\S` force un début de titre non-blanc : frontière déterministe entre
// `\s+` et la capture (l'ancien `.+?`+`\s*$` backtrackait). Le trim()
// en aval retire les espaces de fin.
const match = line.match(/^##\s+(\S.*)$/);
if (match) { if (match) {
flush(); flush();
current = { title: match[1].trim(), content: '', collapsed: false }; current = { title: match[1].trim(), content: '', collapsed: false };

View File

@@ -154,10 +154,14 @@ export class BlockGridBuilderComponent implements OnDestroy {
addLabel(block: TemplateField): void { block.labels = [...(block.labels ?? []), '']; this.emit(); } addLabel(block: TemplateField): void { block.labels = [...(block.labels ?? []), '']; this.emit(); }
updateLabel(block: TemplateField, i: number, value: string): void { updateLabel(block: TemplateField, i: number, value: string): void {
if (!block.labels) return; block.labels[i] = value; this.emit(); if (!block.labels) return;
block.labels[i] = value;
this.emit();
} }
removeLabel(block: TemplateField, i: number): void { removeLabel(block: TemplateField, i: number): void {
if (!block.labels) return; block.labels = block.labels.filter((_, k) => k !== i); this.emit(); if (!block.labels) return;
block.labels = block.labels.filter((_, k) => k !== i);
this.emit();
} }
// --- Glisser : création / déplacement / redimensionnement --------------- // --- Glisser : création / déplacement / redimensionnement ---------------

View File

@@ -1,4 +1,4 @@
import { Component, OnDestroy, OnInit, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
@@ -33,7 +33,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
templateUrl: './folder-view.component.html', templateUrl: './folder-view.component.html',
styleUrls: ['./folder-view.component.scss'] styleUrls: ['./folder-view.component.scss']
}) })
export class FolderViewComponent implements OnInit, OnDestroy { export class FolderViewComponent implements OnInit {
readonly Folder = Folder; readonly Folder = Folder;
readonly FileText = FileText; readonly FileText = FileText;
readonly Pencil = Pencil; readonly Pencil = Pencil;
@@ -237,11 +237,4 @@ export class FolderViewComponent implements OnInit, OnDestroy {
error: () => console.error('Impossible de récupérer les dépendances du dossier') error: () => console.error('Impossible de récupérer les dépendances du dossier')
}); });
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -11,7 +11,7 @@ import { TranslatePipe } from '@ngx-translate/core';
styleUrls: ['./lore-create.component.scss'] styleUrls: ['./lore-create.component.scss']
}) })
export class LoreCreateComponent { export class LoreCreateComponent {
@Output() close = new EventEmitter<void>(); @Output() closed = new EventEmitter<void>();
@Output() created = new EventEmitter<{ name: string; description: string }>(); @Output() created = new EventEmitter<{ name: string; description: string }>();
readonly BookCopy = BookCopy; readonly BookCopy = BookCopy;
@@ -32,6 +32,6 @@ export class LoreCreateComponent {
} }
onCancel(): void { onCancel(): void {
this.close.emit(); this.closed.emit();
} }
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -11,7 +11,7 @@ import { LayoutService } from '../../services/layout.service';
import { LoreNode } from '../../services/lore.model'; import { LoreNode } from '../../services/lore.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { popReturnTo } from '../return-stack.helper'; import { popReturnTo } from '../return-stack.helper';
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons'; import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
@Component({ @Component({
selector: 'app-lore-node-create', selector: 'app-lore-node-create',
@@ -19,7 +19,7 @@ import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
templateUrl: './lore-node-create.component.html', templateUrl: './lore-node-create.component.html',
styleUrls: ['./lore-node-create.component.scss'] styleUrls: ['./lore-node-create.component.scss']
}) })
export class LoreNodeCreateComponent implements OnInit, OnDestroy { export class LoreNodeCreateComponent implements OnInit {
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS; readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
@@ -109,11 +109,4 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
} }
this.router.navigate(['/lore', this.loreId]); this.router.navigate(['/lore', this.loreId]);
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
@@ -38,7 +38,7 @@ import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
templateUrl: './lore-node-edit.component.html', templateUrl: './lore-node-edit.component.html',
styleUrls: ['./lore-node-edit.component.scss'] styleUrls: ['./lore-node-edit.component.scss']
}) })
export class LoreNodeEditComponent implements OnInit, OnDestroy { export class LoreNodeEditComponent implements OnInit {
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS; readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
@@ -147,11 +147,4 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
if (!key) return null; if (!key) return null;
return this.iconOptions.find(o => o.key === key)?.icon ?? null; return this.iconOptions.find(o => o.key === key)?.icon ?? null;
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -39,7 +39,7 @@
@if (showCreateModal) { @if (showCreateModal) {
<app-lore-create <app-lore-create
(close)="onModalClose()" (closed)="onModalClose()"
(created)="onLoreCreated($event)"> (created)="onLoreCreated($event)">
</app-lore-create> </app-lore-create>
} }

View File

@@ -111,7 +111,7 @@
[systemPromptAddon]="wizardSystemPrompt" [systemPromptAddon]="wizardSystemPrompt"
[quickSuggestions]="wizardSuggestions" [quickSuggestions]="wizardSuggestions"
[primaryAction]="wizardPrimaryAction" [primaryAction]="wizardPrimaryAction"
(close)="closeWizard()" (closed)="closeWizard()"
(assistantReply)="onWizardReply($event)" (assistantReply)="onWizardReply($event)"
(primaryActionClick)="applyWizardAndCreate()"> (primaryActionClick)="applyWizardAndCreate()">
</app-ai-chat-drawer> </app-ai-chat-drawer>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
@@ -32,7 +32,7 @@ import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-d
templateUrl: './page-create.component.html', templateUrl: './page-create.component.html',
styleUrls: ['./page-create.component.scss'] styleUrls: ['./page-create.component.scss']
}) })
export class PageCreateComponent implements OnInit, OnDestroy { export class PageCreateComponent implements OnInit {
readonly FileText = FileText; readonly FileText = FileText;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Plus = Plus; readonly Plus = Plus;
@@ -165,7 +165,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
} }
private restoreDraft(): void { private restoreDraft(): void {
let raw: string | null = null; let raw: string | null;
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; } try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
if (!raw) return; if (!raw) return;
sessionStorage.removeItem(this.draftKey); sessionStorage.removeItem(this.draftKey);
@@ -328,7 +328,9 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués.
* Retourne null si absent ou JSON invalide. * Retourne null si absent ou JSON invalide.
*/ */
private extractValuesBlock(reply: string): Record<string, string> | null { private extractValuesBlock(reply: string): Record<string, string> | null {
const match = reply.match(/<values>\s*([\s\S]*?)\s*<\/values>/i); // Pas de \s* autour de la capture (backtracking quadratique) :
// JSON.parse tolère nativement les blancs en tête/queue.
const match = reply.match(/<values>([\s\S]*?)<\/values>/i);
if (!match) return null; if (!match) return null;
try { try {
const parsed = JSON.parse(match[1]) as Record<string, unknown>; const parsed = JSON.parse(match[1]) as Record<string, unknown>;
@@ -342,11 +344,4 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués.
return null; return null;
} }
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -195,6 +195,6 @@
[isOpen]="chatOpen" [isOpen]="chatOpen"
[quickSuggestions]="chatQuickSuggestions" [quickSuggestions]="chatQuickSuggestions"
[primaryAction]="chatPrimaryAction" [primaryAction]="chatPrimaryAction"
(close)="chatOpen = false" (closed)="chatOpen = false"
(primaryActionClick)="onChatFillRequested()"> (primaryActionClick)="onChatFillRequested()">
</app-ai-chat-drawer> </app-ai-chat-drawer>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -44,7 +44,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
templateUrl: './page-edit.component.html', templateUrl: './page-edit.component.html',
styleUrls: ['./page-edit.component.scss'] styleUrls: ['./page-edit.component.scss']
}) })
export class PageEditComponent implements OnInit, OnDestroy { export class PageEditComponent implements OnInit {
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Plus = Plus; readonly Plus = Plus;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
@@ -84,7 +84,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
/** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */ /** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */
keyValueValues: Record<string, Record<string, string>> = {}; keyValueValues: Record<string, Record<string, string>> = {};
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */ /** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */
tableValues: Record<string, Array<Record<string, string>>> = {}; tableValues: Record<string, Record<string, string>[]> = {};
/** Étiquettes libres (Phase 5B). */ /** Étiquettes libres (Phase 5B). */
tags: string[] = []; tags: string[] = [];
/** IDs des pages liées (Phase 5B). */ /** IDs des pages liées (Phase 5B). */
@@ -200,7 +200,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
const imageBase: Record<string, string[]> = {}; const imageBase: Record<string, string[]> = {};
const framingBase: Record<string, Record<string, ImageFraming>> = {}; const framingBase: Record<string, Record<string, ImageFraming>> = {};
const kvBase: Record<string, Record<string, string>> = {}; const kvBase: Record<string, Record<string, string>> = {};
const tableBase: Record<string, Array<Record<string, string>>> = {}; const tableBase: Record<string, Record<string, string>[]> = {};
// Les valeurs sont rangées par clé STABLE (id) ; on relit d'abord par clé, // Les valeurs sont rangées par clé STABLE (id) ; on relit d'abord par clé,
// puis par nom (pages dont les valeurs étaient encore rangées par nom — elles // puis par nom (pages dont les valeurs étaient encore rangées par nom — elles
// sont ainsi migrées vers la clé id à la prochaine sauvegarde). // sont ainsi migrées vers la clé id à la prochaine sauvegarde).
@@ -259,7 +259,9 @@ export class PageEditComponent implements OnInit, OnDestroy {
addTableRow(fieldName: string, columns: string[] | null | undefined): void { addTableRow(fieldName: string, columns: string[] | null | undefined): void {
const row: Record<string, string> = {}; const row: Record<string, string> = {};
for (const col of columns ?? []) row[col] = ''; for (const col of columns ?? []) row[col] = '';
(this.tableValues[fieldName] ??= []).push(row); const rows = this.tableValues[fieldName] ?? [];
rows.push(row);
this.tableValues[fieldName] = rows;
} }
removeTableRow(fieldName: string, rowIndex: number): void { removeTableRow(fieldName: string, rowIndex: number): void {
@@ -267,7 +269,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
} }
/** Lignes du tableau d'un champ — toujours un tableau (jamais undefined) pour le `@for`. */ /** Lignes du tableau d'un champ — toujours un tableau (jamais undefined) pour le `@for`. */
tableRows(fieldName: string): Array<Record<string, string>> { tableRows(fieldName: string): Record<string, string>[] {
return this.tableValues[fieldName] ?? []; return this.tableValues[fieldName] ?? [];
} }
@@ -343,11 +345,4 @@ export class PageEditComponent implements OnInit, OnDestroy {
}); });
}); });
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core'; import { Component, OnInit, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -36,7 +36,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
templateUrl: './page-view.component.html', templateUrl: './page-view.component.html',
styleUrls: ['./page-view.component.scss'] styleUrls: ['./page-view.component.scss']
}) })
export class PageViewComponent implements OnInit, OnDestroy { export class PageViewComponent implements OnInit {
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
@@ -155,7 +155,7 @@ export class PageViewComponent implements OnInit, OnDestroy {
} }
/** Lignes d'un champ TABLE (liste vide si jamais rempli). */ /** Lignes d'un champ TABLE (liste vide si jamais rempli). */
tableRowsOf(field: TemplateField): Array<Record<string, string>> { tableRowsOf(field: TemplateField): Record<string, string>[] {
const v = this.page?.tableValues; const v = this.page?.tableValues;
return v?.[blockKey(field)] ?? v?.[field.name] ?? []; return v?.[blockKey(field)] ?? v?.[field.name] ?? [];
} }
@@ -196,11 +196,4 @@ export class PageViewComponent implements OnInit, OnDestroy {
}); });
}); });
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -24,7 +24,7 @@ import { popReturnTo } from '../return-stack.helper';
templateUrl: './template-create.component.html', templateUrl: './template-create.component.html',
styleUrls: ['./template-create.component.scss'] styleUrls: ['./template-create.component.scss']
}) })
export class TemplateCreateComponent implements OnInit, OnDestroy { export class TemplateCreateComponent implements OnInit {
form: FormGroup; form: FormGroup;
loreId = ''; loreId = '';
nodes: LoreNode[] = []; nodes: LoreNode[] = [];
@@ -85,7 +85,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
} }
private restoreDraft(): void { private restoreDraft(): void {
let raw: string | null = null; let raw: string | null;
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; } try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
if (!raw) return; if (!raw) return;
sessionStorage.removeItem(this.draftKey); sessionStorage.removeItem(this.draftKey);
@@ -146,11 +146,4 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
} }
this.router.navigate(['/lore', this.loreId]); this.router.navigate(['/lore', this.loreId]);
} }
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
} }

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable, catchError, of } from 'rxjs'; import { Observable, catchError, map, of } from 'rxjs';
/** /**
* Reflet de LicenseStatus (enum cote backend). * Reflet de LicenseStatus (enum cote backend).
@@ -44,13 +44,13 @@ export interface BetaStatusDTO {
enabled: boolean; enabled: boolean;
updateAvailable: boolean; updateAvailable: boolean;
anyUnknown: boolean; anyUnknown: boolean;
images: Array<{ images: {
image: string; image: string;
localVersion: string | null; localVersion: string | null;
remoteVersion: string | null; remoteVersion: string | null;
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN'; status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
updateAvailable: boolean; updateAvailable: boolean;
}>; }[];
checkedAt: string; checkedAt: string;
disabledReason: string | null; disabledReason: string | null;
} }
@@ -86,10 +86,13 @@ export class LicenseService {
disconnect(): Observable<boolean> { disconnect(): Observable<boolean> {
return this.http.delete<void>(this.apiUrl, this.authOptions).pipe( return this.http.delete<void>(this.apiUrl, this.authOptions).pipe(
// Convertit en boolean : true = succes, false = erreur // Convertit en boolean : true = succes, false = erreur.
// (catchError plus bas masque les detail HTTP) // (Avant : le cast `as unknown as` masquait l'absence de map() — le
catchError(() => of(false as any)) // succes emettait `undefined`, donc falsy. Consommateur actuel ignore
) as unknown as Observable<boolean>; // la valeur, mais le type est desormais honnete.)
map(() => true),
catchError(() => of(false))
);
} }
refresh(): Observable<LicenseStatusDTO | null> { refresh(): Observable<LicenseStatusDTO | null> {

View File

@@ -48,7 +48,9 @@ export interface NotebookAction {
resolution?: string; resolution?: string;
} }
const ACTION_RE = /```loremind-action\s*([\s\S]*?)```/g; // Pas de \s* avant la capture (backtracking quadratique) : la capture est
// trim()ée avant JSON.parse de toute façon.
const ACTION_RE = /```loremind-action([\s\S]*?)```/g;
const VALID_TYPES = new Set(['npc', 'scene', 'chapter', 'arc', 'table']); const VALID_TYPES = new Set(['npc', 'scene', 'chapter', 'arc', 'table']);
/** /**

View File

@@ -6,6 +6,13 @@ import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChat
import { LanguageService } from './language.service'; import { LanguageService } from './language.service';
import { parseSseStream } from '../shared/sse.util'; import { parseSseStream } from '../shared/sse.util';
/** Normalise les sources renvoyées par le Brain (événement SSE `sources`). */
function mapSseSources(sources: { source_id?: string; page?: number | null; score?: number }[] | undefined) {
return (sources ?? []).map(s => ({
sourceId: s.source_id ?? '', page: s.page ?? null, score: s.score ?? 0
}));
}
/** /**
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources, * Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
* et chat ancré STREAMÉ (SSE via fetch, comme ai-chat.service). * et chat ancré STREAMÉ (SSE via fetch, comme ai-chat.service).
@@ -96,12 +103,7 @@ export class NotebookService {
} else if (event === 'sources') { } else if (event === 'sources') {
try { try {
const o = JSON.parse(data) as { sources?: { source_id?: string; page?: number | null; score?: number }[] }; const o = JSON.parse(data) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
emit({ emit({ type: 'sources', sources: mapSseSources(o.sources) });
type: 'sources',
sources: (o.sources ?? []).map(s => ({
sourceId: s.source_id ?? '', page: s.page ?? null, score: s.score ?? 0
}))
});
} catch { /* ignore */ } } catch { /* ignore */ }
} else if (event === 'progress') { } else if (event === 'progress') {
try { try {

View File

@@ -40,7 +40,7 @@ export interface Page {
* Pour chaque champ TABLE (colonnes figees au template, lignes libres) : * Pour chaque champ TABLE (colonnes figees au template, lignes libres) :
* fieldName → lignes ordonnees, chaque ligne = colonne → cellule. * fieldName → lignes ordonnees, chaque ligne = colonne → cellule.
*/ */
tableValues?: Record<string, Array<Record<string, string>>>; tableValues?: Record<string, Record<string, string>[]>;
notes?: string | null; notes?: string | null;
tags?: string[]; tags?: string[];
relatedPageIds?: string[]; relatedPageIds?: string[];

View File

@@ -46,9 +46,10 @@ export class VersionCheckerService {
*/ */
start(): void { start(): void {
if (this.timer !== null) return; if (this.timer !== null) return;
void this.fetchInitial(); // Fire-and-forget : fetchInitial/poll gèrent leurs erreurs en interne.
this.fetchInitial();
this.timer = setInterval( this.timer = setInterval(
() => void this.poll(), () => { this.poll(); },
VersionCheckerService.POLL_INTERVAL_MS, VersionCheckerService.POLL_INTERVAL_MS,
); );
} }

View File

@@ -24,7 +24,7 @@
name="editName" name="editName"
(keydown.enter)="saveRename()" (keydown.enter)="saveRename()"
(keydown.escape)="cancelRename()" (keydown.escape)="cancelRename()"
autofocus /> />
<button type="button" class="btn-icon" (click)="saveRename()" [disabled]="!editName.trim()" [title]="'common.validate' | translate"> <button type="button" class="btn-icon" (click)="saveRename()" [disabled]="!editName.trim()" [title]="'common.validate' | translate">
<lucide-icon [img]="Check" [size]="14"></lucide-icon> <lucide-icon [img]="Check" [size]="14"></lucide-icon>
</button> </button>

View File

@@ -281,11 +281,14 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
if (!this.session) return; if (!this.session) return;
const session = this.session; const session = this.session;
const entryCount = this.entries.length; const entryCount = this.entries.length;
const entriesDetail = entryCount === 0 let entriesDetail: string;
? this.translate.instant('sessionDetail.deleteConfirm.noEntries') if (entryCount === 0) {
: entryCount === 1 entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.noEntries');
? this.translate.instant('sessionDetail.deleteConfirm.entriesOne') } else if (entryCount === 1) {
: this.translate.instant('sessionDetail.deleteConfirm.entriesMany', { n: entryCount }); entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.entriesOne');
} else {
entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.entriesMany', { n: entryCount });
}
const details = [ const details = [
entriesDetail, entriesDetail,
this.translate.instant('sessionDetail.deleteConfirm.irreversible') this.translate.instant('sessionDetail.deleteConfirm.irreversible')

View File

@@ -58,7 +58,9 @@ export class SessionDicePanelComponent {
} }
const sumRolls = rolls.reduce((s, n) => s + n, 0); const sumRolls = rolls.reduce((s, n) => s + n, 0);
const total = sumRolls + this.modifier; const total = sumRolls + this.modifier;
const modPart = this.modifier === 0 ? '' : (this.modifier > 0 ? `+${this.modifier}` : `${this.modifier}`); let modPart = '';
if (this.modifier > 0) modPart = `+${this.modifier}`;
else if (this.modifier < 0) modPart = `${this.modifier}`;
const notation = `${safeCount}d${this.selectedFace}${modPart}`; const notation = `${safeCount}d${this.selectedFace}${modPart}`;
const detailsPart = rolls.length > 1 ? ` [${rolls.join(', ')}]` : ''; const detailsPart = rolls.length > 1 ? ` [${rolls.join(', ')}]` : '';
const summary = `🎲 ${notation}${detailsPart} = ${total}`; const summary = `🎲 ${notation}${detailsPart} = ${total}`;

View File

@@ -52,7 +52,11 @@ export class SessionItemCatalogsPanelComponent implements OnInit {
map.get(cat)!.push(it); map.get(cat)!.push(it);
} }
return [...map.entries()] return [...map.entries()]
.sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr'))) .sort(([a], [b]) => {
if (a === '—') return 1; // catégorie "sans catégorie" toujours en dernier
if (b === '—') return -1;
return a.localeCompare(b, 'fr');
})
.map(([category, items]) => ({ category, items })); .map(([category, items]) => ({ category, items }));
} }

View File

@@ -209,9 +209,10 @@ export class OllamaModelManagerComponent implements OnDestroy {
}); });
} }
private extractError(err: any, fallback: string): string { private extractError(err: unknown, fallback: string): string {
if (err?.error?.detail) return String(err.error.detail); const e = err as { error?: { detail?: unknown }; message?: string } | null;
if (err?.message) return err.message; if (e?.error?.detail) return String(e.error.detail);
if (e?.message) return e.message;
return fallback; return fallback;
} }
} }

View File

@@ -56,7 +56,7 @@ export class SettingsComponent implements OnInit {
/** Catalogue Gemini (dynamique si cle configuree, repli statique sinon). */ /** Catalogue Gemini (dynamique si cle configuree, repli statique sinon). */
geminiModels: GeminiModel[] = []; geminiModels: GeminiModel[] = [];
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */ /** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
oneminProvider: string = ''; oneminProvider = '';
loadingModels = false; loadingModels = false;
saving = false; saving = false;
@@ -419,9 +419,10 @@ export class SettingsComponent implements OnInit {
}); });
} }
private extractError(err: any, fallback: string): string { private extractError(err: unknown, fallback: string): string {
if (err?.error?.detail) return String(err.error.detail); const e = err as { error?: { detail?: unknown }; message?: string } | null;
if (err?.message) return err.message; if (e?.error?.detail) return String(e.error.detail);
if (e?.message) return e.message;
return fallback; return fallback;
} }
} }

View File

@@ -130,11 +130,12 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
} }
this.licenseError = ''; this.licenseError = '';
this.licenseService.install(jwt).subscribe((res) => { this.licenseService.install(jwt).subscribe((res) => {
if ((res as any)?.error) { // install() renvoie une union typée : le garde `in` suffit à discriminer.
this.licenseError = (res as any).error; if ('error' in res) {
this.licenseError = res.error;
return; return;
} }
this.licenseStatus = res as LicenseStatusDTO; this.licenseStatus = res;
this.licenseJwtInput = ''; this.licenseJwtInput = '';
this.licenseSuccess = this.translate.instant('updatesSection.patreonConnectedSuccess'); this.licenseSuccess = this.translate.instant('updatesSection.patreonConnectedSuccess');
if (this.licenseStatus.betaChannelEnabled) { if (this.licenseStatus.betaChannelEnabled) {

View File

@@ -80,7 +80,7 @@
(keyup.enter)="submitRenameTitle()" (keyup.enter)="submitRenameTitle()"
(keyup.escape)="cancelRenameTitle()" (keyup.escape)="cancelRenameTitle()"
(blur)="submitRenameTitle()" (blur)="submitRenameTitle()"
autofocus /> />
} }
} @else { } @else {
<h2 class="header-title">{{ 'aiChatDrawer.title' | translate }}</h2> <h2 class="header-title">{{ 'aiChatDrawer.title' | translate }}</h2>

View File

@@ -88,7 +88,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
/** Persistance activee ? false = mode wizard ephemere. */ /** Persistance activee ? false = mode wizard ephemere. */
@Input() persistent = true; @Input() persistent = true;
@Output() close = new EventEmitter<void>(); @Output() closed = new EventEmitter<void>();
@Output() primaryActionClick = new EventEmitter<void>(); @Output() primaryActionClick = new EventEmitter<void>();
@Output() assistantReply = new EventEmitter<string>(); @Output() assistantReply = new EventEmitter<string>();
@@ -180,7 +180,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
this.isWide = !this.isWide; this.isWide = !this.isWide;
try { try {
localStorage.setItem(this.LS_WIDE, this.isWide ? '1' : '0'); localStorage.setItem(this.LS_WIDE, this.isWide ? '1' : '0');
} catch {} } catch { /* localStorage indisponible : ignoré */ }
} }
/** Debut du drag : enregistre la position de depart + abonne listeners globaux. */ /** Debut du drag : enregistre la position de depart + abonne listeners globaux. */
@@ -216,7 +216,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
if (this.customWidth !== null) { if (this.customWidth !== null) {
try { try {
localStorage.setItem(this.LS_WIDTH, String(this.customWidth)); localStorage.setItem(this.LS_WIDTH, String(this.customWidth));
} catch {} } catch { /* localStorage indisponible : ignoré */ }
} }
} }
@@ -231,7 +231,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
} }
} }
this.isWide = localStorage.getItem(this.LS_WIDE) === '1'; this.isWide = localStorage.getItem(this.LS_WIDE) === '1';
} catch {} } catch { /* localStorage indisponible : ignoré */ }
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
@@ -374,7 +374,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
onClose(): void { onClose(): void {
this.abortStream(); this.abortStream();
this.close.emit(); this.closed.emit();
} }
send(): void { send(): void {
@@ -450,7 +450,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
this.scrollToBottom(); this.scrollToBottom();
if (convId) { if (convId) {
this.conversationService.appendMessage(convId, 'user', text).subscribe({ error: () => {} }); this.conversationService.appendMessage(convId, 'user', text).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
} }
this.streamSub = this.buildStream().subscribe({ this.streamSub = this.buildStream().subscribe({
@@ -477,7 +477,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
next: () => { next: () => {
if (wasEmpty) this.triggerAutoTitle(convId); if (wasEmpty) this.triggerAutoTitle(convId);
}, },
error: () => {}, error: () => { /* best-effort : erreur ignorée volontairement */ },
}); });
} }
} }
@@ -505,7 +505,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
c.id === convId ? { ...c, title } : c, c.id === convId ? { ...c, title } : c,
); );
}, },
error: () => {}, error: () => { /* best-effort : erreur ignorée volontairement */ },
}); });
} }

View File

@@ -10,7 +10,7 @@ import { TranslatePipe } from '@ngx-translate/core';
*/ */
export interface BreadcrumbItem { export interface BreadcrumbItem {
label: string; label: string;
route?: string | any[]; route?: string | unknown[];
} }
/** /**

View File

@@ -26,7 +26,9 @@ export class DiceUtils {
/** Parse une formule simple `[N]dM`. Renvoie null si invalide. */ /** Parse une formule simple `[N]dM`. Renvoie null si invalide. */
static parse(formula: string | null | undefined): ParsedDice | null { static parse(formula: string | null | undefined): ParsedDice | null {
const m = /^\s*(\d*)\s*[dD]\s*(\d+)\s*$/.exec(formula ?? ''); // trim() préalable au lieu de \s* aux extrémités : `\s*(\d*)\s*` était
// ambigu (quadratique) quand \d* est vide. Même langage accepté.
const m = /^(\d*)\s*[dD]\s*(\d+)$/.exec((formula ?? '').trim());
if (!m) return null; if (!m) return null;
const count = m[1] ? parseInt(m[1], 10) : 1; const count = m[1] ? parseInt(m[1], 10) : 1;
const faces = parseInt(m[2], 10); const faces = parseInt(m[2], 10);

View File

@@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular'; import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular';
@@ -17,7 +17,7 @@ import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular';
templateUrl: './expandable-section.component.html', templateUrl: './expandable-section.component.html',
styleUrls: ['./expandable-section.component.scss'] styleUrls: ['./expandable-section.component.scss']
}) })
export class ExpandableSectionComponent { export class ExpandableSectionComponent implements OnInit {
readonly ChevronDown = ChevronDown; readonly ChevronDown = ChevronDown;
readonly ChevronUp = ChevronUp; readonly ChevronUp = ChevronUp;

View File

@@ -12,24 +12,34 @@ import { PageService } from '../../services/page.service';
import { TemplateService } from '../../services/template.service'; import { TemplateService } from '../../services/template.service';
import { CampaignService } from '../../services/campaign.service'; import { CampaignService } from '../../services/campaign.service';
import { NpcService } from '../../services/npc.service'; import { NpcService } from '../../services/npc.service';
import { CharacterService } from '../../services/character.service'; import { CharacterService, CharacterSearchResult } from '../../services/character.service';
import { RandomTableService } from '../../services/random-table.service'; import { RandomTableService } from '../../services/random-table.service';
import { ItemCatalogService } from '../../services/item-catalog.service'; import { ItemCatalogService } from '../../services/item-catalog.service';
import { EnemyService } from '../../services/enemy.service'; import { EnemyService } from '../../services/enemy.service';
import { Lore, LoreNode } from '../../services/lore.model';
import { Template } from '../../services/template.model';
import { Page } from '../../services/page.model';
import { Campaign } from '../../services/campaign.model';
import { Npc } from '../../services/npc.model';
import { RandomTable } from '../../services/random-table.model';
import { ItemCatalog } from '../../services/item-catalog.model';
import { Enemy } from '../../services/enemy.model';
type ResultKind = type ResultKind =
| 'lore' | 'node' | 'template' | 'page' | 'campaign' | 'lore' | 'node' | 'template' | 'page' | 'campaign'
| 'npc' | 'character' | 'random-table' | 'item-catalog' | 'enemy'; | 'npc' | 'character' | 'random-table' | 'item-catalog' | 'enemy';
interface SearchResult { interface SearchResult {
id: string; /** Optionnel dans les DTOs (objets pas encore persistés) mais toujours
* présent sur des résultats de recherche serveur. */
id: string | undefined;
kind: ResultKind; kind: ResultKind;
title: string; title: string;
subtitle: string; subtitle: string;
/** Tag affiché sous le titre (ex: "Lore", "Dossier", "Template", "Page"). */ /** Tag affiché sous le titre (ex: "Lore", "Dossier", "Template", "Page"). */
tag: string; tag: string;
/** Route Angular (array pour router.navigate). */ /** Route Angular (array pour router.navigate). */
route: any[]; route: (string | undefined)[];
} }
/** /**
@@ -140,8 +150,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
* noeuds/templates, et enfin les racines (campagnes, lores). * noeuds/templates, et enfin les racines (campagnes, lores).
*/ */
private buildResults(r: { private buildResults(r: {
lores: any[]; nodes: any[]; templates: any[]; pages: any[]; campaigns: any[]; lores: Lore[]; nodes: LoreNode[]; templates: Template[]; pages: Page[]; campaigns: Campaign[];
npcs: any[]; characters: any[]; tables: any[]; catalogs: any[]; enemies: any[]; npcs: Npc[]; characters: CharacterSearchResult[]; tables: RandomTable[]; catalogs: ItemCatalog[]; enemies: Enemy[];
}): SearchResult[] { }): SearchResult[] {
const { lores, nodes, templates, pages, campaigns, npcs, characters, tables, catalogs, enemies } = r; const { lores, nodes, templates, pages, campaigns, npcs, characters, tables, catalogs, enemies } = r;
const pageResults: SearchResult[] = pages.map(p => ({ const pageResults: SearchResult[] = pages.map(p => ({

View File

@@ -130,7 +130,7 @@ export class ImageBlockComponent implements OnDestroy {
const id = this.currentId; const id = this.currentId;
if (!id) return; if (!id) return;
// Best-effort côté serveur (pas d'orpheline) ; on n'attend pas la réponse. // Best-effort côté serveur (pas d'orpheline) ; on n'attend pas la réponse.
this.imageService.delete(id).subscribe({ error: () => {} }); this.imageService.delete(id).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
const ids = this.imageIds.filter(i => i !== id); const ids = this.imageIds.filter(i => i !== id);
if (this.framing?.[id]) { if (this.framing?.[id]) {
const next = { ...this.framing }; const next = { ...this.framing };

View File

@@ -96,7 +96,7 @@ export class ImageGalleryComponent {
event.stopPropagation(); // Evite d'ouvrir le lightbox en cliquant sur X. event.stopPropagation(); // Evite d'ouvrir le lightbox en cliquant sur X.
// On supprime aussi cote serveur pour ne pas laisser d'image orpheline. // On supprime aussi cote serveur pour ne pas laisser d'image orpheline.
// Best-effort : on n'attend pas le retour pour emettre la nouvelle liste. // Best-effort : on n'attend pas le retour pour emettre la nouvelle liste.
this.imageService.delete(id).subscribe({ error: () => {} }); this.imageService.delete(id).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
this.imageIdsChange.emit(this.imageIds.filter(i => i !== id)); this.imageIdsChange.emit(this.imageIds.filter(i => i !== id));
} }

View File

@@ -18,6 +18,10 @@ export class MarkdownPipe implements PipeTransform {
if (!value) return ''; if (!value) return '';
const html = marked.parse(value, { async: false, gfm: true, breaks: true }) as string; const html = marked.parse(value, { async: false, gfm: true, breaks: true }) as string;
const clean = DOMPurify.sanitize(html); const clean = DOMPurify.sanitize(html);
// Revue sécurité : le bypass est sûr ICI car le HTML vient d'être passé
// par DOMPurify juste au-dessus (le sanitizer Angular, moins permissif,
// casserait le rendu markdown). Ne jamais bypasser sans DOMPurify amont.
// eslint-disable-next-line sonarjs/no-angular-bypass-sanitization
return this.sanitizer.bypassSecurityTrustHtml(clean); return this.sanitizer.bypassSecurityTrustHtml(clean);
} }
} }

View File

@@ -36,8 +36,9 @@
</button> </button>
} }
<!-- Mode normal : spacer + outils --> <!-- Mode normal : spacer + outils. `=== null` (et pas `!`) : l'async pipe
@if (!(layoutConfig$ | async)) { émet null tant que rien n'est reçu, la négation brute serait ambiguë. -->
@if ((layoutConfig$ | async) === null) {
<div class="spacer"></div> <div class="spacer"></div>
} }

View File

@@ -4,6 +4,8 @@
"baseUrl": "./", "baseUrl": "./",
"outDir": "./dist/out-tsc", "outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"types": [],
"strict": true, "strict": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
@@ -29,6 +31,7 @@
"strictTemplates": true "strictTemplates": true
}, },
"exclude": [ "exclude": [
"node_modules",
"e2e/**/*", "e2e/**/*",
"playwright.config.ts", "playwright.config.ts",
"vitest.config.ts", "vitest.config.ts",