Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
Some checks failed
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled

Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
This commit is contained in:
2026-06-04 13:55:27 +02:00
parent 091de0daf7
commit 6d00543a59
78 changed files with 5250 additions and 183 deletions

View File

@@ -0,0 +1,183 @@
<div class="import-page">
<div class="page-header">
<button type="button" class="btn-back" (click)="cancel()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne
</button>
<h1>Importer un PDF de campagne</h1>
<p class="subtitle">
L'IA propose une arborescence arc → chapitre → scène. Vous la révisez et l'ajustez
avant de créer quoi que ce soit.
</p>
</div>
<!-- Étape 1 : upload (masqué une fois en revue) -->
<section class="upload-area" *ngIf="!reviewing">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
{{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }}
</button>
<!-- Progression live -->
<div class="import-progress" *ngIf="importing">
<p class="import-phase">{{ importPhase }}</p>
<div class="progress-bar" *ngIf="importProgress">
<div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
</div>
</div>
<p class="import-counts" *ngIf="importCounts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s)
</p>
</div>
<p class="import-error" *ngIf="importError">{{ importError }}</p>
</section>
<!-- Étape 2 : revue de l'arbre éditable -->
<section class="review-area" *ngIf="reviewing">
<div class="review-header">
<p class="review-summary">
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
<strong>{{ chapterCount }}</strong> chapitre(s),
<strong>{{ sceneCount }}</strong> scène(s).
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
</p>
</div>
<div class="tree">
<!-- Arc -->
<div class="arc-card" *ngFor="let arc of tree; let ai = index">
<div class="node-head arc-head" [class.existing-node]="arc.existing">
<button type="button" class="btn-collapse" (click)="toggleArc(arc)">
<lucide-icon [img]="arc.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
</button>
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
[readonly]="arc.existing" placeholder="Nom de l'arc" />
<span class="badge-existing" *ngIf="arc.existing">déjà présent</span>
<button type="button" class="btn-remove" *ngIf="!arc.existing" (click)="removeArc(ai)" title="Supprimer l'arc">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<div class="node-body" *ngIf="!arc.collapsed">
<div class="arc-type-toggle" *ngIf="!arc.existing">
<span class="toggle-label">Type :</span>
<button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
(click)="setArcType(arc, 'LINEAR')">Linéaire</button>
<button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
(click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
</div>
<textarea class="node-desc" *ngIf="!arc.existing" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
<!-- Chapitres -->
<div class="chapter-card" *ngFor="let chapter of arc.chapters; let ci = index">
<div class="node-head chapter-head" [class.existing-node]="chapter.existing">
<button type="button" class="btn-collapse" (click)="toggleChapter(chapter)">
<lucide-icon [img]="chapter.collapsed ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
</button>
<lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
[readonly]="chapter.existing" placeholder="Nom du chapitre" />
<span class="badge-existing" *ngIf="chapter.existing">déjà présent</span>
<button type="button" class="btn-remove" *ngIf="!chapter.existing" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<div class="node-body" *ngIf="!chapter.collapsed">
<textarea class="node-desc" *ngIf="!chapter.existing" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci"
rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea>
<!-- Scènes -->
<div class="scene-card" *ngFor="let scene of chapter.scenes; let si = index"
[class.existing-node]="scene.existing">
<div class="scene-row">
<lucide-icon [img]="MapPin" [size]="13" class="node-icon scene-icon"></lucide-icon>
<div class="scene-fields">
<input type="text" class="node-name" [(ngModel)]="scene.name"
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
placeholder="Nom de la scène" />
<input type="text" class="node-desc-inline" *ngIf="!scene.existing" [(ngModel)]="scene.description"
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
</div>
<span class="badge-existing" *ngIf="scene.existing">déjà présent</span>
<button type="button" class="btn-details" *ngIf="!scene.existing" (click)="toggleDetails(scene)"
title="Narration joueurs, notes MJ, pièces">
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
Détails<span *ngIf="scene.rooms.length"> · {{ scene.rooms.length }} pièce(s)</span>
</button>
<button type="button" class="btn-remove" *ngIf="!scene.existing" (click)="removeScene(chapter, si)" title="Supprimer la scène">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<div class="scene-details" *ngIf="scene.detailsOpen && !scene.existing">
<label class="field-label">À lire aux joueurs</label>
<textarea class="node-desc" [(ngModel)]="scene.playerNarration"
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea>
<label class="field-label">Notes MJ</label>
<textarea class="node-desc" [(ngModel)]="scene.gmNotes"
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Secrets, développement, conséquences (optionnel)"></textarea>
<!-- Pièces (donjon) : vide = scène narrative classique -->
<span class="field-label">Pièces (lieu explorable)</span>
<div class="rooms">
<div class="room-row" *ngFor="let room of scene.rooms; let ri = index">
<input type="text" class="room-name" [(ngModel)]="room.name"
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" />
<input type="text" class="room-field" [(ngModel)]="room.description"
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Description" />
<input type="text" class="room-field" [(ngModel)]="room.enemies"
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Ennemis" />
<input type="text" class="room-field" [(ngModel)]="room.loot"
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Trésor" />
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</div>
<button type="button" class="btn-add-inline" (click)="addRoom(scene)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
</button>
</div>
</div>
</div>
<button type="button" class="btn-add-inline" (click)="addScene(chapter)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
</button>
</div>
</div>
<button type="button" class="btn-add-inline" (click)="addChapter(arc)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
</button>
</div>
</div>
<button type="button" class="btn-add" (click)="addArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
</button>
</div>
<p class="apply-error" *ngIf="applyError">{{ applyError }}</p>
<div class="review-actions">
<button type="button" class="btn-primary" [disabled]="applying || !hasNewContent" (click)="apply()">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
{{ applying ? 'Création…' : 'Créer dans la campagne' }}
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div>
</section>
</div>

View File

@@ -0,0 +1,323 @@
.import-page {
padding: 2rem 2.5rem;
max-width: 900px;
}
.page-header {
margin-bottom: 1.5rem;
h1 { margin: 0.6rem 0 0.3rem; font-size: 1.5rem; color: #f3f4f6; }
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
&:hover { color: #c4b5fd; }
}
.subtitle { margin: 0; color: #9ca3af; font-size: 0.9rem; }
// --- Upload -----------------------------------------------------------------
.upload-area {
padding: 2rem;
border: 1px dashed #374151;
border-radius: 12px;
text-align: center;
background: #0b1220;
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.6rem 1.1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-primary {
background: #6c63ff;
color: white;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.6; cursor: progress; }
&.big { padding: 0.8rem 1.4rem; font-size: 0.95rem; }
}
.btn-secondary {
background: #1f2937;
color: #e5e7eb;
border: 1px solid #374151;
&:hover { background: #273244; }
}
.import-progress {
margin: 1.25rem auto 0;
max-width: 460px;
text-align: left;
}
.import-phase { margin: 0 0 0.5rem; color: #c4b5fd; font-size: 0.85rem; font-weight: 500; }
.progress-bar {
height: 6px;
background: #1f2937;
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #6c63ff;
border-radius: 999px;
transition: width 0.3s ease;
}
.import-counts { margin: 0.55rem 0 0; color: #9ca3af; font-size: 0.8rem; }
.import-error,
.apply-error {
margin: 1rem 0 0;
padding: 0.55rem 0.8rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: 8px;
color: #fca5a5;
font-size: 0.85rem;
}
// --- Revue (arbre) ----------------------------------------------------------
.review-summary { margin: 0 0 1rem; color: #d1d5db; font-size: 0.9rem; strong { color: #fff; } em { color: #9ca3af; font-style: italic; } }
// Nœuds déjà présents dans la campagne : grisés, lecture seule.
.existing-node {
opacity: 0.6;
.node-name { background: transparent; border-color: transparent; cursor: default; }
}
.badge-existing {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
background: rgba(255, 255, 255, 0.06);
border: 1px solid #1f2937;
border-radius: 999px;
padding: 0.1rem 0.45rem;
white-space: nowrap;
}
.tree { display: flex; flex-direction: column; gap: 0.75rem; }
.arc-card {
border: 1px solid #1f2937;
border-radius: 10px;
background: #0b1220;
overflow: hidden;
}
.node-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.7rem;
}
.arc-head { background: #111827; }
.chapter-head { background: rgba(255, 255, 255, 0.02); }
.btn-collapse {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0;
display: flex;
&:hover { color: #c4b5fd; }
}
.node-icon { color: #a78bfa; flex-shrink: 0; }
.scene-icon { color: #6b7280; }
.node-name {
flex: 1;
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 6px;
color: #f3f4f6;
padding: 0.35rem 0.5rem;
font-size: 0.88rem;
&:focus { outline: none; border-color: #6c63ff; }
}
.btn-remove {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.2rem;
display: flex;
&:hover { color: #f87171; }
}
.node-body { padding: 0.5rem 0.7rem 0.7rem 1.7rem; display: flex; flex-direction: column; gap: 0.5rem; }
.node-desc,
.node-desc-inline {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 6px;
color: #d1d5db;
padding: 0.35rem 0.5rem;
font-size: 0.82rem;
font-family: inherit;
resize: vertical;
&:focus { outline: none; border-color: #6c63ff; }
}
.chapter-card {
border: 1px solid #1f2937;
border-radius: 8px;
overflow: hidden;
}
// Sélecteur Linéaire / Hub
.arc-type-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
}
.toggle-label { color: #9ca3af; font-size: 0.8rem; }
.type-btn {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
font-size: 0.78rem;
padding: 0.3rem 0.6rem;
&:hover { border-color: #6c63ff; }
&.active { background: rgba(108, 99, 255, 0.18); border-color: #6c63ff; color: #c4b5fd; font-weight: 600; }
}
.scene-card {
border: 1px solid #161e2e;
border-radius: 6px;
padding: 0.3rem 0.4rem;
}
.scene-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.btn-details {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: 1px solid #1f2937;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
white-space: nowrap;
&:hover { color: #c4b5fd; border-color: #6c63ff; }
}
.scene-details {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0.4rem 0 0.2rem 1.5rem;
padding-left: 0.6rem;
border-left: 2px solid #1f2937;
}
.field-label {
color: #9ca3af;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-top: 0.3rem;
}
.rooms {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.room-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.room-name,
.room-field {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 5px;
color: #d1d5db;
padding: 0.25rem 0.4rem;
font-size: 0.78rem;
&:focus { outline: none; border-color: #6c63ff; }
}
.room-name { flex: 0 0 22%; }
.room-field { flex: 1; min-width: 0; }
.scene-fields {
flex: 1;
display: flex;
gap: 0.4rem;
.node-name { flex: 0 0 40%; }
.node-desc-inline { flex: 1; }
}
.btn-add,
.btn-add-inline {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: transparent;
border: 1px dashed #374151;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
font-size: 0.8rem;
padding: 0.35rem 0.6rem;
align-self: flex-start;
&:hover { color: #c4b5fd; border-color: #6c63ff; }
}
.btn-add { margin-top: 0.25rem; }
.review-actions {
display: flex;
gap: 0.6rem;
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,374 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import {
LucideAngularModule, ArrowLeft, Upload, Plus, Trash2,
ChevronDown, ChevronRight, Swords, BookOpen, MapPin, Check
} from 'lucide-angular';
import { CampaignImportService } from '../../../services/campaign-import.service';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service';
import { ArcKind, ArcProposal, ChapterProposal, SceneProposal } from '../../../services/campaign-import.model';
import { CampaignImportProposal } from '../../../services/campaign-import.model';
import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
/**
* Nœuds éditables (= proposition + état d'UI). `existing` = déjà présent dans la
* campagne (chargé pour la revue) : lecture seule, sert de parent aux ajouts.
* `existingId` = l'ID de l'entité existante (envoyé à l'apply pour s'y rattacher).
*/
interface RoomNode { name: string; description: string; enemies: string; loot: string; }
interface SceneNode {
name: string; description: string; playerNarration: string; gmNotes: string;
rooms: RoomNode[]; detailsOpen: boolean; existing: boolean; existingId?: string;
}
interface ChapterNode {
name: string; description: string; scenes: SceneNode[]; collapsed: boolean;
existing: boolean; existingId?: string;
}
interface ArcNode {
name: string; description: string; type: ArcKind; chapters: ChapterNode[]; collapsed: boolean;
existing: boolean; existingId?: string;
}
/**
* Page d'import d'un PDF de campagne → arbre arc/chapitre/scène.
* Route : /campaigns/:campaignId/import
*
* 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 ».
*/
@Component({
selector: 'app-campaign-import',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './campaign-import.component.html',
styleUrls: ['./campaign-import.component.scss']
})
export class CampaignImportComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Upload = Upload;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
readonly Swords = Swords;
readonly BookOpen = BookOpen;
readonly MapPin = MapPin;
readonly Check = Check;
campaignId = '';
// --- État import (streaming) ---
importing = false;
importPhase = '';
importProgress: { current: number; total: number } | null = null;
importCounts: { arcs: number; chapters: number; scenes: number } | null = null;
importError: string | null = null;
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
reviewing = false;
// --- Arbre éditable ---
tree: ArcNode[] = [];
/** Structure actuelle de la campagne (chargée pour la fusion à la revue). */
private existingData: CampaignTreeData | null = null;
// --- État application (création) ---
applying = false;
applyError: string | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: CampaignImportService,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private campaignSidebar: CampaignSidebarService,
private pageTitle: PageTitleService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
this.pageTitle.set('Importer une campagne');
this.campaignSidebar.show(this.campaignId);
// Pré-chargement de l'arborescence existante (pour fusionner à la revue).
// En cas d'échec on dégrade : tout sera considéré comme nouveau.
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
.pipe(catchError(() => of(null)))
.subscribe(data => this.existingData = data);
}
// --- Upload + streaming --------------------------------------------------
onPdfSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) this.importPdf(file);
}
private importPdf(file: File): void {
this.importing = true;
this.reviewing = false;
this.importError = null;
this.applyError = null;
this.importPhase = 'Extraction du texte…';
this.importProgress = null;
this.importCounts = null;
this.tree = [];
this.service.importStructureStream(this.campaignId, file).subscribe({
next: (ev) => {
if (ev.type === 'progress') {
if (ev.total === 0) {
this.importPhase = 'Extraction du texte…';
this.importProgress = null;
} else {
this.importPhase = `Analyse de la campagne… (${ev.current}/${ev.total})`;
this.importProgress = { current: ev.current, total: ev.total };
this.importCounts = { arcs: ev.arcCount, chapters: ev.chapterCount, scenes: ev.sceneCount };
}
} else if (ev.type === 'done') {
this.importing = false;
this.importPhase = '';
this.importProgress = null;
if ((ev.arcs ?? []).length === 0) {
this.importError = "Aucune structure narrative détectée dans ce PDF.";
this.reviewing = false;
} else {
this.tree = this.buildMergedTree(ev.arcs);
this.reviewing = true;
}
}
},
error: (err: Error) => {
this.importing = false;
this.importPhase = '';
this.importProgress = null;
this.importError = err?.message ? `Échec de l'import : ${err.message}` : "Échec de l'import du PDF.";
}
});
}
// --- Construction de l'arbre fusionné (existant + proposition) -----------
/**
* Construit l'arbre de revue : d'abord l'arborescence ACTUELLE de la campagne
* (nœuds `existing`, lecture seule), puis on y fusionne la proposition par NOM
* (insensible à la casse). Ce qui matche un nœud existant est rattaché ; ce qui
* ne matche pas devient un nouveau nœud éditable.
*/
private buildMergedTree(proposalArcs: ArcProposal[]): ArcNode[] {
const byOrder = (a: { order?: number }, b: { order?: number }) => (a.order ?? 0) - (b.order ?? 0);
const arcs: ArcNode[] = [];
// 1. Arbre existant.
const data = this.existingData;
if (data) {
for (const arc of [...data.arcs].sort(byOrder)) {
const chapters: ChapterNode[] = [];
for (const ch of [...(data.chaptersByArc[arc.id!] ?? [])].sort(byOrder)) {
const scenes: SceneNode[] = [];
for (const sc of [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder)) {
scenes.push(this.existingSceneNode(sc.id!, sc.name, sc.description));
}
chapters.push({
name: ch.name, description: ch.description ?? '', scenes,
collapsed: true, existing: true, existingId: ch.id
});
}
arcs.push({
name: arc.name, description: arc.description ?? '',
type: (arc.type === 'HUB' ? 'HUB' : 'LINEAR'), chapters,
collapsed: true, existing: true, existingId: arc.id
});
}
}
// 2. Fusion de la proposition.
for (const pa of proposalArcs ?? []) {
const match = arcs.find(a => a.existing && this.sameName(a.name, pa.name));
if (match) {
this.mergeChaptersInto(match, pa.chapters ?? []);
match.collapsed = false;
} else {
arcs.push(this.newArcNode(pa));
}
}
return arcs;
}
private mergeChaptersInto(arc: ArcNode, propChapters: ChapterProposal[]): void {
for (const pc of propChapters) {
const match = arc.chapters.find(c => c.existing && this.sameName(c.name, pc.name));
if (match) {
this.mergeScenesInto(match, pc.scenes ?? []);
match.collapsed = false;
} else {
arc.chapters.push(this.newChapterNode(pc));
}
}
}
private mergeScenesInto(chapter: ChapterNode, propScenes: SceneProposal[]): void {
for (const ps of propScenes) {
// Scène de même nom déjà présente → on ne duplique pas (dédup).
if (chapter.scenes.some(s => this.sameName(s.name, ps.name))) continue;
chapter.scenes.push(this.newSceneNode(ps));
}
}
private sameName(a: string, b: string): boolean {
return (a ?? '').trim().toLowerCase() === (b ?? '').trim().toLowerCase();
}
// --- Mappers proposition → nœud NEUF -------------------------------------
private newArcNode(a: ArcProposal): ArcNode {
return {
name: a.name ?? '', description: a.description ?? '',
type: (a.type === 'HUB' ? 'HUB' : 'LINEAR'),
collapsed: false, existing: false,
chapters: (a.chapters ?? []).map(c => this.newChapterNode(c))
};
}
private newChapterNode(c: ChapterProposal): ChapterNode {
return {
name: c.name ?? '', description: c.description ?? '',
collapsed: false, existing: false,
scenes: (c.scenes ?? []).map(s => this.newSceneNode(s))
};
}
private newSceneNode(s: SceneProposal): SceneNode {
return {
name: s.name ?? '', description: s.description ?? '',
playerNarration: s.playerNarration ?? '', gmNotes: s.gmNotes ?? '',
detailsOpen: false, existing: false,
rooms: (s.rooms ?? []).map(r => ({
name: r.name ?? '', description: r.description ?? '',
enemies: r.enemies ?? '', loot: r.loot ?? ''
}))
};
}
private existingSceneNode(id: string, name: string, description?: string): SceneNode {
return {
name, description: description ?? '', playerNarration: '', gmNotes: '',
detailsOpen: false, existing: true, existingId: id, rooms: []
};
}
setArcType(arc: ArcNode, type: ArcKind): void { arc.type = type; }
toggleDetails(scene: SceneNode): void { scene.detailsOpen = !scene.detailsOpen; }
addRoom(scene: SceneNode): void {
scene.rooms.push({ name: '', description: '', enemies: '', loot: '' });
scene.detailsOpen = true;
}
removeRoom(scene: SceneNode, index: number): void { scene.rooms.splice(index, 1); }
// --- Édition de l'arbre --------------------------------------------------
toggleArc(arc: ArcNode): void { arc.collapsed = !arc.collapsed; }
toggleChapter(chapter: ChapterNode): void { chapter.collapsed = !chapter.collapsed; }
addArc(): void {
this.tree.push({ name: '', description: '', type: 'LINEAR', chapters: [], collapsed: false, existing: false });
}
removeArc(index: number): void { this.tree.splice(index, 1); }
addChapter(arc: ArcNode): void {
arc.chapters.push({ name: '', description: '', scenes: [], collapsed: false, existing: false });
}
removeChapter(arc: ArcNode, index: number): void { arc.chapters.splice(index, 1); }
addScene(chapter: ChapterNode): void {
chapter.scenes.push({
name: '', description: '', playerNarration: '', gmNotes: '', rooms: [], detailsOpen: true, existing: false
});
}
removeScene(chapter: ChapterNode, index: number): void { chapter.scenes.splice(index, 1); }
/** Compteurs des nœuds NOUVEAUX (= ce qui sera réellement créé). */
get arcCount(): number { return this.tree.filter(a => !a.existing && a.name.trim()).length; }
get chapterCount(): number {
return this.tree.reduce((n, a) => n + a.chapters.filter(c => !c.existing && c.name.trim()).length, 0);
}
get sceneCount(): number {
return this.tree.reduce((n, a) =>
n + a.chapters.reduce((m, c) => m + c.scenes.filter(s => !s.existing && s.name.trim()).length, 0), 0);
}
/** Vrai s'il y a au moins un nœud nouveau à créer (sinon « Créer » désactivé). */
get hasNewContent(): boolean {
return this.arcCount > 0 || this.chapterCount > 0 || this.sceneCount > 0;
}
// --- Application (création) ----------------------------------------------
apply(): void {
if (this.applying || !this.hasNewContent) return;
this.applying = true;
this.applyError = null;
// On envoie l'arbre fusionné COMPLET (existants + nouveaux) : les nœuds
// `existing` portent leur existingId et servent de parents — l'apply ne
// recrée que les nœuds sans existingId.
const proposal: CampaignImportProposal = {
arcs: this.tree
.filter(a => a.name.trim())
.map(a => ({
name: a.name.trim(),
description: a.description.trim(),
type: a.type,
existingId: a.existingId ?? null,
chapters: a.chapters
.filter(c => c.name.trim())
.map(c => ({
name: c.name.trim(),
description: c.description.trim(),
existingId: c.existingId ?? null,
scenes: c.scenes
.filter(s => s.name.trim())
.map(s => ({
name: s.name.trim(),
description: s.description.trim(),
playerNarration: s.playerNarration.trim(),
gmNotes: s.gmNotes.trim(),
existingId: s.existingId ?? null,
rooms: 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()
}))
}))
}))
}))
};
this.service.applyStructure(this.campaignId, proposal).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => {
this.applying = false;
this.applyError = "Échec de la création. La campagne existe-t-elle toujours ?";
}
});
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}