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

@@ -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">

View File

@@ -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;

View File

@@ -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);
}