Migration Angular 20 -> 21 : fin de la migration securite (0 vulnerabilite npm)

- ng update @angular/core@21 @angular/cli@21 (corrige GHSA-jrmj-c5cx-3cw6, XSS SVG)
- Migration automatique des templates vers le control flow natif (@if/@for, 138 fichiers)
- Correction des 5 `track` invalides generes par la migration (trackBy a 1 argument
  -> track $index) + suppression des fonctions trackBy mortes
- TypeScript 5.9, zone.js 0.15 ; npm audit : 0 vulnerabilite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:55:36 +02:00
parent 6acad41672
commit 23878f1c63
142 changed files with 7055 additions and 6292 deletions

View File

@@ -5,37 +5,55 @@
<span class="nac-name">{{ action.name }}</span>
</div>
<p class="nac-desc" *ngIf="action.description">{{ action.description }}</p>
@if (action.description) {
<p class="nac-desc">{{ action.description }}</p>
}
<!-- Cibles -->
<div class="nac-targets" *ngIf="status !== 'created' && needsArc">
<label>
Arc
<select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()">
<option *ngFor="let a of arcs" [value]="a.id">{{ a.name }}</option>
</select>
</label>
<label *ngIf="needsChapter">
Chapitre
<select [(ngModel)]="selectedChapterId">
<option *ngFor="let c of targetChapters" [value]="c.id">{{ c.name }}</option>
</select>
</label>
</div>
@if (status !== 'created' && needsArc) {
<div class="nac-targets">
<label>
Arc
<select [(ngModel)]="selectedArcId" (ngModelChange)="syncChapter()">
@for (a of arcs; track a) {
<option [value]="a.id">{{ a.name }}</option>
}
</select>
</label>
@if (needsChapter) {
<label>
Chapitre
<select [(ngModel)]="selectedChapterId">
@for (c of targetChapters; track c) {
<option [value]="c.id">{{ c.name }}</option>
}
</select>
</label>
}
</div>
}
<p class="nac-warn" *ngIf="needsArc && arcs.length === 0">
Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément.
</p>
@if (needsArc && arcs.length === 0) {
<p class="nac-warn">
Crée d'abord un arc {{ needsChapter ? 'et un chapitre ' : '' }}dans la campagne pour pouvoir y rattacher cet élément.
</p>
}
<div class="nac-foot">
<button class="nac-create" *ngIf="status !== 'created'" (click)="create()" [disabled]="!canCreate">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
{{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }}
</button>
<button class="nac-open" *ngIf="status === 'created'" (click)="openCreated()">
<lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon>
</button>
<span class="nac-error" *ngIf="status === 'error'">{{ errorMsg }}</span>
@if (status !== 'created') {
<button class="nac-create" (click)="create()" [disabled]="!canCreate">
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
{{ status === 'creating' ? 'Création…' : 'Créer dans la campagne' }}
</button>
}
@if (status === 'created') {
<button class="nac-open" (click)="openCreated()">
<lucide-icon [img]="Check" [size]="13"></lucide-icon> Créé — ouvrir
<lucide-icon [img]="ExternalLink" [size]="12"></lucide-icon>
</button>
}
@if (status === 'error') {
<span class="nac-error">{{ errorMsg }}</span>
}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, Plus, Check, Drama, Clapperboard, BookText, GitBranch, Dices, ExternalLink } from 'lucide-angular';
@@ -15,7 +15,7 @@ import { NotebookAction } from '../../../services/notebook-action.model';
*/
@Component({
selector: 'app-notebook-action-card',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './notebook-action-card.component.html',
styleUrls: ['./notebook-action-card.component.scss']
})

View File

@@ -1,98 +1,117 @@
<div class="nbd-page" *ngIf="detail">
<div class="nbd-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers
</button>
<input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()">
</div>
<div class="nbd-grid">
<!-- Sources -->
<aside class="nbd-sources">
<div class="nbd-sources-head">
<h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> Sources</h3>
<label class="btn-upload" [class.disabled]="uploading">
<lucide-icon [img]="Upload" [size]="13"></lucide-icon>
{{ uploading ? 'Indexation…' : 'Ajouter un PDF' }}
<input type="file" accept="application/pdf" hidden (change)="onFile($event)" [disabled]="uploading">
</label>
</div>
<p class="nbd-upload-error" *ngIf="uploadError">{{ uploadError }}</p>
<div class="nbd-source" *ngFor="let s of sources">
<lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
[size]="14"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon>
<div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta">
<span *ngIf="s.status === 'READY'">{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
<span *ngIf="s.status === 'INDEXING'">indexation en cours…</span>
<span *ngIf="s.status === 'FAILED'">échec de l'indexation</span>
</div>
@if (detail) {
<div class="nbd-page">
<div class="nbd-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon> Ateliers
</button>
<input class="nbd-title" [(ngModel)]="detail.name" (blur)="rename()" (keyup.enter)="rename()">
</div>
<div class="nbd-grid">
<!-- Sources -->
<aside class="nbd-sources">
<div class="nbd-sources-head">
<h3><lucide-icon [img]="FileText" [size]="15"></lucide-icon> Sources</h3>
<label class="btn-upload" [class.disabled]="uploading">
<lucide-icon [img]="Upload" [size]="13"></lucide-icon>
{{ uploading ? 'Indexation…' : 'Ajouter un PDF' }}
<input type="file" accept="application/pdf" hidden (change)="onFile($event)" [disabled]="uploading">
</label>
</div>
<button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<p class="nbd-empty" *ngIf="sources.length === 0 && !uploading">
Ajoute un PDF source pour commencer à discuter avec.
</p>
</aside>
<!-- Chat -->
<section class="nbd-chat">
<div class="nbd-messages">
<p class="nbd-empty" *ngIf="messages.length === 0">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
<span *ngIf="!hasReadySource()"><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
</p>
<div class="nbd-msg" *ngFor="let m of messages" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
<lucide-icon *ngIf="m.role === 'assistant'" [img]="Sparkles" [size]="12"></lucide-icon>
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
<ng-container *ngIf="m.role === 'assistant'; else userContent">
<ng-container *ngIf="parsedOf(m) as p">
<div class="nbd-deep-progress" *ngIf="sending && deepProgress && !p.text">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
@if (uploadError) {
<p class="nbd-upload-error">{{ uploadError }}</p>
}
@for (s of sources; track s) {
<div class="nbd-source">
<lucide-icon
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
[size]="14"
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
</lucide-icon>
<div class="nbd-source-info">
<div class="nbd-source-name">{{ s.filename }}</div>
<div class="nbd-source-meta">
@if (s.status === 'READY') {
<span>{{ s.pageCount }} p. · {{ s.chunkCount }} extraits</span>
}
@if (s.status === 'INDEXING') {
<span>indexation en cours…</span>
}
@if (s.status === 'FAILED') {
<span>échec de l'indexation</span>
}
</div>
<div class="nbd-msg-content">{{ p.text }}<span class="cursor" *ngIf="sending && !p.text && !p.actions.length && !deepProgress"></span></div>
<app-notebook-action-card
*ngFor="let a of p.actions; trackBy: trackAction"
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
</ng-container>
</ng-container>
<ng-template #userContent>
<div class="nbd-msg-content">{{ m.content }}</div>
</ng-template>
</div>
<button class="nbd-source-del" (click)="removeSource(s)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
}
@if (sources.length === 0 && !uploading) {
<p class="nbd-empty">
Ajoute un PDF source pour commencer à discuter avec.
</p>
}
</aside>
<!-- Chat -->
<section class="nbd-chat">
<div class="nbd-messages">
@if (messages.length === 0) {
<p class="nbd-empty">
Pose une question sur ta source, ou demande une adaptation pour ta campagne.
@if (!hasReadySource()) {
<span><br>(Ajoute d'abord une source indexée pour des réponses ancrées.)</span>
}
</p>
}
@for (m of messages; track m) {
<div class="nbd-msg" [class.user]="m.role === 'user'" [class.assistant]="m.role === 'assistant'">
<div class="nbd-msg-role">
@if (m.role === 'assistant') {
<lucide-icon [img]="Sparkles" [size]="12"></lucide-icon>
}
{{ m.role === 'user' ? 'Vous' : 'IA' }}
</div>
@if (m.role === 'assistant') {
@if (parsedOf(m); as p) {
@if (sending && deepProgress && !p.text) {
<div class="nbd-deep-progress">
<lucide-icon [img]="Layers" [size]="13"></lucide-icon>
Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }}
</div>
}
<div class="nbd-msg-content">{{ p.text }}@if (sending && !p.text && !p.actions.length && !deepProgress) {
<span class="cursor"></span>
}</div>
@for (a of p.actions; track $index) {
<app-notebook-action-card
[action]="a"
[campaignId]="campaignId"
[arcs]="arcs"
[chaptersByArc]="chaptersByArc"
(created)="onActionCreated()">
</app-notebook-action-card>
}
}
} @else {
<div class="nbd-msg-content">{{ m.content }}</div>
}
</div>
}
</div>
</div>
<div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
(keydown.enter)="$event.preventDefault(); send()"></textarea>
<button class="btn-deep" (click)="send(true)" [disabled]="sending || !draft.trim()"
title="Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »">
<lucide-icon [img]="Layers" [size]="15"></lucide-icon>
</button>
<button class="btn-send" (click)="send()" [disabled]="sending || !draft.trim()"
title="Réponse rapide (recherche ciblée dans le document)">
<lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button>
</div>
</section>
<div class="nbd-input">
<textarea [(ngModel)]="draft" rows="2"
placeholder="Demande une adaptation, un résumé, un PNJ inspiré de la source…"
(keydown.enter)="$event.preventDefault(); send()"></textarea>
<button class="btn-deep" (click)="send(true)" [disabled]="sending || !draft.trim()"
title="Analyse approfondie : lit TOUT le document (plus lent, exhaustif). Idéal pour « liste tous les… »">
<lucide-icon [img]="Layers" [size]="15"></lucide-icon>
</button>
<button class="btn-send" (click)="send()" [disabled]="sending || !draft.trim()"
title="Réponse rapide (recherche ciblée dans le document)">
<lucide-icon [img]="Send" [size]="15"></lucide-icon>
</button>
</div>
</section>
</div>
</div>
</div>
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader, CheckCircle2, AlertCircle, Sparkles, Layers } from 'lucide-angular';
@@ -20,7 +20,7 @@ import { NotebookActionCardComponent } from '../notebook-action-card/notebook-ac
*/
@Component({
selector: 'app-notebook-detail',
imports: [CommonModule, FormsModule, LucideAngularModule, NotebookActionCardComponent],
imports: [FormsModule, LucideAngularModule, NotebookActionCardComponent],
templateUrl: './notebook-detail.component.html',
styleUrls: ['./notebook-detail.component.scss']
})
@@ -108,7 +108,6 @@ export class NotebookDetailComponent implements OnInit {
}
/** trackBy stable pour les cartes d'action (évite toute recréation parasite). */
trackAction(index: number): number { return index; }
load(): void {
this.service.get(this.notebookId).subscribe({

View File

@@ -15,7 +15,7 @@
<div class="nbl-create">
<input type="text" [(ngModel)]="newName" placeholder="Nom de l'atelier (ex: Adaptation du module X)"
(keyup.enter)="create()">
(keyup.enter)="create()">
<button class="btn-create" (click)="create()" [disabled]="creating">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
{{ creating ? 'Création…' : 'Nouvel atelier' }}
@@ -23,13 +23,17 @@
</div>
<div class="nbl-list">
<p class="empty" *ngIf="notebooks.length === 0">Aucun atelier pour l'instant.</p>
<button class="nbl-item" *ngFor="let nb of notebooks" (click)="open(nb)">
<lucide-icon [img]="BookOpen" [size]="16"></lucide-icon>
<span class="nbl-item-name">{{ nb.name }}</span>
<span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
@if (notebooks.length === 0) {
<p class="empty">Aucun atelier pour l'instant.</p>
}
@for (nb of notebooks; track nb) {
<button class="nbl-item" (click)="open(nb)">
<lucide-icon [img]="BookOpen" [size]="16"></lucide-icon>
<span class="nbl-item-name">{{ nb.name }}</span>
<span class="nbl-del" (click)="remove(nb, $event)" title="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, BookOpen } from 'lucide-angular';
@@ -14,7 +14,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-notebook-list',
imports: [CommonModule, FormsModule, LucideAngularModule],
imports: [FormsModule, LucideAngularModule],
templateUrl: './notebook-list.component.html',
styleUrls: ['./notebook-list.component.scss']
})