Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user