Pages de lore en blocs (grille 2D libre) + bloc image recadrable, et import dd2vtt sur les scènes
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m23s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m24s
Build & Push Images / build-switcher (push) Successful in 20s

Refonte du système de templates de lore en assemblage de blocs (façon Lore
d'Amsel), plus le support des cartes DungeonDraft (.dd2vtt) sur les scènes.

Templates de lore — éditeur de blocs en grille :
- Builder partagé `block-grid-builder` : palette de types de blocs à glisser sur
  une grille 12 colonnes, placement LIBRE (x/y) et redimensionnement en largeur
  ET hauteur par poignées, renommage en place. Remplace la liste + flèches de
  template-create/template-edit (dédupliqués).
- Rendu : page-view (aperçu) honore largeur ET hauteur (grille à lignes fixes) ;
  page-edit garde les colonnes (côte-à-côte) avec des hauteurs naturelles pour
  l'édition. Repli empilé pour les templates sans mise en page.
- Modèle : `TemplateField` gagne `id` (clé stable) et `pos {x,y,w,h}`. Les valeurs
  de page sont ancrées sur l'`id` (repli sur le nom) → renommer un bloc ne perd
  plus son contenu. Sérialisation JSON additive, aucune migration ; correction au
  passage de la non-relecture de `foundryPath` dans le converter.

Bloc image :
- Nouveau composant `image-block` (remplace la galerie pour les pages de lore) :
  plusieurs images plein cadre (carrousel), déplacement (recadrage) et zoom
  persistés PAR image et PAR page. Rendu `object-fit: contain` (image entière,
  bordures noires) avec zoom 0,4×–4×.
- Stockage : `Page.imageFraming` (fieldKey → imageId → {x,y,scale}), colonne
  `image_framing` (migration V8), DTO/mapper + passthrough export/import.

Scènes — source de carte au choix :
- Sélecteur Dungeon Alchemist (image/vidéo + .json, inchangé) / DungeonDraft
  (.dd2vtt). Le .dd2vtt embarque l'image en base64 : extraction à l'upload
  (→ média) + sidecar allégé (→ données) pour que l'export Foundry et le module
  fonctionnent sans changement. Mode déduit à la relecture de l'extension.
  Front-only, sans migration.

i18n fr/en, tests (converters back + helper front) et specs e2e mis à jour.
This commit is contained in:
2026-06-26 17:38:27 +02:00
parent f670735100
commit 11a33b1cf0
55 changed files with 2604 additions and 632 deletions

View File

@@ -11,10 +11,12 @@ 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 } from '../../services/page.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 { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
import { ImageBlockComponent } from '../../shared/image-block/image-block.component';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
/**
@@ -29,7 +31,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
*/
@Component({
selector: 'app-page-view',
imports: [RouterModule, LucideAngularModule, TranslatePipe, BreadcrumbComponent, ImageGalleryComponent],
imports: [RouterModule, LucideAngularModule, TranslatePipe, BreadcrumbComponent, ImageBlockComponent],
templateUrl: './page-view.component.html',
styleUrls: ['./page-view.component.scss']
})
@@ -45,6 +47,15 @@ export class PageViewComponent implements OnInit, OnDestroy {
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,
@@ -79,6 +90,8 @@ export class PageViewComponent implements OnInit, OnDestroy {
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);
@@ -106,29 +119,43 @@ export class PageViewComponent implements OnInit, OnDestroy {
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(fieldName: string): string {
return this.page?.values?.[fieldName] ?? '';
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(fieldName: string): string[] {
return this.page?.imageValues?.[fieldName] ?? [];
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(fieldName: string, label: string): string {
return this.page?.keyValueValues?.[fieldName]?.[label] ?? '';
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(fieldName: string, labels: string[] | null | undefined): boolean {
return (labels ?? []).some(lbl => this.kvValueOf(fieldName, lbl).trim() !== '');
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(fieldName: string): Array<Record<string, string>> {
return this.page?.tableValues?.[fieldName] ?? [];
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). */