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:
@@ -36,6 +36,33 @@
|
||||
selon ce qu'elle génère (combat → Combat/Monstres, PNJ → Classes, arc → Lore/Factions).
|
||||
</p>
|
||||
|
||||
<!-- Import d'un PDF de règles : l'IA propose un découpage en sections,
|
||||
que l'utilisateur révise ci-dessous avant d'enregistrer. -->
|
||||
<div class="import-row">
|
||||
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
|
||||
<button type="button" class="btn-import" [disabled]="importing" (click)="pdfInput.click()">
|
||||
<lucide-icon [img]="Upload" [size]="14"></lucide-icon>
|
||||
{{ importing ? 'Import en cours…' : 'Importer un PDF de règles' }}
|
||||
</button>
|
||||
<span class="import-hint" *ngIf="!importing">L'IA propose un découpage en sections — vous révisez avant d'enregistrer.</span>
|
||||
</div>
|
||||
|
||||
<!-- Progression live de l'import (étape + barre + sections trouvées). -->
|
||||
<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-found" *ngIf="importFound.length">
|
||||
Sections trouvées : {{ importFound.join(' · ') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="import-note" *ngIf="importNote">{{ importNote }}</p>
|
||||
<p class="import-error" *ngIf="importError">{{ importError }}</p>
|
||||
|
||||
<div class="section-list">
|
||||
<div class="section-card" *ngFor="let section of sections; let i = index" [class.collapsed]="section.collapsed">
|
||||
|
||||
|
||||
@@ -95,6 +95,94 @@
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
// --- Import PDF de règles ---------------------------------------------------
|
||||
.import-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.btn-import {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) { background: #273244; border-color: #6c63ff; }
|
||||
&:disabled { opacity: 0.6; cursor: progress; }
|
||||
}
|
||||
|
||||
.import-hint {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.import-progress {
|
||||
margin: -0.4rem 0 1rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: #0b1220;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.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-found {
|
||||
margin: 0.55rem 0 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.import-note {
|
||||
margin: -0.4rem 0 1rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: rgba(108, 99, 255, 0.12);
|
||||
border: 1px solid rgba(108, 99, 255, 0.35);
|
||||
border-radius: 8px;
|
||||
color: #c4b5fd;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.import-error {
|
||||
margin: -0.4rem 0 1rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.section-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
|
||||
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight, Upload } from 'lucide-angular';
|
||||
import { GameSystemService } from '../../services/game-system.service';
|
||||
import { TemplateField } from '../../services/template.model';
|
||||
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
|
||||
@@ -54,9 +54,23 @@ export class GameSystemEditComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
readonly Upload = Upload;
|
||||
|
||||
id: string | null = null;
|
||||
|
||||
/** Import PDF en cours (appel LLM long) → désactive le bouton + spinner. */
|
||||
importing = false;
|
||||
/** Message de succès post-import (sections ajoutées, pages, OCR). */
|
||||
importNote: string | null = null;
|
||||
/** Message d'erreur d'import (Brain injoignable, PDF illisible…). */
|
||||
importError: string | null = null;
|
||||
/** Libellé de l'étape courante (« Extraction… », « Analyse… (3/12) »). */
|
||||
importPhase = '';
|
||||
/** Avancement de la structuration ; null pendant l'extraction (total inconnu). */
|
||||
importProgress: { current: number; total: number } | null = null;
|
||||
/** Titres de sections trouvés au fil de l'eau (affichage live). */
|
||||
importFound: string[] = [];
|
||||
|
||||
name = '';
|
||||
description = '';
|
||||
author = '';
|
||||
@@ -108,6 +122,96 @@ export class GameSystemEditComponent implements OnInit {
|
||||
this.sections.push({ title: '', content: '', collapsed: false });
|
||||
}
|
||||
|
||||
// --- Import d'un PDF de règles -------------------------------------------
|
||||
|
||||
/** Déclenché par le <input file> caché : lance l'import du PDF choisi. */
|
||||
onPdfSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
// Reset de la valeur : re-sélectionner le même fichier doit re-déclencher.
|
||||
input.value = '';
|
||||
if (file) this.importPdf(file);
|
||||
}
|
||||
|
||||
private importPdf(file: File): void {
|
||||
this.importing = true;
|
||||
this.importNote = null;
|
||||
this.importError = null;
|
||||
this.importPhase = 'Extraction du texte…';
|
||||
this.importProgress = null;
|
||||
this.importFound = [];
|
||||
|
||||
this.service.importRulesStream(file).subscribe({
|
||||
next: (ev) => {
|
||||
if (ev.type === 'progress') {
|
||||
if (ev.total === 0) {
|
||||
// Phase d'extraction (total encore inconnu).
|
||||
this.importPhase = 'Extraction du texte…';
|
||||
this.importProgress = null;
|
||||
} else {
|
||||
this.importPhase = `Analyse des règles… (${ev.current}/${ev.total})`;
|
||||
this.importProgress = { current: ev.current, total: ev.total };
|
||||
for (const t of ev.newSectionTitles) {
|
||||
if (!this.importFound.includes(t)) this.importFound.push(t);
|
||||
}
|
||||
}
|
||||
} else if (ev.type === 'done') {
|
||||
this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount);
|
||||
}
|
||||
},
|
||||
error: (err: Error) => {
|
||||
this.resetImportProgress();
|
||||
this.importError = err?.message
|
||||
? `Échec de l'import : ${err.message}`
|
||||
: "Échec de l'import du PDF.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private finishImport(sections: Record<string, string>, pageCount: number, ocrPageCount: number): void {
|
||||
this.resetImportProgress();
|
||||
const added = this.mergeImportedSections(sections);
|
||||
if (added === 0) {
|
||||
this.importError = "Aucune règle exploitable n'a été extraite de ce PDF "
|
||||
+ "(scan sans OCR, ou contenu non reconnu).";
|
||||
return;
|
||||
}
|
||||
const ocr = ocrPageCount > 0 ? ` (dont ${ocrPageCount} page(s) via OCR)` : '';
|
||||
this.importNote = `${added} section(s) proposée(s) depuis ${pageCount} page(s)${ocr}. `
|
||||
+ `Relisez et ajustez ci-dessous avant d'enregistrer.`;
|
||||
}
|
||||
|
||||
private resetImportProgress(): void {
|
||||
this.importing = false;
|
||||
this.importPhase = '';
|
||||
this.importProgress = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fusionne les sections proposées dans l'éditeur. Section de même titre
|
||||
* (insensible à la casse) → contenu ajouté à la suite ; sinon nouvelle carte.
|
||||
* Retourne le nombre de sections effectivement intégrées.
|
||||
*/
|
||||
private mergeImportedSections(sections: Record<string, string>): number {
|
||||
let count = 0;
|
||||
for (const [rawTitle, rawContent] of Object.entries(sections ?? {})) {
|
||||
const title = (rawTitle ?? '').trim();
|
||||
const content = (rawContent ?? '').trim();
|
||||
if (!title || !content) continue;
|
||||
const existing = this.sections.find(
|
||||
s => s.title.trim().toLowerCase() === title.toLowerCase()
|
||||
);
|
||||
if (existing) {
|
||||
existing.content = `${existing.content.trim()}\n\n${content}`.trim();
|
||||
existing.collapsed = false;
|
||||
} else {
|
||||
this.sections.push({ title, content, collapsed: false });
|
||||
}
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
removeSection(index: number): void {
|
||||
this.sections.splice(index, 1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user