Livret PDF (export campagne) :
- refonte lisibilité façon livre de JdR : sommaire paginé, libellés de champs
en tête de ligne, encadrés « À lire aux joueurs » / secrets MJ / combat,
ligne de contexte des scènes, en-tête courant, tables zébrées
- reflète la fusion quête/conteneur : arc SYSTEM masqué, quêtes de hub
fusionnées dans leur chapitre, partie « Quêtes » sans doublon
- contenu complété : sorties de scènes, ennemis liés au bestiaire, pièces
explorables, partie « Tables aléatoires »
- polices DejaVu embarquées (couverture Unicode, licence incluse) et harness
visuel PdfExportPreviewTest (Mockito pur, tourne sans Postgres)
Graphe des liens :
- déplacé du Lore vers la campagne (les PNJ sont des entités de campagne) ;
endpoint /api/npcs/lore/{id} supprimé
- layout retravaillé (anti-chevauchement des libellés, éventail des feuilles
autour des hubs), scènes et quêtes ajoutées, libellés sur 2 lignes,
filtres de légende mémorisés, focus au survol
- positions drag & drop persistées sur la campagne (migration V23,
PUT /api/campaigns/{id}/graph-positions, bouton « Disposition auto »)
Divers :
- ImportService découpé en classes dédiées (parser d'archive, remapper d'ids,
inserters campagne/lore/état de jeu, conversion legacy des quêtes)
- fuites d'abonnements corrigées (takeUntilDestroyed sur paramMap) sur ~15 écrans
- garde-fou i18n fragments/consolidés branché sur npm run build (check-i18n.mjs),
budget SCSS anyComponentStyle relevé à 12 ko
207 lines
8.3 KiB
TypeScript
207 lines
8.3 KiB
TypeScript
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
|
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|
import { forkJoin } from 'rxjs';
|
|
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
|
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
|
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 { Lore, LoreNode } from '../../services/lore.model';
|
|
import { Template } from '../../services/template.model';
|
|
import { Page, ImageFraming } from '../../services/page.model';
|
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
|
import { hasBlockLayout, orderedBlocks, blockGridColumn, blockGridRow, blockKey } from '../block-layout.helper';
|
|
import { TemplateField } from '../../services/template.model';
|
|
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
|
import { ImageBlockComponent } from '../../shared/image-block/image-block.component';
|
|
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
|
|
|
/**
|
|
* Écran de consultation d'une Page (mode lecture seule).
|
|
*
|
|
* Responsabilité : afficher une belle fiche, sans formulaire ni scrollbar interne.
|
|
* Chaque champ du template est rendu en bloc titré dont le corps s'étend
|
|
* verticalement selon le contenu (via CSS `white-space: pre-wrap`).
|
|
*
|
|
* Route : /lore/:loreId/pages/:pageId
|
|
* Pour modifier → bouton "Modifier" qui navigue vers /lore/:loreId/pages/:pageId/edit.
|
|
*/
|
|
@Component({
|
|
selector: 'app-page-view',
|
|
imports: [RouterModule, LucideAngularModule, TranslatePipe, BreadcrumbComponent, ImageBlockComponent],
|
|
templateUrl: './page-view.component.html',
|
|
styleUrls: ['./page-view.component.scss']
|
|
})
|
|
export class PageViewComponent implements OnInit, OnDestroy {
|
|
readonly Pencil = Pencil;
|
|
readonly Trash2 = Trash2;
|
|
|
|
loreId = '';
|
|
pageId = '';
|
|
lore: Lore | null = null;
|
|
page: Page | null = null;
|
|
template: Template | null = null;
|
|
nodes: LoreNode[] = [];
|
|
allPages: Page[] = [];
|
|
|
|
/** Blocs du template ordonnes pour le rendu (tries par grille si placee). */
|
|
orderedFields: TemplateField[] = [];
|
|
/** True si le template porte une mise en page grille -> rendu en grille 12 col. */
|
|
hasLayout = false;
|
|
|
|
/** Placement CSS d'un bloc dans la grille (binde via [style.grid-column/row]). */
|
|
readonly gridColumn = blockGridColumn;
|
|
readonly gridRow = blockGridRow;
|
|
|
|
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,
|
|
private translate: TranslateService,
|
|
private destroyRef: DestroyRef
|
|
) {}
|
|
|
|
ngOnInit(): void {
|
|
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
|
// Même pattern que page-edit : on s'abonne à paramMap pour gérer la
|
|
// navigation d'une page à l'autre (Angular réutilise le composant).
|
|
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).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.template = sidebar.templates.find(t => t.id === page.templateId) ?? null;
|
|
this.orderedFields = orderedBlocks(this.template?.fields);
|
|
this.hasLayout = hasBlockLayout(this.template?.fields);
|
|
this.page = page;
|
|
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
|
this.pageTitleService.set(page.title);
|
|
});
|
|
}
|
|
|
|
/** Fil d'Ariane — même logique que page-edit (remontée via parentId). */
|
|
get breadcrumbItems(): BreadcrumbItem[] {
|
|
if (!this.lore || !this.page) return [];
|
|
const items: BreadcrumbItem[] = [
|
|
{ label: this.lore.name, route: ['/lore', this.loreId] }
|
|
];
|
|
const folderChain: LoreNode[] = [];
|
|
let currentNode = this.nodes.find(n => n.id === this.page!.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] });
|
|
}
|
|
items.push({ label: this.page.title });
|
|
return items;
|
|
}
|
|
|
|
// Les valeurs sont ancrées sur la clé STABLE du bloc ({@link blockKey} = id,
|
|
// repli sur le nom). Le repli supplémentaire sur `field.name` couvre les pages
|
|
// dont les valeurs étaient encore rangées par nom (migrées au prochain save).
|
|
|
|
/** Récupère la valeur d'un champ dynamique TEXT du template. */
|
|
valueOf(field: TemplateField): string {
|
|
const v = this.page?.values;
|
|
return v?.[blockKey(field)] ?? v?.[field.name] ?? '';
|
|
}
|
|
|
|
/** IDs d'images pour un champ IMAGE (liste vide si aucune). */
|
|
imageIdsOf(field: TemplateField): string[] {
|
|
const v = this.page?.imageValues;
|
|
return v?.[blockKey(field)] ?? v?.[field.name] ?? [];
|
|
}
|
|
|
|
/** Cadrage (pan/zoom) des images d'un champ IMAGE : imageId → {x,y,scale}. */
|
|
imageFramingOf(field: TemplateField): Record<string, ImageFraming> {
|
|
const v = this.page?.imageFraming;
|
|
return v?.[blockKey(field)] ?? v?.[field.name] ?? {};
|
|
}
|
|
|
|
/** Valeur d'un libellé d'un champ KEY_VALUE_LIST (tableau). */
|
|
kvValueOf(field: TemplateField, label: string): string {
|
|
const v = this.page?.keyValueValues;
|
|
return v?.[blockKey(field)]?.[label] ?? v?.[field.name]?.[label] ?? '';
|
|
}
|
|
|
|
/** True si au moins une valeur de la liste clé/valeur est renseignée. */
|
|
kvHasContent(field: TemplateField): boolean {
|
|
return (field.labels ?? []).some(lbl => this.kvValueOf(field, lbl).trim() !== '');
|
|
}
|
|
|
|
/** Lignes d'un champ TABLE (liste vide si jamais rempli). */
|
|
tableRowsOf(field: TemplateField): Array<Record<string, string>> {
|
|
const v = this.page?.tableValues;
|
|
return v?.[blockKey(field)] ?? v?.[field.name] ?? [];
|
|
}
|
|
|
|
/** Helper — résout l'ID d'une page liée en son titre (pour affichage dans les chips). */
|
|
titleOfRelated(pageId: string): string {
|
|
return this.allPages.find(p => p.id === pageId)?.title ?? this.translate.instant('pageView.deletedPage');
|
|
}
|
|
|
|
editMode(): void {
|
|
this.router.navigate(['/lore', this.loreId, 'pages', this.pageId, 'edit']);
|
|
}
|
|
|
|
/**
|
|
* Suppression simple : pas d'enfants. On remonte au dossier parent
|
|
* si on peut, sinon à la racine du Lore.
|
|
*/
|
|
deletePage(): void {
|
|
if (!this.page) return;
|
|
const page = this.page;
|
|
this.confirmDialog.confirm({
|
|
title: this.translate.instant('pageView.deleteTitle'),
|
|
message: this.translate.instant('pageView.deleteMessage', { title: page.title }),
|
|
details: [this.translate.instant('pageView.deleteIrreversible')],
|
|
confirmLabel: this.translate.instant('common.delete'),
|
|
variant: 'danger'
|
|
}).then(ok => {
|
|
if (!ok) return;
|
|
this.pageService.delete(page.id!).subscribe({
|
|
next: () => {
|
|
if (page.nodeId) {
|
|
this.router.navigate(['/lore', this.loreId, 'folders', page.nodeId]);
|
|
} else {
|
|
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.
|
|
}
|
|
}
|