321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { Component, OnInit } from '@angular/core';
|
|
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { FormsModule } from '@angular/forms';
|
|
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';
|
|
|
|
/**
|
|
* Éditeur plein écran d'un GameSystem. Rôle double création/édition :
|
|
* - `/game-systems/create` → POST au submit
|
|
* - `/game-systems/:id/edit` → PUT au submit
|
|
*
|
|
* UI par sections : au lieu d'un gros textarea de markdown, on présente
|
|
* une liste de cartes repliables (une par section H2). Au load on parse
|
|
* le markdown existant, au submit on le reconstruit. Les noms suggérés
|
|
* (Combat, Classes…) guident sans imposer — c'est le même mapping que
|
|
* le backend utilise pour filtrer les sections à injecter dans l'IA.
|
|
*/
|
|
interface RuleSection {
|
|
title: string;
|
|
content: string;
|
|
collapsed: boolean;
|
|
}
|
|
|
|
/**
|
|
* Sections suggérées : mécaniques de jeu uniquement. Lore/factions/PNJ
|
|
* appartiennent au module Lore de LoreMind (décrit l'univers de l'utilisateur),
|
|
* pas au GameSystem (décrit les règles). Séparation volontaire.
|
|
*/
|
|
const SUGGESTED_SECTIONS = [
|
|
'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression'
|
|
];
|
|
|
|
/** Suggestions de champs pour la fiche PJ — generiques (extension par template). */
|
|
const CHARACTER_FIELD_SUGGESTIONS = ['Histoire', 'Personnalite', 'Apparence', 'Notes'];
|
|
|
|
/** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */
|
|
const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ'];
|
|
|
|
@Component({
|
|
selector: 'app-game-system-edit',
|
|
imports: [FormsModule, LucideAngularModule, TemplateFieldsEditorComponent],
|
|
templateUrl: './game-system-edit.component.html',
|
|
styleUrls: ['./game-system-edit.component.scss']
|
|
})
|
|
export class GameSystemEditComponent implements OnInit {
|
|
readonly Save = Save;
|
|
readonly ArrowLeft = ArrowLeft;
|
|
readonly Dices = Dices;
|
|
readonly Plus = Plus;
|
|
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[] = [];
|
|
/**
|
|
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
|
|
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
|
|
* en cours, pas l'historique.
|
|
*/
|
|
importStatus: string | null = null;
|
|
|
|
name = '';
|
|
description = '';
|
|
author = '';
|
|
sections: RuleSection[] = [];
|
|
characterTemplate: TemplateField[] = [];
|
|
npcTemplate: TemplateField[] = [];
|
|
|
|
readonly suggestedSections = SUGGESTED_SECTIONS;
|
|
readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS;
|
|
readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS;
|
|
|
|
constructor(
|
|
private route: ActivatedRoute,
|
|
private router: Router,
|
|
private service: GameSystemService
|
|
) {}
|
|
|
|
ngOnInit(): void {
|
|
this.id = this.route.snapshot.paramMap.get('id');
|
|
if (this.id) {
|
|
this.service.getById(this.id).subscribe({
|
|
next: (gs) => {
|
|
this.name = gs.name;
|
|
this.description = gs.description ?? '';
|
|
this.author = gs.author ?? '';
|
|
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
|
|
this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : [];
|
|
this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : [];
|
|
},
|
|
error: () => this.back()
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Noms de section déjà utilisés — pour désactiver les suggestions en doublon. */
|
|
isSectionUsed(title: string): boolean {
|
|
const lower = title.toLowerCase();
|
|
return this.sections.some(s => s.title.toLowerCase() === lower);
|
|
}
|
|
|
|
/** Ajoute une section avec un nom suggéré. */
|
|
addSuggested(title: string): void {
|
|
if (this.isSectionUsed(title)) return;
|
|
this.sections.push({ title, content: '', collapsed: false });
|
|
}
|
|
|
|
/** Ajoute une section vierge (titre à remplir). */
|
|
addBlank(): void {
|
|
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.importStatus = null;
|
|
|
|
this.service.importRulesStream(file).subscribe({
|
|
next: (ev) => {
|
|
if (ev.type === 'progress') {
|
|
// Un morceau vient d'aboutir : le message d'attente est obsolète.
|
|
this.importStatus = null;
|
|
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 === 'status') {
|
|
this.importStatus = ev.message;
|
|
} 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);
|
|
}
|
|
|
|
toggleCollapse(section: RuleSection): void {
|
|
section.collapsed = !section.collapsed;
|
|
}
|
|
|
|
submit(): void {
|
|
if (!this.name.trim()) return;
|
|
if (this.hasInvalidTemplateFields()) {
|
|
console.warn('Champs templates invalides (noms vides ou doublons) — sauvegarde bloquee.');
|
|
return;
|
|
}
|
|
const payload = {
|
|
name: this.name.trim(),
|
|
description: this.description.trim() || null,
|
|
author: this.author.trim() || null,
|
|
rulesMarkdown: this.serializeMarkdown(),
|
|
characterTemplate: this.characterTemplate,
|
|
npcTemplate: this.npcTemplate,
|
|
isPublic: false
|
|
};
|
|
const req = this.id
|
|
? this.service.update(this.id, payload)
|
|
: this.service.create(payload);
|
|
req.subscribe({
|
|
next: () => this.back(),
|
|
error: () => console.error('Erreur sauvegarde GameSystem')
|
|
});
|
|
}
|
|
|
|
back(): void {
|
|
this.router.navigate(['/game-systems']);
|
|
}
|
|
|
|
/** Validation cote front : nom vide ou doublons (case-insensitive). */
|
|
private hasInvalidTemplateFields(): boolean {
|
|
return this.hasInvalidList(this.characterTemplate) || this.hasInvalidList(this.npcTemplate);
|
|
}
|
|
|
|
private hasInvalidList(fields: TemplateField[]): boolean {
|
|
const seen = new Set<string>();
|
|
for (const f of fields) {
|
|
const name = f.name?.trim().toLowerCase();
|
|
if (!name) return true;
|
|
if (seen.has(name)) return true;
|
|
seen.add(name);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- Parse / Serialize markdown ------------------------------------------
|
|
|
|
/**
|
|
* Découpe un markdown par titres H2 en sections. Doit rester cohérent avec
|
|
* le parser Java côté backend (regex `^##\s+…$`). Le texte avant le premier
|
|
* H2 est volontairement perdu — le backend l'ignore aussi pour l'injection IA.
|
|
*/
|
|
private parseMarkdown(markdown: string): RuleSection[] {
|
|
if (!markdown) return [];
|
|
const sections: RuleSection[] = [];
|
|
const lines = markdown.split('\n');
|
|
let current: RuleSection | null = null;
|
|
const buffer: string[] = [];
|
|
|
|
const flush = () => {
|
|
if (current) {
|
|
current.content = buffer.join('\n').trim();
|
|
sections.push(current);
|
|
}
|
|
buffer.length = 0;
|
|
};
|
|
|
|
for (const line of lines) {
|
|
const match = line.match(/^##\s+(.+?)\s*$/);
|
|
if (match) {
|
|
flush();
|
|
current = { title: match[1].trim(), content: '', collapsed: false };
|
|
} else if (current) {
|
|
buffer.push(line);
|
|
}
|
|
}
|
|
flush();
|
|
return sections;
|
|
}
|
|
|
|
/** Reconstruit le markdown à partir des sections (ignore les sections vides). */
|
|
private serializeMarkdown(): string | null {
|
|
const valid = this.sections.filter(s => s.title.trim() || s.content.trim());
|
|
if (valid.length === 0) return null;
|
|
return valid
|
|
.map(s => `## ${s.title.trim() || 'Sans titre'}\n${s.content.trim()}`)
|
|
.join('\n\n');
|
|
}
|
|
}
|