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 { 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[] { 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') }); }); } }