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 :
+ *
+ * {@link #x}, {@link #y} : object-position en pourcentage (0..100).
+ * 50/50 = centré (défaut).
+ * {@link #scale} : facteur de zoom (>= 1). 1 = ajusté plein cadre (cover).
+ *
+ *
+ * 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 :
+ *
+ * {@link #x} : colonne de depart, 0..11
+ * {@link #y} : ligne de depart, 0..n
+ * {@link #w} : largeur en colonnes, 1..12
+ * {@link #h} : hauteur en lignes, 1..n
+ *
+ *
+ * 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.battlemapLabel' | translate }}
{{ 'sceneEdit.battlemapHint' | translate }}
-
-
{{ 'sceneEdit.battlemapMedia' | translate }}
- @if (battlemapMediaFileId) {
-
{{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }}
-
- {{ 'sceneEdit.battlemapRemove' | translate }}
-
- } @else {
-
- {{ (battlemapUploadingMedia ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
-
-
- }
+
+
+ {{ 'sceneEdit.battlemapSourceDA' | translate }}
+
+
+ {{ 'sceneEdit.battlemapSourceDD' | translate }}
+
-
-
{{ 'sceneEdit.battlemapData' | translate }}
- @if (battlemapDataFileId) {
-
{{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }}
-
- {{ 'sceneEdit.battlemapRemove' | translate }}
-
- } @else {
-
- {{ (battlemapUploadingData ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
-
-
+ @if (battlemapSource === 'DUNGEON_ALCHEMIST') {
+
+ {{ 'sceneEdit.battlemapMedia' | translate }}
+ @if (battlemapMediaFileId) {
+ {{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }}
+
+ {{ 'sceneEdit.battlemapRemove' | translate }}
+
+ } @else {
+
+ {{ (battlemapUploadingMedia ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
+
+
+ }
+
+
+
+ {{ 'sceneEdit.battlemapData' | translate }}
+ @if (battlemapDataFileId) {
+ {{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }}
+
+ {{ 'sceneEdit.battlemapRemove' | translate }}
+
+ } @else {
+
+ {{ (battlemapUploadingData ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
+
+
+ }
+
+ } @else {
+
+ {{ 'sceneEdit.battlemapDd2vttSlot' | translate }}
+ @if (battlemapDataFileId) {
+ {{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }}
+
+ {{ 'sceneEdit.battlemapRemove' | translate }}
+
+ } @else {
+
+ {{ (battlemapUploadingData ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
+
+
+ }
+
+ @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) {
+
+
+
+
+
+
+ }
+
+
+ {{ (block.type === 'TABLE' ? 'templateEdit.column' : 'templateEdit.label') | translate }}
+
+
+ }
+
+
+
+
+
+
+ }
+
+
+ @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') {
-
- {{ field.name }}
-
-
- }
-
- @if (field.type === 'IMAGE') {
-
- }
-
- @if (field.type === 'KEY_VALUE_LIST') {
-
-
{{ field.name }}
-
- @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') {
+
+ {{ field.name }}
+
-
- }
-
- @if (field.type === 'TABLE') {
-
-
{{ field.name }}
- @if ((field.labels ?? []).length) {
-
-
-
-
- @for (col of field.labels; track $index) {
- {{ col }}
- }
-
-
-
-
- @for (row of tableRows(field.name); track $index; let ri = $index) {
+ }
+
+ @if (field.type === 'IMAGE') {
+
+ }
+
+ @if (field.type === 'KEY_VALUE_LIST') {
+
+
{{ field.name }}
+
+ @for (lbl of field.labels ?? []; track $index) {
+
+ {{ lbl }}
+
+
+ }
+ @if (!(field.labels ?? []).length) {
+
{{ 'pageEdit.noLabels' | translate }}
+ }
+
+
+ }
+
+ @if (field.type === 'TABLE') {
+
+
{{ field.name }}
+ @if ((field.labels ?? []).length) {
+
+
-
-
- {{ 'pageEdit.addRow' | translate }}
-
-
- } @else {
-
{{ 'pageEdit.noColumns' | translate }}
- }
-
+
+
+ @for (row of tableRows(keyOf(field)); track $index; let ri = $index) {
+
+ @for (col of field.labels; track $index) {
+
+
+
+ }
+
+
+
+
+
+
+ }
+
+
+
+
+ {{ 'pageEdit.addRow' | translate }}
+
+
+ } @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') {
-
- }
- @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) {
- {{ col }}
- }
-
-
-
- @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') {
+
+ }
+ @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) {
+
+
@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 @@
{{ 'templateCreate.fieldsLabel' | translate }}
-
-
-
-
-
- {{ 'templateCreate.typeText' | translate }}
- {{ 'templateCreate.typeImage' | translate }}
- {{ 'templateCreate.typeKeyValue' | translate }}
- {{ 'templateCreate.typeTable' | 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 @@
{{ 'templateEdit.fieldsLabel' | translate }}
-
-
-
-
- {{ 'templateEdit.typeText' | translate }}
- {{ 'templateEdit.typeImage' | translate }}
- {{ 'templateEdit.typeKeyValue' | translate }}
- {{ 'templateEdit.typeTable' | 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",