>> : 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) {
+
+
Pages de Lore liées
+
+
+
Reliez ce PNJ aux pages de l'univers (sa ville, sa faction, sa région…). Ces liens apparaissent dans le graphe du Lore.
+
+ }
+
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 ebfc46a..e2b5dd5 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
@@ -6,11 +6,14 @@ import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'l
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 { Page } from '../../../services/page.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
+import { LoreLinkPickerComponent } from '../../../shared/lore-link-picker/lore-link-picker.component';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
/**
@@ -21,7 +24,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/
@Component({
selector: 'app-npc-edit',
- imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
+ imports: [FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent, LoreLinkPickerComponent],
templateUrl: './npc-edit.component.html',
styleUrls: ['./npc-edit.component.scss']
})
@@ -56,12 +59,20 @@ export class NpcEditComponent implements OnInit {
templateFields: TemplateField[] = [];
private order = 0;
+ /** Lore lié à la campagne (null = pas de lore → section liens masquée). */
+ loreId: string | null = null;
+ /** Pages du lore lié — référentiel du picker. */
+ lorePages: Page[] = [];
+ /** IDs des pages de lore référencées par ce PNJ. */
+ relatedPageIds: string[] = [];
+
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService,
+ private pageService: PageService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
) {}
@@ -87,6 +98,7 @@ export class NpcEditComponent implements OnInit {
this.values = n.values ?? {};
this.imageValues = n.imageValues ?? {};
this.keyValueValues = n.keyValueValues ?? {};
+ this.relatedPageIds = [...(n.relatedPageIds ?? [])];
this.order = n.order ?? 0;
},
error: () => this.back()
@@ -108,6 +120,14 @@ export class NpcEditComponent implements OnInit {
private loadTemplateForCampaign(campaignId: string): void {
this.campaignService.getCampaignById(campaignId).subscribe({
next: (campaign) => {
+ // Lore lié → charge ses pages pour le picker de références.
+ if (campaign.loreId) {
+ this.loreId = campaign.loreId;
+ this.pageService.getByLoreId(campaign.loreId).subscribe({
+ next: (pages) => { this.lorePages = pages; },
+ error: () => { this.lorePages = []; }
+ });
+ }
if (!campaign.gameSystemId) {
this.templateFields = [];
return;
@@ -132,7 +152,8 @@ export class NpcEditComponent implements OnInit {
values: this.values,
imageValues: this.imageValues,
keyValueValues: this.keyValueValues,
- campaignId: this.campaignId
+ campaignId: this.campaignId,
+ relatedPageIds: this.relatedPageIds
};
const isCreation = !this.npcId;
const req = this.npcId
diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.html b/web/src/app/campaigns/npc/npc-view/npc-view.component.html
index efe6682..582901c 100644
--- a/web/src/app/campaigns/npc/npc-view/npc-view.component.html
+++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.html
@@ -18,6 +18,23 @@
+
+
+ @if (loreId && (npc?.relatedPageIds?.length ?? 0) > 0) {
+
+
+
+ Pages de Lore liées
+
+
+
+ }
@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 }}
}
+
+ @if (field.type === 'KEY_VALUE_LIST') {
+
+
{{ field.name }}
+
+ @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') {
+
+
{{ field.name }}
+ @if ((field.labels ?? []).length) {
+
+
+
+
+ Ajouter une ligne
+
+
+ } @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) {
+ {{ col }}
+ }
+
+
+
+ @for (row of tableRowsOf(field.name); track $index) {
+
+ @for (col of field.labels ?? []; track $index) {
+ {{ 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 }}
-
- {{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
-
+
+ Texte
+ Image
+ Liste clé/valeur
+ Tableau
+
@if (f.type === 'IMAGE') {
+
+ @if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') {
+
+ @for (lbl of f.labels ?? []; track $index; let li = $index) {
+
+
+
+
+
+
+ }
+
+
+ {{ f.type === 'TABLE' ? 'Colonne' : 'Libellé' }}
+
+ @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">
Texte
Image
+ Liste clé/valeur
+ Tableau
- 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 }}
-
- {{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
-
+
+ Texte
+ Image
+ Liste clé/valeur
+ Tableau
+
@if (f.type === 'IMAGE') {
+
+ @if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') {
+
+ @for (lbl of f.labels ?? []; track $index; let li = $index) {
+
+
+
+
+
+
+ }
+
+
+ {{ f.type === 'TABLE' ? 'Colonne' : 'Libellé' }}
+
+ @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
Image
+ Liste clé/valeur
+ Tableau
- 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;