Files
LoreMind/web/src/app/lore/page-view/page-view.component.ts
IETM_FIXE\ietm6 42c4b53ced
Some checks failed
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m9s
Build & Push Images / build (brain) (push) Successful in 1m10s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 2m36s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (core) (push) Successful in 3m9s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 38s
Stack qualité CI (MegaLinter, Trivy, ESLint) et purge du backlog lint web
CI (Gitea Actions) :
- Nouveau workflow quality.yml (séparé de ci.yml, non bloquant pour la release) :
  MegaLinter v9 (PMD, Ruff, Bandit, gitleaks, hadolint), ng lint (web) et
  Trivy (CVE des dépendances Maven/pip/npm), sur main + beta + PR
- Trivy : préchargement du cache Maven avant le scan — sans ça Maven Central
  rate-limite l'IP du runner (429) en résolvant les BOMs du parent Spring Boot
- upload-artifact v4 → v3 : l'API artifacts v4 n'est pas supportée par Gitea
  (corrige aussi l'upload du rapport Playwright de e2e.yml, cassé depuis toujours)
- Ruleset PMD projet (java-pmd-ruleset.xml, auto-détecté par MegaLinter) orienté
  bugs réels, sans le style Lombok-hostile du défaut : 11 337 → 65 findings ;
  PMD en mode rapport (JAVA_PMD_DISABLE_ERRORS) le temps de purger ce backlog

Web :
- angular-eslint 21 + eslint-plugin-sonarjs : les règles Sonar vivent désormais
  dans ESLint (config web/eslint.config.js, réglages justifiés en commentaire)
- tsconfig : skipLibCheck + types:[] + node_modules ré-exclu — répare les builds
  desktop Windows/Linux cassés par le conflit @types/eslint-scope vs ESLint 9
- Purge du backlog lint : 114 erreurs → 0 sur 66 fichiers (ngOnDestroy vides
  supprimés, any typés avec les vrais DTOs, outputs (close) → (closed), regex
  super-linéaires désamorcées, ternaires/fonctions imbriqués dépliés, intention
  documentée sur les catch/error volontairement vides)
- Bug latent corrigé au passage : license.service.disconnect() émettait
  undefined au lieu de true en cas de succès (masqué par un double cast)
2026-07-07 01:05:00 +02:00

200 lines
7.9 KiB
TypeScript

import { Component, OnInit, 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 {
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): 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')
});
});
}
}