diff --git a/brain/app/main.py b/brain/app/main.py index ac70b29..6ee4ce6 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="0.12.2-beta", + version="0.12.3-beta", ) logger = logging.getLogger(__name__) diff --git a/core/pom.xml b/core/pom.xml index 1977439..bd3be9b 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.12.2-beta + 0.12.3-beta LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java index 7759768..ced84df 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignImportService.java @@ -163,7 +163,7 @@ public class CampaignImportService { isBlank(p.description()) ? java.util.Map.of() : java.util.Map.of("Description", p.description().trim()), - null, null, campaignId, null, null)); + null, null, campaignId, null, null, null)); created++; } return created; 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 5beb2d9..e7361e2 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java @@ -1,9 +1,12 @@ package com.loremind.application.campaigncontext; +import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Npc; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository; import org.springframework.stereotype.Service; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -16,9 +19,11 @@ import java.util.Optional; public class NpcService { private final NpcRepository npcRepository; + private final CampaignRepository campaignRepository; - public NpcService(NpcRepository npcRepository) { + public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) { this.npcRepository = npcRepository; + this.campaignRepository = campaignRepository; } public record NpcData( @@ -29,6 +34,7 @@ public class NpcService { Map> imageValues, Map> keyValueValues, String campaignId, + List relatedPageIds, String folder, Integer order ) {} @@ -45,6 +51,7 @@ public class NpcService { .imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()) .keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()) .campaignId(data.campaignId()) + .relatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>()) .folder(normalizeFolder(data.folder())) .order(order) .build(); @@ -59,6 +66,21 @@ public class NpcService { return npcRepository.findByCampaignId(campaignId); } + /** + * PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}). + * Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent. + * Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé. + */ + public List getNpcsByLoreId(String loreId) { + List out = new ArrayList<>(); + for (Campaign campaign : campaignRepository.findAll()) { + if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) { + out.addAll(npcRepository.findByCampaignId(campaign.getId())); + } + } + return out; + } + public Npc updateNpc(String id, NpcData data) { Npc existing = npcRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id)); @@ -68,6 +90,7 @@ public class NpcService { 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<>()); + existing.setRelatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>()); existing.setFolder(normalizeFolder(data.folder())); if (data.order() != null) { existing.setOrder(data.order()); diff --git a/core/src/main/java/com/loremind/application/lorecontext/PageService.java b/core/src/main/java/com/loremind/application/lorecontext/PageService.java index 77b10cc..afc9838 100644 --- a/core/src/main/java/com/loremind/application/lorecontext/PageService.java +++ b/core/src/main/java/com/loremind/application/lorecontext/PageService.java @@ -76,6 +76,8 @@ public class PageService { existing.setNodeId(changes.getNodeId()); existing.setValues(CollectionUtils.copyMap(changes.getValues())); existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues())); + existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues())); + existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues())); existing.setNotes(changes.getNotes()); existing.setTags(CollectionUtils.copyList(changes.getTags())); existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds())); 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 82c6b81..b94ec70 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Data; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -46,6 +47,13 @@ public class Npc { /** Référence vers la Campaign parente (cross-aggregate via ID). */ private String campaignId; + /** + * IDs de Pages de Lore référencées par ce PNJ (sa ville, sa faction, sa + * région…). Référence faible cross-context, même principe que sur + * Arc/Chapter/Scene — alimente notamment le graphe du Lore. + */ + private List relatedPageIds; + /** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */ private String folder; @@ -69,4 +77,9 @@ public class Npc { if (keyValueValues == null) keyValueValues = new HashMap<>(); return keyValueValues; } + + public List getRelatedPageIds() { + if (relatedPageIds == null) relatedPageIds = new ArrayList<>(); + return relatedPageIds; + } } diff --git a/core/src/main/java/com/loremind/domain/lorecontext/Page.java b/core/src/main/java/com/loremind/domain/lorecontext/Page.java index 01ea645..d96f2ac 100644 --- a/core/src/main/java/com/loremind/domain/lorecontext/Page.java +++ b/core/src/main/java/com/loremind/domain/lorecontext/Page.java @@ -39,6 +39,20 @@ public class Page { */ private Map> imageValues; + /** + * Valeurs des champs KEY_VALUE_LIST (tableau libelle → valeur, comme sur les + * fiches de personnage) : fieldName → (label → valeur). Les labels sont + * definis par le Template ; seules les valeurs vivent sur la page. + */ + private Map> keyValueValues; + + /** + * Valeurs des champs TABLE (colonnes figees au template, lignes libres) : + * fieldName → liste ordonnee de lignes, chaque ligne = colonne → cellule. + * Usage type : inventaire de boutique, table d'objets. + */ + private Map>> tableValues; + /** Notes privées du MJ (non exportées vers FoundryVTT). */ private String notes; 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 021af2d..aabf157 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 @@ -9,6 +9,11 @@ package com.loremind.domain.shared.template; * - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template * (Map> : fieldName -> label -> value). * Usage : stat blocks, listes de competences, traits. + * - TABLE : tableau a colonnes figees au template (TemplateField.labels = + * noms de colonnes) et lignes LIBRES ajoutees au remplissage + * (Map>> : fieldName -> lignes, + * chaque ligne = colonne -> cellule). + * Usage : inventaire de boutique, tables d'objets, listes de prix. *

* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE... */ @@ -16,5 +21,6 @@ public enum FieldType { TEXT, IMAGE, NUMBER, - KEY_VALUE_LIST + KEY_VALUE_LIST, + TABLE } 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 98d3b27..6c26b60 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 @@ -30,8 +30,9 @@ public class TemplateField { /** 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". + * Labels predefinis (ordre significatif), selon le type : + * - KEY_VALUE_LIST : libelles des lignes. Ex: ["FOR","DEX","CON","INT","SAG","CHA"]. + * - TABLE : noms des COLONNES. Ex: ["Objet","Prix","Description"]. * Null/vide pour les autres types. */ private List labels; @@ -70,4 +71,9 @@ public class TemplateField { public static TemplateField keyValueList(String name, List labels) { return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels); } + + /** Raccourci : construit un champ TABLE avec ses noms de colonnes. */ + public static TemplateField table(String name, List columns) { + return new TemplateField(name, FieldType.TABLE, null, columns); + } } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/converter/StringRowListMapJsonConverter.java b/core/src/main/java/com/loremind/infrastructure/persistence/converter/StringRowListMapJsonConverter.java new file mode 100644 index 0000000..53125cb --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/converter/StringRowListMapJsonConverter.java @@ -0,0 +1,51 @@ +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.List; +import java.util.Map; + +/** + * Convertit une Map>> en JSON et inversement. + *

+ * Utilise pour Page.tableValues : pour chaque champ TABLE du template, stocke + * la liste ordonnee des LIGNES du tableau, chaque ligne etant une map + * colonne -> cellule. Exemple : + * {"Inventaire": [{"Objet":"Potion","Prix":"50 po"}, {"Objet":"Corde","Prix":"1 po"}]} + *

+ * Adaptateur technique pur : le domaine ignore ce converter. + */ +@Converter +public class StringRowListMapJsonConverter + 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 7c11811..b3218b1 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 @@ -86,7 +86,7 @@ public class TemplateFieldListJsonConverter } } List labels = null; - if (type == FieldType.KEY_VALUE_LIST) { + if (type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) { JsonNode labelsNode = item.path("labels"); if (labelsNode.isArray()) { labels = new ArrayList<>(); 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 01ac4c4..3bac2f2 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 @@ -1,5 +1,6 @@ package com.loremind.infrastructure.persistence.entity; +import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter; @@ -54,6 +55,11 @@ public class NpcJpaEntity { @Column(name = "campaign_id", nullable = false) private Long campaignId; + /** IDs de Pages de Lore référencées (référence faible cross-context). JSON TEXT. */ + @Convert(converter = StringListJsonConverter.class) + @Column(name = "related_page_ids", columnDefinition = "TEXT") + private List relatedPageIds; + @Column(name = "folder") private String folder; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java index a79e4dd..9c4b554 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/PageJpaEntity.java @@ -3,6 +3,8 @@ package com.loremind.infrastructure.persistence.entity; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter; +import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter; +import com.loremind.infrastructure.persistence.converter.StringRowListMapJsonConverter; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -52,6 +54,16 @@ public class PageJpaEntity { @Convert(converter = StringListMapJsonConverter.class) private Map> imageValues; + /** Valeurs des champs KEY_VALUE_LIST : fieldName → (label → valeur). JSON TEXT. */ + @Column(name = "key_value_values", columnDefinition = "TEXT") + @Convert(converter = StringMapMapJsonConverter.class) + private Map> keyValueValues; + + /** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). JSON TEXT. */ + @Column(name = "table_values", columnDefinition = "TEXT") + @Convert(converter = StringRowListMapJsonConverter.class) + private Map>> tableValues; + @Column(columnDefinition = "TEXT") private String notes; 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 8ae7cbd..4a7de8b 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 @@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity; import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository; import org.springframework.stereotype.Repository; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Optional; @@ -59,6 +60,7 @@ public class PostgresNpcRepository implements NpcRepository { .imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>()) .keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>()) .campaignId(e.getCampaignId().toString()) + .relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>()) .folder(e.getFolder()) .order(e.getOrder()) .createdAt(e.getCreatedAt()) @@ -77,6 +79,7 @@ public class PostgresNpcRepository implements NpcRepository { .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())) + .relatedPageIds(n.getRelatedPageIds() != null ? new ArrayList<>(n.getRelatedPageIds()) : new ArrayList<>()) .folder(n.getFolder()) .order(n.getOrder()) .createdAt(n.getCreatedAt()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java index e3b807e..ade5adc 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresPageRepository.java @@ -93,6 +93,8 @@ public class PostgresPageRepository implements PageRepository { .title(e.getTitle()) .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<>()) + .tableValues(e.getTableValues() != null ? new HashMap<>(e.getTableValues()) : new HashMap<>()) .notes(e.getNotes()) .tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>()) .relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>()) @@ -111,6 +113,8 @@ public class PostgresPageRepository implements PageRepository { .title(p.getTitle()) .values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>()) .imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>()) + .keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>()) + .tableValues(p.getTableValues() != null ? new HashMap<>(p.getTableValues()) : new HashMap<>()) .notes(p.getNotes()) .tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>()) .relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>()) 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 41b072c..b473b37 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 @@ -43,6 +43,15 @@ public class NpcController { return ResponseEntity.ok(dtos); } + /** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */ + @GetMapping("/lore/{loreId}") + public ResponseEntity> getNpcsByLore(@PathVariable String loreId) { + List dtos = npcService.getNpcsByLoreId(loreId).stream() + .map(npcMapper::toDTO) + .collect(Collectors.toList()); + return ResponseEntity.ok(dtos); + } + @PutMapping("/{id}") public ResponseEntity updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) { Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder())); @@ -64,6 +73,7 @@ public class NpcController { dto.getImageValues(), dto.getKeyValueValues(), dto.getCampaignId(), + dto.getRelatedPageIds(), dto.getFolder(), 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 7e9c7af..439a99a 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 @@ -2,6 +2,7 @@ package com.loremind.infrastructure.web.dto.campaigncontext; import lombok.Data; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -20,6 +21,8 @@ public class NpcDTO { private Map> imageValues = new HashMap<>(); private Map> keyValueValues = new HashMap<>(); private String campaignId; + /** IDs de Pages de Lore référencées par ce PNJ (référence faible cross-context). */ + private List relatedPageIds = new ArrayList<>(); private String folder; private int order; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java index be2d9b4..88269b1 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/lorecontext/PageDTO.java @@ -20,6 +20,10 @@ public class PageDTO { private Map values; /** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */ private Map> imageValues; + /** Pour chaque champ KEY_VALUE_LIST du template : label → valeur. */ + private Map> keyValueValues; + /** Pour chaque champ TABLE du template : lignes (colonne → cellule). */ + private Map>> tableValues; private String notes; private List tags; private List relatedPageIds; 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 c2be917..db5dafc 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 @@ -4,6 +4,7 @@ import com.loremind.domain.campaigncontext.Npc; import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.HashMap; @Component @@ -20,6 +21,7 @@ public class NpcMapper { 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.setRelatedPageIds(n.getRelatedPageIds() != null ? new ArrayList<>(n.getRelatedPageIds()) : new ArrayList<>()); dto.setFolder(n.getFolder()); dto.setOrder(n.getOrder()); return dto; @@ -36,6 +38,7 @@ public class NpcMapper { .imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>()) .keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>()) .campaignId(dto.getCampaignId()) + .relatedPageIds(dto.getRelatedPageIds() != null ? new ArrayList<>(dto.getRelatedPageIds()) : new ArrayList<>()) .folder(dto.getFolder()) .order(dto.getOrder()) .build(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java index e5901e7..188f71a 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/PageMapper.java @@ -23,6 +23,8 @@ public class PageMapper { dto.setTitle(page.getTitle()); dto.setValues(CollectionUtils.copyMap(page.getValues())); dto.setImageValues(CollectionUtils.copyMap(page.getImageValues())); + dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues())); + dto.setTableValues(CollectionUtils.copyMap(page.getTableValues())); dto.setNotes(page.getNotes()); dto.setTags(CollectionUtils.copyList(page.getTags())); dto.setRelatedPageIds(CollectionUtils.copyList(page.getRelatedPageIds())); @@ -41,6 +43,8 @@ public class PageMapper { .title(dto.getTitle()) .values(CollectionUtils.copyMap(dto.getValues())) .imageValues(CollectionUtils.copyMap(dto.getImageValues())) + .keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues())) + .tableValues(CollectionUtils.copyMap(dto.getTableValues())) .notes(dto.getNotes()) .tags(CollectionUtils.copyList(dto.getTags())) .relatedPageIds(CollectionUtils.copyList(dto.getRelatedPageIds())) 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 db98630..0807726 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 @@ -29,7 +29,8 @@ public class TemplateFieldMapper { layoutStr = layout.name(); } List labels = null; - if (field.getType() == FieldType.KEY_VALUE_LIST && field.getLabels() != null) { + if ((field.getType() == FieldType.KEY_VALUE_LIST || field.getType() == FieldType.TABLE) + && field.getLabels() != null) { labels = new ArrayList<>(field.getLabels()); } return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels); @@ -54,7 +55,7 @@ public class TemplateFieldMapper { } } List labels = null; - if (type == FieldType.KEY_VALUE_LIST && dto.getLabels() != null) { + if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) { labels = new ArrayList<>(dto.getLabels()); } return new TemplateField(dto.getName(), type, layout, labels); 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 d21ef9f..08e7559 100644 --- a/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java +++ b/core/src/test/java/com/loremind/application/campaigncontext/NpcServiceTest.java @@ -1,6 +1,7 @@ package com.loremind.application.campaigncontext; import com.loremind.domain.campaigncontext.Npc; +import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,6 +30,9 @@ public class NpcServiceTest { @Mock private NpcRepository npcRepository; + @Mock + private CampaignRepository campaignRepository; + @InjectMocks private NpcService npcService; @@ -51,7 +55,7 @@ public class NpcServiceTest { Npc result = npcService.createNpc( new NpcService.NpcData("Borin le forgeron", null, null, - Map.of("Notes", "Borin"), null, null, "camp-1", null,5)); + Map.of("Notes", "Borin"), null, null, "camp-1", null, null, 5)); assertNotNull(result); ArgumentCaptor captor = ArgumentCaptor.forClass(Npc.class); @@ -67,7 +71,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, null, "camp-1", null,null)); + npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null, null, null)); ArgumentCaptor captor = ArgumentCaptor.forClass(Npc.class); verify(npcRepository).save(captor.capture()); @@ -79,7 +83,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, null, "camp-1", null,null)); + npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null, null, null)); ArgumentCaptor captor = ArgumentCaptor.forClass(Npc.class); verify(npcRepository).save(captor.capture()); @@ -124,7 +128,7 @@ public class NpcServiceTest { Npc result = npcService.updateNpc("npc-1", new NpcService.NpcData("Borin renommé", null, null, - Map.of("Notes", "v2"), null, null, "camp-1", null,7)); + Map.of("Notes", "v2"), null, null, "camp-1", null, null, 7)); assertEquals("Borin renommé", result.getName()); assertEquals("v2", result.getValues().get("Notes")); @@ -138,7 +142,7 @@ public class NpcServiceTest { Npc result = npcService.updateNpc("npc-1", new NpcService.NpcData("Borin", null, null, - Map.of("Notes", "txt"), null, null, "camp-1", null,null)); + Map.of("Notes", "txt"), null, null, "camp-1", null, null, null)); // testNpc avait order=1 → préservé assertEquals(1, result.getOrder()); @@ -150,7 +154,7 @@ public class NpcServiceTest { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> npcService.updateNpc("missing", - new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null,null))); + new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null, null, null))); assertTrue(ex.getMessage().contains("missing")); verify(npcRepository, never()).save(any()); } diff --git a/web/package-lock.json b/web/package-lock.json index 6e59ad3..d97f91c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.12.2-beta", + "version": "0.12.3-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.12.2-beta", + "version": "0.12.3-beta", "dependencies": { "@angular/animations": "^21.2.16", "@angular/common": "^21.2.16", diff --git a/web/package.json b/web/package.json index 34b5738..ac7e471 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.12.2-beta", + "version": "0.12.3-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index 736e5ae..4478e15 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -4,6 +4,7 @@ import { hiddenInDemoGuard } from './guards/demo-mode.guard'; export const routes: Routes = [ { path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) }, { path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) }, + { path: 'lore/:loreId/graph', loadComponent: () => import('./lore/lore-graph/lore-graph.component').then(m => m.LoreGraphComponent) }, { path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) }, { path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) }, { path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) }, 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 3da29cf..7197b3a 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 @@ -87,6 +87,20 @@ + + @if (loreId) { +

+ + + +

Reliez ce PNJ aux pages de l'univers (sa ville, sa faction, sa région…). Ces liens apparaissent dans le graphe du Lore.

+
+ } +
+ + + @if (loreId && (npc?.relatedPageIds?.length ?? 0) > 0) { + + } @if (npcId && campaignId) { diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.scss b/web/src/app/campaigns/npc/npc-view/npc-view.component.scss index cf9f3b5..406bcad 100644 --- a/web/src/app/campaigns/npc/npc-view/npc-view.component.scss +++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.scss @@ -49,3 +49,46 @@ border-color: rgba(168, 85, 247, 0.5); color: #d8b4fe; } + +// Pages de Lore liées au PNJ — chips cliquables sous la fiche. +.nv-lore-links { + max-width: 1100px; + margin: 24px auto 0; + padding: 0 32px; + + .nv-lore-links-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.72rem; + font-weight: 600; + color: #a5b4fc; + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 0.6rem; + } + + .nv-lore-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + } + + .nv-lore-chip { + display: inline-flex; + align-items: center; + padding: 0.3rem 0.7rem; + background: #1a1a2e; + border: 1px solid #2a2a3d; + border-radius: 999px; + color: #d1d5db; + font-size: 0.82rem; + text-decoration: none; + transition: border-color 0.15s, color 0.15s; + + &:hover { + border-color: #6c63ff; + color: white; + } + } +} diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.ts b/web/src/app/campaigns/npc/npc-view/npc-view.component.ts index 59b9dff..07aee29 100644 --- a/web/src/app/campaigns/npc/npc-view/npc-view.component.ts +++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.ts @@ -1,14 +1,16 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { Subscription } from 'rxjs'; -import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'; +import { LucideAngularModule, ArrowLeft, Edit3, Sparkles, Link2 } from 'lucide-angular'; import { NpcService } from '../../../services/npc.service'; import { CampaignService } from '../../../services/campaign.service'; import { GameSystemService } from '../../../services/game-system.service'; +import { PageService } from '../../../services/page.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { TemplateField } from '../../../services/template.model'; import { Npc } from '../../../services/npc.model'; +import { Page } from '../../../services/page.model'; import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component'; import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; @@ -18,7 +20,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr */ @Component({ selector: 'app-npc-view', - imports: [LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent], + imports: [LucideAngularModule, RouterLink, PersonaViewComponent, AiChatDrawerComponent], templateUrl: './npc-view.component.html', styleUrls: ['./npc-view.component.scss'] }) @@ -26,12 +28,17 @@ export class NpcViewComponent implements OnInit, OnDestroy { readonly ArrowLeft = ArrowLeft; readonly Edit3 = Edit3; readonly Sparkles = Sparkles; + readonly Link2 = Link2; campaignId: string | null = null; npcId: string | null = null; npc: Npc | null = null; templateFields: TemplateField[] = []; + /** Lore lié à la campagne (résolution des chips de pages liées). */ + loreId: string | null = null; + /** Pages du lore lié, indexées pour résoudre les titres des chips. */ + private lorePagesById = new Map(); chatOpen = false; toggleChat(): void { this.chatOpen = !this.chatOpen; } @@ -44,6 +51,7 @@ export class NpcViewComponent implements OnInit, OnDestroy { private service: NpcService, private campaignService: CampaignService, private gameSystemService: GameSystemService, + private pageService: PageService, private campaignSidebar: CampaignSidebarService ) {} @@ -75,6 +83,13 @@ export class NpcViewComponent implements OnInit, OnDestroy { this.templateFields = gs.npcTemplate ?? []; }); } + // Lore lié → référentiel de pages pour résoudre les chips de liens. + if (camp.loreId) { + this.loreId = camp.loreId; + this.pageService.getByLoreId(camp.loreId).subscribe(pages => { + this.lorePagesById = new Map(pages.map(p => [p.id!, p])); + }); + } }); } else if (newCampaignId) { this.campaignId = newCampaignId; @@ -86,6 +101,11 @@ export class NpcViewComponent implements OnInit, OnDestroy { this.paramsSub?.unsubscribe(); } + /** Titre d'une page de lore liée (pour les chips). */ + titleOfPage(pageId: string): string { + return this.lorePagesById.get(pageId)?.title ?? '(page supprimée)'; + } + edit(): void { if (this.campaignId && this.npcId) { this.router.navigate(['/campaigns', this.campaignId, 'npcs', this.npcId, 'edit']); diff --git a/web/src/app/lore/lore-detail/lore-detail.component.html b/web/src/app/lore/lore-detail/lore-detail.component.html index c6d5a2e..fabde25 100644 --- a/web/src/app/lore/lore-detail/lore-detail.component.html +++ b/web/src/app/lore/lore-detail/lore-detail.component.html @@ -8,6 +8,11 @@

{{ lore.description }}

+ +
+

+ + {{ lore.name }} — Graphe +

+

+ {{ nodes.length - npcCount }} page(s) · {{ npcCount }} PNJ · {{ edgeCount }} lien(s). + Cliquez sur un nœud pour l'ouvrir, glissez-le pour réarranger. +

+
+
+ Page de Lore + PNJ + Lien PNJ → page +
+ + + @if (nodes.length === 0) { +
+ Aucune page dans ce Lore pour l'instant — le graphe se remplira au fur et à mesure. +
+ } @else { +
+ + + + @for (e of edges; track e.key) { + + } + + + @for (n of nodes; track n.id) { + + + {{ n.displayLabel }} + {{ n.label }} + + } + +
+ @if (edgeCount === 0) { +

+ Aucun lien pour l'instant : liez des pages entre elles (« Pages liées » d'une page) + ou rattachez un PNJ à des pages de Lore depuis sa fiche. +

+ } + } +
+} diff --git a/web/src/app/lore/lore-graph/lore-graph.component.scss b/web/src/app/lore/lore-graph/lore-graph.component.scss new file mode 100644 index 0000000..986eee8 --- /dev/null +++ b/web/src/app/lore/lore-graph/lore-graph.component.scss @@ -0,0 +1,163 @@ +.graph-page { + padding: 1.5rem 2rem; +} + +.graph-header { + display: flex; + align-items: flex-start; + gap: 1.25rem; + flex-wrap: wrap; + margin-bottom: 1rem; + + .btn-back { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + color: #d1d5db; + font-size: 0.85rem; + cursor: pointer; + + &:hover { background: rgba(255, 255, 255, 0.1); color: #fff; } + } + + .graph-title { + flex: 1; + min-width: 240px; + + h1 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.4rem; + color: white; + margin: 0 0 0.25rem; + } + + .graph-subtitle { + color: #9ca3af; + font-size: 0.82rem; + margin: 0; + } + } + + .graph-legend { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.78rem; + color: #9ca3af; + + .legend-item { + display: inline-flex; + align-items: center; + gap: 0.45rem; + } + + .legend-dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + + &.legend-dot--page { background: #4338ca; border: 1px solid #818cf8; } + &.legend-dot--npc { background: #92400e; border: 1px solid #fbbf24; } + } + + .legend-line { + width: 18px; + height: 0; + display: inline-block; + + &.legend-line--npc { border-top: 2px dashed #d97706; } + } + } +} + +// Le SVG peut dépasser l'écran sur un gros lore : scroll dans les 2 sens. +.graph-scroll { + overflow: auto; + border: 1px solid #1e1e3a; + border-radius: 8px; + background: + radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.05) 1px, transparent 0) 0 0 / 26px 26px, + #0d0d1c; +} + +svg { + display: block; + touch-action: none; // requis pour le drag au pointeur sur tactile + + line { + &.edge-page { + stroke: #4f4f7a; + stroke-width: 1.6; + } + + &.edge-npc { + stroke: #d97706; + stroke-width: 1.4; + stroke-dasharray: 5 4; + opacity: 0.8; + } + } + + .node { + cursor: grab; + + circle { + fill: #1e1b4b; + stroke: #6366f1; + stroke-width: 2; + transition: stroke 0.12s, fill 0.12s; + } + + .node-label { + fill: #c7d2fe; + font-size: 11px; + pointer-events: none; + user-select: none; + } + + &:hover circle { + fill: #312e81; + stroke: #a5b4fc; + } + + &.dragging { cursor: grabbing; } + + // PNJ : palette ambre, distincte des pages. + &.node--npc { + circle { + fill: #451a03; + stroke: #d97706; + } + + .node-label { fill: #fcd34d; } + + &:hover circle { + fill: #78350f; + stroke: #fbbf24; + } + } + } +} + +.graph-empty { + padding: 3rem; + text-align: center; + color: #6b7280; + font-style: italic; + border: 1px dashed #2a2a3d; + border-radius: 8px; +} + +.graph-hint { + margin-top: 0.75rem; + font-size: 0.82rem; + color: #6b7280; + font-style: italic; +} diff --git a/web/src/app/lore/lore-graph/lore-graph.component.ts b/web/src/app/lore/lore-graph/lore-graph.component.ts new file mode 100644 index 0000000..5cf9137 --- /dev/null +++ b/web/src/app/lore/lore-graph/lore-graph.component.ts @@ -0,0 +1,350 @@ +import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core'; + +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { forkJoin } from 'rxjs'; +import { LucideAngularModule, ArrowLeft, Network } from 'lucide-angular'; +import { LoreService } from '../../services/lore.service'; +import { TemplateService } from '../../services/template.service'; +import { PageService } from '../../services/page.service'; +import { NpcService } from '../../services/npc.service'; +import { LayoutService } from '../../services/layout.service'; +import { PageTitleService } from '../../services/page-title.service'; +import { Lore } from '../../services/lore.model'; +import { Page } from '../../services/page.model'; +import { Npc } from '../../services/npc.model'; +import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; + +/** Nœud du graphe : une page de Lore ou un PNJ qui référence des pages. */ +interface GraphNode { + id: string; // 'page:' ou 'npc:' (évite les collisions d'IDs) + kind: 'page' | 'npc'; + label: string; + displayLabel: string; + route: string[]; // navigation au clic + x: number; // centre du nœud (coords SVG) + y: number; + degree: number; // nombre de liens (taille du nœud) +} + +interface GraphEdge { + key: string; + kind: 'page' | 'npc'; // page↔page ou npc→page (style distinct) + x1: number; y1: number; x2: number; y2: number; +} + +/** + * Graphe du Lore : vue d'ensemble des pages et de leurs liens. + * + * Nœuds = toutes les pages du Lore + les PNJ (toutes campagnes liées au Lore) + * qui référencent au moins une page. Arêtes = `relatedPageIds` des pages + * (liens page↔page) et des PNJ (liens PNJ→page). + * + * Layout force-directed (Fruchterman-Reingold simplifié) calculé une fois au + * chargement, puis nœuds déplaçables à la souris — même approche SVG custom + * que chapter-graph, sans dépendance externe. + */ +@Component({ + selector: 'app-lore-graph', + imports: [RouterModule, LucideAngularModule], + templateUrl: './lore-graph.component.html', + styleUrls: ['./lore-graph.component.scss'] +}) +export class LoreGraphComponent implements OnInit, OnDestroy { + readonly ArrowLeft = ArrowLeft; + readonly Network = Network; + + loreId = ''; + lore: Lore | null = null; + + nodes: GraphNode[] = []; + edges: GraphEdge[] = []; + npcCount = 0; + edgeCount = 0; + + readonly MAX_LABEL_CHARS = 22; + private readonly MARGIN = 70; + + svgWidth = 800; + svgHeight = 600; + + @ViewChild('svgEl') svgEl?: ElementRef; + + draggingId: string | null = null; + private dragOffsetX = 0; + private dragOffsetY = 0; + private dragMoved = false; + private readonly DRAG_THRESHOLD = 4; + + // Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag. + private adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = []; + + constructor( + private route: ActivatedRoute, + private router: Router, + private loreService: LoreService, + private templateService: TemplateService, + private pageService: PageService, + private npcService: NpcService, + private layoutService: LayoutService, + private pageTitleService: PageTitleService + ) {} + + ngOnInit(): void { + this.loreId = this.route.snapshot.paramMap.get('loreId')!; + forkJoin({ + sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService), + npcs: this.npcService.getByLore(this.loreId) + }).subscribe(({ sidebar, npcs }) => { + this.lore = sidebar.lore; + this.layoutService.show(buildLoreSidebarConfig(sidebar)); + this.pageTitleService.set(`${sidebar.lore.name} — Graphe`); + this.buildGraph(sidebar.pages, npcs); + }); + } + + // --- Construction du graphe ---------------------------------------------- + + private buildGraph(pages: Page[], npcs: Npc[]): void { + const pageIds = new Set(pages.map(p => p.id!)); + + // Nœuds pages (toutes, même isolées : la vue d'ensemble inclut les orphelines). + const nodes: GraphNode[] = pages.map(p => ({ + id: `page:${p.id}`, + kind: 'page' as const, + label: p.title, + displayLabel: this.truncate(p.title), + route: ['/lore', this.loreId, 'pages', p.id!], + x: 0, y: 0, degree: 0 + })); + + // Nœuds PNJ : seulement ceux qui référencent au moins une page de CE lore + // (un PNJ sans lien n'apporte rien à la carte des connexions). + const linkedNpcs = npcs.filter(n => + (n.relatedPageIds ?? []).some(pid => pageIds.has(pid))); + for (const n of linkedNpcs) { + nodes.push({ + id: `npc:${n.id}`, + kind: 'npc', + label: n.name, + displayLabel: this.truncate(n.name), + route: ['/campaigns', n.campaignId, 'npcs', n.id!], + x: 0, y: 0, degree: 0 + }); + } + this.npcCount = linkedNpcs.length; + + // Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée + // (A→B et B→A = un seul trait). + const adjacency: Array<{ key: string; kind: 'page' | 'npc'; a: string; b: string }> = []; + const seenPairs = new Set(); + for (const p of pages) { + for (const targetId of p.relatedPageIds ?? []) { + if (!pageIds.has(targetId) || targetId === p.id) continue; + const pair = [p.id!, targetId].sort().join('|'); + if (seenPairs.has(pair)) continue; + seenPairs.add(pair); + adjacency.push({ key: `pp:${pair}`, kind: 'page', a: `page:${p.id}`, b: `page:${targetId}` }); + } + } + for (const n of linkedNpcs) { + for (const targetId of new Set(n.relatedPageIds ?? [])) { + if (!pageIds.has(targetId)) continue; + adjacency.push({ key: `np:${n.id}|${targetId}`, kind: 'npc', a: `npc:${n.id}`, b: `page:${targetId}` }); + } + } + this.adjacency = adjacency; + this.edgeCount = adjacency.length; + + // Degré (pondère la taille des nœuds : une capitale très liée ressort). + const degree = new Map(); + for (const e of adjacency) { + degree.set(e.a, (degree.get(e.a) ?? 0) + 1); + degree.set(e.b, (degree.get(e.b) ?? 0) + 1); + } + for (const node of nodes) { + node.degree = degree.get(node.id) ?? 0; + } + + this.nodes = nodes; + this.runForceLayout(); + this.recomputeEdges(); + } + + /** + * Layout force-directed (Fruchterman-Reingold simplifié) : + * répulsion entre tous les nœuds, ressorts sur les arêtes, gravité vers le + * centre (regroupe les composantes déconnectées), refroidissement progressif. + * Positions initiales sur un cercle (déterministe : pas d'aléatoire, cf. + * convention projet d'éviter Math.random pour des rendus reproductibles). + */ + private runForceLayout(): void { + const n = this.nodes.length; + if (n === 0) { + this.svgWidth = 800; this.svgHeight = 400; + return; + } + const side = Math.max(600, Math.ceil(170 * Math.sqrt(n))); + const w = side, h = side; + const cx = w / 2, cy = h / 2; + + // Init en spirale : angle d'or → répartition uniforme et déterministe. + const golden = Math.PI * (3 - Math.sqrt(5)); + this.nodes.forEach((node, i) => { + const r = (Math.sqrt(i + 0.5) / Math.sqrt(n)) * (side / 2 - this.MARGIN); + const a = i * golden; + node.x = cx + r * Math.cos(a); + node.y = cy + r * Math.sin(a); + }); + + const index = new Map(this.nodes.map(node => [node.id, node])); + const k = 0.9 * Math.sqrt((w * h) / n); // distance "idéale" entre nœuds + let temperature = side / 8; + + for (let iter = 0; iter < 300; iter++) { + const dx = new Map(); + const dy = new Map(); + for (const node of this.nodes) { dx.set(node.id, 0); dy.set(node.id, 0); } + + // Répulsion entre toutes les paires. + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const a = this.nodes[i], b = this.nodes[j]; + let vx = a.x - b.x, vy = a.y - b.y; + let d = Math.hypot(vx, vy); + if (d < 0.01) { vx = 0.1 * ((i % 3) - 1) || 0.1; vy = 0.1; d = Math.hypot(vx, vy); } + const force = (k * k) / d; + dx.set(a.id, dx.get(a.id)! + (vx / d) * force); + dy.set(a.id, dy.get(a.id)! + (vy / d) * force); + dx.set(b.id, dx.get(b.id)! - (vx / d) * force); + dy.set(b.id, dy.get(b.id)! - (vy / d) * force); + } + } + + // Attraction le long des arêtes. + for (const e of this.adjacency) { + const a = index.get(e.a)!, b = index.get(e.b)!; + const vx = a.x - b.x, vy = a.y - b.y; + const d = Math.max(0.01, Math.hypot(vx, vy)); + const force = (d * d) / k; + dx.set(a.id, dx.get(a.id)! - (vx / d) * force); + dy.set(a.id, dy.get(a.id)! - (vy / d) * force); + dx.set(b.id, dx.get(b.id)! + (vx / d) * force); + dy.set(b.id, dy.get(b.id)! + (vy / d) * force); + } + + // Gravité douce vers le centre (sinon les composantes isolées fuient). + for (const node of this.nodes) { + dx.set(node.id, dx.get(node.id)! + (cx - node.x) * 0.06); + dy.set(node.id, dy.get(node.id)! + (cy - node.y) * 0.06); + } + + // Application bornée par la température, dans le cadre. + for (const node of this.nodes) { + const ddx = dx.get(node.id)!, ddy = dy.get(node.id)!; + const d = Math.max(0.01, Math.hypot(ddx, ddy)); + const step = Math.min(d, temperature); + node.x = Math.min(w - this.MARGIN, Math.max(this.MARGIN, node.x + (ddx / d) * step)); + node.y = Math.min(h - this.MARGIN, Math.max(this.MARGIN, node.y + (ddy / d) * step)); + } + temperature *= 0.96; + } + + this.svgWidth = w; + this.svgHeight = h; + } + + /** Recalcule la géométrie des arêtes depuis les positions courantes des nœuds. */ + private recomputeEdges(): void { + const index = new Map(this.nodes.map(n => [n.id, n])); + this.edges = this.adjacency + .filter(e => index.has(e.a) && index.has(e.b)) + .map(e => { + const a = index.get(e.a)!, b = index.get(e.b)!; + return { key: e.key, kind: e.kind, x1: a.x, y1: a.y, x2: b.x, y2: b.y }; + }); + } + + /** Rayon d'un nœud : grossit doucement avec son nombre de liens. */ + radiusOf(node: GraphNode): number { + return 14 + Math.min(10, node.degree * 1.5); + } + + // --- Interactions (drag pour réarranger, clic pour ouvrir) ---------------- + + private toSvgCoords(evt: PointerEvent): { x: number; y: number } { + const svg = this.svgEl?.nativeElement; + if (!svg) return { x: evt.clientX, y: evt.clientY }; + const pt = svg.createSVGPoint(); + pt.x = evt.clientX; + pt.y = evt.clientY; + const ctm = svg.getScreenCTM(); + if (!ctm) return { x: evt.clientX, y: evt.clientY }; + const local = pt.matrixTransform(ctm.inverse()); + return { x: local.x, y: local.y }; + } + + onPointerDown(evt: PointerEvent, node: GraphNode): void { + if (evt.button !== 0) return; + evt.preventDefault(); + const { x, y } = this.toSvgCoords(evt); + this.draggingId = node.id; + this.dragOffsetX = x - node.x; + this.dragOffsetY = y - node.y; + this.dragMoved = false; + (evt.target as Element).setPointerCapture?.(evt.pointerId); + } + + onPointerMove(evt: PointerEvent): void { + if (!this.draggingId) return; + const node = this.nodes.find(n => n.id === this.draggingId); + if (!node) return; + const { x, y } = this.toSvgCoords(evt); + const newX = Math.max(this.MARGIN / 2, x - this.dragOffsetX); + const newY = Math.max(this.MARGIN / 2, y - this.dragOffsetY); + if (!this.dragMoved) { + if (Math.hypot(newX - node.x, newY - node.y) < this.DRAG_THRESHOLD) return; + this.dragMoved = true; + } + node.x = newX; + node.y = newY; + this.recomputeEdges(); + this.fitSvgToNodes(); + } + + onPointerUp(evt: PointerEvent): void { + if (!this.draggingId) return; + const id = this.draggingId; + const moved = this.dragMoved; + this.draggingId = null; + this.dragMoved = false; + (evt.target as Element).releasePointerCapture?.(evt.pointerId); + if (moved) return; + // Clic simple → ouvre la page / la fiche PNJ. + const node = this.nodes.find(n => n.id === id); + if (node) this.router.navigate(node.route); + } + + /** Agrandit le SVG si un nœud déplacé s'approche du bord (jamais de réduction). */ + private fitSvgToNodes(): void { + for (const n of this.nodes) { + if (n.x + this.MARGIN > this.svgWidth) this.svgWidth = n.x + this.MARGIN; + if (n.y + this.MARGIN > this.svgHeight) this.svgHeight = n.y + this.MARGIN; + } + } + + private truncate(text: string): string { + return text.length > this.MAX_LABEL_CHARS + ? text.slice(0, this.MAX_LABEL_CHARS - 1) + '…' + : text; + } + + back(): void { + this.router.navigate(['/lore', this.loreId]); + } + + ngOnDestroy(): void { + // Volontairement vide : la sidebar reste prise en charge par le composant + // suivant (autre sous-route ou le composant detail parent) qui appellera + // show(). Eviter d'appeler hide() ici previent le clignotement. + } +} diff --git a/web/src/app/lore/page-edit/page-edit.component.html b/web/src/app/lore/page-edit/page-edit.component.html index 9889078..b1eb2d1 100644 --- a/web/src/app/lore/page-edit/page-edit.component.html +++ b/web/src/app/lore/page-edit/page-edit.component.html @@ -71,6 +71,75 @@ } + + @if (field.type === 'KEY_VALUE_LIST') { +
+ +
+ @for (lbl of field.labels ?? []; track $index) { +
+ {{ lbl }} + +
+ } + @if (!(field.labels ?? []).length) { +

Aucun libellé défini dans le template pour ce champ.

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

Aucune colonne définie dans le template pour ce tableau.

+ } +
+ } } } diff --git a/web/src/app/lore/page-edit/page-edit.component.scss b/web/src/app/lore/page-edit/page-edit.component.scss index 87837b9..bf4ec50 100644 --- a/web/src/app/lore/page-edit/page-edit.component.scss +++ b/web/src/app/lore/page-edit/page-edit.component.scss @@ -126,6 +126,127 @@ } } +// Grille de saisie d'un champ Tableau (KEY_VALUE_LIST) — même esthétique +// que la grille des fiches de personnage (dynamic-fields-form). +.kv-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px; + + .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; + + .kv-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #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: white; + font-family: 'Cinzel', 'EB Garamond', Georgia, serif; + font-weight: 700; + font-size: 1.05rem; + + &:focus { outline: none; border-color: #6c63ff; } + } + } + + .kv-empty { + padding: 8px; + font-size: 0.8rem; + color: #6b7280; + font-style: italic; + margin: 0; + } +} + +// Éditeur d'un champ Tableau (TABLE) : colonnes du template, lignes libres. +.table-edit-wrap { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +.table-edit { + width: 100%; + border-collapse: collapse; + + th { + text-align: left; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #99f6e4; + padding: 6px 8px; + border-bottom: 1px solid rgba(20, 184, 166, 0.35); + } + + td { + padding: 4px 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + + input { + width: 100%; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 3px; + color: white; + font-size: 0.88rem; + + &:focus { outline: none; border-color: #14b8a6; } + } + } + + .table-edit-actions-col { + width: 34px; + text-align: center; + } + + .btn-row-delete { + background: transparent; + border: none; + color: #6b7280; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 4px; + + &:hover { color: #f87171; } + } +} + +.btn-row-add { + display: inline-flex; + align-items: center; + gap: 0.3rem; + background: transparent; + border: 1px dashed rgba(20, 184, 166, 0.5); + border-radius: 5px; + color: #5eead4; + font-size: 0.8rem; + padding: 0.35rem 0.7rem; + cursor: pointer; + + &:hover { background: rgba(20, 184, 166, 0.08); } +} + .ai-error-banner { display: flex; align-items: center; diff --git a/web/src/app/lore/page-edit/page-edit.component.ts b/web/src/app/lore/page-edit/page-edit.component.ts index 1b0d7ae..9ec92c8 100644 --- a/web/src/app/lore/page-edit/page-edit.component.ts +++ b/web/src/app/lore/page-edit/page-edit.component.ts @@ -3,7 +3,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { forkJoin } from 'rxjs'; -import { LucideAngularModule, Sparkles } from 'lucide-angular'; +import { LucideAngularModule, Sparkles, Plus, Trash2 } from 'lucide-angular'; import { LoreService } from '../../services/lore.service'; import { TemplateService } from '../../services/template.service'; import { PageService } from '../../services/page.service'; @@ -42,6 +42,8 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog }) export class PageEditComponent implements OnInit, OnDestroy { readonly Sparkles = Sparkles; + readonly Plus = Plus; + readonly Trash2 = Trash2; loreId = ''; pageId = ''; @@ -63,6 +65,10 @@ export class PageEditComponent implements OnInit, OnDestroy { * la liste ordonnee des IDs d'images uploadees. */ imageValues: Record = {}; + /** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */ + keyValueValues: Record> = {}; + /** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */ + tableValues: Record>> = {}; /** Étiquettes libres (Phase 5B). */ tags: string[] = []; /** IDs des pages liées (Phase 5B). */ @@ -169,16 +175,27 @@ export class PageEditComponent implements OnInit, OnDestroy { // structure `imageValues: Map>` a l'etape 5). const base: Record = {}; const imageBase: Record = {}; + const kvBase: Record> = {}; + const tableBase: Record>> = {}; for (const f of this.template?.fields ?? []) { if (f.type === 'TEXT') { base[f.name] = page.values?.[f.name] ?? ''; } else if (f.type === 'IMAGE') { // Initialise la galerie d'images pour ce champ (vide si jamais rempli). imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])]; + } else if (f.type === 'KEY_VALUE_LIST') { + // Toujours initialiser l'objet interne : le ngModel du formulaire + // bind directement keyValueValues[field.name][label]. + kvBase[f.name] = { ...(page.keyValueValues?.[f.name] ?? {}) }; + } else if (f.type === 'TABLE') { + // Copie profonde des lignes : chaque ligne est éditée par ngModel. + tableBase[f.name] = (page.tableValues?.[f.name] ?? []).map(row => ({ ...row })); } } this.values = base; this.imageValues = imageBase; + this.keyValueValues = kvBase; + this.tableValues = tableBase; this.tags = [...(page.tags ?? [])]; this.relatedPageIds = [...(page.relatedPageIds ?? [])]; this.pageTitleService.set(page.title); @@ -193,6 +210,8 @@ export class PageEditComponent implements OnInit, OnDestroy { notes: this.notes, values: this.values, imageValues: this.imageValues, + keyValueValues: this.keyValueValues, + tableValues: this.tableValues, tags: this.tags, relatedPageIds: this.relatedPageIds }; @@ -202,6 +221,20 @@ export class PageEditComponent implements OnInit, OnDestroy { }); } + // --- Champs TABLE (lignes libres) --------------------------------------- + // Mutation en place des lignes : recréer le tableau à chaque frappe ferait + // perdre le focus de la cellule en cours d'édition. + + addTableRow(fieldName: string, columns: string[] | null | undefined): void { + const row: Record = {}; + for (const col of columns ?? []) row[col] = ''; + (this.tableValues[fieldName] ??= []).push(row); + } + + removeTableRow(fieldName: string, rowIndex: number): void { + this.tableValues[fieldName]?.splice(rowIndex, 1); + } + // --- Chat IA conversationnel (Phase b5) -------------------------------- toggleChat(): void { diff --git a/web/src/app/lore/page-view/page-view.component.html b/web/src/app/lore/page-view/page-view.component.html index 68b1198..c7e16fa 100644 --- a/web/src/app/lore/page-view/page-view.component.html +++ b/web/src/app/lore/page-view/page-view.component.html @@ -40,6 +40,50 @@ } + @if (field.type === 'KEY_VALUE_LIST') { +
+

{{ field.name }}

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

Non renseigné

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

{{ field.name }}

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

Non renseigné

+ } +
+ } } } diff --git a/web/src/app/lore/page-view/page-view.component.ts b/web/src/app/lore/page-view/page-view.component.ts index 35b5c42..3910f19 100644 --- a/web/src/app/lore/page-view/page-view.component.ts +++ b/web/src/app/lore/page-view/page-view.component.ts @@ -114,6 +114,21 @@ export class PageViewComponent implements OnInit, OnDestroy { return this.page?.imageValues?.[fieldName] ?? []; } + /** Valeur d'un libellé d'un champ KEY_VALUE_LIST (tableau). */ + kvValueOf(fieldName: string, label: string): string { + return this.page?.keyValueValues?.[fieldName]?.[label] ?? ''; + } + + /** True si au moins une valeur de la liste clé/valeur est renseignée. */ + kvHasContent(fieldName: string, labels: string[] | null | undefined): boolean { + return (labels ?? []).some(lbl => this.kvValueOf(fieldName, lbl).trim() !== ''); + } + + /** Lignes d'un champ TABLE (liste vide si jamais rempli). */ + tableRowsOf(fieldName: string): Array> { + return this.page?.tableValues?.[fieldName] ?? []; + } + /** Helper — résout l'ID d'une page liée en son titre (pour affichage dans les chips). */ titleOfRelated(pageId: string): string { return this.allPages.find(p => p.id === pageId)?.title ?? '(page supprimée)'; diff --git a/web/src/app/lore/template-create/template-create.component.html b/web/src/app/lore/template-create/template-create.component.html index 8a899aa..3c2285c 100644 --- a/web/src/app/lore/template-create/template-create.component.html +++ b/web/src/app/lore/template-create/template-create.component.html @@ -66,17 +66,24 @@ - - + + {{ f.name }} - + @if (f.type === 'IMAGE') { + + + } + + @if (!(f.labels ?? []).length) { + + {{ f.type === 'TABLE' + ? 'Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)' + : 'Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)' }} + + } + + } } @@ -111,13 +148,15 @@ aria-label="Type du champ"> + + -

Les champs Texte sont editables librement et utilisables par l'IA. Les champs Image hebergent une galerie d'illustrations.

+

Texte = libre + utilisable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).

diff --git a/web/src/app/lore/template-create/template-create.component.scss b/web/src/app/lore/template-create/template-create.component.scss index 63b3ce0..955c275 100644 --- a/web/src/app/lore/template-create/template-create.component.scss +++ b/web/src/app/lore/template-create/template-create.component.scss @@ -90,6 +90,70 @@ gap: 0.5rem; } +// Sous-ligne des libellés d'un champ Tableau (KEY_VALUE_LIST). +.kv-labels-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + // Aligne visuellement sous le chip du champ (décalage = reorder-stack + gap). + padding-left: 30px; + + .kv-label-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + background: #1a1a2e; + border: 1px solid #4a3a1f; + border-radius: 5px; + padding: 0.15rem 0.3rem; + + input { + width: 90px; + background: transparent; + border: none; + color: #fbd38d; + font-size: 0.8rem; + padding: 0.2rem 0.3rem; + + &:focus { outline: none; } + } + + .kv-label-remove { + background: transparent; + border: none; + color: #6b7280; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 2px; + + &:hover { color: #f87171; } + } + } + + .btn-kv-add-label { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: transparent; + border: 1px dashed #4a3a1f; + border-radius: 5px; + color: #fbd38d; + font-size: 0.78rem; + padding: 0.3rem 0.55rem; + cursor: pointer; + + &:hover { background: rgba(251, 211, 141, 0.08); } + } + + .kv-labels-hint { + font-size: 0.75rem; + color: #6b7280; + font-style: italic; + } +} + .field-row { display: flex; align-items: center; @@ -111,6 +175,18 @@ background: #312b5c; color: #c7b8ff; } + + // Couleur discriminante pour les champs Liste clé/valeur (palette ambre). + &.field-chip-kv { + background: #4a3a1f; + color: #fbd38d; + } + + // Couleur discriminante pour les champs Tableau (palette sarcelle). + &.field-chip-table { + background: #134e4a; + color: #99f6e4; + } } .btn-type-toggle { diff --git a/web/src/app/lore/template-create/template-create.component.ts b/web/src/app/lore/template-create/template-create.component.ts index f359d83..38e0fc2 100644 --- a/web/src/app/lore/template-create/template-create.component.ts +++ b/web/src/app/lore/template-create/template-create.component.ts @@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular'; +import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular'; import { LoreService } from '../../services/lore.service'; import { TemplateService } from '../../services/template.service'; import { PageService } from '../../services/page.service'; import { LayoutService } from '../../services/layout.service'; import { LoreNode } from '../../services/lore.model'; -import { FieldType, ImageLayout, TemplateField } from '../../services/template.model'; +import { FieldType, ImageLayout, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; import { popReturnTo } from '../return-stack.helper'; @@ -31,6 +31,19 @@ export class TemplateCreateComponent implements OnInit, OnDestroy { readonly ImageIcon = ImageIcon; readonly ChevronUp = ChevronUp; readonly ChevronDown = ChevronDown; + readonly ListOrdered = ListOrdered; + readonly TableIcon = TableIcon; + readonly X = X; + + /** Icone du chip selon le type du champ. */ + iconFor(type: FieldType) { + switch (type) { + case 'IMAGE': return this.ImageIcon; + case 'KEY_VALUE_LIST': return this.ListOrdered; + case 'TABLE': return this.TableIcon; + default: return this.Type; + } + } form: FormGroup; loreId = ''; @@ -123,10 +136,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy { if (!name) return; // Unicite par nom (on ignore le type pour eviter des collisions d'affichage). if (this.fields.some(f => f.name === name)) return; - const newField: TemplateField = this.newFieldType === 'IMAGE' - ? { name, type: 'IMAGE', layout: 'GALLERY' } - : { name, type: 'TEXT' }; - this.fields = [...this.fields, newField]; + this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)]; this.newFieldName = ''; // Le type reste sur la derniere valeur choisie : pratique pour enchainer // plusieurs champs du meme type. @@ -145,17 +155,11 @@ export class TemplateCreateComponent implements OnInit, OnDestroy { this.fields = next; } - /** Bascule le type d'un champ existant (TEXT <-> IMAGE). */ - toggleFieldType(index: number): void { - const field = this.fields[index]; - if (!field) return; - const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT'; - this.fields = this.fields.map((f, i) => { - if (i !== index) return f; - return nextType === 'IMAGE' - ? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' } - : { name: f.name, type: 'TEXT' }; - }); + /** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */ + setFieldType(index: number, type: FieldType): void { + this.fields = this.fields.map((f, i) => + i === index ? buildLoreTemplateField(f.name, type, f) : f + ); } /** Met a jour le layout d'un champ IMAGE. */ @@ -165,6 +169,24 @@ export class TemplateCreateComponent implements OnInit, OnDestroy { ); } + // --- Sous-editeur des libelles (KEY_VALUE_LIST) ------------------------- + // Mutation en place des labels : recreer le tableau de fields a chaque + // frappe ferait perdre le focus de l'input en cours d'edition. + + addLabel(field: TemplateField): void { + field.labels = [...(field.labels ?? []), '']; + } + + updateLabel(field: TemplateField, labelIndex: number, value: string): void { + if (!field.labels) return; + field.labels[labelIndex] = value; + } + + removeLabel(field: TemplateField, labelIndex: number): void { + if (!field.labels) return; + field.labels = field.labels.filter((_, i) => i !== labelIndex); + } + submit(): void { if (this.form.invalid) return; const raw = this.form.value; @@ -173,7 +195,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy { name: raw.name, description: raw.description, defaultNodeId: raw.defaultNodeId, - fields: this.fields + fields: cleanFieldLabels(this.fields) }).subscribe({ next: (created) => this.navigateBack(created.id ?? null), error: () => console.error('Erreur lors de la création du template') diff --git a/web/src/app/lore/template-edit/template-edit.component.html b/web/src/app/lore/template-edit/template-edit.component.html index 7cef67d..62dc604 100644 --- a/web/src/app/lore/template-edit/template-edit.component.html +++ b/web/src/app/lore/template-edit/template-edit.component.html @@ -54,17 +54,24 @@ - + [class.field-chip-kv]="f.type === 'KEY_VALUE_LIST'" + [class.field-chip-table]="f.type === 'TABLE'" + [class.field-chip-existing]="f.type === 'TEXT' && isExistingField(f)" + [class.field-chip-new]="f.type === 'TEXT' && !isExistingField(f)"> + {{ f.name }} - + @if (f.type === 'IMAGE') { + + + } + + @if (!(f.labels ?? []).length) { + + {{ f.type === 'TABLE' + ? 'Ajoutez les colonnes du tableau (ex : Objet, Prix, Description…)' + : 'Ajoutez les libellés des lignes (ex : FOR, DEX, CON…)' }} + + } + + } }
@@ -98,12 +135,14 @@ aria-label="Type du champ"> + +
-

Texte = zone editable + generable par l'IA. Image = galerie d'illustrations.

+

Texte = libre + generable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).

diff --git a/web/src/app/lore/template-edit/template-edit.component.scss b/web/src/app/lore/template-edit/template-edit.component.scss index 5213a31..4e4bc6f 100644 --- a/web/src/app/lore/template-edit/template-edit.component.scss +++ b/web/src/app/lore/template-edit/template-edit.component.scss @@ -90,6 +90,70 @@ gap: 0.5rem; } +// Sous-ligne des libellés d'un champ Tableau (KEY_VALUE_LIST). +.kv-labels-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + // Aligne visuellement sous le chip du champ (décalage = reorder-stack + gap). + padding-left: 30px; + + .kv-label-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + background: #1a1a2e; + border: 1px solid #4a3a1f; + border-radius: 5px; + padding: 0.15rem 0.3rem; + + input { + width: 90px; + background: transparent; + border: none; + color: #fbd38d; + font-size: 0.8rem; + padding: 0.2rem 0.3rem; + + &:focus { outline: none; } + } + + .kv-label-remove { + background: transparent; + border: none; + color: #6b7280; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 2px; + + &:hover { color: #f87171; } + } + } + + .btn-kv-add-label { + display: inline-flex; + align-items: center; + gap: 0.25rem; + background: transparent; + border: 1px dashed #4a3a1f; + border-radius: 5px; + color: #fbd38d; + font-size: 0.78rem; + padding: 0.3rem 0.55rem; + cursor: pointer; + + &:hover { background: rgba(251, 211, 141, 0.08); } + } + + .kv-labels-hint { + font-size: 0.75rem; + color: #6b7280; + font-style: italic; + } +} + .field-row { display: flex; align-items: center; @@ -127,6 +191,20 @@ border-color: #3d3566; color: #c7b8ff; } + + // Champ Liste clé/valeur (palette ambre) — prioritaire sur existing/new. + &.field-chip-kv { + background: #4a3a1f; + border-color: #6b5328; + color: #fbd38d; + } + + // Champ Tableau (palette sarcelle) — prioritaire sur existing/new. + &.field-chip-table { + background: #134e4a; + border-color: #14b8a6; + color: #99f6e4; + } } .btn-type-toggle { diff --git a/web/src/app/lore/template-edit/template-edit.component.ts b/web/src/app/lore/template-edit/template-edit.component.ts index b1f9040..06faa42 100644 --- a/web/src/app/lore/template-edit/template-edit.component.ts +++ b/web/src/app/lore/template-edit/template-edit.component.ts @@ -4,14 +4,14 @@ import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, Subject } from 'rxjs'; import { switchMap, takeUntil } from 'rxjs/operators'; -import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular'; +import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular'; import { LoreService } from '../../services/lore.service'; import { TemplateService } from '../../services/template.service'; import { PageService } from '../../services/page.service'; import { LayoutService } from '../../services/layout.service'; import { PageTitleService } from '../../services/page-title.service'; import { LoreNode } from '../../services/lore.model'; -import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model'; +import { FieldType, ImageLayout, Template, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service'; @@ -32,6 +32,19 @@ export class TemplateEditComponent implements OnInit, OnDestroy { readonly ImageIcon = ImageIcon; readonly ChevronUp = ChevronUp; readonly ChevronDown = ChevronDown; + readonly ListOrdered = ListOrdered; + readonly TableIcon = TableIcon; + readonly X = X; + + /** Icone du chip selon le type du champ. */ + iconFor(type: FieldType) { + switch (type) { + case 'IMAGE': return this.ImageIcon; + case 'KEY_VALUE_LIST': return this.ListOrdered; + case 'TABLE': return this.TableIcon; + default: return this.Type; + } + } form: FormGroup; loreId = ''; @@ -100,10 +113,9 @@ export class TemplateEditComponent implements OnInit, OnDestroy { // Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant, // utile pour les templates legacy cote frontend meme si le backend le fait aussi). this.fields = (template.fields ?? []).map(f => { - const type: FieldType = f.type === 'IMAGE' ? 'IMAGE' : 'TEXT'; - return type === 'IMAGE' - ? { name: f.name, type, layout: f.layout ?? 'GALLERY' } - : { name: f.name, type }; + const type: FieldType = + f.type === 'IMAGE' || f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE' ? f.type : 'TEXT'; + return buildLoreTemplateField(f.name, type, f); }); this.originalFieldNames = new Set(this.fields.map(f => f.name)); this.form.patchValue({ @@ -118,10 +130,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy { const name = this.newFieldName.trim(); if (!name) return; if (this.fields.some(f => f.name === name)) return; - const newField: TemplateField = this.newFieldType === 'IMAGE' - ? { name, type: 'IMAGE', layout: 'GALLERY' } - : { name, type: 'TEXT' }; - this.fields = [...this.fields, newField]; + this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)]; this.newFieldName = ''; } @@ -138,17 +147,11 @@ export class TemplateEditComponent implements OnInit, OnDestroy { this.fields = next; } - /** Bascule le type d'un champ (TEXT <-> IMAGE). */ - toggleFieldType(index: number): void { - const field = this.fields[index]; - if (!field) return; - const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT'; - this.fields = this.fields.map((f, i) => { - if (i !== index) return f; - return nextType === 'IMAGE' - ? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' } - : { name: f.name, type: 'TEXT' }; - }); + /** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */ + setFieldType(index: number, type: FieldType): void { + this.fields = this.fields.map((f, i) => + i === index ? buildLoreTemplateField(f.name, type, f) : f + ); } /** Met a jour le layout d'un champ IMAGE. */ @@ -158,6 +161,24 @@ export class TemplateEditComponent implements OnInit, OnDestroy { ); } + // --- Sous-editeur des libelles (KEY_VALUE_LIST) ------------------------- + // Mutation en place des labels : recreer le tableau de fields a chaque + // frappe ferait perdre le focus de l'input en cours d'edition. + + addLabel(field: TemplateField): void { + field.labels = [...(field.labels ?? []), '']; + } + + updateLabel(field: TemplateField, labelIndex: number, value: string): void { + if (!field.labels) return; + field.labels[labelIndex] = value; + } + + removeLabel(field: TemplateField, labelIndex: number): void { + if (!field.labels) return; + field.labels = field.labels.filter((_, i) => i !== labelIndex); + } + save(): void { if (this.form.invalid || !this.template) return; const raw = this.form.value; @@ -166,7 +187,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy { name: raw.name, description: raw.description, defaultNodeId: raw.defaultNodeId || null, - fields: this.fields + fields: cleanFieldLabels(this.fields) }).subscribe({ next: () => this.router.navigate(['/lore', this.loreId]), error: () => console.error('Erreur lors de la sauvegarde du template') diff --git a/web/src/app/services/npc.model.ts b/web/src/app/services/npc.model.ts index e629b27..a508db3 100644 --- a/web/src/app/services/npc.model.ts +++ b/web/src/app/services/npc.model.ts @@ -11,6 +11,8 @@ export interface Npc { imageValues?: Record; keyValueValues?: Record>; campaignId: string; + /** IDs de Pages de Lore référencées (sa ville, sa faction…). */ + relatedPageIds?: string[]; /** Dossier de classement (ex. « Bard's Gate »). Vide/absent = non classé. */ folder?: string | null; order?: number; @@ -24,5 +26,6 @@ export interface NpcCreate { imageValues?: Record; keyValueValues?: Record>; campaignId: string; + relatedPageIds?: string[]; folder?: string | null; } diff --git a/web/src/app/services/npc.service.ts b/web/src/app/services/npc.service.ts index 22f9c45..c87c567 100644 --- a/web/src/app/services/npc.service.ts +++ b/web/src/app/services/npc.service.ts @@ -16,6 +16,11 @@ export class NpcService { return this.http.get(`${this.apiUrl}/campaign/${campaignId}`); } + /** PNJ de toutes les campagnes liées à un Lore — alimente le graphe du Lore. */ + getByLore(loreId: string): Observable { + return this.http.get(`${this.apiUrl}/lore/${loreId}`); + } + getById(id: string): Observable { return this.http.get(`${this.apiUrl}/${id}`); } diff --git a/web/src/app/services/page.model.ts b/web/src/app/services/page.model.ts index 3574b9d..2b3927b 100644 --- a/web/src/app/services/page.model.ts +++ b/web/src/app/services/page.model.ts @@ -12,6 +12,16 @@ export interface Page { * uploadees (Shared Kernel images). Structure separee de `values`. */ imageValues?: Record; + /** + * Pour chaque champ KEY_VALUE_LIST (tableau libelle → valeur, comme sur les + * fiches de personnage) : fieldName → (label → valeur). + */ + keyValueValues?: Record>; + /** + * Pour chaque champ TABLE (colonnes figees au template, lignes libres) : + * fieldName → lignes ordonnees, chaque ligne = colonne → cellule. + */ + tableValues?: Record>>; notes?: string | null; tags?: string[]; relatedPageIds?: string[]; diff --git a/web/src/app/services/template.model.ts b/web/src/app/services/template.model.ts index 04740dd..c782df5 100644 --- a/web/src/app/services/template.model.ts +++ b/web/src/app/services/template.model.ts @@ -6,8 +6,10 @@ * - '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 + * - 'TABLE' : tableau a colonnes figees (labels = noms de colonnes) et + * lignes libres ajoutees au remplissage (boutique, inventaire…) */ -export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST'; +export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST' | 'TABLE'; /** * Variante de rendu pour un champ IMAGE. Miroir de @@ -28,10 +30,45 @@ export interface TemplateField { type: FieldType; /** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */ layout?: ImageLayout | null; - /** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */ + /** + * Labels predefinis (ordre significatif) : + * KEY_VALUE_LIST = libelles des lignes ; TABLE = noms des colonnes. + */ labels?: string[] | null; } +/** + * Construit un TemplateField propre pour un type donne (attributs par defaut, + * conservation des attributs compatibles de `previous` lors d'un changement de + * type). Partage par les editeurs de template du Lore (create/edit). + */ +export function buildLoreTemplateField( + name: string, + type: FieldType, + previous?: TemplateField +): TemplateField { + switch (type) { + case 'IMAGE': + return { name, type, layout: previous?.layout ?? 'GALLERY' }; + case 'KEY_VALUE_LIST': + case 'TABLE': + // Les labels (lignes KV / colonnes TABLE) survivent au changement de type + // entre ces deux variantes. + return { name, type, labels: previous?.labels ?? [] }; + default: + return { name, type: 'TEXT' }; + } +} + +/** Retire les libelles vides (lignes KV / colonnes TABLE) avant sauvegarde. */ +export function cleanFieldLabels(fields: TemplateField[]): TemplateField[] { + return fields.map(f => + f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE' + ? { ...f, labels: (f.labels ?? []).map(l => l.trim()).filter(l => !!l) } + : f + ); +} + export interface Template { id?: string; loreId: string; diff --git a/web/src/styles/_view.scss b/web/src/styles/_view.scss index a5e88d3..45c314b 100644 --- a/web/src/styles/_view.scss +++ b/web/src/styles/_view.scss @@ -142,6 +142,71 @@ } } + // Tableau libellé/valeur (champ KEY_VALUE_LIST) en lecture seule. + // Même esthétique que la grille de saisie des fiches de personnage. + .view-kv-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px; + + .view-kv-cell { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 6px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + + .view-kv-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #9ca3af; + } + + .view-kv-value { + color: #e0e0e0; + font-family: 'Cinzel', 'EB Garamond', Georgia, serif; + font-weight: 700; + font-size: 1.05rem; + text-align: center; + word-break: break-word; + } + } + } + + // Tableau à colonnes (champ TABLE) en lecture seule : boutique, inventaire… + .view-table { + width: 100%; + border-collapse: collapse; + + th { + text-align: left; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #99f6e4; + padding: 8px 10px; + border-bottom: 1px solid rgba(20, 184, 166, 0.35); + } + + td { + color: #e0e0e0; + font-size: 0.92rem; + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + vertical-align: top; + white-space: pre-wrap; + word-break: break-word; + } + + tbody tr:nth-child(odd) { + background: rgba(255, 255, 255, 0.02); + } + } + // Chips (tags et pages liées en lecture seule) .view-chips { display: flex;