diff --git a/core/src/main/java/com/loremind/application/lorecontext/PageService.java b/core/src/main/java/com/loremind/application/lorecontext/PageService.java index fe5ff86..23ff51f 100644 --- a/core/src/main/java/com/loremind/application/lorecontext/PageService.java +++ b/core/src/main/java/com/loremind/application/lorecontext/PageService.java @@ -86,6 +86,7 @@ public class PageService { existing.setNodeId(changes.getNodeId()); existing.setValues(CollectionUtils.copyMap(changes.getValues())); existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues())); + existing.setImageFraming(CollectionUtils.copyMap(changes.getImageFraming())); existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues())); existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues())); existing.setNotes(changes.getNotes()); diff --git a/core/src/main/java/com/loremind/domain/lorecontext/ImageFraming.java b/core/src/main/java/com/loremind/domain/lorecontext/ImageFraming.java new file mode 100644 index 0000000..5879836 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/lorecontext/ImageFraming.java @@ -0,0 +1,17 @@ +package com.loremind.domain.lorecontext; + +/** + * Cadrage d'une image dans son bloc IMAGE d'une page de lore. + *

+ * Donnée purement présentationnelle, définie PAR IMAGE et PAR PAGE : + *

+ *

+ * Stockée sur la Page dans {@code imageFraming} (fieldKey → imageId → cadrage), + * sérialisée en JSON. Entité pure du domaine : aucune dépendance technique. + */ +public record ImageFraming(double x, double y, double scale) { +} diff --git a/core/src/main/java/com/loremind/domain/lorecontext/Page.java b/core/src/main/java/com/loremind/domain/lorecontext/Page.java index fd35821..e27ccec 100644 --- a/core/src/main/java/com/loremind/domain/lorecontext/Page.java +++ b/core/src/main/java/com/loremind/domain/lorecontext/Page.java @@ -42,6 +42,13 @@ public class Page { */ private Map> imageValues; + /** + * Cadrage (pan/zoom) des images dans leur bloc IMAGE : fieldKey → imageId → + * {@link ImageFraming}. Purement presentationnel ; l'absence d'entree = cadrage + * par defaut (centre, plein cadre). Voir {@link ImageFraming}. + */ + private Map> imageFraming; + /** * Valeurs des champs KEY_VALUE_LIST (tableau libelle → valeur, comme sur les * fiches de personnage) : fieldName → (label → valeur). Les labels sont diff --git a/core/src/main/java/com/loremind/domain/shared/template/BlockPosition.java b/core/src/main/java/com/loremind/domain/shared/template/BlockPosition.java new file mode 100644 index 0000000..717a0a0 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/shared/template/BlockPosition.java @@ -0,0 +1,45 @@ +package com.loremind.domain.shared.template; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Value Object decrivant le placement d'un bloc dans la grille du template + * (kernel partage). + *

+ * Modele : grille responsive a 12 colonnes (facon page-builder), inspire de + * l'app "Lore" d'Amsel. Chaque bloc occupe un rectangle de la grille : + *

+ *

+ * Tous les champs sont nullables : un {@code pos} absent (null) ou des + * coordonnees nulles signifient "auto-flow" — le bloc est empile a la suite + * des precedents, exactement comme le rendu historique en une seule colonne. + * C'est ce qui garantit la retrocompatibilite des templates existants (qui + * n'ont aucune donnee de placement) : ils continuent de s'afficher empiles. + *

+ * Donnee purement presentationnelle : ignoree par l'IA, l'export campagne et + * l'export Foundry, qui ne lisent que l'ordre et les valeurs par nom de champ. + *

+ * Entite pure du domaine : aucune dependance technique. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BlockPosition { + /** Colonne de depart dans la grille 12 colonnes (0..11). Null = auto-flow. */ + private Integer x; + /** Ligne de depart (0..n). Null = auto-flow. */ + private Integer y; + /** Largeur en colonnes (1..12). Null = pleine largeur par defaut. */ + private Integer w; + /** Hauteur en lignes (1..n). Null = hauteur du contenu. */ + private Integer h; +} diff --git a/core/src/main/java/com/loremind/domain/shared/template/TemplateField.java b/core/src/main/java/com/loremind/domain/shared/template/TemplateField.java index 000d61e..5b316fe 100644 --- a/core/src/main/java/com/loremind/domain/shared/template/TemplateField.java +++ b/core/src/main/java/com/loremind/domain/shared/template/TemplateField.java @@ -17,12 +17,29 @@ import java.util.List; * Pour les champs IMAGE, {@link #layout} precise la variante de rendu * (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY. * Ignore pour les autres types. + *

+ * {@link #id} est la cle STABLE du bloc : les valeurs des Pages s'ancrent + * dessus (et non sur {@link #name}), ce qui permet de renommer un bloc sans + * orpheliner son contenu. Pour les templates anterieurs (qui n'ont pas d'id), + * l'id est retro-rempli avec le nom courant a la lecture : comme les valeurs + * existantes sont deja rangees par nom, {@code id == name} au depart et rien + * n'a besoin d'etre migre. + *

+ * {@link #pos} porte le placement du bloc dans la grille 12 colonnes du + * template (voir {@link BlockPosition}). Null = auto-flow empile (rendu + * historique). */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class TemplateField { + /** + * Identifiant STABLE du bloc, cle d'ancrage des valeurs de Page. + * Retro-rempli avec {@link #name} pour les templates sans id. Immuable : + * survit aux renommages du bloc. + */ + private String id; /** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */ private String name; /** Type du champ, pilote le rendu et la generation IA. */ @@ -44,6 +61,12 @@ public class TemplateField { */ private String foundryPath; + /** + * Placement du bloc dans la grille 12 colonnes du template. Null = auto-flow + * (le bloc s'empile a la suite des precedents, comme le rendu historique). + */ + private BlockPosition pos; + /** Constructeur de retrocompat : type seul, layout/labels=null. */ public TemplateField(String name, FieldType type) { this(name, type, null, null, null); @@ -59,6 +82,14 @@ public class TemplateField { this(name, type, layout, labels, null); } + /** + * Constructeur de retrocompat (5 args) : sans id ni pos. Conserve la + * signature publique historique (id retro-rempli plus tard, pos=null). + */ + public TemplateField(String name, FieldType type, ImageLayout layout, List labels, String foundryPath) { + this(null, name, type, layout, labels, foundryPath, null); + } + /** Raccourci : construit un champ de type TEXT (cas le plus courant). */ public static TemplateField text(String name) { return new TemplateField(name, FieldType.TEXT, null, null); diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/ImageFramingMapJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/ImageFramingMapJsonConverter.java new file mode 100644 index 0000000..cd020f8 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/ImageFramingMapJsonConverter.java @@ -0,0 +1,53 @@ +package com.loremind.infrastructure.persistence.converter; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.lorecontext.ImageFraming; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.Collections; +import java.util.Map; + +/** + * Convertit une {@code Map>} en JSON et inversement. + *

+ * Utilisé pour {@code Page.imageFraming} : pour chaque champ IMAGE (clé = id du bloc), + * un cadrage (pan/zoom) par image. Exemple : + * {"blk-illu": {"img-42": {"x":50.0,"y":30.0,"scale":1.4}}} + *

+ * Tolérant aux propriétés inconnues (évolution de {@link ImageFraming}). + * Adaptateur technique pur : le domaine ignore ce converter. + */ +@Converter +public class ImageFramingMapJsonConverter + implements AttributeConverter>, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private static final TypeReference>> TYPE_REF = + new TypeReference<>() {}; + + @Override + public String convertToDatabaseColumn(Map> attribute) { + if (attribute == null || attribute.isEmpty()) return "{}"; + try { + return MAPPER.writeValueAsString(attribute); + } catch (Exception e) { + throw new IllegalStateException( + "Erreur serialisation Map> -> JSON", e); + } + } + + @Override + public Map> convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) return Collections.emptyMap(); + try { + return MAPPER.readValue(dbData, TYPE_REF); + } catch (Exception e) { + throw new IllegalStateException( + "Erreur deserialisation JSON -> Map>", e); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java index b3218b1..2316e8a 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverter.java @@ -3,6 +3,7 @@ package com.loremind.infrastructure.persistence.converter; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.domain.shared.template.BlockPosition; import com.loremind.domain.shared.template.FieldType; import com.loremind.domain.shared.template.ImageLayout; import com.loremind.domain.shared.template.TemplateField; @@ -61,9 +62,14 @@ public class TemplateFieldListJsonConverter for (JsonNode item : root) { if (item.isTextual()) { // Format legacy : chaine simple, on suppose TEXT par defaut. - result.add(TemplateField.text(item.asText())); + // L'id stable est retro-rempli avec le nom (les valeurs de Page + // sont deja rangees par nom -> id == name, aucune migration). + String name = item.asText(); + TemplateField legacy = TemplateField.text(name); + legacy.setId(name); + result.add(legacy); } else if (item.isObject()) { - // Nouveau format : {name, type} + // Nouveau format : {id?, name, type, layout?, labels?, foundryPath?, pos?} String name = item.path("name").asText(null); String typeStr = item.path("type").asText("TEXT"); FieldType type; @@ -95,8 +101,27 @@ public class TemplateFieldListJsonConverter } } } + // foundryPath : lu via hasNonNull pour eviter le piege NullNode + // (asText() renverrait la chaine "null"). Historiquement omis a la + // relecture -> il etait perdu au save+reload : corrige ici. + String foundryPath = item.hasNonNull("foundryPath") + ? item.get("foundryPath").asText() : null; + // id : explicite si present, sinon retro-rempli avec le nom. + String id = item.hasNonNull("id") ? item.get("id").asText() : null; + if (id == null || id.isBlank()) { + id = name; + } + BlockPosition pos = readPos(item.path("pos")); if (name != null && !name.isBlank()) { - result.add(new TemplateField(name, type, layout, labels)); + result.add(TemplateField.builder() + .id(id) + .name(name) + .type(type) + .layout(layout) + .labels(labels) + .foundryPath(foundryPath) + .pos(pos) + .build()); } } // Autres types de noeuds (nombre, booleen...) : ignores silencieusement. @@ -108,6 +133,31 @@ public class TemplateFieldListJsonConverter } } + /** + * Lit le placement {@code pos: {x, y, w, h}} d'un bloc. Renvoie null si le + * noeud n'est pas un objet ou si toutes les coordonnees sont absentes + * (-> auto-flow empile, comportement historique). + */ + private static BlockPosition readPos(JsonNode posNode) { + if (posNode == null || !posNode.isObject()) { + return null; + } + Integer x = intOrNull(posNode, "x"); + Integer y = intOrNull(posNode, "y"); + Integer w = intOrNull(posNode, "w"); + Integer h = intOrNull(posNode, "h"); + if (x == null && y == null && w == null && h == null) { + return null; + } + return new BlockPosition(x, y, w, h); + } + + /** Lit un entier optionnel d'un noeud JSON ; null si absent ou non numerique. */ + private static Integer intOrNull(JsonNode node, String field) { + JsonNode n = node.path(field); + return n.isNumber() ? n.asInt() : null; + } + // typeRef garde pour reference future si on veut deserialiser directement. @SuppressWarnings("unused") private static final TypeReference> TYPE_REF = diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java index 2c3487e..07784f1 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java @@ -1,5 +1,7 @@ package com.loremind.infrastructure.persistence.entity; +import com.loremind.domain.lorecontext.ImageFraming; +import com.loremind.infrastructure.persistence.converter.ImageFramingMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter; @@ -58,6 +60,11 @@ public class PageJpaEntity { @Convert(converter = StringListMapJsonConverter.class) private Map> imageValues; + /** Cadrage (pan/zoom) des images : fieldKey → imageId → ImageFraming. JSON TEXT. */ + @Column(name = "image_framing", columnDefinition = "TEXT") + @Convert(converter = ImageFramingMapJsonConverter.class) + private Map> imageFraming; + /** Valeurs des champs KEY_VALUE_LIST : fieldName → (label → valeur). JSON TEXT. */ @Column(name = "key_value_values", columnDefinition = "TEXT") @Convert(converter = StringMapMapJsonConverter.class) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java index 469ee3b..e7ca5fd 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java @@ -94,6 +94,7 @@ public class PostgresPageRepository implements PageRepository { .order(e.getOrder()) .values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>()) .imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>()) + .imageFraming(e.getImageFraming() != null ? new HashMap<>(e.getImageFraming()) : new HashMap<>()) .keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>()) .tableValues(e.getTableValues() != null ? new HashMap<>(e.getTableValues()) : new HashMap<>()) .notes(e.getNotes()) @@ -115,6 +116,7 @@ public class PostgresPageRepository implements PageRepository { .order(p.getOrder()) .values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>()) .imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>()) + .imageFraming(p.getImageFraming() != null ? new HashMap<>(p.getImageFraming()) : new HashMap<>()) .keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>()) .tableValues(p.getTableValues() != null ? new HashMap<>(p.getTableValues()) : new HashMap<>()) .notes(p.getNotes()) diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java index bcc01de..55126a7 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java @@ -468,8 +468,8 @@ public class ExportService { private ContentExport.PageDto toPageDto(PageJpaEntity e) { return new ContentExport.PageDto(e.getId(), e.getLoreId(), e.getNodeId(), e.getTemplateId(), e.getTitle(), e.getValues(), e.getImageValues(), - e.getKeyValueValues(), e.getTableValues(), e.getNotes(), e.getTags(), - e.getRelatedPageIds()); + e.getImageFraming(), e.getKeyValueValues(), e.getTableValues(), e.getNotes(), + e.getTags(), e.getRelatedPageIds()); } private ContentExport.CampaignDto toCampaignDto(CampaignJpaEntity e) { diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java index 229d762..774f675 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java @@ -210,6 +210,7 @@ public class ImportService { e.setTitle(d.title()); e.setValues(d.values()); e.setImageValues(d.imageValues()); + e.setImageFraming(d.imageFraming()); e.setKeyValueValues(d.keyValueValues()); e.setTableValues(d.tableValues()); e.setNotes(d.notes()); diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java index ea2d96f..8babf54 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java @@ -105,6 +105,7 @@ public record ContentExport( String title, Map values, Map> imageValues, + Map> imageFraming, Map> keyValueValues, Map>> tableValues, String notes, diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java index 91c6e9f..5bd7cac 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java @@ -1,5 +1,6 @@ package com.loremind.infrastructure.web.dto.lorecontext; +import com.loremind.domain.lorecontext.ImageFraming; import lombok.Data; import java.util.List; @@ -21,6 +22,8 @@ public class PageDTO { private Map values; /** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */ private Map> imageValues; + /** Cadrage (pan/zoom) des images : fieldKey → imageId → {x, y, scale}. */ + private Map> imageFraming; /** Pour chaque champ KEY_VALUE_LIST du template : label → valeur. */ private Map> keyValueValues; /** Pour chaque champ TABLE du template : lignes (colonne → cellule). */ diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/shared/BlockPositionDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/shared/BlockPositionDTO.java new file mode 100644 index 0000000..3606525 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/shared/BlockPositionDTO.java @@ -0,0 +1,26 @@ +package com.loremind.infrastructure.web.dto.shared; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO wire-friendly du placement d'un bloc dans la grille du template. + *

+ * Miroir de {@link com.loremind.domain.shared.template.BlockPosition} : + * grille 12 colonnes, {@code {x, y, w, h}} en unites de grille. Tous les + * champs sont nullables (Integer) — absence = auto-flow empile cote front. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BlockPositionDTO { + /** Colonne de depart (0..11). */ + private Integer x; + /** Ligne de depart (0..n). */ + private Integer y; + /** Largeur en colonnes (1..12). */ + private Integer w; + /** Hauteur en lignes (1..n). */ + private Integer h; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java index f8a3b5b..9e3d79f 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/shared/TemplateFieldDTO.java @@ -29,6 +29,21 @@ public class TemplateFieldDTO { /** Chemin Foundry du champ (mapping pour l'export d'acteur typé). Nullable. */ private String foundryPath; + /** + * Identifiant STABLE du bloc (cle d'ancrage des valeurs de Page). Retro-rempli + * avec le nom cote backend pour les templates anterieurs. Appended en fin de + * classe pour preserver la compat des constructeurs historiques. + */ + private String id; + + /** Placement du bloc dans la grille 12 colonnes. Null = auto-flow empile. */ + private BlockPositionDTO pos; + + /** Retrocompat : constructeur sans id ni pos. */ + public TemplateFieldDTO(String name, String type, String layout, List labels, String foundryPath) { + this(name, type, layout, labels, foundryPath, null, null); + } + /** Retrocompat : constructeur sans foundryPath. */ public TemplateFieldDTO(String name, String type, String layout, List labels) { this(name, type, layout, labels, null); diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java index d5512ec..fb540bf 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java @@ -24,6 +24,7 @@ public class PageMapper { dto.setOrder(page.getOrder()); dto.setValues(CollectionUtils.copyMap(page.getValues())); dto.setImageValues(CollectionUtils.copyMap(page.getImageValues())); + dto.setImageFraming(CollectionUtils.copyMap(page.getImageFraming())); dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues())); dto.setTableValues(CollectionUtils.copyMap(page.getTableValues())); dto.setNotes(page.getNotes()); @@ -45,6 +46,7 @@ public class PageMapper { .order(dto.getOrder()) .values(CollectionUtils.copyMap(dto.getValues())) .imageValues(CollectionUtils.copyMap(dto.getImageValues())) + .imageFraming(CollectionUtils.copyMap(dto.getImageFraming())) .keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues())) .tableValues(CollectionUtils.copyMap(dto.getTableValues())) .notes(dto.getNotes()) diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java index e017d8a..7df9785 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/TemplateFieldMapper.java @@ -1,8 +1,10 @@ package com.loremind.infrastructure.web.mapper; +import com.loremind.domain.shared.template.BlockPosition; import com.loremind.domain.shared.template.FieldType; import com.loremind.domain.shared.template.ImageLayout; import com.loremind.domain.shared.template.TemplateField; +import com.loremind.infrastructure.web.dto.shared.BlockPositionDTO; import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO; import org.springframework.stereotype.Component; @@ -33,7 +35,11 @@ public class TemplateFieldMapper { && field.getLabels() != null) { labels = new ArrayList<>(field.getLabels()); } - return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels, field.getFoundryPath()); + TemplateFieldDTO dto = new TemplateFieldDTO( + field.getName(), typeStr, layoutStr, labels, field.getFoundryPath()); + dto.setId(resolveId(field.getId(), field.getName())); + dto.setPos(toPosDTO(field.getPos())); + return dto; } public TemplateField toDomain(TemplateFieldDTO dto) { @@ -58,7 +64,34 @@ public class TemplateFieldMapper { if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) { labels = new ArrayList<>(dto.getLabels()); } - return new TemplateField(dto.getName(), type, layout, labels, dto.getFoundryPath()); + return TemplateField.builder() + .id(resolveId(dto.getId(), dto.getName())) + .name(dto.getName()) + .type(type) + .layout(layout) + .labels(labels) + .foundryPath(dto.getFoundryPath()) + .pos(toPosDomain(dto.getPos())) + .build(); + } + + /** + * Renvoie l'id du bloc, retro-rempli avec le nom quand il est absent. + * Garantit une cle d'ancrage stable meme pour les clients qui n'envoient + * pas encore d'id (front anterieur a la grille). + */ + private String resolveId(String id, String name) { + return id != null && !id.isBlank() ? id : name; + } + + private BlockPositionDTO toPosDTO(BlockPosition pos) { + if (pos == null) return null; + return new BlockPositionDTO(pos.getX(), pos.getY(), pos.getW(), pos.getH()); + } + + private BlockPosition toPosDomain(BlockPositionDTO pos) { + if (pos == null) return null; + return new BlockPosition(pos.getX(), pos.getY(), pos.getW(), pos.getH()); } /** Mappe une liste de champs domaine → DTO ({@code null} → liste vide). */ diff --git a/core/src/main/resources/db/migration/V8__page_image_framing.sql b/core/src/main/resources/db/migration/V8__page_image_framing.sql new file mode 100644 index 0000000..8291543 --- /dev/null +++ b/core/src/main/resources/db/migration/V8__page_image_framing.sql @@ -0,0 +1,4 @@ +-- Cadrage (pan/zoom) des images dans les blocs IMAGE des pages de lore. +-- Stocké en JSON dans une colonne TEXT (fieldKey -> imageId -> {x, y, scale}). +-- Compatible Postgres (Docker) et H2 en MODE=PostgreSQL (desktop). +alter table pages add column image_framing TEXT; diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/converter/ImageFramingMapJsonConverterTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/converter/ImageFramingMapJsonConverterTest.java new file mode 100644 index 0000000..5493c80 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/converter/ImageFramingMapJsonConverterTest.java @@ -0,0 +1,59 @@ +package com.loremind.infrastructure.persistence.converter; + +import com.loremind.domain.lorecontext.ImageFraming; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests du converter de cadrage d'images (Map>). + * Vérifie notamment que le record {@link ImageFraming} fait bien l'aller-retour + * JSON (Jackson supporte les records nativement) — sinon les valeurs seraient + * perdues silencieusement. + */ +class ImageFramingMapJsonConverterTest { + + private final ImageFramingMapJsonConverter converter = new ImageFramingMapJsonConverter(); + + @Test + void toDb_nullOrEmpty_yieldsEmptyObject() { + assertEquals("{}", converter.convertToDatabaseColumn(null)); + assertEquals("{}", converter.convertToDatabaseColumn(Map.of())); + } + + @Test + void fromDb_nullOrBlank_yieldsEmptyMap() { + assertTrue(converter.convertToEntityAttribute(null).isEmpty()); + assertTrue(converter.convertToEntityAttribute(" ").isEmpty()); + } + + @Test + void roundTrip_preservesFramingPerImage() { + Map> source = Map.of( + "blk-illu", Map.of( + "img-42", new ImageFraming(30.0, 70.0, 1.5), + "img-7", new ImageFraming(50.0, 50.0, 1.0))); + + Map> back = + converter.convertToEntityAttribute(converter.convertToDatabaseColumn(source)); + + ImageFraming f = back.get("blk-illu").get("img-42"); + assertEquals(30.0, f.x()); + assertEquals(70.0, f.y()); + assertEquals(1.5, f.scale()); + assertEquals(1.0, back.get("blk-illu").get("img-7").scale()); + } + + @Test + void fromDb_readsExplicitJson() { + Map> back = converter.convertToEntityAttribute( + "{\"blk\":{\"img\":{\"x\":10.0,\"y\":20.0,\"scale\":2.0}}}"); + ImageFraming f = back.get("blk").get("img"); + assertEquals(10.0, f.x()); + assertEquals(20.0, f.y()); + assertEquals(2.0, f.scale()); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java index 249e0ae..9d302d8 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/converter/TemplateFieldListJsonConverterTest.java @@ -1,5 +1,6 @@ package com.loremind.infrastructure.persistence.converter; +import com.loremind.domain.shared.template.BlockPosition; import com.loremind.domain.shared.template.FieldType; import com.loremind.domain.shared.template.ImageLayout; import com.loremind.domain.shared.template.TemplateField; @@ -8,6 +9,7 @@ import org.junit.jupiter.api.Test; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -208,4 +210,94 @@ class TemplateFieldListJsonConverterTest { assertEquals(pass1.get(i).getType(), pass2.get(i).getType()); } } + + // ---------- id stable (ancrage des valeurs de Page) -------------------- + + @Test + void fromDb_legacyFormat_backfillsIdWithName() { + // Format legacy (chaines) : l'id stable est retro-rempli avec le nom, + // donc id == name -> les valeurs deja rangees par nom resolvent toujours. + List result = converter.convertToEntityAttribute("[\"Histoire\"]"); + assertEquals("Histoire", result.get(0).getName()); + assertEquals("Histoire", result.get(0).getId()); + } + + @Test + void fromDb_newFormat_withoutId_backfillsIdWithName() { + List result = converter.convertToEntityAttribute( + "[{\"name\":\"Ambiance\",\"type\":\"TEXT\"}]"); + assertEquals("Ambiance", result.get(0).getId()); + } + + @Test + void fromDb_newFormat_withExplicitId_keepsItEvenWhenNameDiffers() { + // Simule un bloc renomme : l'id stable est decorrele du nom courant. + List result = converter.convertToEntityAttribute( + "[{\"id\":\"blk-1\",\"name\":\"Nouveau nom\",\"type\":\"TEXT\"}]"); + assertEquals("blk-1", result.get(0).getId()); + assertEquals("Nouveau nom", result.get(0).getName()); + } + + // ---------- foundryPath : non-regression (perdu au save+reload avant) -- + + @Test + void roundTrip_preservesFoundryPath() { + // Garde-fou : foundryPath n'etait pas relu et disparaissait au + // save+reload. Il doit desormais survivre a l'aller-retour. + List source = List.of( + new TemplateField("PV", FieldType.NUMBER, null, null, "attributes.hp.value")); + + List back = converter.convertToEntityAttribute( + converter.convertToDatabaseColumn(source)); + + assertEquals("attributes.hp.value", back.get(0).getFoundryPath()); + } + + @Test + void fromDb_nullFoundryPath_readsAsNull_notLiteralNullString() { + // Piege Jackson : sur un NullNode, asText() renverrait la chaine "null". + List result = converter.convertToEntityAttribute( + "[{\"name\":\"Histoire\",\"type\":\"TEXT\",\"foundryPath\":null}]"); + assertNull(result.get(0).getFoundryPath()); + } + + // ---------- pos : placement dans la grille ----------------------------- + + @Test + void roundTrip_preservesBlockPosition() { + TemplateField illustration = TemplateField.builder() + .id("blk-illu").name("Illustration").type(FieldType.IMAGE) + .layout(ImageLayout.GALLERY) + .pos(new BlockPosition(0, 0, 6, 4)) + .build(); + TemplateField ambiance = TemplateField.builder() + .id("blk-amb").name("Ambiance générale").type(FieldType.TEXT) + .pos(new BlockPosition(6, 0, 6, 4)) + .build(); + + List back = converter.convertToEntityAttribute( + converter.convertToDatabaseColumn(List.of(illustration, ambiance))); + + BlockPosition p0 = back.get(0).getPos(); + assertNotNull(p0); + assertEquals(0, p0.getX()); + assertEquals(6, p0.getW()); + // Le second bloc est place a droite du premier (cote-a-cote). + assertEquals(6, back.get(1).getPos().getX()); + } + + @Test + void fromDb_withoutPos_yieldsNullPosition() { + // Pas de pos -> auto-flow empile (rendu historique). + List result = converter.convertToEntityAttribute( + "[{\"name\":\"Histoire\",\"type\":\"TEXT\"}]"); + assertNull(result.get(0).getPos()); + } + + @Test + void fromDb_nullPos_yieldsNullPosition() { + List result = converter.convertToEntityAttribute( + "[{\"name\":\"Histoire\",\"type\":\"TEXT\",\"pos\":null}]"); + assertNull(result.get(0).getPos()); + } } diff --git a/web/e2e/tests/lore/template-create.spec.ts b/web/e2e/tests/lore/template-create.spec.ts index f8a35d3..84314c0 100644 --- a/web/e2e/tests/lore/template-create.spec.ts +++ b/web/e2e/tests/lore/template-create.spec.ts @@ -53,15 +53,15 @@ test.describe('Template creation', () => { const addFieldInput = page.getByPlaceholder('+ Ajouter un champ'); await addFieldInput.fill('Pouvoir'); await addFieldInput.press('Enter'); - await expect(page.locator('.fields-list .field-chip', { hasText: 'Pouvoir' })).toBeVisible(); + await expect(page.locator('.grid-block[data-block-name="Pouvoir"]')).toBeVisible(); await addFieldInput.fill('Origine'); await addFieldInput.press('Enter'); - await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toBeVisible(); + await expect(page.locator('.grid-block[data-block-name="Origine"]')).toBeVisible(); - const origineRow = page.locator('.fields-list .field-row', { hasText: 'Origine' }); - await origineRow.getByRole('button', { name: 'Supprimer' }).click(); - await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toHaveCount(0); + const origineBlock = page.locator('.grid-block[data-block-name="Origine"]'); + await origineBlock.getByRole('button', { name: 'Supprimer' }).click(); + await expect(page.locator('.grid-block[data-block-name="Origine"]')).toHaveCount(0); await page.getByRole('button', { name: /^Créer le template$/i }).click(); await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`)); diff --git a/web/e2e/tests/lore/template-edit.spec.ts b/web/e2e/tests/lore/template-edit.spec.ts index 3620aff..477ff0c 100644 --- a/web/e2e/tests/lore/template-edit.spec.ts +++ b/web/e2e/tests/lore/template-edit.spec.ts @@ -30,8 +30,8 @@ test.describe('Template edit', () => { await expect(page.getByLabel(/^Nom$/)).toHaveValue(template.name); await expect(page.getByLabel(/Dossier par défaut/i)).toHaveValue(seeded.rootFolderId); - await expect(page.locator('.fields-list .field-chip', { hasText: 'Nom' })).toBeVisible(); - await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toBeVisible(); + await expect(page.locator('.grid-block[data-block-name="Nom"]')).toBeVisible(); + await expect(page.locator('.grid-block[data-block-name="Description"]')).toBeVisible(); }); test('renames the template and persists to API', async ({ page, request }) => { @@ -58,11 +58,11 @@ test.describe('Template edit', () => { const addInput = page.getByPlaceholder('+ Ajouter un champ'); await addInput.fill('Stats'); await addInput.press('Enter'); - await expect(page.locator('.fields-list .field-chip', { hasText: 'Stats' })).toBeVisible(); + await expect(page.locator('.grid-block[data-block-name="Stats"]')).toBeVisible(); - const descriptionRow = page.locator('.fields-list .field-row', { hasText: 'Description' }); - await descriptionRow.getByRole('button', { name: 'Supprimer' }).click(); - await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toHaveCount(0); + const descriptionBlock = page.locator('.grid-block[data-block-name="Description"]'); + await descriptionBlock.getByRole('button', { name: 'Supprimer' }).click(); + await expect(page.locator('.grid-block[data-block-name="Description"]')).toHaveCount(0); await page.getByRole('button', { name: /^Sauvegarder$/i }).click(); await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`)); diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html index 70d36b8..a11c8de 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html @@ -38,44 +38,79 @@ {{ 'sceneEdit.illustrationsHint' | translate }} - +

{{ 'sceneEdit.battlemapHint' | translate }} -
- {{ 'sceneEdit.battlemapMedia' | translate }} - @if (battlemapMediaFileId) { - {{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }} - - } @else { - - } +
+ +
-
- {{ 'sceneEdit.battlemapData' | translate }} - @if (battlemapDataFileId) { - {{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }} - - } @else { - + @if (battlemapSource === 'DUNGEON_ALCHEMIST') { +
+ {{ 'sceneEdit.battlemapMedia' | translate }} + @if (battlemapMediaFileId) { + {{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }} + + } @else { + + } +
+ +
+ {{ 'sceneEdit.battlemapData' | translate }} + @if (battlemapDataFileId) { + {{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }} + + } @else { + + } +
+ } @else { +
+ {{ 'sceneEdit.battlemapDd2vttSlot' | translate }} + @if (battlemapDataFileId) { + {{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }} + + } @else { + + } +
+ @if (battlemapDd2vttNoImage) { + {{ 'sceneEdit.battlemapDd2vttNoImage' | translate }} } -
+ {{ 'sceneEdit.battlemapDd2vttHint' | translate }} + }
diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss index d1f70c2..27888a9 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss @@ -3,6 +3,34 @@ max-width: 760px; } +// Sélecteur de source de carte (Dungeon Alchemist / DungeonDraft). +.battlemap-source { + display: inline-flex; + gap: 0; + margin: 0.5rem 0; + border: 1px solid #2a2a3d; + border-radius: 6px; + overflow: hidden; + + button { + background: #1a1a2e; + border: none; + color: #9ca3af; + padding: 0.45rem 0.8rem; + font-size: 0.82rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; + + & + button { border-left: 1px solid #2a2a3d; } + &:hover { color: #d1d5db; } + &.active { background: #6c63ff; color: white; } + } +} + +.battlemap-warn { + color: #fca5a5 !important; +} + // Widget battlemap (export Foundry) : deux emplacements media + sidecar JSON. .battlemap-slot { display: flex; diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts index 8181a3c..70f6e1e 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { forkJoin, of } from 'rxjs'; +import { forkJoin, of, firstValueFrom } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -80,6 +80,17 @@ export class SceneEditComponent implements OnInit, OnDestroy { battlemapUploadingMedia = false; battlemapUploadingData = false; + /** + * Source de carte choisie pour CETTE scene : + * - 'DUNGEON_ALCHEMIST' : export Foundry = image/video + .json (2 fichiers). + * - 'DUNGEONDRAFT' : .dd2vtt unique (Universal VTT, image embarquee) ; + * on extrait l'image a l'upload -> media, et on range le .dd2vtt en donnees. + * Non persistee : deduite au chargement de l'extension du fichier de donnees. + */ + battlemapSource: 'DUNGEON_ALCHEMIST' | 'DUNGEONDRAFT' = 'DUNGEON_ALCHEMIST'; + /** Le .dd2vtt deposse ne contenait pas d'image embarquee (carte sans fond). */ + battlemapDd2vttNoImage = false; + /** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */ siblingScenes: Scene[] = []; /** Branches narratives (état local mutable, persisté au submit). */ @@ -173,13 +184,21 @@ export class SceneEditComponent implements OnInit, OnDestroy { this.battlemapDataFileId = scene.battlemapDataFileId ?? null; this.battlemapMediaName = null; this.battlemapDataName = null; + this.battlemapSource = 'DUNGEON_ALCHEMIST'; + this.battlemapDd2vttNoImage = false; if (this.battlemapMediaFileId) { this.storedFileService.getById(this.battlemapMediaFileId) .subscribe({ next: f => this.battlemapMediaName = f.filename, error: () => {} }); } if (this.battlemapDataFileId) { - this.storedFileService.getById(this.battlemapDataFileId) - .subscribe({ next: f => this.battlemapDataName = f.filename, error: () => {} }); + this.storedFileService.getById(this.battlemapDataFileId).subscribe({ + next: f => { + this.battlemapDataName = f.filename; + // Deduit la source : un .dd2vtt/.uvtt => DungeonDraft. + if (/\.(dd2vtt|uvtt)$/i.test(f.filename)) this.battlemapSource = 'DUNGEONDRAFT'; + }, + error: () => {} + }); } this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId); this.branches = (scene.branches ?? []).map(b => ({ ...b })); @@ -334,6 +353,106 @@ export class SceneEditComponent implements OnInit, OnDestroy { this.battlemapDataName = null; } + // ─────────────── Source de carte (Dungeon Alchemist / DungeonDraft) ────────── + + /** Change la source ; repart d'une carte vierge (les deux formats diffèrent). */ + setBattlemapSource(src: 'DUNGEON_ALCHEMIST' | 'DUNGEONDRAFT'): void { + if (src === this.battlemapSource) return; + this.battlemapSource = src; + this.battlemapMediaFileId = null; + this.battlemapMediaName = null; + this.battlemapDataFileId = null; + this.battlemapDataName = null; + this.battlemapDd2vttNoImage = false; + } + + /** Retire la carte DungeonDraft (data + media dérivé). */ + removeDd2vtt(): void { + this.battlemapMediaFileId = null; + this.battlemapMediaName = null; + this.battlemapDataFileId = null; + this.battlemapDataName = null; + this.battlemapDd2vttNoImage = false; + } + + onDd2vttSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (file) void this.uploadDd2vtt(file); + input.value = ''; + } + + onDd2vttDropped(files: File[]): void { + if (files[0]) void this.uploadDd2vtt(files[0]); + } + + /** + * Upload d'un .dd2vtt (Universal VTT) : on en EXTRAIT l'image embarquee (base64) + * pour en faire le media (fond de carte pour l'export Foundry), et on range le + * sidecar (murs/lumieres/grille, SANS l'image pour ne pas dupliquer le binaire) + * comme fichier de donnees. + */ + private async uploadDd2vtt(file: File): Promise { + this.battlemapUploadingData = true; + this.battlemapDd2vttNoImage = false; + try { + const json = JSON.parse(await file.text()); + const { image, ...sidecar } = json ?? {}; + // Sidecar allégé (sans l'image embarquée) ; on garde le nom .dd2vtt. + const dataFile = new File([JSON.stringify(sidecar)], file.name, { type: 'application/json' }); + const storedData = await firstValueFrom(this.storedFileService.upload(dataFile)); + this.battlemapDataFileId = storedData.id; + this.battlemapDataName = storedData.filename; + + if (typeof image === 'string' && image.length > 0) { + const blob = this.base64ToImageBlob(image); + const imgName = this.baseName(file.name) + this.extForType(blob.type); + const imgFile = new File([blob], imgName, { type: blob.type }); + const storedMedia = await firstValueFrom(this.storedFileService.upload(imgFile)); + this.battlemapMediaFileId = storedMedia.id; + this.battlemapMediaName = storedMedia.filename; + } else { + this.battlemapMediaFileId = null; + this.battlemapMediaName = null; + this.battlemapDd2vttNoImage = true; + } + } catch { + // JSON illisible : on stocke le fichier brut comme données, sans image. + try { + const stored = await firstValueFrom(this.storedFileService.upload(file)); + this.battlemapDataFileId = stored.id; + this.battlemapDataName = stored.filename; + this.battlemapDd2vttNoImage = true; + } catch { /* upload échoué : on ignore */ } + } finally { + this.battlemapUploadingData = false; + } + } + + /** Décode une image base64 (sans préfixe data:) en Blob, type sniffé. */ + private base64ToImageBlob(b64: string): Blob { + const clean = b64.includes(',') ? b64.slice(b64.indexOf(',') + 1) : b64.trim(); + const bin = atob(clean); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return new Blob([bytes], { type: this.sniffImageType(bytes) }); + } + + private sniffImageType(b: Uint8Array): string { + if (b[0] === 0x89 && b[1] === 0x50) return 'image/png'; + if (b[0] === 0xff && b[1] === 0xd8) return 'image/jpeg'; + if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46) return 'image/webp'; // RIFF + return 'image/png'; + } + + private baseName(name: string): string { + return name.replace(/\.[^./\\]+$/, ''); + } + + private extForType(type: string): string { + return type === 'image/jpeg' ? '.jpg' : type === 'image/webp' ? '.webp' : '.png'; + } + ngOnDestroy(): void { // Volontairement vide : la sidebar reste prise en charge par le composant // suivant (autre sous-route ou le composant detail parent) qui appellera diff --git a/web/src/app/lore/block-grid-builder/block-grid-builder.component.html b/web/src/app/lore/block-grid-builder/block-grid-builder.component.html new file mode 100644 index 0000000..d292429 --- /dev/null +++ b/web/src/app/lore/block-grid-builder/block-grid-builder.component.html @@ -0,0 +1,100 @@ +
+
+ +
+
{{ 'templateEdit.paletteTitle' | translate }}
+ @for (pt of paletteTypes; track pt.type) { +
+ + {{ pt.labelKey | translate }} +
+ } +
+ + +
+ @for (block of blocks; track block.id) { +
+
+ + + + +
+ + @if (block.type === 'KEY_VALUE_LIST' || block.type === 'TABLE') { +
+ @for (lbl of block.labels ?? []; track $index; let li = $index) { + + + + + } + +
+ } + + +
+
+
+
+ } + + + @if (drag?.kind === 'create' && drag?.over) { +
+ } + + @if (!blocks.length && !drag) { +

{{ 'templateEdit.canvasEmpty' | translate }}

+ } +
+
+ + + @if (drag?.kind === 'create') { +
+ +
+ } + +

{{ 'templateEdit.gridHelp' | translate }}

+
diff --git a/web/src/app/lore/block-grid-builder/block-grid-builder.component.scss b/web/src/app/lore/block-grid-builder/block-grid-builder.component.scss new file mode 100644 index 0000000..c7c15c2 --- /dev/null +++ b/web/src/app/lore/block-grid-builder/block-grid-builder.component.scss @@ -0,0 +1,289 @@ +.grid-builder { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.builder-body { + display: flex; + align-items: flex-start; + gap: 0.85rem; +} + +// --- Palette -------------------------------------------------------------- +.palette { + flex: 0 0 150px; + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.6rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid #23233a; + border-radius: 8px; + + .palette-title { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #6b7280; + margin-bottom: 0.15rem; + } +} + +.palette-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.6rem; + background: #1a1a2e; + border: 1px solid #2a2a3d; + border-radius: 6px; + color: #d1d5db; + font-size: 0.82rem; + cursor: grab; + user-select: none; + touch-action: none; + transition: border-color 0.12s, background 0.12s, color 0.12s; + + lucide-icon { color: #a5b4fc; flex-shrink: 0; } + + &:hover { border-color: #6c63ff; background: #20203a; color: white; } + &:active { cursor: grabbing; } +} + +// --- Toile (grille 2D libre) ---------------------------------------------- +.canvas { + flex: 1 1 auto; + min-width: 0; + position: relative; + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 4px; + padding: 4px; + border: 1px dashed #2a2a3d; + border-radius: 8px; + background-color: rgba(255, 255, 255, 0.012); + background-image: + repeating-linear-gradient(to right, rgba(255, 255, 255, 0.045) 0 1px, transparent 1px calc((100% - 8px) / 12)), + repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.045) 0 1px, transparent 1px 36px); + background-position: 4px 4px; + + &.dragging { border-color: #6c63ff; } +} + +.canvas-empty { + grid-column: 1 / -1; + grid-row: 3 / span 3; + align-self: center; + text-align: center; + color: #6b7280; + font-size: 0.85rem; + font-style: italic; + margin: 0; +} + +.grid-block { + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + background: #1a1a2e; + border: 1px solid #2a2a3d; + border-left: 3px solid #6c63ff; // accent par type (TEXT par défaut) + border-radius: 5px; + min-width: 0; + + &.is-image { border-left-color: #818cf8; } + &.is-kv { border-left-color: #f59e0b; } + &.is-table { border-left-color: #14b8a6; } + &.is-new { border-left-color: #34d399; } + + &.dragged { + border-color: #6c63ff; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); + z-index: 5; + } +} + +.block-head { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 0.3rem; + padding: 3px 16px 3px 3px; // marge droite pour la poignée de largeur +} + +.block-grip { + display: inline-flex; + align-items: center; + background: transparent; + border: none; + color: #6b7280; + cursor: grab; + padding: 2px; + touch-action: none; + flex-shrink: 0; + + &:active { cursor: grabbing; } + &:hover { color: #9ca3af; } +} + +.block-type-icon { color: #a5b4fc; flex-shrink: 0; } + +.block-name-input { + font-size: 0.82rem; + font-weight: 600; + color: white; + flex: 1; + min-width: 0; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + padding: 2px 4px; + font-family: inherit; + + &:hover { border-color: rgba(255, 255, 255, 0.12); } + &:focus { outline: none; background: rgba(0, 0, 0, 0.3); border-color: #6c63ff; } +} + +.block-remove { + display: inline-flex; + align-items: center; + background: transparent; + border: none; + color: #6b7280; + cursor: pointer; + padding: 2px; + flex-shrink: 0; + + &:hover { color: #f87171; } +} + +// Libellés KV/TABLE — zone défilante (sous l'en-tête) +.block-labels { + flex: 1 1 auto; + overflow: auto; + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 0.3rem; + padding: 0 14px 12px 8px; +} + +.kv-label-chip { + display: inline-flex; + align-items: center; + gap: 2px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + padding: 1px 2px 1px 4px; + height: fit-content; + + input { + background: transparent; + border: none; + color: white; + font-size: 0.74rem; + width: 78px; + padding: 2px; + + &:focus { outline: none; } + } + + .kv-label-remove { + display: inline-flex; + background: transparent; + border: none; + color: #6b7280; + cursor: pointer; + padding: 1px; + + &:hover { color: #f87171; } + } +} + +.btn-kv-add-label { + display: inline-flex; + align-items: center; + gap: 2px; + background: transparent; + border: 1px dashed rgba(255, 255, 255, 0.18); + border-radius: 4px; + color: #9ca3af; + font-size: 0.72rem; + padding: 2px 6px; + cursor: pointer; + height: fit-content; + + &:hover { color: white; border-color: #6c63ff; } +} + +// --- Poignées de redimensionnement ---------------------------------------- +.rh { + position: absolute; + z-index: 3; + touch-action: none; +} +.rh-e { top: 0; right: 0; width: 10px; height: 100%; cursor: ew-resize; } +.rh-s { left: 0; bottom: 0; height: 10px; width: 100%; cursor: ns-resize; } +.rh-se { + right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; z-index: 4; + + &::after { + content: ''; + position: absolute; + right: 3px; + bottom: 3px; + width: 7px; + height: 7px; + border-right: 2px solid #6c63ff; + border-bottom: 2px solid #6c63ff; + opacity: 0.6; + } +} +.grid-block:hover .rh-se::after { opacity: 1; } + +// Aperçu du bloc en création (rectangle en pointillés) +.create-preview { + border: 2px dashed #6c63ff; + border-radius: 5px; + background: rgba(108, 99, 255, 0.12); + pointer-events: none; +} + +// Fantôme qui suit le pointeur pendant la création +.drag-ghost { + position: fixed; + z-index: 1000; + transform: translate(8px, 8px); + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + background: #6c63ff; + color: white; + border-radius: 6px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); + pointer-events: none; +} + +.hint { + font-size: 0.76rem; + color: #6b7280; + margin: 0; +} + +// Repli mobile : palette au-dessus, toile pleine largeur. +@media (max-width: 820px) { + .builder-body { flex-direction: column; } + .palette { + flex: 1 1 auto; + flex-direction: row; + flex-wrap: wrap; + width: 100%; + .palette-title { width: 100%; } + } + .canvas { width: 100%; } +} diff --git a/web/src/app/lore/block-grid-builder/block-grid-builder.component.ts b/web/src/app/lore/block-grid-builder/block-grid-builder.component.ts new file mode 100644 index 0000000..b8898be --- /dev/null +++ b/web/src/app/lore/block-grid-builder/block-grid-builder.component.ts @@ -0,0 +1,350 @@ +import { + ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + LucideAngularModule, Trash2, Type, Image as ImageIcon, + ListOrdered, Table as TableIcon, X, GripVertical, Plus, +} from 'lucide-angular'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { FieldType, TemplateField, buildLoreTemplateField } from '../../services/template.model'; +import { GRID_COLS, GRID_ROW_HEIGHT, DEFAULT_BLOCK_H } from '../block-layout.helper'; + +const MAX_H = 40; + +function genId(): string { + try { return 'blk-' + crypto.randomUUID(); } + catch { return 'blk-' + Math.random().toString(36).slice(2) + Date.now().toString(36); } +} +function clampW(w: number | null | undefined): number { + return Math.min(GRID_COLS, Math.max(1, Math.round(w ?? GRID_COLS))); +} +function clampH(h: number | null | undefined): number { + return Math.min(MAX_H, Math.max(1, Math.round(h ?? DEFAULT_BLOCK_H))); +} + +interface PaletteType { + type: FieldType; + icon: typeof Type; + labelKey: string; + nameKey: string; +} + +interface CanvasGeom { left: number; top: number; colUnit: number; rowUnit: number; } + +interface DragOp { + kind: 'create' | 'move' | 'resize'; + type?: FieldType; + block?: TemplateField; + edge?: 'e' | 's' | 'se'; + g: CanvasGeom; + grabCol: number; + grabRow: number; + // Aperçu (création) : rectangle cible dans la grille. + px: number; py: number; pw: number; ph: number; + over: boolean; // pointeur au-dessus de la toile (création) + pointerX: number; pointerY: number; // pour le fantôme suiveur + moved: boolean; +} + +/** + * Builder de blocs en grille 2D LIBRE (inspiré de l'app "Lore" d'Amsel). + * + * - PALETTE latérale de types de blocs : on les glisse sur la toile (ou on + * clique pour les ajouter en bas). + * - chaque bloc se PLACE où on veut (x ET y) en glissant sa poignée, et se + * REDIMENSIONNE en largeur ET hauteur via les poignées de bord/coin (snap à + * la grille 12 colonnes × lignes fixes de {@link GRID_ROW_HEIGHT}px). + * - le nom est éditable en place (id stable → les valeurs des pages suivent). + * + * Le placement {x, y, w, h} est explicite (pas de "flow-packing") : le rendu + * (page-view / page-edit) le reproduit exactement. Les chevauchements sont + * permis (placement libre) — c'est à l'utilisateur d'arranger. + * + * Composant partagé par template-create et template-edit. E/S : `[(fields)]`. + */ +@Component({ + selector: 'app-block-grid-builder', + imports: [FormsModule, LucideAngularModule, TranslatePipe], + templateUrl: './block-grid-builder.component.html', + styleUrls: ['./block-grid-builder.component.scss'], +}) +export class BlockGridBuilderComponent implements OnDestroy { + readonly Trash2 = Trash2; + readonly X = X; + readonly GripVertical = GripVertical; + readonly Plus = Plus; + readonly GRID_ROW_HEIGHT = GRID_ROW_HEIGHT; + + readonly paletteTypes: PaletteType[] = [ + { type: 'TEXT', icon: Type, labelKey: 'templateEdit.typeText', nameKey: 'templateEdit.defaultNameText' }, + { type: 'IMAGE', icon: ImageIcon, labelKey: 'templateEdit.typeImage', nameKey: 'templateEdit.defaultNameImage' }, + { type: 'KEY_VALUE_LIST', icon: ListOrdered, labelKey: 'templateEdit.typeKeyValue', nameKey: 'templateEdit.defaultNameKeyValue' }, + { type: 'TABLE', icon: TableIcon, labelKey: 'templateEdit.typeTable', nameKey: 'templateEdit.defaultNameTable' }, + ]; + + @Input() existingFieldNames: Set | null = null; + @Output() fieldsChange = new EventEmitter(); + + @ViewChild('canvasEl') private canvasRef?: ElementRef; + + private lastEmitted: TemplateField[] | null = null; + blocks: TemplateField[] = []; + + private renameOrig = ''; + drag: DragOp | null = null; + + constructor(private translate: TranslateService, private cdr: ChangeDetectorRef) {} + + @Input() set fields(value: TemplateField[] | null | undefined) { + if (value && value === this.lastEmitted) return; + this.blocks = this.normalize(value ?? []); + queueMicrotask(() => this.emit()); + } + + iconFor(type: FieldType) { + switch (type) { + case 'IMAGE': return ImageIcon; + case 'KEY_VALUE_LIST': return ListOrdered; + case 'TABLE': return TableIcon; + default: return Type; + } + } + + isExisting(block: TemplateField): boolean { + return !!this.existingFieldNames?.has(block.name); + } + + /** Nombre de lignes visibles de la toile (au moins 12, + marge sous le contenu). */ + get gridRows(): number { + return Math.max(12, this.maxBottom() + 3); + } + + gridColumn(block: TemplateField): string { + return `${(block.pos?.x ?? 0) + 1} / span ${clampW(block.pos?.w)}`; + } + gridRow(block: TemplateField): string { + return `${(block.pos?.y ?? 0) + 1} / span ${clampH(block.pos?.h)}`; + } + + // --- Ajout / suppression ------------------------------------------------ + + /** Clic palette : ajoute un bloc pleine largeur sous le contenu existant. */ + addType(type: FieldType): void { + const block = this.makeBlock(type, 0, this.maxBottom(), GRID_COLS, DEFAULT_BLOCK_H); + this.blocks = [...this.blocks, block]; + this.emit(); + } + + remove(block: TemplateField): void { + this.blocks = this.blocks.filter(b => b !== block); + this.emit(); + } + + // --- Renommage ---------------------------------------------------------- + + onNameFocus(block: TemplateField): void { this.renameOrig = block.name; } + rename(block: TemplateField, value: string): void { block.name = value; this.emit(); } + commitRename(block: TemplateField): void { + block.name = block.name.trim() || this.renameOrig; + this.emit(); + } + + // --- Libellés (KEY_VALUE_LIST / TABLE) ---------------------------------- + + addLabel(block: TemplateField): void { block.labels = [...(block.labels ?? []), '']; this.emit(); } + updateLabel(block: TemplateField, i: number, value: string): void { + if (!block.labels) return; block.labels[i] = value; this.emit(); + } + removeLabel(block: TemplateField, i: number): void { + if (!block.labels) return; block.labels = block.labels.filter((_, k) => k !== i); this.emit(); + } + + // --- Glisser : création / déplacement / redimensionnement --------------- + + /** Démarre la création d'un bloc en glissant un type depuis la palette. */ + startCreate(event: PointerEvent, type: FieldType): void { + event.preventDefault(); + const g = this.geom(); + if (!g) return; + this.drag = { + kind: 'create', type, g, grabCol: 0, grabRow: 0, + px: 0, py: 0, pw: 6, ph: DEFAULT_BLOCK_H, over: false, + pointerX: event.clientX, pointerY: event.clientY, moved: false, + }; + this.listen(); + } + + /** Démarre le déplacement d'un bloc (poignée). */ + startMove(event: PointerEvent, block: TemplateField): void { + event.preventDefault(); + event.stopPropagation(); + const g = this.geom(); + if (!g) return; + const cell = this.cellAt(event.clientX, event.clientY, g); + this.drag = { + kind: 'move', block, g, + grabCol: cell.col - (block.pos?.x ?? 0), + grabRow: cell.row - (block.pos?.y ?? 0), + px: 0, py: 0, pw: 0, ph: 0, over: true, + pointerX: event.clientX, pointerY: event.clientY, moved: false, + }; + this.listen(); + } + + /** Démarre le redimensionnement d'un bloc (bord/coin). */ + startResize(event: PointerEvent, block: TemplateField, edge: 'e' | 's' | 'se'): void { + event.preventDefault(); + event.stopPropagation(); + const g = this.geom(); + if (!g) return; + this.drag = { + kind: 'resize', block, edge, g, grabCol: 0, grabRow: 0, + px: 0, py: 0, pw: 0, ph: 0, over: true, + pointerX: event.clientX, pointerY: event.clientY, moved: false, + }; + this.listen(); + } + + private onPointerMove = (event: PointerEvent): void => { + const d = this.drag; + if (!d) return; + d.moved = true; + d.pointerX = event.clientX; + d.pointerY = event.clientY; + const cell = this.cellAt(event.clientX, event.clientY, d.g); + + if (d.kind === 'move' && d.block) { + const w = clampW(d.block.pos?.w); + const x = Math.min(GRID_COLS - w, Math.max(0, cell.col - d.grabCol)); + const y = Math.max(0, cell.row - d.grabRow); + d.block.pos = { ...(d.block.pos ?? {}), x, y, w, h: clampH(d.block.pos?.h) }; + this.cdr.detectChanges(); + } else if (d.kind === 'resize' && d.block) { + const x = d.block.pos?.x ?? 0; + const y = d.block.pos?.y ?? 0; + let w = clampW(d.block.pos?.w); + let h = clampH(d.block.pos?.h); + if (d.edge === 'e' || d.edge === 'se') w = Math.min(GRID_COLS - x, Math.max(1, cell.col - x + 1)); + if (d.edge === 's' || d.edge === 'se') h = Math.min(MAX_H, Math.max(1, cell.row - y + 1)); + d.block.pos = { x, y, w, h }; + this.cdr.detectChanges(); + } else if (d.kind === 'create') { + d.over = this.isOverCanvas(event.clientX, event.clientY); + if (d.over) { + d.px = Math.min(GRID_COLS - d.pw, Math.max(0, cell.col)); + d.py = Math.max(0, cell.row); + } + this.cdr.detectChanges(); + } + }; + + private onPointerUp = (): void => { + const d = this.drag; + this.unlisten(); + this.drag = null; + if (!d) return; + if (d.kind === 'create' && d.over) { + const block = this.makeBlock(d.type!, d.px, d.py, d.pw, d.ph); + this.blocks = [...this.blocks, block]; + this.emit(); + } else if ((d.kind === 'move' || d.kind === 'resize') && d.moved) { + this.emit(); + } + this.cdr.detectChanges(); + }; + + private listen(): void { + window.addEventListener('pointermove', this.onPointerMove); + window.addEventListener('pointerup', this.onPointerUp, { once: true }); + } + private unlisten(): void { + window.removeEventListener('pointermove', this.onPointerMove); + window.removeEventListener('pointerup', this.onPointerUp); + } + + ngOnDestroy(): void { this.unlisten(); } + + // --- Helpers ------------------------------------------------------------ + + private geom(): CanvasGeom | null { + const el = this.canvasRef?.nativeElement; + if (!el) return null; + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + const padL = parseFloat(style.paddingLeft) || 0; + const padT = parseFloat(style.paddingTop) || 0; + const padR = parseFloat(style.paddingRight) || 0; + const colGap = parseFloat(style.columnGap || style.gap) || 0; + const rowGap = parseFloat(style.rowGap || style.gap) || 0; + const innerW = rect.width - padL - padR; + return { + left: rect.left + padL, + top: rect.top + padT, + colUnit: (innerW + colGap) / GRID_COLS, + rowUnit: GRID_ROW_HEIGHT + rowGap, + }; + } + + private cellAt(px: number, py: number, g: CanvasGeom): { col: number; row: number } { + return { + col: Math.min(GRID_COLS - 1, Math.max(0, Math.floor((px - g.left) / g.colUnit))), + row: Math.max(0, Math.floor((py - g.top) / g.rowUnit)), + }; + } + + private isOverCanvas(px: number, py: number): boolean { + const el = this.canvasRef?.nativeElement; + if (!el) return false; + const r = el.getBoundingClientRect(); + return px >= r.left && px <= r.right && py >= r.top && py <= r.bottom; + } + + private maxBottom(blocks: TemplateField[] = this.blocks): number { + return blocks.reduce((m, b) => Math.max(m, (b.pos?.y ?? 0) + clampH(b.pos?.h)), 0); + } + + private makeBlock(type: FieldType, x: number, y: number, w: number, h: number): TemplateField { + return { + ...buildLoreTemplateField(this.uniqueDefaultName(type), type), + id: genId(), + pos: { x, y, w: clampW(w), h: clampH(h) }, + }; + } + + private uniqueDefaultName(type: FieldType): string { + const pt = this.paletteTypes.find(p => p.type === type); + const base = this.translate.instant(pt?.nameKey ?? 'templateEdit.defaultNameText'); + let name = base; + let i = 2; + while (this.blocks.some(b => b.name === name)) name = `${base} ${i++}`; + return name; + } + + /** + * Normalise les champs entrants : id stable + position {x,y,w,h} complète. + * Les blocs sans aucune position (legacy) sont empilés pleine largeur ; les + * blocs partiels (largeur seule, ancien format) reçoivent une hauteur par défaut. + */ + private normalize(fields: TemplateField[]): TemplateField[] { + let nextY = 0; + return fields.map(f => { + const id = f.id && f.id.trim() ? f.id : (f.name || genId()); + const labels = f.labels ? [...f.labels] : f.labels; + const p = f.pos; + let pos; + if (p && (p.x != null || p.y != null || p.w != null || p.h != null)) { + pos = { x: p.x ?? 0, y: p.y ?? nextY, w: clampW(p.w), h: clampH(p.h) }; + } else { + pos = { x: 0, y: nextY, w: GRID_COLS, h: DEFAULT_BLOCK_H }; + } + nextY = Math.max(nextY, pos.y + pos.h); + return { ...f, id, labels, pos }; + }); + } + + private emit(): void { + this.lastEmitted = this.blocks; + this.fieldsChange.emit(this.blocks); + } +} diff --git a/web/src/app/lore/block-layout.helper.spec.ts b/web/src/app/lore/block-layout.helper.spec.ts new file mode 100644 index 0000000..3d2cd50 --- /dev/null +++ b/web/src/app/lore/block-layout.helper.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { + hasBlockLayout, + orderedBlocks, + blockGridColumn, + blockGridRow, + blockKey, +} from './block-layout.helper'; +import { TemplateField } from '../services/template.model'; + +function field(name: string, pos?: TemplateField['pos']): TemplateField { + return { id: name, name, type: 'TEXT', pos }; +} + +describe('blockKey', () => { + it('utilise l id du bloc quand il est present', () => { + expect(blockKey({ id: 'blk-1', name: 'Ambiance', type: 'TEXT' })).toBe('blk-1'); + }); + + it('retombe sur le nom quand l id est absent ou vide (templates legacy)', () => { + expect(blockKey({ name: 'Ambiance', type: 'TEXT' })).toBe('Ambiance'); + expect(blockKey({ id: ' ', name: 'Ambiance', type: 'TEXT' })).toBe('Ambiance'); + }); + + it('reste stable quand le nom change mais pas l id (renommage)', () => { + expect(blockKey({ id: 'blk-1', name: 'Nouveau nom', type: 'TEXT' })).toBe('blk-1'); + }); +}); + +describe('hasBlockLayout', () => { + it('false quand aucun bloc n a de position (templates legacy)', () => { + expect(hasBlockLayout([field('A'), field('B')])).toBe(false); + }); + + it('false pour une liste vide / nullish', () => { + expect(hasBlockLayout([])).toBe(false); + expect(hasBlockLayout(null)).toBe(false); + expect(hasBlockLayout(undefined)).toBe(false); + }); + + it('true des qu un bloc porte x, y ou w', () => { + expect(hasBlockLayout([field('A'), field('B', { x: 0 })])).toBe(true); + expect(hasBlockLayout([field('A', { w: 6 })])).toBe(true); + expect(hasBlockLayout([field('A', { y: 2 })])).toBe(true); + }); + + it('false quand pos est present mais entierement nul', () => { + expect(hasBlockLayout([field('A', {})])).toBe(false); + }); +}); + +describe('orderedBlocks', () => { + it('conserve l ordre du tableau (et les references) sans mise en page', () => { + const a = field('A'); + const b = field('B'); + const out = orderedBlocks([a, b]); + expect(out).toEqual([a, b]); + expect(out[0]).toBe(a); // meme reference -> track field stable + }); + + it('trie par (ligne, colonne) quand une mise en page est presente', () => { + const topRight = field('TR', { x: 6, y: 0, w: 6 }); + const topLeft = field('TL', { x: 0, y: 0, w: 6 }); + const bottom = field('B', { x: 0, y: 1, w: 12 }); + const sorted = orderedBlocks([bottom, topRight, topLeft]); + expect(sorted.map(f => f.name)).toEqual(['TL', 'TR', 'B']); + }); + + it('ne mute pas le tableau source', () => { + const src = [field('B', { x: 6, y: 0 }), field('A', { x: 0, y: 0 })]; + const copy = [...src]; + orderedBlocks(src); + expect(src).toEqual(copy); + }); +}); + +describe('blockGridColumn', () => { + it('null sans colonne definie', () => { + expect(blockGridColumn(field('A'))).toBeNull(); + expect(blockGridColumn(field('A', { y: 3 }))).toBeNull(); + }); + + it('convertit x (0-based) en colonne 1-based avec span w', () => { + expect(blockGridColumn(field('A', { x: 0, w: 6 }))).toBe('1 / span 6'); + expect(blockGridColumn(field('A', { x: 6, w: 6 }))).toBe('7 / span 6'); + }); + + it('pleine largeur (span 12) quand w absent', () => { + expect(blockGridColumn(field('A', { x: 0 }))).toBe('1 / span 12'); + }); +}); + +describe('blockGridRow', () => { + it('null sans ligne definie', () => { + expect(blockGridRow(field('A'))).toBeNull(); + expect(blockGridRow(field('A', { x: 0, w: 6 }))).toBeNull(); + }); + + it('place sur la ligne y+1 avec la hauteur par defaut quand h absent', () => { + expect(blockGridRow(field('A', { y: 0 }))).toBe('1 / span 4'); + expect(blockGridRow(field('A', { y: 2 }))).toBe('3 / span 4'); + }); + + it('place avec span h quand h present', () => { + expect(blockGridRow(field('A', { y: 1, h: 6 }))).toBe('2 / span 6'); + }); +}); diff --git a/web/src/app/lore/block-layout.helper.ts b/web/src/app/lore/block-layout.helper.ts new file mode 100644 index 0000000..49f53f9 --- /dev/null +++ b/web/src/app/lore/block-layout.helper.ts @@ -0,0 +1,84 @@ +import { TemplateField } from '../services/template.model'; + +/** + * Helpers de mise en page "blocs" des pages de lore (grille 12 colonnes libre, + * inspiree de l'app "Lore" d'Amsel). Partages par page-view (apercu) et + * page-edit (saisie) pour rendre une page selon le placement defini sur son + * template. + * + * Modele : grille 2D LIBRE. Chaque bloc occupe un rectangle explicite de la + * grille via {@link TemplateField.pos} {x, y, w, h} (en unites de grille) : + * on le place ou on veut (x ET y) et on le dimensionne en largeur ET hauteur. + * Les lignes ont une hauteur fixe ({@link GRID_ROW_HEIGHT}px), donc `h` a un + * sens visuel. + * + * Retrocompatibilite : un template dont AUCUN bloc ne porte de position est + * rendu comme avant — une seule colonne, blocs empiles dans l'ordre du tableau. + * La grille ne s'active que lorsqu'au moins un bloc a ete place via le builder. + */ + +/** Nombre de colonnes de la grille. */ +export const GRID_COLS = 12; +/** Hauteur d'une unite de ligne (px). Partagee builder + rendu pour coherence. */ +export const GRID_ROW_HEIGHT = 32; +/** Hauteur par defaut d'un nouveau bloc, en unites de ligne. */ +export const DEFAULT_BLOCK_H = 4; + +/** + * Clé STABLE d'ancrage des valeurs de Page : l'id du bloc, avec repli sur le + * nom (les templates anterieurs ont id == name, donc transparent). Permet de + * renommer un bloc sans orpheliner ses valeurs (qui restent rangees sous l'id). + */ +export function blockKey(field: TemplateField): string { + return field.id && field.id.trim() ? field.id : field.name; +} + +/** True si au moins un bloc porte une position de grille exploitable. */ +export function hasBlockLayout(fields: TemplateField[] | null | undefined): boolean { + return (fields ?? []).some(f => { + const p = f.pos; + return !!p && (p.x != null || p.y != null || p.w != null || p.h != null); + }); +} + +/** + * Retourne les blocs ordonnes pour le rendu : tries par (ligne, colonne) quand + * une mise en page est presente, sinon l'ordre d'origine du tableau (rendu + * historique empile). Ne mute jamais l'entree (copie avant tri), et conserve + * les memes references d'objets (pour un `track field` stable cote template). + */ +export function orderedBlocks(fields: TemplateField[] | null | undefined): TemplateField[] { + const list = fields ?? []; + if (!hasBlockLayout(list)) return list; + return [...list].sort((a, b) => { + const ay = a.pos?.y ?? 0; + const by = b.pos?.y ?? 0; + if (ay !== by) return ay - by; + return (a.pos?.x ?? 0) - (b.pos?.x ?? 0); + }); +} + +/** + * Valeur CSS `grid-column` d'un bloc (colonne de depart x+1, sur w colonnes). + * Null si aucune coordonnee horizontale -> repli empile. A binder via + * `[style.grid-column]`, inerte hors d'un conteneur grid. + */ +export function blockGridColumn(field: TemplateField): string | null { + const p = field.pos; + if (!p || (p.x == null && p.w == null)) return null; + const x = (p.x ?? 0) + 1; + const w = p.w ?? GRID_COLS; + return `${x} / span ${w}`; +} + +/** + * Valeur CSS `grid-row` d'un bloc (ligne de depart y+1, sur h lignes). + * Null si aucune coordonnee verticale -> repli empile (hauteur auto). + */ +export function blockGridRow(field: TemplateField): string | null { + const p = field.pos; + if (!p || (p.y == null && p.h == null)) return null; + const y = (p.y ?? 0) + 1; + const h = p.h ?? DEFAULT_BLOCK_H; + return `${y} / span ${h}`; +} diff --git a/web/src/app/lore/page-edit/page-edit.component.html b/web/src/app/lore/page-edit/page-edit.component.html index b3d72ab..6a0b32b 100644 --- a/web/src/app/lore/page-edit/page-edit.component.html +++ b/web/src/app/lore/page-edit/page-edit.component.html @@ -43,104 +43,113 @@

{{ 'pageEdit.folderHint' | translate }}

- + @if (template?.fields?.length) {

{{ 'pageEdit.fields' | translate }}

- @for (field of template!.fields; track field) { - - @if (field.type === 'TEXT') { -
- - -
- } - - @if (field.type === 'IMAGE') { -
- - - -
- } - - @if (field.type === 'KEY_VALUE_LIST') { -
- -
- @for (lbl of field.labels ?? []; track $index) { -
- {{ lbl }} - -
- } - @if (!(field.labels ?? []).length) { -

{{ 'pageEdit.noLabels' | translate }}

- } +
+ @for (field of orderedFields; track field) { + + @if (field.type === 'TEXT') { +
+ +
-
- } - - @if (field.type === 'TABLE') { -
- - @if ((field.labels ?? []).length) { -
- - - - @for (col of field.labels; track $index) { - - } - - - - - @for (row of tableRows(field.name); track $index; let ri = $index) { + } + + @if (field.type === 'IMAGE') { +
+ + + +
+ } + + @if (field.type === 'KEY_VALUE_LIST') { +
+ +
+ @for (lbl of field.labels ?? []; track $index) { +
+ {{ lbl }} + +
+ } + @if (!(field.labels ?? []).length) { +

{{ 'pageEdit.noLabels' | translate }}

+ } +
+
+ } + + @if (field.type === 'TABLE') { +
+ + @if ((field.labels ?? []).length) { +
+
{{ col }}
+ @for (col of field.labels; track $index) { - + } - + - } - -
- - {{ col }} - -
- -
- } @else { -

{{ 'pageEdit.noColumns' | translate }}

- } -
+ + + @for (row of tableRows(keyOf(field)); track $index; let ri = $index) { + + @for (col of field.labels; track $index) { + + + + } + + + + + } + + + +
+ } @else { +

{{ 'pageEdit.noColumns' | translate }}

+ } +
+ } } - } +
}

{{ 'pageEdit.tags' | translate }}

diff --git a/web/src/app/lore/page-edit/page-edit.component.scss b/web/src/app/lore/page-edit/page-edit.component.scss index bf4ec50..3edd765 100644 --- a/web/src/app/lore/page-edit/page-edit.component.scss +++ b/web/src/app/lore/page-edit/page-edit.component.scss @@ -42,6 +42,35 @@ gap: 1.25rem; } +// Conteneur des champs dynamiques. +// - Sans mise en page (hasLayout=false) : `display: contents` -> les .field +// restent des enfants flex directs de .edit-form (même gap, rendu inchangé). +// - Avec mise en page : grille 12 colonnes ; chaque .field est placé via +// [style.grid-column] / [style.grid-row]. +.edit-fields { + display: contents; + + // En saisie, on honore la LARGEUR (colonnes, pour le côte-à-côte) mais la + // hauteur reste NATURELLE (les widgets — textarea, tableau, image — ont besoin + // de respirer). Les blocs s'écoulent par ligne dans l'ordre (ligne, colonne). + &.edit-fields--grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-auto-flow: row; + gap: 1.25rem 1rem; + align-items: start; + } +} + +// Repli mobile : la grille se réempile en une colonne, en conservant l'espacement. +@media (max-width: 820px) { + .edit-fields.edit-fields--grid { + display: flex; + flex-direction: column; + gap: 1.25rem; + } +} + .field { display: flex; flex-direction: column; diff --git a/web/src/app/lore/page-edit/page-edit.component.ts b/web/src/app/lore/page-edit/page-edit.component.ts index 415db58..d3534c1 100644 --- a/web/src/app/lore/page-edit/page-edit.component.ts +++ b/web/src/app/lore/page-edit/page-edit.component.ts @@ -12,13 +12,15 @@ import { LayoutService } from '../../services/layout.service'; import { PageTitleService } from '../../services/page-title.service'; import { 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, blockKey } from '../block-layout.helper'; +import { TemplateField } from '../../services/template.model'; import { ChipsInputComponent } from '../../shared/chips-input/chips-input.component'; import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component'; import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component'; import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component'; -import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component'; +import { ImageBlockComponent } from '../../shared/image-block/image-block.component'; import { Lore } from '../../services/lore.model'; import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service'; @@ -37,7 +39,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog */ @Component({ selector: 'app-page-edit', - imports: [FormsModule, RouterLink, LucideAngularModule, TranslatePipe, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent], + imports: [FormsModule, RouterLink, LucideAngularModule, TranslatePipe, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageBlockComponent], templateUrl: './page-edit.component.html', styleUrls: ['./page-edit.component.scss'] }) @@ -55,6 +57,16 @@ export class PageEditComponent implements OnInit, OnDestroy { /** Toutes les pages du lore — nécessaire au lore-link-picker pour l'autocomplete. */ 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 -> saisie en grille 12 col. */ + hasLayout = false; + + /** Placement horizontal d'un bloc (colonnes) ; hauteur naturelle en édition. */ + readonly gridColumn = blockGridColumn; + /** Clé stable d'ancrage des valeurs (id, repli sur le nom). */ + readonly keyOf = blockKey; + /** Modèle du formulaire (bindé via ngModel). */ title = ''; nodeId = ''; @@ -66,6 +78,8 @@ export class PageEditComponent implements OnInit, OnDestroy { * la liste ordonnee des IDs d'images uploadees. */ imageValues: Record = {}; + /** Cadrage (pan/zoom) des images : fieldKey → imageId → {x, y, scale}. */ + imageFraming: Record> = {}; /** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */ keyValueValues: Record> = {}; /** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */ @@ -171,6 +185,8 @@ export class PageEditComponent implements OnInit, OnDestroy { private hydrate(page: Page, templates: Template[]): void { this.page = page; this.template = templates.find(t => t.id === page.templateId) ?? null; + this.orderedFields = orderedBlocks(this.template?.fields); + this.hasLayout = hasBlockLayout(this.template?.fields); this.title = page.title; this.nodeId = page.nodeId; this.notes = page.notes ?? ''; @@ -180,25 +196,32 @@ export class PageEditComponent implements OnInit, OnDestroy { // structure `imageValues: Map>` a l'etape 5). const base: Record = {}; const imageBase: Record = {}; + const framingBase: Record> = {}; const kvBase: Record> = {}; const tableBase: Record>> = {}; + // Les valeurs sont rangées par clé STABLE (id) ; on relit d'abord par clé, + // puis par nom (pages dont les valeurs étaient encore rangées par nom — elles + // sont ainsi migrées vers la clé id à la prochaine sauvegarde). for (const f of this.template?.fields ?? []) { + const key = blockKey(f); if (f.type === 'TEXT') { - base[f.name] = page.values?.[f.name] ?? ''; + base[key] = page.values?.[key] ?? page.values?.[f.name] ?? ''; } else if (f.type === 'IMAGE') { // Initialise la galerie d'images pour ce champ (vide si jamais rempli). - imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])]; + imageBase[key] = [...(page.imageValues?.[key] ?? page.imageValues?.[f.name] ?? [])]; + framingBase[key] = { ...(page.imageFraming?.[key] ?? page.imageFraming?.[f.name] ?? {}) }; } else if (f.type === 'KEY_VALUE_LIST') { // Toujours initialiser l'objet interne : le ngModel du formulaire - // bind directement keyValueValues[field.name][label]. - kvBase[f.name] = { ...(page.keyValueValues?.[f.name] ?? {}) }; + // bind directement keyValueValues[keyOf(field)][label]. + kvBase[key] = { ...(page.keyValueValues?.[key] ?? page.keyValueValues?.[f.name] ?? {}) }; } else if (f.type === 'TABLE') { // Copie profonde des lignes : chaque ligne est éditée par ngModel. - tableBase[f.name] = (page.tableValues?.[f.name] ?? []).map(row => ({ ...row })); + tableBase[key] = (page.tableValues?.[key] ?? page.tableValues?.[f.name] ?? []).map(row => ({ ...row })); } } this.values = base; this.imageValues = imageBase; + this.imageFraming = framingBase; this.keyValueValues = kvBase; this.tableValues = tableBase; this.tags = [...(page.tags ?? [])]; @@ -215,6 +238,7 @@ export class PageEditComponent implements OnInit, OnDestroy { notes: this.notes, values: this.values, imageValues: this.imageValues, + imageFraming: this.imageFraming, keyValueValues: this.keyValueValues, tableValues: this.tableValues, tags: this.tags, @@ -291,11 +315,13 @@ export class PageEditComponent implements OnInit, OnDestroy { */ private mergeSuggestions(suggestions: Record): void { // L'IA ne genere que des valeurs texte : on ignore les champs IMAGE. + // L'IA renvoie ses suggestions par NOM de champ (le prompt liste les noms) ; + // on les range sous la clé STABLE (id) attendue par le formulaire. for (const field of this.template?.fields ?? []) { if (field.type !== 'TEXT') continue; const suggestion = suggestions[field.name]; if (suggestion && suggestion.trim()) { - this.values[field.name] = suggestion; + this.values[blockKey(field)] = suggestion; } } } diff --git a/web/src/app/lore/page-view/page-view.component.html b/web/src/app/lore/page-view/page-view.component.html index 042b6fb..d796933 100644 --- a/web/src/app/lore/page-view/page-view.component.html +++ b/web/src/app/lore/page-view/page-view.component.html @@ -17,74 +17,86 @@ - + @if (template?.fields?.length) { - @for (field of template!.fields; track field) { - @if (field.type === 'TEXT') { -
-

{{ field.name }}

- @if (valueOf(field.name)) { -

{{ valueOf(field.name) }}

- } @else { -

{{ 'pageView.notFilled' | translate }}

- } -
- } - @if (field.type === 'IMAGE') { -
-

{{ field.name }}

- - -
- } - @if (field.type === 'KEY_VALUE_LIST') { -
-

{{ field.name }}

- @if (kvHasContent(field.name, field.labels)) { -
- @for (lbl of field.labels ?? []; track $index) { -
- {{ lbl }} - {{ kvValueOf(field.name, lbl) || '—' }} -
- } -
- } @else { -

{{ 'pageView.notFilled' | translate }}

- } -
- } - @if (field.type === 'TABLE') { -
-

{{ field.name }}

- @if (tableRowsOf(field.name).length) { - - - - @for (col of field.labels ?? []; track $index) { - - } - - - - @for (row of tableRowsOf(field.name); track $index) { +
+ @for (field of orderedFields; track field) { + @if (field.type === 'TEXT') { +
+

{{ field.name }}

+ @if (valueOf(field)) { +

{{ valueOf(field) }}

+ } @else { +

{{ 'pageView.notFilled' | translate }}

+ } +
+ } + @if (field.type === 'IMAGE') { +
+

{{ field.name }}

+ + +
+ } + @if (field.type === 'KEY_VALUE_LIST') { +
+

{{ field.name }}

+ @if (kvHasContent(field)) { +
+ @for (lbl of field.labels ?? []; track $index) { +
+ {{ lbl }} + {{ kvValueOf(field, lbl) || '—' }} +
+ } +
+ } @else { +

{{ 'pageView.notFilled' | translate }}

+ } +
+ } + @if (field.type === 'TABLE') { +
+

{{ field.name }}

+ @if (tableRowsOf(field).length) { +
{{ col }}
+ @for (col of field.labels ?? []; track $index) { - + } - } - -
{{ row[col] || '—' }}{{ col }}
- } @else { -

{{ 'pageView.notFilled' | translate }}

- } -
+ + + @for (row of tableRowsOf(field); track $index) { + + @for (col of field.labels ?? []; track $index) { + {{ row[col] || '—' }} + } + + } + + + } @else { +

{{ 'pageView.notFilled' | translate }}

+ } + + } } - } + } @if ((page.tags?.length ?? 0) > 0) { diff --git a/web/src/app/lore/page-view/page-view.component.scss b/web/src/app/lore/page-view/page-view.component.scss index 03644ec..a479f52 100644 --- a/web/src/app/lore/page-view/page-view.component.scss +++ b/web/src/app/lore/page-view/page-view.component.scss @@ -1,4 +1,52 @@ // Styles spécifiques à page-view. // Le gros du style "fiche de jeu" vient du partial global `styles/_view.scss`. -// Aucun override nécessaire pour l'instant — ce fichier existe pour rester -// cohérent avec la structure des autres composants (ts/html/scss). + +// Conteneur des champs dynamiques. +// - Sans mise en page (hasLayout=false) : `display: contents` -> les sections +// se comportent EXACTEMENT comme avant (enfants visuels directs de .view-page, +// empilées, bordures de séparation gérées par _view.scss). Aucun changement. +// - Avec mise en page : grille 12 colonnes ; chaque section est placée via +// [style.grid-column] / [style.grid-row]. On neutralise alors les bordures +// top (un séparateur horizontal n'a pas de sens entre blocs côte-à-côte). +.view-fields { + display: contents; + + // Grille 2D fidèle : largeur (colonnes) ET hauteur (lignes de 32px = GRID_ROW_HEIGHT) + // honorées. Chaque bloc remplit sa cellule ; le contenu trop grand défile. + &.view-fields--grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-auto-rows: 32px; // = GRID_ROW_HEIGHT (block-layout.helper.ts) + gap: 4px; + align-items: stretch; + + .view-section { + display: flex; + flex-direction: column; + border-top: none; + padding: 0.5rem 0.75rem; + overflow: auto; + min-height: 0; + + // L'image (et autres contenus extensibles) remplit la hauteur restante. + app-image-block { flex: 1 1 auto; min-height: 0; } + } + } +} + +// Depuis que les champs vivent dans `.view-fields`, la première section "meta" +// (tags / pages liées / notes privées) devient `:first-of-type` parmi les +// enfants directs de `.view-page` et perdrait sa bordure de séparation héritée +// de _view.scss. On la lui restitue (scopé à page-view, sans impact sur +// arc/chapter/scene-view qui n'ont pas ce wrapper). +.view-page > .view-section:first-of-type { + border-top: 1px solid #1e1e3a; + padding-top: 1.25rem; +} + +// Repli mobile : la grille se réempile en une colonne. +@media (max-width: 820px) { + .view-fields.view-fields--grid { + display: block; + } +} diff --git a/web/src/app/lore/page-view/page-view.component.ts b/web/src/app/lore/page-view/page-view.component.ts index 8d54e4f..a50d89e 100644 --- a/web/src/app/lore/page-view/page-view.component.ts +++ b/web/src/app/lore/page-view/page-view.component.ts @@ -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 { + 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> { - return this.page?.tableValues?.[fieldName] ?? []; + tableRowsOf(field: TemplateField): Array> { + 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). */ diff --git a/web/src/app/lore/template-create/template-create.component.html b/web/src/app/lore/template-create/template-create.component.html index daba2c5..ff1a7f7 100644 --- a/web/src/app/lore/template-create/template-create.component.html +++ b/web/src/app/lore/template-create/template-create.component.html @@ -49,112 +49,10 @@ -
    - @for (f of fields; track f; let i = $index; let first = $first; let last = $last) { -
  • -
    - - -
    - - - {{ f.name }} - - - @if (f.type === 'IMAGE') { - - } - -
  • - - @if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') { -
  • - @for (lbl of f.labels ?? []; track $index; let li = $index) { - - - - - } - - @if (!(f.labels ?? []).length) { - - {{ (f.type === 'TABLE' ? 'templateCreate.tableLabelsHint' : 'templateCreate.kvLabelsHint') | translate }} - - } -
  • - } - } -
- -
- - - -
- -

{{ 'templateCreate.fieldsHelp' | translate }}

+ + diff --git a/web/src/app/lore/template-create/template-create.component.scss b/web/src/app/lore/template-create/template-create.component.scss index 955c275..8a34d16 100644 --- a/web/src/app/lore/template-create/template-create.component.scss +++ b/web/src/app/lore/template-create/template-create.component.scss @@ -20,18 +20,22 @@ } .template-form { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem 2.5rem; + display: flex; + flex-direction: column; + gap: 1.75rem; - .col-left, .col-right { + .col-left { display: flex; flex-direction: column; gap: 1.25rem; } + .col-right { + display: flex; + flex-direction: column; + gap: 0.5rem; + } .actions-row { - grid-column: 1 / -1; display: flex; justify-content: flex-start; gap: 0.75rem; diff --git a/web/src/app/lore/template-create/template-create.component.ts b/web/src/app/lore/template-create/template-create.component.ts index 3d46c93..8f89dcd 100644 --- a/web/src/app/lore/template-create/template-create.component.ts +++ b/web/src/app/lore/template-create/template-create.component.ts @@ -1,67 +1,42 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular'; import { TranslatePipe } 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 { LoreNode } from '../../services/lore.model'; -import { FieldType, ImageLayout, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model'; +import { TemplateField, cleanFieldLabels } from '../../services/template.model'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; +import { BlockGridBuilderComponent } from '../block-grid-builder/block-grid-builder.component'; 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. + * - Agencement des blocs délégué à {@link BlockGridBuilderComponent} (grille 12 col). */ @Component({ selector: 'app-template-create', - imports: [FormsModule, ReactiveFormsModule, RouterModule, LucideAngularModule, TranslatePipe], + imports: [ReactiveFormsModule, RouterModule, TranslatePipe, BlockGridBuilderComponent], 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; - readonly ListOrdered = ListOrdered; - readonly TableIcon = TableIcon; - readonly X = X; - - /** Icone du chip selon le type du champ. */ - iconFor(type: FieldType) { - switch (type) { - case 'IMAGE': return this.ImageIcon; - case 'KEY_VALUE_LIST': return this.ListOrdered; - case 'TABLE': return this.TableIcon; - default: return this.Type; - } - } - 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. + * Champs dynamiques par défaut d'un nouveau template. Le builder leur affecte + * id stable + placement (pos) à l'affichage. */ fields: TemplateField[] = [ { name: 'Nom', type: 'TEXT' }, { name: 'Description', type: 'TEXT' }, { name: 'Illustration', type: 'IMAGE', layout: 'GALLERY' } ]; - /** 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, @@ -132,62 +107,6 @@ export class TemplateCreateComponent implements OnInit, OnDestroy { 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; - this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)]; - 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; - } - - /** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */ - setFieldType(index: number, type: FieldType): void { - this.fields = this.fields.map((f, i) => - i === index ? buildLoreTemplateField(f.name, type, f) : f - ); - } - - /** 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 - ); - } - - // --- Sous-editeur des libelles (KEY_VALUE_LIST) ------------------------- - // Mutation en place des labels : recreer le tableau de fields a chaque - // frappe ferait perdre le focus de l'input en cours d'edition. - - addLabel(field: TemplateField): void { - field.labels = [...(field.labels ?? []), '']; - } - - updateLabel(field: TemplateField, labelIndex: number, value: string): void { - if (!field.labels) return; - field.labels[labelIndex] = value; - } - - removeLabel(field: TemplateField, labelIndex: number): void { - if (!field.labels) return; - field.labels = field.labels.filter((_, i) => i !== labelIndex); - } - submit(): void { if (this.form.invalid) return; const raw = this.form.value; diff --git a/web/src/app/lore/template-edit/template-edit.component.html b/web/src/app/lore/template-edit/template-edit.component.html index 6b8e7a5..89d8202 100644 --- a/web/src/app/lore/template-edit/template-edit.component.html +++ b/web/src/app/lore/template-edit/template-edit.component.html @@ -35,112 +35,11 @@
-
    - @for (f of fields; track f; let i = $index; let first = $first; let last = $last) { -
  • -
    - - -
    - - - {{ f.name }} - - - @if (f.type === 'IMAGE') { - - } - -
  • - - @if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') { -
  • - @for (lbl of f.labels ?? []; track $index; let li = $index) { - - - - - } - - @if (!(f.labels ?? []).length) { - - {{ (f.type === 'TABLE' ? 'templateEdit.tableLabelsHint' : 'templateEdit.kvLabelsHint') | translate }} - - } -
  • - } - } -
-
- - - -
-

{{ 'templateEdit.fieldsHelp' | translate }}

+ +
diff --git a/web/src/app/lore/template-edit/template-edit.component.scss b/web/src/app/lore/template-edit/template-edit.component.scss index 4e4bc6f..fccee73 100644 --- a/web/src/app/lore/template-edit/template-edit.component.scss +++ b/web/src/app/lore/template-edit/template-edit.component.scss @@ -27,15 +27,26 @@ } } -.template-form { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem 2.5rem; +.page { max-width: 1100px; } - .col-left, .col-right { +// Empilé sur une colonne : la méta (nom/dossier/description) en haut, puis le +// builder de blocs en pleine largeur (la grille 12 colonnes a besoin de place). +.template-form { + display: flex; + flex-direction: column; + gap: 1.75rem; + + .col-left { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; + + .field:last-child { grid-column: 1 / -1; } // description en pleine largeur + } + .col-right { display: flex; flex-direction: column; - gap: 1.25rem; + gap: 0.5rem; } } diff --git a/web/src/app/lore/template-edit/template-edit.component.ts b/web/src/app/lore/template-edit/template-edit.component.ts index a8efe47..d3a0408 100644 --- a/web/src/app/lore/template-edit/template-edit.component.ts +++ b/web/src/app/lore/template-edit/template-edit.component.ts @@ -1,10 +1,9 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; -import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, Subject } from 'rxjs'; import { switchMap, takeUntil } from 'rxjs/operators'; -import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { LoreService } from '../../services/lore.service'; import { TemplateService } from '../../services/template.service'; @@ -12,63 +11,38 @@ import { PageService } from '../../services/page.service'; import { LayoutService } from '../../services/layout.service'; import { PageTitleService } from '../../services/page-title.service'; import { LoreNode } from '../../services/lore.model'; -import { FieldType, ImageLayout, Template, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model'; +import { FieldType, Template, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; +import { BlockGridBuilderComponent } from '../block-grid-builder/block-grid-builder.component'; import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service'; /** * Écran d'édition d'un Template existant. * Mêmes champs que la création + bouton Supprimer. + * L'agencement des blocs est délégué à {@link BlockGridBuilderComponent}. */ @Component({ selector: 'app-template-edit', - imports: [FormsModule, ReactiveFormsModule, LucideAngularModule, TranslatePipe], + imports: [ReactiveFormsModule, TranslatePipe, BlockGridBuilderComponent], templateUrl: './template-edit.component.html', styleUrls: ['./template-edit.component.scss'] }) export class TemplateEditComponent implements OnInit, OnDestroy { - readonly Plus = Plus; - readonly Trash2 = Trash2; - readonly Type = Type; - readonly ImageIcon = ImageIcon; - readonly ChevronUp = ChevronUp; - readonly ChevronDown = ChevronDown; - readonly ListOrdered = ListOrdered; - readonly TableIcon = TableIcon; - readonly X = X; - - /** Icone du chip selon le type du champ. */ - iconFor(type: FieldType) { - switch (type) { - case 'IMAGE': return this.ImageIcon; - case 'KEY_VALUE_LIST': return this.ListOrdered; - case 'TABLE': return this.TableIcon; - default: return this.Type; - } - } - form: FormGroup; loreId = ''; templateId = ''; template: Template | null = null; nodes: LoreNode[] = []; fields: TemplateField[] = []; - newFieldName = ''; - newFieldType: FieldType = 'TEXT'; /** - * Noms des champs chargés depuis le backend — utilisés pour discriminer - * visuellement les champs existants (orange) des champs ajoutés dans cette - * session d'édition (vert). Non muté ensuite. + * Noms des champs chargés depuis le backend — passés au builder pour + * discriminer visuellement les champs existants des champs ajoutés dans + * cette session d'édition. Non muté ensuite. */ - private originalFieldNames = new Set(); + originalFieldNames = new Set(); private destroy$ = new Subject(); - /** True si le champ est présent depuis le chargement du template. */ - isExistingField(field: TemplateField): boolean { - return this.originalFieldNames.has(field.name); - } - constructor( private fb: FormBuilder, private route: ActivatedRoute, @@ -114,10 +88,11 @@ export class TemplateEditComponent implements OnInit, OnDestroy { this.template = template; // Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant, // utile pour les templates legacy cote frontend meme si le backend le fait aussi). + // On PRESERVE id et pos : le builder en a besoin pour recharger l'agencement. this.fields = (template.fields ?? []).map(f => { const type: FieldType = f.type === 'IMAGE' || f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE' ? f.type : 'TEXT'; - return buildLoreTemplateField(f.name, type, f); + return { ...buildLoreTemplateField(f.name, type, f), id: f.id, pos: f.pos }; }); this.originalFieldNames = new Set(this.fields.map(f => f.name)); this.form.patchValue({ @@ -128,59 +103,6 @@ export class TemplateEditComponent implements OnInit, OnDestroy { this.pageTitleService.set(template.name); } - addField(): void { - const name = this.newFieldName.trim(); - if (!name) return; - if (this.fields.some(f => f.name === name)) return; - this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)]; - this.newFieldName = ''; - } - - 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; - } - - /** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */ - setFieldType(index: number, type: FieldType): void { - this.fields = this.fields.map((f, i) => - i === index ? buildLoreTemplateField(f.name, type, f) : f - ); - } - - /** 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 - ); - } - - // --- Sous-editeur des libelles (KEY_VALUE_LIST) ------------------------- - // Mutation en place des labels : recreer le tableau de fields a chaque - // frappe ferait perdre le focus de l'input en cours d'edition. - - addLabel(field: TemplateField): void { - field.labels = [...(field.labels ?? []), '']; - } - - updateLabel(field: TemplateField, labelIndex: number, value: string): void { - if (!field.labels) return; - field.labels[labelIndex] = value; - } - - removeLabel(field: TemplateField, labelIndex: number): void { - if (!field.labels) return; - field.labels = field.labels.filter((_, i) => i !== labelIndex); - } - save(): void { if (this.form.invalid || !this.template) return; const raw = this.form.value; diff --git a/web/src/app/services/page.model.ts b/web/src/app/services/page.model.ts index 6a08116..1b4856b 100644 --- a/web/src/app/services/page.model.ts +++ b/web/src/app/services/page.model.ts @@ -1,5 +1,17 @@ // Interfaces TypeScript pour PageDTO (Backend Java). +/** + * Cadrage (pan/zoom) d'une image dans son bloc IMAGE. + * Miroir de com.loremind.domain.lorecontext.ImageFraming. + * - x, y : object-position en pourcentage (0..100), 50/50 = centré. + * - scale : facteur de zoom (>= 1), 1 = plein cadre (cover). + */ +export interface ImageFraming { + x: number; + y: number; + scale: number; +} + export interface Page { id?: string; loreId: string; @@ -14,6 +26,11 @@ export interface Page { * uploadees (Shared Kernel images). Structure separee de `values`. */ imageValues?: Record; + /** + * Cadrage (pan/zoom) des images : fieldKey → imageId → {x, y, scale}. + * Purement présentationnel ; absence = cadrage par défaut (centré, plein cadre). + */ + imageFraming?: Record>; /** * Pour chaque champ KEY_VALUE_LIST (tableau libelle → valeur, comme sur les * fiches de personnage) : fieldName → (label → valeur). diff --git a/web/src/app/services/template.model.ts b/web/src/app/services/template.model.ts index 353615f..10b0b13 100644 --- a/web/src/app/services/template.model.ts +++ b/web/src/app/services/template.model.ts @@ -21,11 +21,34 @@ export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST' | 'TABLE' */ export type ImageLayout = 'GALLERY' | 'HERO' | 'MASONRY' | 'CAROUSEL' | 'EDITORIAL' | 'MAPS'; +/** + * Placement d'un bloc dans la grille 12 colonnes du template. + * Miroir de com.loremind.domain.shared.template.BlockPosition. + * Tout nullable : absence (ou coordonnees nulles) = auto-flow empile, + * exactement le rendu historique en une seule colonne. + */ +export interface BlockPosition { + /** Colonne de depart (0..11). */ + x?: number | null; + /** Ligne de depart (0..n). */ + y?: number | null; + /** Largeur en colonnes (1..12). */ + w?: number | null; + /** Hauteur en lignes (1..n). */ + h?: number | null; +} + /** * Champ d'un Template : nom + type discriminant. * Miroir de TemplateFieldDTO (backend). */ export interface TemplateField { + /** + * Identifiant STABLE du bloc : cle d'ancrage des valeurs de Page, survit aux + * renommages. Retro-rempli avec le nom cote backend pour les templates + * anterieurs (donc id == name au depart). + */ + id?: string; name: string; type: FieldType; /** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */ @@ -37,6 +60,8 @@ export interface TemplateField { labels?: string[] | null; /** Chemin Foundry du champ (system.) quand le template est calqué Foundry. */ foundryPath?: string | null; + /** Placement dans la grille 12 colonnes. Null = auto-flow empile (rendu historique). */ + pos?: BlockPosition | null; } /** diff --git a/web/src/app/shared/image-block/image-block.component.html b/web/src/app/shared/image-block/image-block.component.html new file mode 100644 index 0000000..d9b32c7 --- /dev/null +++ b/web/src/app/shared/image-block/image-block.component.html @@ -0,0 +1,66 @@ +@if (currentId) { +
+
+ +
+ + @if (hasMany) { + + +
+ @for (id of imageIds; track id; let i = $index) { + + } +
+ } + + @if (editable) { +
+
+ + + +
+ {{ 'imageBlock.dragHint' | translate }} +
+ + +
+
+ } +
+} @else if (editable) { +
+ + +
+} @else { +
+ +
+} diff --git a/web/src/app/shared/image-block/image-block.component.scss b/web/src/app/shared/image-block/image-block.component.scss new file mode 100644 index 0000000..8ab66aa --- /dev/null +++ b/web/src/app/shared/image-block/image-block.component.scss @@ -0,0 +1,168 @@ +.image-block { + position: relative; + + // Mode "remplir le bloc" (page-view sur grille à hauteur fixe). + &.fill { + height: 100%; + display: flex; + flex-direction: column; + + .frame { flex: 1 1 auto; aspect-ratio: auto; min-height: 0; } + } +} + +.frame { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 8px; + background: #000; // bordures noires quand l'image entière ne remplit pas le bloc + touch-action: none; // évite le scroll pendant le drag tactile + + &.grabbable { cursor: grab; } + &.grabbable:active { cursor: grabbing; } +} + +.img { + width: 100%; + height: 100%; + // contain : l'image entière est visible (letterbox/pillarbox) ; le zoom (scale) + // permet ensuite de l'agrandir jusqu'à remplir le bloc, voire rogner. + object-fit: contain; + display: block; + user-select: none; + -webkit-user-drag: none; + will-change: transform; +} + +// Flèches de navigation (plusieurs images) +.nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + background: rgba(0, 0, 0, 0.45); + border: none; + border-radius: 50%; + color: white; + cursor: pointer; + transition: background 0.15s; + + &:hover { background: rgba(0, 0, 0, 0.7); } + &.prev { left: 8px; } + &.next { right: 8px; } +} + +.dots { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 6px; + padding: 4px 8px; + background: rgba(0, 0, 0, 0.35); + border-radius: 999px; + + .dot { + width: 7px; + height: 7px; + padding: 0; + border-radius: 50%; + border: none; + background: rgba(255, 255, 255, 0.45); + cursor: pointer; + transition: background 0.15s; + + &.active { background: white; } + &:hover { background: rgba(255, 255, 255, 0.8); } + } +} + +// Barre d'édition (zoom + déplacer + ajouter/supprimer) +.edit-bar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 0.5rem; + + .zoom { + display: flex; + align-items: center; + gap: 0.35rem; + + button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + background: #1a1a2e; + border: 1px solid #2a2a3d; + border-radius: 5px; + color: #d1d5db; + cursor: pointer; + + &:hover { border-color: #6c63ff; color: white; } + } + + input[type='range'] { + width: 120px; + accent-color: #6c63ff; + cursor: pointer; + } + } + + .move-hint { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.74rem; + color: #6b7280; + } + + .edit-actions { + display: flex; + align-items: center; + gap: 0.4rem; + margin-left: auto; + } + + .btn-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + background: #3f1f1f; + border: none; + border-radius: 6px; + color: #fca5a5; + cursor: pointer; + + &:hover { background: #5a2a2a; } + } +} + +// État vide +.image-empty { + width: 100%; + aspect-ratio: 16 / 9; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + border: 1px dashed #2a2a3d; + border-radius: 8px; + background: rgba(255, 255, 255, 0.015); + color: #4b5563; + + &.fill { height: 100%; aspect-ratio: auto; } +} diff --git a/web/src/app/shared/image-block/image-block.component.ts b/web/src/app/shared/image-block/image-block.component.ts new file mode 100644 index 0000000..3d58bd6 --- /dev/null +++ b/web/src/app/shared/image-block/image-block.component.ts @@ -0,0 +1,209 @@ +import { + ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, Output, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + LucideAngularModule, ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut, Move, Image as ImageIcon, +} from 'lucide-angular'; +import { TranslatePipe } from '@ngx-translate/core'; +import { ImageService } from '../../services/image.service'; +import { Image } from '../../services/image.model'; +import { ImageFraming } from '../../services/page.model'; +import { ImageUploaderComponent } from '../image-uploader/image-uploader.component'; + +const DEFAULT_FRAMING: ImageFraming = { x: 50, y: 50, scale: 1 }; +// scale 1 = image ENTIÈRE visible (object-fit contain, bordures noires si besoin). +// < 1 : encore plus petite (plus de marge) ; > 1 : on zoome -> remplit puis rogne. +const MIN_SCALE = 0.4; +const MAX_SCALE = 4; + +function clamp(v: number, min: number, max: number): number { + return Math.min(max, Math.max(min, v)); +} + +/** + * Bloc image d'une page de lore : une ou plusieurs images, chacune remplissant + * tout le bloc (object-fit cover). En mode édition, on peut DÉPLACER l'image + * (glisser pour recadrer, via object-position) et la ZOOMER (molette / curseur), + * le cadrage étant persisté PAR IMAGE (pan/zoom) côté page. + * + * Remplace `app-image-gallery` pour les blocs IMAGE des pages (les variantes + * grille/mosaïque/héros n'ont plus de sens : l'image occupe le bloc entier). + * + * E/S : + * [imageIds] (string[]) + (imageIdsChange) + * [framing] (imageId → {x,y,scale}) + (framingChange) + * [editable] + */ +@Component({ + selector: 'app-image-block', + imports: [FormsModule, LucideAngularModule, ImageUploaderComponent, TranslatePipe], + templateUrl: './image-block.component.html', + styleUrls: ['./image-block.component.scss'], +}) +export class ImageBlockComponent implements OnDestroy { + readonly ChevronLeft = ChevronLeft; + readonly ChevronRight = ChevronRight; + readonly X = X; + readonly ZoomIn = ZoomIn; + readonly ZoomOut = ZoomOut; + readonly Move = Move; + readonly ImageIcon = ImageIcon; + + @Input() imageIds: string[] = []; + @Input() framing: Record = {}; + @Input() editable = false; + /** Remplit la hauteur du conteneur (bloc à hauteur fixe) au lieu d'un ratio 16/9. */ + @Input() fill = false; + + @Output() imageIdsChange = new EventEmitter(); + @Output() framingChange = new EventEmitter>(); + + /** Index de l'image affichée. */ + current = 0; + + private panState: + | { id: string; startX: number; startY: number; fw: number; fh: number; ox: number; oy: number; scale: number } + | null = null; + + constructor(private imageService: ImageService, private cdr: ChangeDetectorRef) {} + + // --- Accès aux images --------------------------------------------------- + + get currentId(): string | null { + const i = clamp(this.current, 0, Math.max(0, this.imageIds.length - 1)); + return this.imageIds[i] ?? null; + } + + get hasMany(): boolean { + return this.imageIds.length > 1; + } + + urlFor(id: string): string { + return this.imageService.contentUrl(id); + } + + framingOf(id: string | null): ImageFraming { + return (id && this.framing?.[id]) || DEFAULT_FRAMING; + } + + /** object-position CSS du cadrage courant (sert aussi de transform-origin). */ + objectPosition(id: string | null): string { + const f = this.framingOf(id); + return `${f.x}% ${f.y}%`; + } + + /** transform CSS (zoom) du cadrage courant. */ + zoomTransform(id: string | null): string { + return `scale(${this.framingOf(id).scale})`; + } + + scaleOf(id: string | null): number { + return this.framingOf(id).scale; + } + + // --- Navigation --------------------------------------------------------- + + prev(): void { + if (!this.imageIds.length) return; + this.current = (this.current - 1 + this.imageIds.length) % this.imageIds.length; + } + + next(): void { + if (!this.imageIds.length) return; + this.current = (this.current + 1) % this.imageIds.length; + } + + goTo(i: number): void { + this.current = clamp(i, 0, this.imageIds.length - 1); + } + + // --- Ajout / suppression (édition) -------------------------------------- + + onUploaded(image: Image): void { + const ids = [...this.imageIds, image.id]; + this.imageIdsChange.emit(ids); + this.current = ids.length - 1; // affiche la nouvelle image + } + + removeCurrent(): void { + const id = this.currentId; + if (!id) return; + // Best-effort côté serveur (pas d'orpheline) ; on n'attend pas la réponse. + this.imageService.delete(id).subscribe({ error: () => {} }); + const ids = this.imageIds.filter(i => i !== id); + if (this.framing?.[id]) { + const next = { ...this.framing }; + delete next[id]; + this.framing = next; + this.framingChange.emit(next); + } + this.imageIdsChange.emit(ids); + this.current = clamp(this.current, 0, Math.max(0, ids.length - 1)); + } + + // --- Recadrage : déplacement (drag) ------------------------------------- + + startPan(event: PointerEvent): void { + if (!this.editable) return; + const id = this.currentId; + if (!id) return; + event.preventDefault(); + const frame = event.currentTarget as HTMLElement; + const rect = frame.getBoundingClientRect(); + const f = this.framingOf(id); + this.panState = { + id, startX: event.clientX, startY: event.clientY, + fw: rect.width, fh: rect.height, ox: f.x, oy: f.y, scale: f.scale, + }; + frame.setPointerCapture?.(event.pointerId); + window.addEventListener('pointermove', this.onPanMove); + window.addEventListener('pointerup', this.onPanUp, { once: true }); + } + + private onPanMove = (event: PointerEvent): void => { + const s = this.panState; + if (!s) return; + // Glisser l'image la fait suivre le pointeur (object-position diminue quand + // on tire vers la droite -> on révèle la partie gauche). Plus on est zoomé, + // plus le déplacement est fin (/ scale). + const dxPct = ((event.clientX - s.startX) / s.fw) * 100 / s.scale; + const dyPct = ((event.clientY - s.startY) / s.fh) * 100 / s.scale; + const x = clamp(s.ox - dxPct, 0, 100); + const y = clamp(s.oy - dyPct, 0, 100); + this.framing[s.id] = { x, y, scale: s.scale }; // mise à jour live (sans émettre) + this.cdr.detectChanges(); + }; + + private onPanUp = (): void => { + window.removeEventListener('pointermove', this.onPanMove); + if (!this.panState) return; + this.panState = null; + this.framing = { ...this.framing }; + this.framingChange.emit(this.framing); + }; + + // --- Recadrage : zoom --------------------------------------------------- + + setZoom(scale: number): void { + const id = this.currentId; + if (!id) return; + const f = this.framingOf(id); + this.framing = { ...this.framing, [id]: { ...f, scale: clamp(scale, MIN_SCALE, MAX_SCALE) } }; + this.framingChange.emit(this.framing); + } + + onWheel(event: WheelEvent): void { + if (!this.editable) return; + const id = this.currentId; + if (!id) return; + event.preventDefault(); + const delta = event.deltaY < 0 ? 0.12 : -0.12; + this.setZoom(this.framingOf(id).scale + delta); + } + + ngOnDestroy(): void { + window.removeEventListener('pointermove', this.onPanMove); + window.removeEventListener('pointerup', this.onPanUp); + } +} diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index 2dd369f..2b1a4ac 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -905,7 +905,23 @@ "addFieldTitle": "Add the field", "fieldsHelp": "Text = free + AI-generatable. Image = gallery. Key/value list = label/value pairs (stats). Table = fixed columns + rows added freely (shop, inventory…).", "deleteTitle": "Delete the template", - "deleteMessage": "Delete the template \"{{name}}\"?" + "deleteMessage": "Delete the template \"{{name}}\"?", + "move": "Move (drag)", + "narrower": "Narrower", + "wider": "Wider", + "widthLabel": "Width in columns", + "gridHelp": "Drag a block type from the palette onto the grid. Move a block with its handle (top-left), resize its width AND height with the edge and corner handles. Click a name to rename. The layout is preserved in the preview.", + "blockNameLabel": "Block name", + "paletteTitle": "Blocks", + "addBlockHint": "Drag onto the grid (or click) to add", + "canvasEmpty": "Drag a block from the palette to start", + "resize": "Resize (drag)", + "defaultNameText": "Text", + "defaultNameImage": "Illustration", + "defaultNameKeyValue": "Characteristics", + "defaultNameTable": "Table", + "resizeWidth": "Width (drag)", + "resizeHeight": "Height (drag)" }, "sceneCreate": { "title": "Create a new scene", @@ -985,7 +1001,13 @@ "deleteMessage": "Delete the scene \"{{name}}\"?", "deleteIrreversible": "This action is irreversible.", "saveError": "Error while saving", - "deleteError": "Error while deleting" + "deleteError": "Error while deleting", + "battlemapSourceLabel": "Map source", + "battlemapSourceDA": "Dungeon Alchemist (image/video + JSON)", + "battlemapSourceDD": "DungeonDraft (.dd2vtt)", + "battlemapDd2vttSlot": ".dd2vtt file", + "battlemapDd2vttHint": "The .dd2vtt contains the map AND the walls/lights; the embedded image is extracted automatically for the Foundry export.", + "battlemapDd2vttNoImage": "No image found in this .dd2vtt — the exported map will have no background." }, "sceneView": { "subtitle": "Scene", @@ -1531,5 +1553,15 @@ "importInterrupted": "The import was interrupted before completion (connection dropped or timed out). Please try again.", "unknownServerError": "Unknown server error.", "networkError": "Network error" + }, + "imageBlock": { + "prev": "Previous image", + "next": "Next image", + "goTo": "Go to image {{n}}", + "zoom": "Zoom", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "dragHint": "Drag to reframe", + "remove": "Remove this image" } } diff --git a/web/src/assets/i18n/fr.json b/web/src/assets/i18n/fr.json index 658efcf..3c9ecaa 100644 --- a/web/src/assets/i18n/fr.json +++ b/web/src/assets/i18n/fr.json @@ -905,7 +905,23 @@ "addFieldTitle": "Ajouter le champ", "fieldsHelp": "Texte = libre + générable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).", "deleteTitle": "Supprimer le template", - "deleteMessage": "Supprimer le template « {{name}} » ?" + "deleteMessage": "Supprimer le template « {{name}} » ?", + "move": "Déplacer (glisser)", + "narrower": "Rétrécir", + "wider": "Élargir", + "widthLabel": "Largeur en colonnes", + "gridHelp": "Glissez un type de bloc depuis la palette sur la grille. Déplacez un bloc avec sa poignée (en haut à gauche), redimensionnez-le en largeur ET hauteur avec les poignées de bord et de coin. Cliquez sur un nom pour le renommer. La disposition est conservée dans l'aperçu.", + "blockNameLabel": "Nom du bloc", + "paletteTitle": "Blocs", + "addBlockHint": "Glissez sur la grille (ou cliquez) pour ajouter", + "canvasEmpty": "Glissez un bloc depuis la palette pour commencer", + "resize": "Redimensionner (glisser)", + "defaultNameText": "Texte", + "defaultNameImage": "Illustration", + "defaultNameKeyValue": "Caractéristiques", + "defaultNameTable": "Tableau", + "resizeWidth": "Largeur (glisser)", + "resizeHeight": "Hauteur (glisser)" }, "sceneCreate": { "title": "Créer une nouvelle scène", @@ -985,7 +1001,13 @@ "deleteMessage": "Supprimer la scène \"{{name}}\" ?", "deleteIrreversible": "Cette action est irréversible.", "saveError": "Erreur lors de la sauvegarde", - "deleteError": "Erreur lors de la suppression" + "deleteError": "Erreur lors de la suppression", + "battlemapSourceLabel": "Source de la carte", + "battlemapSourceDA": "Dungeon Alchemist (image/vidéo + JSON)", + "battlemapSourceDD": "DungeonDraft (.dd2vtt)", + "battlemapDd2vttSlot": "Fichier .dd2vtt", + "battlemapDd2vttHint": "Le .dd2vtt contient la carte ET les murs/lumières ; l'image embarquée est extraite automatiquement pour l'export Foundry.", + "battlemapDd2vttNoImage": "Aucune image trouvée dans ce .dd2vtt — la carte exportée n'aura pas de fond." }, "sceneView": { "subtitle": "Scène", @@ -1531,5 +1553,15 @@ "importInterrupted": "L'import s'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.", "unknownServerError": "Erreur inconnue côté serveur.", "networkError": "Erreur réseau" + }, + "imageBlock": { + "prev": "Image précédente", + "next": "Image suivante", + "goTo": "Aller à l'image {{n}}", + "zoom": "Zoom", + "zoomIn": "Zoomer", + "zoomOut": "Dézoomer", + "dragHint": "Glisser pour recadrer", + "remove": "Retirer cette image" } } diff --git a/web/src/assets/i18n/fragments/lore-pages.en.json b/web/src/assets/i18n/fragments/lore-pages.en.json index b163fed..fa24aa7 100644 --- a/web/src/assets/i18n/fragments/lore-pages.en.json +++ b/web/src/assets/i18n/fragments/lore-pages.en.json @@ -136,6 +136,19 @@ "addFieldPlaceholder": "+ Add a field", "addFieldTitle": "Add the field", "fieldsHelp": "Text = free + AI-generatable. Image = gallery. Key/value list = label/value pairs (stats). Table = fixed columns + rows added freely (shop, inventory…).", + "blockNameLabel": "Block name", + "paletteTitle": "Blocks", + "addBlockHint": "Drag onto the grid (or click) to add", + "canvasEmpty": "Drag a block from the palette to start", + "move": "Move (drag)", + "resize": "Resize (drag)", + "resizeWidth": "Width (drag)", + "resizeHeight": "Height (drag)", + "defaultNameText": "Text", + "defaultNameImage": "Illustration", + "defaultNameKeyValue": "Characteristics", + "defaultNameTable": "Table", + "gridHelp": "Drag a block type from the palette onto the grid. Move a block with its handle (top-left), resize its width AND height with the edge and corner handles. Click a name to rename. The layout is preserved in the preview.", "deleteTitle": "Delete the template", "deleteMessage": "Delete the template \"{{name}}\"?" } diff --git a/web/src/assets/i18n/fragments/lore-pages.fr.json b/web/src/assets/i18n/fragments/lore-pages.fr.json index bc319ee..0d8bdf6 100644 --- a/web/src/assets/i18n/fragments/lore-pages.fr.json +++ b/web/src/assets/i18n/fragments/lore-pages.fr.json @@ -136,6 +136,19 @@ "addFieldPlaceholder": "+ Ajouter un champ", "addFieldTitle": "Ajouter le champ", "fieldsHelp": "Texte = libre + générable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).", + "blockNameLabel": "Nom du bloc", + "paletteTitle": "Blocs", + "addBlockHint": "Glissez sur la grille (ou cliquez) pour ajouter", + "canvasEmpty": "Glissez un bloc depuis la palette pour commencer", + "move": "Déplacer (glisser)", + "resize": "Redimensionner (glisser)", + "resizeWidth": "Largeur (glisser)", + "resizeHeight": "Hauteur (glisser)", + "defaultNameText": "Texte", + "defaultNameImage": "Illustration", + "defaultNameKeyValue": "Caractéristiques", + "defaultNameTable": "Tableau", + "gridHelp": "Glissez un type de bloc depuis la palette sur la grille. Déplacez un bloc avec sa poignée (en haut à gauche), redimensionnez-le en largeur ET hauteur avec les poignées de bord et de coin. Cliquez sur un nom pour le renommer. La disposition est conservée dans l'aperçu.", "deleteTitle": "Supprimer le template", "deleteMessage": "Supprimer le template « {{name}} » ?" } diff --git a/web/src/assets/i18n/fragments/scenes.en.json b/web/src/assets/i18n/fragments/scenes.en.json index b4bb679..9b197fa 100644 --- a/web/src/assets/i18n/fragments/scenes.en.json +++ b/web/src/assets/i18n/fragments/scenes.en.json @@ -25,6 +25,12 @@ "battlemapUploading": "Uploading...", "battlemapAttached": "File attached", "battlemapRemove": "Remove", + "battlemapSourceLabel": "Map source", + "battlemapSourceDA": "Dungeon Alchemist (image/video + JSON)", + "battlemapSourceDD": "DungeonDraft (.dd2vtt)", + "battlemapDd2vttSlot": ".dd2vtt file", + "battlemapDd2vttHint": "The .dd2vtt contains the map AND the walls/lights; the embedded image is extracted automatically for the Foundry export.", + "battlemapDd2vttNoImage": "No image found in this .dd2vtt — the exported map will have no background.", "nameLabel": "Scene title *", "namePlaceholder": "E.g.: Arrival at the village", "descriptionLabel": "Short description *", diff --git a/web/src/assets/i18n/fragments/scenes.fr.json b/web/src/assets/i18n/fragments/scenes.fr.json index 4440fd4..d9e9496 100644 --- a/web/src/assets/i18n/fragments/scenes.fr.json +++ b/web/src/assets/i18n/fragments/scenes.fr.json @@ -25,6 +25,12 @@ "battlemapUploading": "Envoi...", "battlemapAttached": "Fichier attache", "battlemapRemove": "Retirer", + "battlemapSourceLabel": "Source de la carte", + "battlemapSourceDA": "Dungeon Alchemist (image/vidéo + JSON)", + "battlemapSourceDD": "DungeonDraft (.dd2vtt)", + "battlemapDd2vttSlot": "Fichier .dd2vtt", + "battlemapDd2vttHint": "Le .dd2vtt contient la carte ET les murs/lumières ; l'image embarquée est extraite automatiquement pour l'export Foundry.", + "battlemapDd2vttNoImage": "Aucune image trouvée dans ce .dd2vtt — la carte exportée n'aura pas de fond.", "nameLabel": "Titre de la scène *", "namePlaceholder": "Ex: Arrivée au village", "descriptionLabel": "Description courte *", diff --git a/web/src/assets/i18n/fragments/shared-dialogs.en.json b/web/src/assets/i18n/fragments/shared-dialogs.en.json index 594d18e..f4519f9 100644 --- a/web/src/assets/i18n/fragments/shared-dialogs.en.json +++ b/web/src/assets/i18n/fragments/shared-dialogs.en.json @@ -40,6 +40,16 @@ "lightboxAria": "Full-screen image", "lightboxAlt": "Enlarged image" }, + "imageBlock": { + "prev": "Previous image", + "next": "Next image", + "goTo": "Go to image {{n}}", + "zoom": "Zoom", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "dragHint": "Drag to reframe", + "remove": "Remove this image" + }, "imageUploader": { "addImage": "Add an image", "uploading": "Uploading", diff --git a/web/src/assets/i18n/fragments/shared-dialogs.fr.json b/web/src/assets/i18n/fragments/shared-dialogs.fr.json index 5041d54..08b93b3 100644 --- a/web/src/assets/i18n/fragments/shared-dialogs.fr.json +++ b/web/src/assets/i18n/fragments/shared-dialogs.fr.json @@ -40,6 +40,16 @@ "lightboxAria": "Image en plein écran", "lightboxAlt": "Image agrandie" }, + "imageBlock": { + "prev": "Image précédente", + "next": "Image suivante", + "goTo": "Aller à l'image {{n}}", + "zoom": "Zoom", + "zoomIn": "Zoomer", + "zoomOut": "Dézoomer", + "dragHint": "Glisser pour recadrer", + "remove": "Retirer cette image" + }, "imageUploader": { "addImage": "Ajouter une image", "uploading": "Upload en cours",