Files
LoreMind/web/src/app/lore/template-create/template-create.component.ts
IETM_FIXE\ietm6 84ccdd53ad Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00

207 lines
7.5 KiB
TypeScript

import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } 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 { LoreNode } from '../../services/lore.model';
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { popReturnTo } from '../return-stack.helper';
/**
* Écran de création d'un Template (gabarit de Page).
* - Champs principaux : nom, description, noeud par défaut.
* - Liste dynamique de "champs du template" (ex: "Nom", "Description", "Personnalité").
* Le user peut ajouter/retirer n'importe lequel — tous sont égaux.
*/
@Component({
selector: 'app-template-create',
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule, RouterModule, LucideAngularModule],
templateUrl: './template-create.component.html',
styleUrls: ['./template-create.component.scss']
})
export class TemplateCreateComponent implements OnInit, OnDestroy {
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
readonly ChevronUp = ChevronUp;
readonly ChevronDown = ChevronDown;
form: FormGroup;
loreId = '';
nodes: LoreNode[] = [];
/**
* Champs dynamiques actuellement definis. Chaque champ a un type discriminant
* (TEXT ou IMAGE) qui pilote son rendu sur les pages.
*/
fields: TemplateField[] = [
{ name: 'Nom', type: 'TEXT' },
{ name: 'Description', type: 'TEXT' }
];
/** Valeur courante de l'input d'ajout de champ (non binding direct pour reset facile). */
newFieldName = '';
/** Type choisi pour le prochain champ a ajouter. */
newFieldType: FieldType = 'TEXT';
constructor(
private fb: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private loreService: LoreService,
private templateService: TemplateService,
private pageService: PageService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
defaultNodeId: ['', Validators.required]
});
}
ngOnInit(): void {
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService).subscribe(data => {
this.nodes = data.nodes;
this.layoutService.show(buildLoreSidebarConfig(data));
this.restoreDraft();
});
}
/** Clé sessionStorage pour le brouillon de template — scopée au lore. */
private get draftKey(): string {
return `template-create-draft:${this.loreId}`;
}
/**
* Sauvegarde le formulaire courant avant un détour (création de dossier).
* defaultNodeId volontairement omis : il référence potentiellement un dossier
* qui n'existe pas encore.
*/
saveDraft(): void {
const draft = {
name: this.form.value.name ?? '',
description: this.form.value.description ?? '',
fields: this.fields
};
try {
sessionStorage.setItem(this.draftKey, JSON.stringify(draft));
} catch { /* storage indisponible : on ignore */ }
}
private restoreDraft(): void {
let raw: string | null = null;
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
if (!raw) return;
sessionStorage.removeItem(this.draftKey);
try {
const draft = JSON.parse(raw) as { name?: string; description?: string; fields?: TemplateField[] };
if (draft.name) this.form.patchValue({ name: draft.name });
if (draft.description) this.form.patchValue({ description: draft.description });
if (Array.isArray(draft.fields) && draft.fields.length) this.fields = draft.fields;
} catch { /* JSON corrompu : on ignore */ }
}
/**
* Construit le `returnTo` à passer à l'écran de création de dossier :
* on empile 'template-create' par-dessus la pile courante, pour que node-create
* revienne ici puis remonte à l'écran d'origine le cas échéant.
*/
get nodeCreateReturnTo(): string {
const current = this.route.snapshot.queryParamMap.get('returnTo');
return current ? `template-create,${current}` : 'template-create';
}
addField(): void {
const name = this.newFieldName.trim();
if (!name) return;
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
if (this.fields.some(f => f.name === name)) return;
const newField: TemplateField = this.newFieldType === 'IMAGE'
? { name, type: 'IMAGE', layout: 'GALLERY' }
: { name, type: 'TEXT' };
this.fields = [...this.fields, newField];
this.newFieldName = '';
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
// plusieurs champs du meme type.
}
removeField(index: number): void {
this.fields = this.fields.filter((_, i) => i !== index);
}
/** Deplace un champ d'un cran vers le haut ou le bas. No-op aux bords. */
moveField(index: number, direction: -1 | 1): void {
const target = index + direction;
if (target < 0 || target >= this.fields.length) return;
const next = [...this.fields];
[next[index], next[target]] = [next[target], next[index]];
this.fields = next;
}
/** Bascule le type d'un champ existant (TEXT <-> IMAGE). */
toggleFieldType(index: number): void {
const field = this.fields[index];
if (!field) return;
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
this.fields = this.fields.map((f, i) => {
if (i !== index) return f;
return nextType === 'IMAGE'
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
: { name: f.name, type: 'TEXT' };
});
}
/** Met a jour le layout d'un champ IMAGE. */
setFieldLayout(index: number, layout: ImageLayout): void {
this.fields = this.fields.map((f, i) =>
i === index && f.type === 'IMAGE' ? { ...f, layout } : f
);
}
submit(): void {
if (this.form.invalid) return;
const raw = this.form.value;
this.templateService.create({
loreId: this.loreId,
name: raw.name,
description: raw.description,
defaultNodeId: raw.defaultNodeId,
fields: this.fields
}).subscribe({
next: () => this.navigateBack(),
error: () => console.error('Erreur lors de la création du template')
});
}
cancel(): void {
this.navigateBack();
}
/**
* Redirige vers l'écran d'origine en dépilant le premier élément du query-param
* `returnTo` (pile de retours séparés par des virgules, ex : `page-create` ou
* `template-create,page-create`). Sinon retombe sur la page détail du Lore.
*/
private navigateBack(): void {
const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
if (next === 'page-create') {
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], {
queryParams: rest ? { returnTo: rest } : {}
});
return;
}
this.router.navigate(['/lore', this.loreId]);
}
ngOnDestroy(): void {
this.layoutService.hide();
}
}