diff --git a/brain/app/main.py b/brain/app/main.py index c91a146..d390ff3 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -41,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="0.6.6", + version="0.8.3", ) diff --git a/core/pom.xml b/core/pom.xml index 48c0507..e1f8d54 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.8.1 + 0.8.3 LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java index 0b43a61..0e42f91 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CharacterService.java @@ -32,6 +32,7 @@ public class CharacterService { String headerImageId, Map values, Map> imageValues, + Map> keyValueValues, String campaignId, Integer order ) {} @@ -46,6 +47,7 @@ public class CharacterService { .headerImageId(data.headerImageId()) .values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()) .imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()) + .keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()) .campaignId(data.campaignId()) .order(order) .build(); @@ -68,6 +70,7 @@ public class CharacterService { existing.setHeaderImageId(data.headerImageId()); existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()); existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()); + existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()); if (data.order() != null) { existing.setOrder(data.order()); } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java index 9e001e5..0d23b29 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java @@ -27,6 +27,7 @@ public class NpcService { String headerImageId, Map values, Map> imageValues, + Map> keyValueValues, String campaignId, Integer order ) {} @@ -41,6 +42,7 @@ public class NpcService { .headerImageId(data.headerImageId()) .values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()) .imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()) + .keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()) .campaignId(data.campaignId()) .order(order) .build(); @@ -63,6 +65,7 @@ public class NpcService { existing.setHeaderImageId(data.headerImageId()); existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()); existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()); + existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()); if (data.order() != null) { existing.setOrder(data.order()); } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java index b5d31cd..ab46e87 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Character.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Character.java @@ -51,6 +51,14 @@ public class Character { */ private Map> imageValues; + /** + * Valeurs des champs KEY_VALUE_LIST du template PJ. Cle externe = nom du + * champ template (ex: "Caracteristiques"), cle interne = label predefini + * dans le template (ex: "FOR"), valeur = valeur saisie (ex: "16"). + * Les labels suivent l'ordre defini dans TemplateField.labels. + */ + private Map> keyValueValues; + /** Référence vers la Campaign parente. */ private String campaignId; @@ -70,4 +78,9 @@ public class Character { if (imageValues == null) imageValues = new HashMap<>(); return imageValues; } + + public Map> getKeyValueValues() { + if (keyValueValues == null) keyValueValues = new HashMap<>(); + return keyValueValues; + } } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java b/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java index 609d526..b57ba54 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java @@ -40,6 +40,9 @@ public class Npc { /** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */ private Map> imageValues; + /** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */ + private Map> keyValueValues; + /** Référence vers la Campaign parente (cross-aggregate via ID). */ private String campaignId; @@ -58,4 +61,9 @@ public class Npc { if (imageValues == null) imageValues = new HashMap<>(); return imageValues; } + + public Map> 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 }}
+
+
+
+ +
+
+ {{ e.label }} + + {{ e.value }} +
+
+
+
+ + +
+

{{ s.name }}

+ +
+
+
{{ e.label }}
+
+
+
{{ e.value || '—' }}
+
+
+
+ +
+
+ {{ e.label }} + + {{ e.value || '—' }} +
+
+
+
+ + +
+

{{ s.name }}

+ + +
+
- -
-

{{ 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 @@
-
-
- - + +
+ + + + + + + +
- - - - - - - + +
+
+ + Labels (cles fixes pour toutes les fiches) +
+
+
+ + + + +
+
+ +
@@ -75,5 +110,9 @@ Image(s) +
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]); }