> getKeyValueValues() {
+ if (keyValueValues == null) keyValueValues = new HashMap<>();
+ return keyValueValues;
+ }
}
diff --git a/core/src/main/java/com/loremind/domain/shared/template/FieldType.java b/core/src/main/java/com/loremind/domain/shared/template/FieldType.java
index bed317e..021af2d 100644
--- a/core/src/main/java/com/loremind/domain/shared/template/FieldType.java
+++ b/core/src/main/java/com/loremind/domain/shared/template/FieldType.java
@@ -3,14 +3,18 @@ package com.loremind.domain.shared.template;
/**
* Type d'un champ dynamique de template (kernel partage).
*
- * - TEXT : valeur textuelle libre (Map)
- * - IMAGE : galerie d'images, liste d'IDs (Map>)
- * - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
+ * - TEXT : valeur textuelle libre (Map)
+ * - IMAGE : galerie d'images, liste d'IDs (Map>)
+ * - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
+ * - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
+ * (Map> : fieldName -> label -> value).
+ * Usage : stat blocks, listes de competences, traits.
*
- * Extension future possible : RICH_TEXT, DATE, BOOLEAN, KEY_VALUE_LIST, REFERENCE...
+ * Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
*/
public enum FieldType {
TEXT,
IMAGE,
- NUMBER
+ NUMBER,
+ KEY_VALUE_LIST
}
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 7cdef3b..98d3b27 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
@@ -5,6 +5,8 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.util.List;
+
/**
* Value Object d'un champ de Template (kernel partage).
*
@@ -27,29 +29,45 @@ public class TemplateField {
private FieldType type;
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout;
+ /**
+ * Labels predefinis pour les champs KEY_VALUE_LIST (ordre significatif).
+ * Ex: ["FOR","DEX","CON","INT","SAG","CHA"] pour un champ "Caracteristiques".
+ * Null/vide pour les autres types.
+ */
+ private List labels;
- /** Constructeur de retrocompat : type seul, layout=null. */
+ /** Constructeur de retrocompat : type seul, layout/labels=null. */
public TemplateField(String name, FieldType type) {
- this(name, type, null);
+ this(name, type, null, null);
+ }
+
+ /** Constructeur de retrocompat : type + layout, labels=null. */
+ public TemplateField(String name, FieldType type, ImageLayout layout) {
+ this(name, type, layout, 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);
+ return new TemplateField(name, FieldType.TEXT, null, null);
}
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
public static TemplateField image(String name) {
- return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY);
+ return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY, null);
}
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
public static TemplateField image(String name, ImageLayout layout) {
- return new TemplateField(name, FieldType.IMAGE, layout);
+ return new TemplateField(name, FieldType.IMAGE, layout, null);
}
/** Raccourci : construit un champ de type NUMBER. */
public static TemplateField number(String name) {
- return new TemplateField(name, FieldType.NUMBER, null);
+ return new TemplateField(name, FieldType.NUMBER, null, null);
+ }
+
+ /** Raccourci : construit un champ KEY_VALUE_LIST avec labels predefinis. */
+ public static TemplateField keyValueList(String name, List labels) {
+ return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
}
}
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java b/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java
index 7cf3f8a..0d9a047 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/GameSystemSeeder.java
@@ -152,12 +152,8 @@ public class GameSystemSeeder {
TemplateField.number("Niveau"),
TemplateField.number("PV max"),
TemplateField.number("CA"),
- TemplateField.number("FOR"),
- TemplateField.number("DEX"),
- TemplateField.number("CON"),
- TemplateField.number("INT"),
- TemplateField.number("SAG"),
- TemplateField.number("CHA"),
+ TemplateField.keyValueList("Caracteristiques",
+ List.of("FOR", "DEX", "CON", "INT", "SAG", "CHA")),
TemplateField.text("Competences"),
TemplateField.text("Equipement"),
TemplateField.text("Sorts"),
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/StringMapMapJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/StringMapMapJsonConverter.java
new file mode 100644
index 0000000..b6bd6e4
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/StringMapMapJsonConverter.java
@@ -0,0 +1,49 @@
+package com.loremind.infrastructure.persistence.converter;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
+
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * Convertit une Map> en JSON et inversement.
+ *
+ * Utilise pour Character/Npc.keyValueValues : pour chaque champ KEY_VALUE_LIST
+ * du template, stocke une map label -> value. Exemple :
+ * {"Caracteristiques": {"FOR":"16","DEX":"12","CON":"14"}}
+ *
+ * Adaptateur technique pur : le domaine ignore ce converter.
+ */
+@Converter
+public class StringMapMapJsonConverter
+ implements AttributeConverter>, String> {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ 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 654ec9d..7c11811 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
@@ -85,8 +85,18 @@ public class TemplateFieldListJsonConverter
}
}
}
+ List labels = null;
+ if (type == FieldType.KEY_VALUE_LIST) {
+ JsonNode labelsNode = item.path("labels");
+ if (labelsNode.isArray()) {
+ labels = new ArrayList<>();
+ for (JsonNode label : labelsNode) {
+ if (label.isTextual()) labels.add(label.asText());
+ }
+ }
+ }
if (name != null && !name.isBlank()) {
- result.add(new TemplateField(name, type, layout));
+ result.add(new TemplateField(name, type, layout, labels));
}
}
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java
index 0533965..37d47e4 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CharacterJpaEntity.java
@@ -2,6 +2,7 @@ package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
+import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -53,6 +54,11 @@ public class CharacterJpaEntity {
@Column(name = "image_values", columnDefinition = "TEXT")
private Map> imageValues;
+ /** Valeurs KEY_VALUE_LIST serialisees JSON (fieldName -> label -> value). */
+ @Convert(converter = StringMapMapJsonConverter.class)
+ @Column(name = "key_value_values", columnDefinition = "TEXT")
+ private Map> keyValueValues;
+
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@@ -71,6 +77,7 @@ public class CharacterJpaEntity {
updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
+ if (keyValueValues == null) keyValueValues = new HashMap<>();
}
@PreUpdate
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java
index 109c3dd..ebb59df 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java
@@ -2,6 +2,7 @@ package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
+import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -46,6 +47,10 @@ public class NpcJpaEntity {
@Column(name = "image_values", columnDefinition = "TEXT")
private Map> imageValues;
+ @Convert(converter = StringMapMapJsonConverter.class)
+ @Column(name = "key_value_values", columnDefinition = "TEXT")
+ private Map> keyValueValues;
+
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@@ -64,6 +69,7 @@ public class NpcJpaEntity {
updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
+ if (keyValueValues == null) keyValueValues = new HashMap<>();
}
@PreUpdate
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java
index d707b5d..2413c96 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCharacterRepository.java
@@ -57,6 +57,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
.headerImageId(e.getHeaderImageId())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
+ .keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
@@ -73,6 +74,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
.headerImageId(c.getHeaderImageId())
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
+ .keyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(c.getCampaignId()))
.order(c.getOrder())
.createdAt(c.getCreatedAt())
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java
index 419ec64..a19478c 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java
@@ -57,6 +57,7 @@ public class PostgresNpcRepository implements NpcRepository {
.headerImageId(e.getHeaderImageId())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
+ .keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
@@ -73,6 +74,7 @@ public class PostgresNpcRepository implements NpcRepository {
.headerImageId(n.getHeaderImageId())
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
+ .keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId()))
.order(n.getOrder())
.createdAt(n.getCreatedAt())
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java
index d21c46e..ccec34a 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CharacterController.java
@@ -62,6 +62,7 @@ public class CharacterController {
dto.getHeaderImageId(),
dto.getValues(),
dto.getImageValues(),
+ dto.getKeyValueValues(),
dto.getCampaignId(),
order
);
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java
index 72b7f7a..2578638 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java
@@ -62,6 +62,7 @@ public class NpcController {
dto.getHeaderImageId(),
dto.getValues(),
dto.getImageValues(),
+ dto.getKeyValueValues(),
dto.getCampaignId(),
order
);
diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java
index 206fd6a..85c7691 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CharacterDTO.java
@@ -21,6 +21,7 @@ public class CharacterDTO {
private String headerImageId;
private Map values = new HashMap<>();
private Map> imageValues = new HashMap<>();
+ private Map> keyValueValues = new HashMap<>();
private String campaignId;
private int order;
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java
index dae8f23..e8207f4 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java
@@ -18,6 +18,7 @@ public class NpcDTO {
private String headerImageId;
private Map values = new HashMap<>();
private Map> imageValues = new HashMap<>();
+ private Map> keyValueValues = new HashMap<>();
private String campaignId;
private int order;
}
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 f35ba23..2b1f5c6 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
@@ -4,6 +4,8 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.util.List;
+
/**
* DTO pour un champ de Template.
*
@@ -17,13 +19,20 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor
public class TemplateFieldDTO {
private String name;
- /** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
+ /** "TEXT" | "IMAGE" | "NUMBER" | "KEY_VALUE_LIST". */
private String type;
- /** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", null si type=TEXT. */
+ /** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", uniquement pour IMAGE. */
private String layout;
+ /** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
+ private List labels;
- /** Retrocompat : constructeur sans layout. */
+ /** Retrocompat : constructeur sans labels. */
+ public TemplateFieldDTO(String name, String type, String layout) {
+ this(name, type, layout, null);
+ }
+
+ /** Retrocompat : constructeur sans layout ni labels. */
public TemplateFieldDTO(String name, String type) {
- this(name, type, null);
+ this(name, type, null, null);
}
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java
index fcb86f9..3269c61 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/CharacterMapper.java
@@ -18,6 +18,7 @@ public class CharacterMapper {
dto.setHeaderImageId(c.getHeaderImageId());
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
+ dto.setKeyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(c.getCampaignId());
dto.setOrder(c.getOrder());
return dto;
@@ -32,6 +33,7 @@ public class CharacterMapper {
.headerImageId(dto.getHeaderImageId())
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
+ .keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId())
.order(dto.getOrder())
.build();
diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java
index 2c9e65c..19786e0 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java
@@ -18,6 +18,7 @@ public class NpcMapper {
dto.setHeaderImageId(n.getHeaderImageId());
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
+ dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(n.getCampaignId());
dto.setOrder(n.getOrder());
return dto;
@@ -32,6 +33,7 @@ public class NpcMapper {
.headerImageId(dto.getHeaderImageId())
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
+ .keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId())
.order(dto.getOrder())
.build();
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 d6da088..db98630 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
@@ -6,14 +6,16 @@ import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component;
+import java.util.ArrayList;
+import java.util.List;
+
/**
* Mapper pour convertir entre {@link TemplateField} (domaine) et
* {@link TemplateFieldDTO} (wire).
*
- * Tolerance : un type inconnu recu du client est interprete comme TEXT
- * (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
+ * Tolerance : un type inconnu recu du client est interprete comme TEXT.
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
- * Le layout est force a null pour les champs TEXT.
+ * Layout/labels forces a null pour les types qui ne les utilisent pas.
*/
@Component
public class TemplateFieldMapper {
@@ -26,7 +28,11 @@ public class TemplateFieldMapper {
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
layoutStr = layout.name();
}
- return new TemplateFieldDTO(field.getName(), typeStr, layoutStr);
+ List labels = null;
+ if (field.getType() == FieldType.KEY_VALUE_LIST && field.getLabels() != null) {
+ labels = new ArrayList<>(field.getLabels());
+ }
+ return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
}
public TemplateField toDomain(TemplateFieldDTO dto) {
@@ -47,6 +53,10 @@ public class TemplateFieldMapper {
layout = ImageLayout.GALLERY;
}
}
- return new TemplateField(dto.getName(), type, layout);
+ List labels = null;
+ if (type == FieldType.KEY_VALUE_LIST && dto.getLabels() != null) {
+ labels = new ArrayList<>(dto.getLabels());
+ }
+ return new TemplateField(dto.getName(), type, layout, labels);
}
}
diff --git a/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java
index 447efbf..d33b54e 100644
--- a/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java
+++ b/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java
@@ -51,7 +51,7 @@ public class NpcServiceTest {
Npc result = npcService.createNpc(
new NpcService.NpcData("Borin le forgeron", null, null,
- Map.of("Notes", "Borin"), null, "camp-1", 5));
+ Map.of("Notes", "Borin"), null, null, "camp-1", 5));
assertNotNull(result);
ArgumentCaptor captor = ArgumentCaptor.forClass(Npc.class);
@@ -67,7 +67,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
- npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, "camp-1", null));
+ npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null));
ArgumentCaptor captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
@@ -79,7 +79,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
- npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, "camp-1", null));
+ npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null));
ArgumentCaptor captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture());
@@ -124,7 +124,7 @@ public class NpcServiceTest {
Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin renommé", null, null,
- Map.of("Notes", "v2"), null, "camp-1", 7));
+ Map.of("Notes", "v2"), null, null, "camp-1", 7));
assertEquals("Borin renommé", result.getName());
assertEquals("v2", result.getValues().get("Notes"));
@@ -138,7 +138,7 @@ public class NpcServiceTest {
Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin", null, null,
- Map.of("Notes", "txt"), null, "camp-1", null));
+ Map.of("Notes", "txt"), null, null, "camp-1", null));
// testNpc avait order=1 → préservé
assertEquals(1, result.getOrder());
@@ -150,7 +150,7 @@ public class NpcServiceTest {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> npcService.updateNpc("missing",
- new NpcService.NpcData("x", null, null, null, null, "camp-1", null)));
+ new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null)));
assertTrue(ex.getMessage().contains("missing"));
verify(npcRepository, never()).save(any());
}
diff --git a/installers/install.ps1 b/installers/install.ps1
index f6287e3..9dee923 100644
--- a/installers/install.ps1
+++ b/installers/install.ps1
@@ -40,7 +40,7 @@
Auteur : ietm64
Licence : AGPL-3.0
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
- Version : 0.8.1
+ Version : 0.8.3
.LINK
https://github.com/IGMLcreation/LoreMind
diff --git a/web/package-lock.json b/web/package-lock.json
index 3df3d75..94c7991 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "loremind-web",
- "version": "0.8.1",
+ "version": "0.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
- "version": "0.8.1",
+ "version": "0.8.3",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",
diff --git a/web/package.json b/web/package.json
index dc5999f..5210136 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "loremind-web",
- "version": "0.8.1",
+ "version": "0.8.3",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",
diff --git a/web/src/app/campaigns/character/character-edit/character-edit.component.html b/web/src/app/campaigns/character/character-edit/character-edit.component.html
index 9cf292d..a308510 100644
--- a/web/src/app/campaigns/character/character-edit/character-edit.component.html
+++ b/web/src/app/campaigns/character/character-edit/character-edit.component.html
@@ -61,8 +61,10 @@
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
+ [keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
- (imageValuesChange)="imageValues = $event">
+ (imageValuesChange)="imageValues = $event"
+ (keyValueValuesChange)="keyValueValues = $event">
diff --git a/web/src/app/campaigns/character/character-edit/character-edit.component.ts b/web/src/app/campaigns/character/character-edit/character-edit.component.ts
index 9b4f1bd..1c91559 100644
--- a/web/src/app/campaigns/character/character-edit/character-edit.component.ts
+++ b/web/src/app/campaigns/character/character-edit/character-edit.component.ts
@@ -53,6 +53,7 @@ export class CharacterEditComponent implements OnInit {
headerImageId: string | null = null;
values: Record = {};
imageValues: Record = {};
+ keyValueValues: Record> = {};
templateFields: TemplateField[] = [];
private order = 0;
@@ -81,6 +82,7 @@ export class CharacterEditComponent implements OnInit {
this.headerImageId = c.headerImageId ?? null;
this.values = c.values ?? {};
this.imageValues = c.imageValues ?? {};
+ this.keyValueValues = c.keyValueValues ?? {};
this.order = c.order ?? 0;
},
error: () => this.back()
@@ -112,6 +114,7 @@ export class CharacterEditComponent implements OnInit {
headerImageId: this.headerImageId,
values: this.values,
imageValues: this.imageValues,
+ keyValueValues: this.keyValueValues,
campaignId: this.campaignId
};
const req = this.characterId
diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html
index d63061f..1b129b6 100644
--- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html
+++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html
@@ -61,8 +61,10 @@
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
+ [keyValueValues]="keyValueValues"
(valuesChange)="values = $event"
- (imageValuesChange)="imageValues = $event">
+ (imageValuesChange)="imageValues = $event"
+ (keyValueValuesChange)="keyValueValues = $event">
diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts
index 3cf2adf..17debcf 100644
--- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts
+++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts
@@ -48,6 +48,7 @@ export class NpcEditComponent implements OnInit {
headerImageId: string | null = null;
values: Record = {};
imageValues: Record = {};
+ keyValueValues: Record> = {};
templateFields: TemplateField[] = [];
private order = 0;
@@ -76,6 +77,7 @@ export class NpcEditComponent implements OnInit {
this.headerImageId = n.headerImageId ?? null;
this.values = n.values ?? {};
this.imageValues = n.imageValues ?? {};
+ this.keyValueValues = n.keyValueValues ?? {};
this.order = n.order ?? 0;
},
error: () => this.back()
@@ -107,6 +109,7 @@ export class NpcEditComponent implements OnInit {
headerImageId: this.headerImageId,
values: this.values,
imageValues: this.imageValues,
+ keyValueValues: this.keyValueValues,
campaignId: this.campaignId
};
const req = this.npcId
diff --git a/web/src/app/services/character.model.ts b/web/src/app/services/character.model.ts
index 4e22169..ec7c621 100644
--- a/web/src/app/services/character.model.ts
+++ b/web/src/app/services/character.model.ts
@@ -13,6 +13,8 @@ export interface Character {
headerImageId?: string | null;
values?: Record;
imageValues?: Record;
+ /** Champs KEY_VALUE_LIST : fieldName -> label -> value. */
+ keyValueValues?: Record>;
campaignId: string;
order?: number;
}
@@ -23,5 +25,6 @@ export interface CharacterCreate {
headerImageId?: string | null;
values?: Record;
imageValues?: Record;
+ keyValueValues?: Record>;
campaignId: string;
}
diff --git a/web/src/app/services/npc.model.ts b/web/src/app/services/npc.model.ts
index ff6a469..56dd9e1 100644
--- a/web/src/app/services/npc.model.ts
+++ b/web/src/app/services/npc.model.ts
@@ -9,6 +9,7 @@ export interface Npc {
headerImageId?: string | null;
values?: Record;
imageValues?: Record;
+ keyValueValues?: Record>;
campaignId: string;
order?: number;
}
@@ -19,5 +20,6 @@ export interface NpcCreate {
headerImageId?: string | null;
values?: Record;
imageValues?: Record;
+ keyValueValues?: Record>;
campaignId: string;
}
diff --git a/web/src/app/services/template.model.ts b/web/src/app/services/template.model.ts
index 0f241d9..04740dd 100644
--- a/web/src/app/services/template.model.ts
+++ b/web/src/app/services/template.model.ts
@@ -2,11 +2,12 @@
/**
* Type d'un champ de Template. Miroir de com.loremind.domain.shared.template.FieldType.
- * - 'TEXT' : champ textuel libre (rendu en textarea)
- * - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
- * - 'NUMBER' : valeur numerique (rendu en input number)
+ * - 'TEXT' : champ textuel libre (rendu en textarea)
+ * - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
+ * - 'NUMBER' : valeur numerique (rendu en input number)
+ * - 'KEY_VALUE_LIST' : liste de paires {label, value} avec labels figes au template
*/
-export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER';
+export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST';
/**
* Variante de rendu pour un champ IMAGE. Miroir de
@@ -27,6 +28,8 @@ export interface TemplateField {
type: FieldType;
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
layout?: ImageLayout | null;
+ /** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
+ labels?: string[] | null;
}
export interface Template {
diff --git a/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.html b/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.html
index 439eb2c..dc30f96 100644
--- a/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.html
+++ b/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.html
@@ -28,6 +28,23 @@
[imageIds]="imagesFor(f)"
(imageIdsChange)="onImageIdsChange(f, $event)">
+
+
+
+
+ {{ lbl }}
+
+
+
+ Aucun label defini dans le template pour ce champ.
+
+
diff --git a/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.scss b/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.scss
index cf0266f..8b87b8d 100644
--- a/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.scss
+++ b/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.scss
@@ -40,6 +40,50 @@
}
}
+.dff-kv-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
+ gap: 8px;
+}
+
+.dff-kv-cell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ padding: 8px 6px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 4px;
+
+ .dff-kv-label {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--color-text-muted, #9ca3af);
+ }
+
+ input {
+ width: 100%;
+ text-align: center;
+ padding: 4px 6px;
+ background: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 3px;
+ color: var(--color-text, #fff);
+ font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
+ font-weight: 700;
+ font-size: 1.05rem;
+ }
+}
+
+.dff-kv-empty {
+ padding: 8px;
+ font-size: 0.8rem;
+ color: var(--color-text-muted, #888);
+ font-style: italic;
+}
+
.dff-empty {
padding: 24px;
text-align: center;
diff --git a/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.ts b/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.ts
index 07c6fea..c5fe81c 100644
--- a/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.ts
+++ b/web/src/app/shared/dynamic-fields-form/dynamic-fields-form.component.ts
@@ -26,9 +26,11 @@ export class DynamicFieldsFormComponent {
@Input() fields: TemplateField[] = [];
@Input() values: Record = {};
@Input() imageValues: Record = {};
+ @Input() keyValueValues: Record> = {};
@Output() valuesChange = new EventEmitter>();
@Output() imageValuesChange = new EventEmitter>();
+ @Output() keyValueValuesChange = new EventEmitter>>();
onTextChange(field: TemplateField, value: string): void {
this.values = { ...this.values, [field.name]: value };
@@ -44,5 +46,18 @@ export class DynamicFieldsFormComponent {
return this.imageValues[field.name] ?? [];
}
+ /** Valeur d'un label particulier dans un champ KEY_VALUE_LIST. */
+ kvValue(field: TemplateField, label: string): string {
+ return this.keyValueValues?.[field.name]?.[label] ?? '';
+ }
+
+ /** Met a jour la valeur d'un label dans un champ KEY_VALUE_LIST. */
+ onKvChange(field: TemplateField, label: string, value: string): void {
+ const inner = { ...(this.keyValueValues[field.name] ?? {}), [label]: value };
+ this.keyValueValues = { ...this.keyValueValues, [field.name]: inner };
+ this.keyValueValuesChange.emit(this.keyValueValues);
+ }
+
trackByName = (_: number, f: TemplateField) => f.name;
+ trackByLabel = (_: number, l: string) => l;
}
diff --git a/web/src/app/shared/persona-view/persona-view.component.html b/web/src/app/shared/persona-view/persona-view.component.html
index 46c8970..5ddb9d1 100644
--- a/web/src/app/shared/persona-view/persona-view.component.html
+++ b/web/src/app/shared/persona-view/persona-view.component.html
@@ -15,44 +15,92 @@
{{ persona.name }}
{{ subtitle }}
+
+
+
+
+ {{ b.label }}
+ {{ b.value }}
+
+
-
-
-
-
- {{ f.name }}
- {{ f.value }}
-
-
-
-
-
+
-
-
- {{ f.name }}
+
+
+
+
+ {{ s.name }}
-
- {{ firstParagraph(f.value) }}
+
+ {{ firstParagraph(s.value) }}
-
- {{ restAfterFirstParagraph(f.value) }}
+
+ {{ restAfterFirstParagraph(s.value) }}
+
+
+
+
+
+
+
+
+
+ {{ e.label }}
+
+ {{ e.value }}
+
+
+
+
+
+
+
+ {{ s.name }}
+
+
+
+
+
+
+ {{ e.label }}
+
+ {{ e.value || '—' }}
+
+
+
+
+
+
+
+
-
-
- {{ img.field.name }}
-
-
-
-
-
+
Cette fiche est encore vide.
diff --git a/web/src/app/shared/persona-view/persona-view.component.scss b/web/src/app/shared/persona-view/persona-view.component.scss
index 919fe16..ba41622 100644
--- a/web/src/app/shared/persona-view/persona-view.component.scss
+++ b/web/src/app/shared/persona-view/persona-view.component.scss
@@ -92,41 +92,145 @@
letter-spacing: 0.05em;
}
-// --- Bandeau de stats (NUMBER) ---------------------------------------------
-
-.pv-stat-band {
+// Badges compacts pour les NUMBER isoles (Niveau, etc.) — evite la grosse card.
+.pv-hero-badges {
+ margin-top: 12px;
display: flex;
flex-wrap: wrap;
- gap: 16px;
- padding: 12px 32px;
- margin: 16px 32px 0;
- background: rgba(255, 255, 255, 0.04);
- border-top: 1px solid rgba(255, 255, 255, 0.08);
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ gap: 8px;
}
-.pv-stat {
- display: none;
+.pv-hero-badge {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 6px;
+ padding: 4px 12px;
+ background: rgba(209, 168, 120, 0.08);
+ border: 1px solid rgba(209, 168, 120, 0.3);
+ border-radius: 100px;
- &.pv-stat-number {
- display: flex;
- flex-direction: column;
- align-items: center;
- min-width: 60px;
- }
-
- .pv-stat-label {
+ .pv-hero-badge-label {
font-size: 0.7rem;
text-transform: uppercase;
- letter-spacing: 0.08em;
+ letter-spacing: 0.1em;
color: #9ca3af;
}
-
- .pv-stat-value {
- font-size: 1.25rem;
+ .pv-hero-badge-value {
+ font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-weight: 700;
- color: #f3f4f6;
- font-family: 'Cinzel', Georgia, serif;
+ font-size: 1.05rem;
+ color: #d1a878;
+ }
+}
+
+// --- Table compacte (NUMBER_GROUP / KEY_VALUE_LIST) ------------------------
+// Style Foundry : 2 rangees (labels uppercase / valeurs serif), separateurs
+// fins, sans gros ornements. --cols est la variable CSS pour le nombre
+// d'entrees, posee inline dans le HTML.
+
+.pv-kv-table {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 4px;
+ overflow: hidden;
+ background: rgba(0, 0, 0, 0.18);
+}
+
+.pv-kv-row {
+ display: grid;
+ grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr));
+}
+
+.pv-kv-row-labels {
+ background: rgba(255, 255, 255, 0.03);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+
+ .pv-kv-cell {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: #9ca3af;
+ padding: 6px 4px;
+ text-align: center;
+ }
+}
+
+.pv-kv-row-values {
+ .pv-kv-cell {
+ font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
+ font-size: 1.15rem;
+ font-weight: 700;
+ color: #d1a878;
+ padding: 8px 4px;
+ text-align: center;
+ }
+}
+
+.pv-kv-cell {
+ border-right: 1px solid rgba(255, 255, 255, 0.04);
+
+ &:last-child {
+ border-right: none;
+ }
+}
+
+@media (max-width: 600px) {
+ // Sur mobile : si plus de 4 colonnes, on retombe en wrap (sacrifice de la
+ // structure tableau pour eviter le scroll horizontal sur les stats blocks).
+ .pv-kv-row {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
+
+// --- Liste 2 colonnes (KV avec >6 entrees, type "skills" Foundry) ---------
+
+.pv-kv-list {
+ column-count: 2;
+ column-gap: 32px;
+ padding: 8px 12px;
+ background: rgba(0, 0, 0, 0.18);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 4px;
+}
+
+.pv-kv-list-row {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ padding: 4px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ break-inside: avoid; // empeche un row de se couper entre 2 colonnes
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .pv-kv-list-label {
+ font-size: 0.85rem;
+ color: #d6d8de;
+ flex-shrink: 0;
+ }
+
+ // Pointilles entre label et valeur (style fiche papier)
+ .pv-kv-list-dots {
+ flex: 1;
+ border-bottom: 1px dotted rgba(255, 255, 255, 0.12);
+ transform: translateY(-3px);
+ }
+
+ .pv-kv-list-value {
+ font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
+ font-weight: 700;
+ font-size: 1rem;
+ color: #d1a878;
+ flex-shrink: 0;
+ }
+}
+
+@media (max-width: 600px) {
+ .pv-kv-list {
+ column-count: 1;
}
}
@@ -144,6 +248,13 @@
}
}
+.pv-section-kv,
+.pv-section-number {
+ .pv-kv-table {
+ margin-top: 8px;
+ }
+}
+
.pv-section-title {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 1.4rem;
diff --git a/web/src/app/shared/persona-view/persona-view.component.ts b/web/src/app/shared/persona-view/persona-view.component.ts
index 117fa9d..8e47d56 100644
--- a/web/src/app/shared/persona-view/persona-view.component.ts
+++ b/web/src/app/shared/persona-view/persona-view.component.ts
@@ -1,7 +1,14 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
-import { TemplateField } from '../../services/template.model';
+import { TemplateField, ImageLayout } from '../../services/template.model';
+
+/** Section rendue dans la vue, dans l'ordre du template. Discriminee par `kind`. */
+export type RenderedSection =
+ | { kind: 'TEXT'; name: string; value: string }
+ | { kind: 'NUMBER_GROUP'; entries: { label: string; value: string }[] }
+ | { kind: 'KEY_VALUE_LIST'; name: string; entries: { label: string; value: string }[] }
+ | { kind: 'IMAGE'; name: string; ids: string[]; layout: ImageLayout };
import { ImageService } from '../../services/image.service';
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
@@ -22,6 +29,7 @@ export interface PersonaLike {
headerImageId?: string | null;
values?: Record
;
imageValues?: Record;
+ keyValueValues?: Record>;
}
@Component({
@@ -46,30 +54,69 @@ export class PersonaViewComponent {
return this.imageService.contentUrl(id);
}
- /** Champs TEXT/NUMBER non vides, dans l'ordre du template. */
- get textFields(): { name: string; value: string; isNumber: boolean }[] {
- if (!this.persona?.values) return [];
- return this.templateFields
- .filter(f => (f.type === 'TEXT' || f.type === 'NUMBER'))
- .map(f => ({
- name: f.name,
- value: this.persona!.values?.[f.name] ?? '',
- isNumber: f.type === 'NUMBER'
- }))
- .filter(x => x.value && x.value.trim().length > 0);
+ /**
+ * Decompose la fiche en (heroBadges, sections) en un seul passage :
+ * - NUMBER consecutifs groupes : 2+ → NUMBER_GROUP en section ; 1 isole → badge hero.
+ * - TEXT / KEY_VALUE_LIST / IMAGE : sections dans l'ordre du template.
+ * Le calcul est fait par rendered() et cache via le get pour eviter les
+ * recalculs multiples par cycle de change detection.
+ */
+ private rendered(): { heroBadges: { label: string; value: string }[]; sections: RenderedSection[] } {
+ const sections: RenderedSection[] = [];
+ const heroBadges: { label: string; value: string }[] = [];
+ let numberBuffer: { label: string; value: string }[] = [];
+
+ const flushNumberBuffer = () => {
+ if (numberBuffer.length === 1) {
+ heroBadges.push(numberBuffer[0]);
+ } else if (numberBuffer.length > 1) {
+ sections.push({ kind: 'NUMBER_GROUP', entries: numberBuffer });
+ }
+ numberBuffer = [];
+ };
+
+ for (const f of this.templateFields) {
+ if (f.type === 'NUMBER') {
+ const value = this.persona?.values?.[f.name] ?? '';
+ if (value.trim()) numberBuffer.push({ label: f.name, value });
+ continue;
+ }
+ flushNumberBuffer();
+ if (f.type === 'TEXT') {
+ const value = this.persona?.values?.[f.name] ?? '';
+ if (value.trim()) sections.push({ kind: 'TEXT', name: f.name, value });
+ } else if (f.type === 'KEY_VALUE_LIST') {
+ const inner = this.persona?.keyValueValues?.[f.name] ?? {};
+ const labels = f.labels ?? [];
+ const entries = labels.map(label => ({ label, value: inner[label] ?? '' }));
+ if (entries.some(e => e.value.trim())) {
+ sections.push({ kind: 'KEY_VALUE_LIST', name: f.name, entries });
+ }
+ } else if (f.type === 'IMAGE') {
+ const ids = this.persona?.imageValues?.[f.name] ?? [];
+ if (ids.length > 0) {
+ sections.push({ kind: 'IMAGE', name: f.name, ids, layout: f.layout ?? 'GALLERY' });
+ }
+ }
+ }
+ flushNumberBuffer();
+ return { heroBadges, sections };
}
- /** Champs IMAGE non vides, dans l'ordre du template. */
- get imageFields(): { field: TemplateField; ids: string[] }[] {
- if (!this.persona?.imageValues) return [];
- return this.templateFields
- .filter(f => f.type === 'IMAGE')
- .map(f => ({ field: f, ids: this.persona!.imageValues?.[f.name] ?? [] }))
- .filter(x => x.ids.length > 0);
+ get heroBadges(): { label: string; value: string }[] {
+ return this.rendered().heroBadges;
}
- hasAnyNumber(fields: { isNumber: boolean }[]): boolean {
- return fields.some(f => f.isNumber);
+ get orderedSections(): RenderedSection[] {
+ return this.rendered().sections;
+ }
+
+ /** Pour la drop cap : seul le 1er TEXT la recoit. */
+ get firstTextSectionName(): string | null {
+ for (const s of this.orderedSections) {
+ if (s.kind === 'TEXT') return s.name;
+ }
+ return null;
}
/** Premier paragraphe d'un texte (utilise pour la drop cap). */
diff --git a/web/src/app/shared/template-fields-editor/template-fields-editor.component.html b/web/src/app/shared/template-fields-editor/template-fields-editor.component.html
index a32ed41..2617f46 100644
--- a/web/src/app/shared/template-fields-editor/template-fields-editor.component.html
+++ b/web/src/app/shared/template-fields-editor/template-fields-editor.component.html
@@ -5,45 +5,80 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ opt.label }}
+
+
+
+ {{ opt.label }}
+
+
+
+
-
-
-
- {{ opt.label }}
-
-
-
- {{ opt.label }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ajouter un label
+
+
@@ -75,5 +110,9 @@
Image(s)
+
+
+ Liste cle/valeur
+
diff --git a/web/src/app/shared/template-fields-editor/template-fields-editor.component.scss b/web/src/app/shared/template-fields-editor/template-fields-editor.component.scss
index e352ab1..351c445 100644
--- a/web/src/app/shared/template-fields-editor/template-fields-editor.component.scss
+++ b/web/src/app/shared/template-fields-editor/template-fields-editor.component.scss
@@ -137,3 +137,88 @@
.chip-custom {
border-style: dashed;
}
+
+.chip-mini {
+ padding: 3px 8px;
+ font-size: 0.72rem;
+ align-self: flex-start;
+}
+
+// --- Sous-editeur labels (KEY_VALUE_LIST) ---
+
+.tfe-item {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.tfe-labels {
+ margin-left: 32px;
+ padding: 8px 10px;
+ background: rgba(0, 0, 0, 0.15);
+ border: 1px dashed rgba(255, 255, 255, 0.08);
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.tfe-labels-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.75rem;
+ color: var(--color-text-muted, #aaa);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.tfe-labels-list {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.tfe-label-row {
+ display: grid;
+ grid-template-columns: auto auto 1fr auto;
+ gap: 4px;
+ align-items: center;
+}
+
+.btn-arrow-mini,
+.btn-remove-mini {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px;
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 3px;
+ color: var(--color-text-muted, #888);
+ cursor: pointer;
+ transition: all 100ms;
+
+ &:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--color-text, #fff);
+ }
+ &:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ }
+}
+
+.btn-remove-mini:hover {
+ border-color: rgba(255, 100, 100, 0.4);
+ color: rgba(255, 100, 100, 0.9);
+}
+
+.tfe-label-input {
+ padding: 4px 6px;
+ background: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 3px;
+ color: var(--color-text, #fff);
+ font-size: 0.8rem;
+}
diff --git a/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts b/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts
index eaf7d60..43eb5a0 100644
--- a/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts
+++ b/web/src/app/shared/template-fields-editor/template-fields-editor.component.ts
@@ -1,7 +1,7 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
-import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash } from 'lucide-angular';
+import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash, ListOrdered, X } from 'lucide-angular';
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
/**
@@ -29,6 +29,8 @@ export class TemplateFieldsEditorComponent {
readonly Type = Type;
readonly ImageIcon = ImageIcon;
readonly Hash = Hash;
+ readonly ListOrdered = ListOrdered;
+ readonly X = X;
/** Liste des champs (binding parent). */
@Input() fields: TemplateField[] = [];
@@ -47,7 +49,8 @@ export class TemplateFieldsEditorComponent {
readonly typeOptions: { value: FieldType; label: string }[] = [
{ value: 'TEXT', label: 'Texte' },
{ value: 'NUMBER', label: 'Nombre' },
- { value: 'IMAGE', label: 'Image(s)' }
+ { value: 'IMAGE', label: 'Image(s)' },
+ { value: 'KEY_VALUE_LIST', label: 'Liste cle/valeur' }
];
readonly layoutOptions: { value: ImageLayout; label: string }[] = [
@@ -75,9 +78,53 @@ export class TemplateFieldsEditorComponent {
addBlank(type: FieldType): void {
const layout: ImageLayout | null = type === 'IMAGE' ? 'GALLERY' : null;
- this.emit([...this.fields, { name: '', type, layout }]);
+ const labels: string[] | null = type === 'KEY_VALUE_LIST' ? [] : null;
+ this.emit([...this.fields, { name: '', type, layout, labels }]);
}
+ // --- Sous-editeur labels (KEY_VALUE_LIST) ---
+
+ addLabel(field: TemplateField): void {
+ const labels = field.labels ? [...field.labels] : [];
+ labels.push('');
+ field.labels = labels;
+ this.onFieldChanged();
+ }
+
+ removeLabel(field: TemplateField, index: number): void {
+ if (!field.labels) return;
+ const labels = [...field.labels];
+ labels.splice(index, 1);
+ field.labels = labels;
+ this.onFieldChanged();
+ }
+
+ updateLabelAt(field: TemplateField, index: number, value: string): void {
+ if (!field.labels) return;
+ const labels = [...field.labels];
+ labels[index] = value;
+ field.labels = labels;
+ this.onFieldChanged();
+ }
+
+ moveLabelUp(field: TemplateField, index: number): void {
+ if (!field.labels || index <= 0) return;
+ const labels = [...field.labels];
+ [labels[index - 1], labels[index]] = [labels[index], labels[index - 1]];
+ field.labels = labels;
+ this.onFieldChanged();
+ }
+
+ moveLabelDown(field: TemplateField, index: number): void {
+ if (!field.labels || index >= field.labels.length - 1) return;
+ const labels = [...field.labels];
+ [labels[index], labels[index + 1]] = [labels[index + 1], labels[index]];
+ field.labels = labels;
+ this.onFieldChanged();
+ }
+
+ trackByIndex = (i: number) => i;
+
remove(index: number): void {
const next = [...this.fields];
next.splice(index, 1);
@@ -100,10 +147,11 @@ export class TemplateFieldsEditorComponent {
/** Notifie les changements internes (input/select sur un champ existant). */
onFieldChanged(): void {
- // Quand le type passe a IMAGE, layout = GALLERY ; sinon null.
for (const f of this.fields) {
if (f.type === 'IMAGE' && !f.layout) f.layout = 'GALLERY';
if (f.type !== 'IMAGE') f.layout = null;
+ if (f.type === 'KEY_VALUE_LIST' && !f.labels) f.labels = [];
+ if (f.type !== 'KEY_VALUE_LIST') f.labels = null;
}
this.emit([...this.fields]);
}