Files
LoreMind/web/src/app/lore/page-edit/page-edit.component.ts
IETM_FIXE\ietm6 f24ef0891e
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 2m53s
Build & Push Images / build (web) (push) Successful in 2m36s
Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées.
Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière.
Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre.

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00

284 lines
11 KiB
TypeScript

import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, Sparkles } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { LoreNode } from '../../services/lore.model';
import { Template } from '../../services/template.model';
import { Page } from '../../services/page.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { ChipsInputComponent } from '../../shared/chips-input/chips-input.component';
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { Lore } from '../../services/lore.model';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
/**
* Écran d'édition d'une Page.
*
* Fonctionnalités actuelles (Phase 5A + 5B) :
* - Titre (modifiable) + Dossier (déplaçable)
* - Champs dynamiques du Template (un textarea par champ, valeurs stockées dans `values`)
* - Tags (chips) — Phase 5B
* - Liens vers d'autres pages (autocomplete) — Phase 5B
* - Notes privées MJ
*
* À venir (Phase 5D) :
* - Bouton "Assistant IA" branché (Phase 3 Python)
*/
@Component({
selector: 'app-page-edit',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent],
templateUrl: './page-edit.component.html',
styleUrls: ['./page-edit.component.scss']
})
export class PageEditComponent implements OnInit, OnDestroy {
readonly Sparkles = Sparkles;
loreId = '';
pageId = '';
lore: Lore | null = null;
page: Page | null = null;
template: Template | null = null;
nodes: LoreNode[] = [];
/** Toutes les pages du lore — nécessaire au lore-link-picker pour l'autocomplete. */
allPages: Page[] = [];
/** Modèle du formulaire (bindé via ngModel). */
title = '';
nodeId = '';
notes = '';
/** Valeurs des champs dynamiques TEXT, indexées par fieldName. */
values: Record<string, string> = {};
/**
* Valeurs des champs dynamiques IMAGE : pour chaque nom de champ IMAGE,
* la liste ordonnee des IDs d'images uploadees.
*/
imageValues: Record<string, string[]> = {};
/** Étiquettes libres (Phase 5B). */
tags: string[] = [];
/** IDs des pages liées (Phase 5B). */
relatedPageIds: string[] = [];
/** Phase 5D — état de l'Assistant IA (one-shot). */
aiLoading = false;
aiError: string | null = null;
/** Phase b5 — drawer chat IA (conversationnel). */
chatOpen = false;
/** Action primaire dans le chat : déclenche le one-shot b4 (remplissage automatique). */
readonly chatPrimaryAction: ChatPrimaryAction = { label: 'Remplir automatiquement tous les champs' };
/** Suggestions rapides hardcodées (MVP). */
readonly chatQuickSuggestions: string[] = [
"Étoffe l'histoire de cette page",
'Suggère des liens avec d\'autres pages du Lore',
'Propose une intrigue secondaire'
];
constructor(
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService
) {}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
// S'abonner à paramMap plutôt que de lire snapshot une fois : sinon, quand on
// navigue d'une page à une autre (ex. via les chips du lore-link-picker),
// Angular réutilise le composant et ngOnInit ne se relance pas → l'écran
// resterait figé sur l'ancienne page.
this.route.paramMap.subscribe(pm => {
const newPageId = pm.get('pageId')!;
if (newPageId && newPageId !== this.pageId) {
this.pageId = newPageId;
this.load();
}
});
}
private load(): void {
forkJoin({
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
page: this.pageService.getById(this.pageId)
}).subscribe(({ sidebar, page }) => {
this.lore = sidebar.lore;
this.nodes = sidebar.nodes;
this.allPages = sidebar.pages;
this.layoutService.show(buildLoreSidebarConfig(sidebar));
this.hydrate(page, sidebar.templates);
});
}
/**
* Construit le fil d'Ariane : Lore > [dossiers parents...] > Dossier courant > Page.
* Les items sont cliquables sauf le dernier (position courante).
* On remonte la hiérarchie via `parentId` jusqu'à la racine, puis on inverse.
*/
get breadcrumbItems(): BreadcrumbItem[] {
if (!this.lore || !this.page) return [];
const items: BreadcrumbItem[] = [
{ label: this.lore.name, route: ['/lore', this.loreId] }
];
// Chemin des dossiers (racine → dossier courant) via remontée parentId.
const folderChain: LoreNode[] = [];
let currentNode = this.nodes.find(n => n.id === this.nodeId);
while (currentNode) {
folderChain.unshift(currentNode);
currentNode = currentNode.parentId
? this.nodes.find(n => n.id === currentNode!.parentId)
: undefined;
}
for (const node of folderChain) {
items.push({
label: node.name,
route: ['/lore', this.loreId, 'folders', node.id]
});
}
// Position courante : la page (non-cliquable).
items.push({ label: this.title || this.page.title });
return items;
}
private hydrate(page: Page, templates: Template[]): void {
this.page = page;
this.template = templates.find(t => t.id === page.templateId) ?? null;
this.title = page.title;
this.nodeId = page.nodeId;
this.notes = page.notes ?? '';
// On initialise une entrée pour chaque field TEXT du template, même vide,
// pour que le formulaire ait toujours les champs attendus.
// Les champs IMAGE ne sont pas geres dans `values` (ils auront leur propre
// structure `imageValues: Map<String, List<String>>` a l'etape 5).
const base: Record<string, string> = {};
const imageBase: Record<string, string[]> = {};
for (const f of this.template?.fields ?? []) {
if (f.type === 'TEXT') {
base[f.name] = page.values?.[f.name] ?? '';
} else if (f.type === 'IMAGE') {
// Initialise la galerie d'images pour ce champ (vide si jamais rempli).
imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])];
}
}
this.values = base;
this.imageValues = imageBase;
this.tags = [...(page.tags ?? [])];
this.relatedPageIds = [...(page.relatedPageIds ?? [])];
this.pageTitleService.set(page.title);
}
save(): void {
if (!this.page || !this.title.trim()) return;
const updated: Page = {
...this.page,
title: this.title,
nodeId: this.nodeId,
notes: this.notes,
values: this.values,
imageValues: this.imageValues,
tags: this.tags,
relatedPageIds: this.relatedPageIds
};
this.pageService.update(this.pageId, updated).subscribe({
next: () => this.router.navigate(['/lore', this.loreId, 'pages', this.pageId]),
error: () => console.error('Erreur lors de la sauvegarde de la page')
});
}
// --- Chat IA conversationnel (Phase b5) --------------------------------
toggleChat(): void {
this.chatOpen = !this.chatOpen;
}
/** Appelé depuis le drawer quand l'utilisateur clique sur l'action primaire. */
onChatFillRequested(): void {
this.chatOpen = false; // on ferme le drawer : le résultat apparaîtra dans les textareas
this.runAssistantAI();
}
/**
* Assistant IA (Phase 5D) — demande au Brain des suggestions de valeurs
* pour les champs dynamiques du template.
*
* Merge soft : on n'écrase pas une valeur déjà saisie par l'utilisateur
* si la suggestion est vide. L'utilisateur garde le contrôle final avant
* de cliquer "Sauvegarder".
*/
runAssistantAI(): void {
if (this.aiLoading || !this.template?.fields?.length) return;
this.aiLoading = true;
this.aiError = null;
this.pageService.generateValues(this.pageId).subscribe({
next: (suggestions) => {
this.mergeSuggestions(suggestions);
this.aiLoading = false;
},
error: (err) => {
this.aiLoading = false;
this.aiError = err?.status === 502
? "L'assistant IA est injoignable. V\u00e9rifiez que le service Brain tourne."
: "\u00c9chec de la g\u00e9n\u00e9ration IA. R\u00e9essayez dans un instant.";
}
});
}
/**
* Fusionne les suggestions dans les valeurs courantes.
* Merge soft :
* - Suggestion non-vide → on applique (l'utilisateur a demandé la génération).
* - Suggestion vide → on NE touche PAS à la valeur courante (l'IA n'a rien à proposer pour ce champ).
*/
private mergeSuggestions(suggestions: Record<string, string>): void {
// L'IA ne genere que des valeurs texte : on ignore les champs IMAGE.
for (const field of this.template?.fields ?? []) {
if (field.type !== 'TEXT') continue;
const suggestion = suggestions[field.name];
if (suggestion && suggestion.trim()) {
this.values[field.name] = suggestion;
}
}
}
delete(): void {
if (!this.page) return;
this.confirmDialog.confirm({
title: 'Supprimer la page',
message: `Supprimer la page "${this.page.title}" ?`,
confirmLabel: 'Supprimer',
variant: 'danger'
}).then(ok => {
if (!ok || !this.page) return;
this.pageService.delete(this.pageId).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
error: () => console.error('Erreur lors de la suppression de la page')
});
});
}
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera
// show(). Eviter d'appeler hide() ici previent le clignotement / la
// disparition de la sidebar lors des navigations internes a la section.
}
}