Compare commits
2 Commits
v0.8.2
...
7c74c12f3e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c74c12f3e | |||
| 86836ad81c |
67
README.md
67
README.md
@@ -1,37 +1,66 @@
|
|||||||
# LoreMind
|
# LoreMind
|
||||||
|
|
||||||
|
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel.
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://loremind-docs.igmlcreation.fr/)
|
||||||
|
[](https://loremind-demo.igmlcreation.fr/)
|
||||||
|
[](https://www.patreon.com/c/IGMLCreation)
|
||||||
|
[](https://discord.gg/cPpFzCjEzQ)
|
||||||
|
|
||||||
|
## Découvrir LoreMind en vidéo
|
||||||
|
|
||||||
|
[](https://www.youtube.com/watch?v=llJkmlotbB8)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Loremind est une application web angular auto-hébergable afin de venir en aide aux Maîtres de jeu qui souhaitent centraliser leur univers et leurs campagnes.
|
## Ce que ça fait
|
||||||
Cette dernière intègre un moteur IA qui va ingérer le contenu du lore et de la campagne afin de pouvoir répondre à des questions précises sur l'univers ou la campagne, mais également proposer des idées de création dans le contexte de la campagne et du lore.
|
|
||||||
Pour le moment seul Ollama est supporté pour la partie locale, il y-a également une intégration pour 1min.ai. Plus tard, d'autres moteurs seront supportés.
|
LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
|
||||||
|
|
||||||
|
### Lore
|
||||||
|
|
||||||
|
Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches.
|
||||||
|
|
||||||
|
### Game System
|
||||||
|
|
||||||
|
Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système.
|
||||||
|
|
||||||
|
### Campaign
|
||||||
|
|
||||||
|
Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu.
|
||||||
|
|
||||||
|
### Assistant IA
|
||||||
|
|
||||||
|
Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table.
|
||||||
|
|
||||||
|
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
La documentation complète est accessible sur le site [loremind-docs](https://loremind-docs.igmlcreation.fr/)
|
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
|
||||||
|
|
||||||
Pour l'installation, consultez le guide dans cette dernière .
|
## Démo en ligne
|
||||||
|
|
||||||
## Fonctionnalités
|
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
|
||||||
|
|
||||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
Quelques limites à connaître :
|
||||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
- 10 utilisateurs maximum simultanés (instances isolées)
|
||||||
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
- Session limitée à 20 minutes avant réinitialisation
|
||||||
|
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
|
||||||
|
|
||||||
## Démo
|
## Soutenir le projet
|
||||||
|
|
||||||
Une démo est disponible sur le site [loremind-demo](https://loremind-demo.igmlcreation.fr/)
|
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
|
||||||
|
|
||||||
!! Attention, la démo est uniquement accessible à 10 personnes à la fois (instances personnalisées). Cette limite est mise en place pour éviter l'overhead sur les ressources serveur.
|
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
|
||||||
|
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
|
||||||
|
|
||||||
Cette dernière est utilisable 20 minutes maximum par session avant d'être réinitialiser.
|
## Licence
|
||||||
Vous comprendrez également qu'elle ne contient pas de démo pour la partie IA, pour laquelle il faut configurer un serveur Ollama (et qui ferait donc exploser le serveur) ou utiliser 1min.ai.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
|
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
|
||||||
|
|
||||||
En pratique :
|
En pratique :
|
||||||
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
|
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
|
||||||
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence.
|
- Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
|
||||||
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application.
|
- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.6.6",
|
version="0.8.3",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.8.1</version>
|
<version>0.8.3</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public class CharacterService {
|
|||||||
String headerImageId,
|
String headerImageId,
|
||||||
Map<String, String> values,
|
Map<String, String> values,
|
||||||
Map<String, List<String>> imageValues,
|
Map<String, List<String>> imageValues,
|
||||||
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
String campaignId,
|
String campaignId,
|
||||||
Integer order
|
Integer order
|
||||||
) {}
|
) {}
|
||||||
@@ -46,6 +47,7 @@ public class CharacterService {
|
|||||||
.headerImageId(data.headerImageId())
|
.headerImageId(data.headerImageId())
|
||||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||||
.campaignId(data.campaignId())
|
.campaignId(data.campaignId())
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -68,6 +70,7 @@ public class CharacterService {
|
|||||||
existing.setHeaderImageId(data.headerImageId());
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
|
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class NpcService {
|
|||||||
String headerImageId,
|
String headerImageId,
|
||||||
Map<String, String> values,
|
Map<String, String> values,
|
||||||
Map<String, List<String>> imageValues,
|
Map<String, List<String>> imageValues,
|
||||||
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
String campaignId,
|
String campaignId,
|
||||||
Integer order
|
Integer order
|
||||||
) {}
|
) {}
|
||||||
@@ -41,6 +42,7 @@ public class NpcService {
|
|||||||
.headerImageId(data.headerImageId())
|
.headerImageId(data.headerImageId())
|
||||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||||
.campaignId(data.campaignId())
|
.campaignId(data.campaignId())
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -63,6 +65,7 @@ public class NpcService {
|
|||||||
existing.setHeaderImageId(data.headerImageId());
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
|
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ public class Character {
|
|||||||
*/
|
*/
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeurs des champs KEY_VALUE_LIST du template PJ. Cle externe = nom du
|
||||||
|
* champ template (ex: "Caracteristiques"), cle interne = label predefini
|
||||||
|
* dans le template (ex: "FOR"), valeur = valeur saisie (ex: "16").
|
||||||
|
* Les labels suivent l'ordre defini dans TemplateField.labels.
|
||||||
|
*/
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
/** Référence vers la Campaign parente. */
|
/** Référence vers la Campaign parente. */
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
|
|
||||||
@@ -70,4 +78,9 @@ public class Character {
|
|||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
return imageValues;
|
return imageValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, Map<String, String>> getKeyValueValues() {
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
|
return keyValueValues;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ public class Npc {
|
|||||||
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
|
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
|
|
||||||
@@ -58,4 +61,9 @@ public class Npc {
|
|||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
return imageValues;
|
return imageValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, Map<String, String>> getKeyValueValues() {
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
|
return keyValueValues;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ package com.loremind.domain.shared.template;
|
|||||||
* - TEXT : valeur textuelle libre (Map<String, String>)
|
* - TEXT : valeur textuelle libre (Map<String, String>)
|
||||||
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
|
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
|
||||||
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
|
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
|
||||||
|
* - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
|
||||||
|
* (Map<String, Map<String, String>> : fieldName -> label -> value).
|
||||||
|
* Usage : stat blocks, listes de competences, traits.
|
||||||
* <p>
|
* <p>
|
||||||
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, KEY_VALUE_LIST, REFERENCE...
|
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
|
||||||
*/
|
*/
|
||||||
public enum FieldType {
|
public enum FieldType {
|
||||||
TEXT,
|
TEXT,
|
||||||
IMAGE,
|
IMAGE,
|
||||||
NUMBER
|
NUMBER,
|
||||||
|
KEY_VALUE_LIST
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Value Object d'un champ de Template (kernel partage).
|
* Value Object d'un champ de Template (kernel partage).
|
||||||
* <p>
|
* <p>
|
||||||
@@ -27,29 +29,45 @@ public class TemplateField {
|
|||||||
private FieldType type;
|
private FieldType type;
|
||||||
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
|
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
|
||||||
private ImageLayout layout;
|
private ImageLayout layout;
|
||||||
|
/**
|
||||||
|
* Labels predefinis pour les champs KEY_VALUE_LIST (ordre significatif).
|
||||||
|
* Ex: ["FOR","DEX","CON","INT","SAG","CHA"] pour un champ "Caracteristiques".
|
||||||
|
* Null/vide pour les autres types.
|
||||||
|
*/
|
||||||
|
private List<String> labels;
|
||||||
|
|
||||||
/** Constructeur de retrocompat : type seul, layout=null. */
|
/** Constructeur de retrocompat : type seul, layout/labels=null. */
|
||||||
public TemplateField(String name, FieldType type) {
|
public TemplateField(String name, FieldType type) {
|
||||||
this(name, type, null);
|
this(name, type, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constructeur de retrocompat : type + layout, labels=null. */
|
||||||
|
public TemplateField(String name, FieldType type, ImageLayout layout) {
|
||||||
|
this(name, type, layout, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
|
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
|
||||||
public static TemplateField text(String name) {
|
public static TemplateField text(String name) {
|
||||||
return new TemplateField(name, FieldType.TEXT, null);
|
return new TemplateField(name, FieldType.TEXT, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
|
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
|
||||||
public static TemplateField image(String name) {
|
public static TemplateField image(String name) {
|
||||||
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY);
|
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
|
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
|
||||||
public static TemplateField image(String name, ImageLayout layout) {
|
public static TemplateField image(String name, ImageLayout layout) {
|
||||||
return new TemplateField(name, FieldType.IMAGE, layout);
|
return new TemplateField(name, FieldType.IMAGE, layout, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raccourci : construit un champ de type NUMBER. */
|
/** Raccourci : construit un champ de type NUMBER. */
|
||||||
public static TemplateField number(String name) {
|
public static TemplateField number(String name) {
|
||||||
return new TemplateField(name, FieldType.NUMBER, null);
|
return new TemplateField(name, FieldType.NUMBER, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raccourci : construit un champ KEY_VALUE_LIST avec labels predefinis. */
|
||||||
|
public static TemplateField keyValueList(String name, List<String> labels) {
|
||||||
|
return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,12 +152,8 @@ public class GameSystemSeeder {
|
|||||||
TemplateField.number("Niveau"),
|
TemplateField.number("Niveau"),
|
||||||
TemplateField.number("PV max"),
|
TemplateField.number("PV max"),
|
||||||
TemplateField.number("CA"),
|
TemplateField.number("CA"),
|
||||||
TemplateField.number("FOR"),
|
TemplateField.keyValueList("Caracteristiques",
|
||||||
TemplateField.number("DEX"),
|
List.of("FOR", "DEX", "CON", "INT", "SAG", "CHA")),
|
||||||
TemplateField.number("CON"),
|
|
||||||
TemplateField.number("INT"),
|
|
||||||
TemplateField.number("SAG"),
|
|
||||||
TemplateField.number("CHA"),
|
|
||||||
TemplateField.text("Competences"),
|
TemplateField.text("Competences"),
|
||||||
TemplateField.text("Equipement"),
|
TemplateField.text("Equipement"),
|
||||||
TemplateField.text("Sorts"),
|
TemplateField.text("Sorts"),
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.converter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit une Map<String, Map<String, String>> en JSON et inversement.
|
||||||
|
* <p>
|
||||||
|
* Utilise pour Character/Npc.keyValueValues : pour chaque champ KEY_VALUE_LIST
|
||||||
|
* du template, stocke une map label -> value. Exemple :
|
||||||
|
* {"Caracteristiques": {"FOR":"16","DEX":"12","CON":"14"}}
|
||||||
|
* <p>
|
||||||
|
* Adaptateur technique pur : le domaine ignore ce converter.
|
||||||
|
*/
|
||||||
|
@Converter
|
||||||
|
public class StringMapMapJsonConverter
|
||||||
|
implements AttributeConverter<Map<String, Map<String, String>>, String> {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final TypeReference<Map<String, Map<String, String>>> TYPE_REF =
|
||||||
|
new TypeReference<>() {};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(Map<String, Map<String, String>> attribute) {
|
||||||
|
if (attribute == null || attribute.isEmpty()) return "{}";
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(attribute);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Erreur serialisation Map<String, Map<String,String>> -> JSON", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, String>> 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<String, Map<String,String>>", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,8 +85,18 @@ public class TemplateFieldListJsonConverter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
List<String> labels = null;
|
||||||
|
if (type == FieldType.KEY_VALUE_LIST) {
|
||||||
|
JsonNode labelsNode = item.path("labels");
|
||||||
|
if (labelsNode.isArray()) {
|
||||||
|
labels = new ArrayList<>();
|
||||||
|
for (JsonNode label : labelsNode) {
|
||||||
|
if (label.isTextual()) labels.add(label.asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (name != null && !name.isBlank()) {
|
if (name != null && !name.isBlank()) {
|
||||||
result.add(new TemplateField(name, type, layout));
|
result.add(new TemplateField(name, type, layout, labels));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.loremind.infrastructure.persistence.entity;
|
|||||||
|
|
||||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -53,6 +54,11 @@ public class CharacterJpaEntity {
|
|||||||
@Column(name = "image_values", columnDefinition = "TEXT")
|
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/** Valeurs KEY_VALUE_LIST serialisees JSON (fieldName -> label -> value). */
|
||||||
|
@Convert(converter = StringMapMapJsonConverter.class)
|
||||||
|
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
@Column(name = "campaign_id", nullable = false)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
|
|
||||||
@@ -71,6 +77,7 @@ public class CharacterJpaEntity {
|
|||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
if (values == null) values = new HashMap<>();
|
if (values == null) values = new HashMap<>();
|
||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.loremind.infrastructure.persistence.entity;
|
|||||||
|
|
||||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -46,6 +47,10 @@ public class NpcJpaEntity {
|
|||||||
@Column(name = "image_values", columnDefinition = "TEXT")
|
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||||
private Map<String, List<String>> imageValues;
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
@Convert(converter = StringMapMapJsonConverter.class)
|
||||||
|
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, Map<String, String>> keyValueValues;
|
||||||
|
|
||||||
@Column(name = "campaign_id", nullable = false)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
|
|
||||||
@@ -64,6 +69,7 @@ public class NpcJpaEntity {
|
|||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
if (values == null) values = new HashMap<>();
|
if (values == null) values = new HashMap<>();
|
||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
.headerImageId(e.getHeaderImageId())
|
.headerImageId(e.getHeaderImageId())
|
||||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(e.getCampaignId().toString())
|
.campaignId(e.getCampaignId().toString())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -73,6 +74,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
.headerImageId(c.getHeaderImageId())
|
.headerImageId(c.getHeaderImageId())
|
||||||
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
|
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
|
||||||
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
|
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(Long.parseLong(c.getCampaignId()))
|
.campaignId(Long.parseLong(c.getCampaignId()))
|
||||||
.order(c.getOrder())
|
.order(c.getOrder())
|
||||||
.createdAt(c.getCreatedAt())
|
.createdAt(c.getCreatedAt())
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
.headerImageId(e.getHeaderImageId())
|
.headerImageId(e.getHeaderImageId())
|
||||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(e.getCampaignId().toString())
|
.campaignId(e.getCampaignId().toString())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -73,6 +74,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
.headerImageId(n.getHeaderImageId())
|
.headerImageId(n.getHeaderImageId())
|
||||||
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
|
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
|
||||||
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(Long.parseLong(n.getCampaignId()))
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
.order(n.getOrder())
|
.order(n.getOrder())
|
||||||
.createdAt(n.getCreatedAt())
|
.createdAt(n.getCreatedAt())
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public class CharacterController {
|
|||||||
dto.getHeaderImageId(),
|
dto.getHeaderImageId(),
|
||||||
dto.getValues(),
|
dto.getValues(),
|
||||||
dto.getImageValues(),
|
dto.getImageValues(),
|
||||||
|
dto.getKeyValueValues(),
|
||||||
dto.getCampaignId(),
|
dto.getCampaignId(),
|
||||||
order
|
order
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ public class NpcController {
|
|||||||
dto.getHeaderImageId(),
|
dto.getHeaderImageId(),
|
||||||
dto.getValues(),
|
dto.getValues(),
|
||||||
dto.getImageValues(),
|
dto.getImageValues(),
|
||||||
|
dto.getKeyValueValues(),
|
||||||
dto.getCampaignId(),
|
dto.getCampaignId(),
|
||||||
order
|
order
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class CharacterDTO {
|
|||||||
private String headerImageId;
|
private String headerImageId;
|
||||||
private Map<String, String> values = new HashMap<>();
|
private Map<String, String> values = new HashMap<>();
|
||||||
private Map<String, List<String>> imageValues = new HashMap<>();
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
|
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class NpcDTO {
|
|||||||
private String headerImageId;
|
private String headerImageId;
|
||||||
private Map<String, String> values = new HashMap<>();
|
private Map<String, String> values = new HashMap<>();
|
||||||
private Map<String, List<String>> imageValues = new HashMap<>();
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
|
private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import lombok.AllArgsConstructor;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO pour un champ de Template.
|
* DTO pour un champ de Template.
|
||||||
* <p>
|
* <p>
|
||||||
@@ -17,13 +19,20 @@ import lombok.NoArgsConstructor;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class TemplateFieldDTO {
|
public class TemplateFieldDTO {
|
||||||
private String name;
|
private String name;
|
||||||
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
|
/** "TEXT" | "IMAGE" | "NUMBER" | "KEY_VALUE_LIST". */
|
||||||
private String type;
|
private String type;
|
||||||
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", null si type=TEXT. */
|
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", uniquement pour IMAGE. */
|
||||||
private String layout;
|
private String layout;
|
||||||
|
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
|
||||||
|
private List<String> labels;
|
||||||
|
|
||||||
/** Retrocompat : constructeur sans layout. */
|
/** Retrocompat : constructeur sans labels. */
|
||||||
|
public TemplateFieldDTO(String name, String type, String layout) {
|
||||||
|
this(name, type, layout, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrocompat : constructeur sans layout ni labels. */
|
||||||
public TemplateFieldDTO(String name, String type) {
|
public TemplateFieldDTO(String name, String type) {
|
||||||
this(name, type, null);
|
this(name, type, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class CharacterMapper {
|
|||||||
dto.setHeaderImageId(c.getHeaderImageId());
|
dto.setHeaderImageId(c.getHeaderImageId());
|
||||||
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
|
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
|
||||||
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
|
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
|
||||||
|
dto.setKeyValueValues(c.getKeyValueValues() != null ? new HashMap<>(c.getKeyValueValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(c.getCampaignId());
|
dto.setCampaignId(c.getCampaignId());
|
||||||
dto.setOrder(c.getOrder());
|
dto.setOrder(c.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -32,6 +33,7 @@ public class CharacterMapper {
|
|||||||
.headerImageId(dto.getHeaderImageId())
|
.headerImageId(dto.getHeaderImageId())
|
||||||
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class NpcMapper {
|
|||||||
dto.setHeaderImageId(n.getHeaderImageId());
|
dto.setHeaderImageId(n.getHeaderImageId());
|
||||||
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
|
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
|
||||||
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
||||||
|
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(n.getCampaignId());
|
dto.setCampaignId(n.getCampaignId());
|
||||||
dto.setOrder(n.getOrder());
|
dto.setOrder(n.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -32,6 +33,7 @@ public class NpcMapper {
|
|||||||
.headerImageId(dto.getHeaderImageId())
|
.headerImageId(dto.getHeaderImageId())
|
||||||
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
|
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -6,14 +6,16 @@ import com.loremind.domain.shared.template.TemplateField;
|
|||||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapper pour convertir entre {@link TemplateField} (domaine) et
|
* Mapper pour convertir entre {@link TemplateField} (domaine) et
|
||||||
* {@link TemplateFieldDTO} (wire).
|
* {@link TemplateFieldDTO} (wire).
|
||||||
* <p>
|
* <p>
|
||||||
* Tolerance : un type inconnu recu du client est interprete comme TEXT
|
* Tolerance : un type inconnu recu du client est interprete comme TEXT.
|
||||||
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
|
|
||||||
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
|
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
|
||||||
* Le layout est force a null pour les champs TEXT.
|
* Layout/labels forces a null pour les types qui ne les utilisent pas.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TemplateFieldMapper {
|
public class TemplateFieldMapper {
|
||||||
@@ -26,7 +28,11 @@ public class TemplateFieldMapper {
|
|||||||
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
|
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
|
||||||
layoutStr = layout.name();
|
layoutStr = layout.name();
|
||||||
}
|
}
|
||||||
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr);
|
List<String> labels = null;
|
||||||
|
if (field.getType() == FieldType.KEY_VALUE_LIST && field.getLabels() != null) {
|
||||||
|
labels = new ArrayList<>(field.getLabels());
|
||||||
|
}
|
||||||
|
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
|
||||||
}
|
}
|
||||||
|
|
||||||
public TemplateField toDomain(TemplateFieldDTO dto) {
|
public TemplateField toDomain(TemplateFieldDTO dto) {
|
||||||
@@ -47,6 +53,10 @@ public class TemplateFieldMapper {
|
|||||||
layout = ImageLayout.GALLERY;
|
layout = ImageLayout.GALLERY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new TemplateField(dto.getName(), type, layout);
|
List<String> labels = null;
|
||||||
|
if (type == FieldType.KEY_VALUE_LIST && dto.getLabels() != null) {
|
||||||
|
labels = new ArrayList<>(dto.getLabels());
|
||||||
|
}
|
||||||
|
return new TemplateField(dto.getName(), type, layout, labels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.createNpc(
|
Npc result = npcService.createNpc(
|
||||||
new NpcService.NpcData("Borin le forgeron", null, null,
|
new NpcService.NpcData("Borin le forgeron", null, null,
|
||||||
Map.of("Notes", "Borin"), null, "camp-1", 5));
|
Map.of("Notes", "Borin"), null, null, "camp-1", 5));
|
||||||
|
|
||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
@@ -67,7 +67,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, "camp-1", null));
|
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -79,7 +79,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, "camp-1", null));
|
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -124,7 +124,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin renommé", null, null,
|
new NpcService.NpcData("Borin renommé", null, null,
|
||||||
Map.of("Notes", "v2"), null, "camp-1", 7));
|
Map.of("Notes", "v2"), null, null, "camp-1", 7));
|
||||||
|
|
||||||
assertEquals("Borin renommé", result.getName());
|
assertEquals("Borin renommé", result.getName());
|
||||||
assertEquals("v2", result.getValues().get("Notes"));
|
assertEquals("v2", result.getValues().get("Notes"));
|
||||||
@@ -138,7 +138,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin", null, null,
|
new NpcService.NpcData("Borin", null, null,
|
||||||
Map.of("Notes", "txt"), null, "camp-1", null));
|
Map.of("Notes", "txt"), null, null, "camp-1", null));
|
||||||
|
|
||||||
// testNpc avait order=1 → préservé
|
// testNpc avait order=1 → préservé
|
||||||
assertEquals(1, result.getOrder());
|
assertEquals(1, result.getOrder());
|
||||||
@@ -150,7 +150,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
() -> npcService.updateNpc("missing",
|
() -> npcService.updateNpc("missing",
|
||||||
new NpcService.NpcData("x", null, null, null, null, "camp-1", null)));
|
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null)));
|
||||||
assertTrue(ex.getMessage().contains("missing"));
|
assertTrue(ex.getMessage().contains("missing"));
|
||||||
verify(npcRepository, never()).save(any());
|
verify(npcRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
Auteur : ietm64
|
Auteur : ietm64
|
||||||
Licence : AGPL-3.0
|
Licence : AGPL-3.0
|
||||||
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
||||||
Version : 0.8.1
|
Version : 0.8.3
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://github.com/IGMLcreation/LoreMind
|
https://github.com/IGMLcreation/LoreMind
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.1",
|
"version": "0.8.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.1",
|
"version": "0.8.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.1",
|
"version": "0.8.3",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -61,8 +61,10 @@
|
|||||||
[fields]="templateFields"
|
[fields]="templateFields"
|
||||||
[values]="values"
|
[values]="values"
|
||||||
[imageValues]="imageValues"
|
[imageValues]="imageValues"
|
||||||
|
[keyValueValues]="keyValueValues"
|
||||||
(valuesChange)="values = $event"
|
(valuesChange)="values = $event"
|
||||||
(imageValuesChange)="imageValues = $event">
|
(imageValuesChange)="imageValues = $event"
|
||||||
|
(keyValueValuesChange)="keyValueValues = $event">
|
||||||
</app-dynamic-fields-form>
|
</app-dynamic-fields-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
headerImageId: string | null = null;
|
headerImageId: string | null = null;
|
||||||
values: Record<string, string> = {};
|
values: Record<string, string> = {};
|
||||||
imageValues: Record<string, string[]> = {};
|
imageValues: Record<string, string[]> = {};
|
||||||
|
keyValueValues: Record<string, Record<string, string>> = {};
|
||||||
templateFields: TemplateField[] = [];
|
templateFields: TemplateField[] = [];
|
||||||
private order = 0;
|
private order = 0;
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
this.headerImageId = c.headerImageId ?? null;
|
this.headerImageId = c.headerImageId ?? null;
|
||||||
this.values = c.values ?? {};
|
this.values = c.values ?? {};
|
||||||
this.imageValues = c.imageValues ?? {};
|
this.imageValues = c.imageValues ?? {};
|
||||||
|
this.keyValueValues = c.keyValueValues ?? {};
|
||||||
this.order = c.order ?? 0;
|
this.order = c.order ?? 0;
|
||||||
},
|
},
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
@@ -112,6 +114,7 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
headerImageId: this.headerImageId,
|
headerImageId: this.headerImageId,
|
||||||
values: this.values,
|
values: this.values,
|
||||||
imageValues: this.imageValues,
|
imageValues: this.imageValues,
|
||||||
|
keyValueValues: this.keyValueValues,
|
||||||
campaignId: this.campaignId
|
campaignId: this.campaignId
|
||||||
};
|
};
|
||||||
const req = this.characterId
|
const req = this.characterId
|
||||||
|
|||||||
@@ -61,8 +61,10 @@
|
|||||||
[fields]="templateFields"
|
[fields]="templateFields"
|
||||||
[values]="values"
|
[values]="values"
|
||||||
[imageValues]="imageValues"
|
[imageValues]="imageValues"
|
||||||
|
[keyValueValues]="keyValueValues"
|
||||||
(valuesChange)="values = $event"
|
(valuesChange)="values = $event"
|
||||||
(imageValuesChange)="imageValues = $event">
|
(imageValuesChange)="imageValues = $event"
|
||||||
|
(keyValueValuesChange)="keyValueValues = $event">
|
||||||
</app-dynamic-fields-form>
|
</app-dynamic-fields-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
headerImageId: string | null = null;
|
headerImageId: string | null = null;
|
||||||
values: Record<string, string> = {};
|
values: Record<string, string> = {};
|
||||||
imageValues: Record<string, string[]> = {};
|
imageValues: Record<string, string[]> = {};
|
||||||
|
keyValueValues: Record<string, Record<string, string>> = {};
|
||||||
templateFields: TemplateField[] = [];
|
templateFields: TemplateField[] = [];
|
||||||
private order = 0;
|
private order = 0;
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
this.headerImageId = n.headerImageId ?? null;
|
this.headerImageId = n.headerImageId ?? null;
|
||||||
this.values = n.values ?? {};
|
this.values = n.values ?? {};
|
||||||
this.imageValues = n.imageValues ?? {};
|
this.imageValues = n.imageValues ?? {};
|
||||||
|
this.keyValueValues = n.keyValueValues ?? {};
|
||||||
this.order = n.order ?? 0;
|
this.order = n.order ?? 0;
|
||||||
},
|
},
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
@@ -107,6 +109,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
headerImageId: this.headerImageId,
|
headerImageId: this.headerImageId,
|
||||||
values: this.values,
|
values: this.values,
|
||||||
imageValues: this.imageValues,
|
imageValues: this.imageValues,
|
||||||
|
keyValueValues: this.keyValueValues,
|
||||||
campaignId: this.campaignId
|
campaignId: this.campaignId
|
||||||
};
|
};
|
||||||
const req = this.npcId
|
const req = this.npcId
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface Character {
|
|||||||
headerImageId?: string | null;
|
headerImageId?: string | null;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
|
/** Champs KEY_VALUE_LIST : fieldName -> label -> value. */
|
||||||
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
}
|
}
|
||||||
@@ -23,5 +25,6 @@ export interface CharacterCreate {
|
|||||||
headerImageId?: string | null;
|
headerImageId?: string | null;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface Npc {
|
|||||||
headerImageId?: string | null;
|
headerImageId?: string | null;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
}
|
}
|
||||||
@@ -19,5 +20,6 @@ export interface NpcCreate {
|
|||||||
headerImageId?: string | null;
|
headerImageId?: string | null;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
* - 'TEXT' : champ textuel libre (rendu en textarea)
|
* - 'TEXT' : champ textuel libre (rendu en textarea)
|
||||||
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
|
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
|
||||||
* - 'NUMBER' : valeur numerique (rendu en input number)
|
* - 'NUMBER' : valeur numerique (rendu en input number)
|
||||||
|
* - 'KEY_VALUE_LIST' : liste de paires {label, value} avec labels figes au template
|
||||||
*/
|
*/
|
||||||
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER';
|
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Variante de rendu pour un champ IMAGE. Miroir de
|
* Variante de rendu pour un champ IMAGE. Miroir de
|
||||||
@@ -27,6 +28,8 @@ export interface TemplateField {
|
|||||||
type: FieldType;
|
type: FieldType;
|
||||||
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
|
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
|
||||||
layout?: ImageLayout | null;
|
layout?: ImageLayout | null;
|
||||||
|
/** Labels predefinis pour KEY_VALUE_LIST (ordre significatif). */
|
||||||
|
labels?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
|
|||||||
@@ -28,6 +28,23 @@
|
|||||||
[imageIds]="imagesFor(f)"
|
[imageIds]="imagesFor(f)"
|
||||||
(imageIdsChange)="onImageIdsChange(f, $event)">
|
(imageIdsChange)="onImageIdsChange(f, $event)">
|
||||||
</app-image-gallery>
|
</app-image-gallery>
|
||||||
|
|
||||||
|
<!-- KEY_VALUE_LIST : grille d'inputs avec labels figes du template -->
|
||||||
|
<div *ngSwitchCase="'KEY_VALUE_LIST'" class="dff-kv-grid">
|
||||||
|
<div class="dff-kv-cell" *ngFor="let lbl of f.labels; trackBy: trackByLabel">
|
||||||
|
<span class="dff-kv-label">{{ lbl }}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[ngModel]="kvValue(f, lbl)"
|
||||||
|
(ngModelChange)="onKvChange(f, lbl, $event)"
|
||||||
|
[name]="'kv-' + f.name + '-' + lbl"
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="!f.labels?.length" class="dff-kv-empty">
|
||||||
|
Aucun label defini dans le template pour ce champ.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dff-kv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dff-kv-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.dff-kv-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--color-text, #fff);
|
||||||
|
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dff-kv-empty {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.dff-empty {
|
.dff-empty {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ export class DynamicFieldsFormComponent {
|
|||||||
@Input() fields: TemplateField[] = [];
|
@Input() fields: TemplateField[] = [];
|
||||||
@Input() values: Record<string, string> = {};
|
@Input() values: Record<string, string> = {};
|
||||||
@Input() imageValues: Record<string, string[]> = {};
|
@Input() imageValues: Record<string, string[]> = {};
|
||||||
|
@Input() keyValueValues: Record<string, Record<string, string>> = {};
|
||||||
|
|
||||||
@Output() valuesChange = new EventEmitter<Record<string, string>>();
|
@Output() valuesChange = new EventEmitter<Record<string, string>>();
|
||||||
@Output() imageValuesChange = new EventEmitter<Record<string, string[]>>();
|
@Output() imageValuesChange = new EventEmitter<Record<string, string[]>>();
|
||||||
|
@Output() keyValueValuesChange = new EventEmitter<Record<string, Record<string, string>>>();
|
||||||
|
|
||||||
onTextChange(field: TemplateField, value: string): void {
|
onTextChange(field: TemplateField, value: string): void {
|
||||||
this.values = { ...this.values, [field.name]: value };
|
this.values = { ...this.values, [field.name]: value };
|
||||||
@@ -44,5 +46,18 @@ export class DynamicFieldsFormComponent {
|
|||||||
return this.imageValues[field.name] ?? [];
|
return this.imageValues[field.name] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
trackByName = (_: number, f: TemplateField) => f.name;
|
/** Valeur d'un label particulier dans un champ KEY_VALUE_LIST. */
|
||||||
|
kvValue(field: TemplateField, label: string): string {
|
||||||
|
return this.keyValueValues?.[field.name]?.[label] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met a jour la valeur d'un label dans un champ KEY_VALUE_LIST. */
|
||||||
|
onKvChange(field: TemplateField, label: string, value: string): void {
|
||||||
|
const inner = { ...(this.keyValueValues[field.name] ?? {}), [label]: value };
|
||||||
|
this.keyValueValues = { ...this.keyValueValues, [field.name]: inner };
|
||||||
|
this.keyValueValuesChange.emit(this.keyValueValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByName = (_: number, f: TemplateField) => f.name;
|
||||||
|
trackByLabel = (_: number, l: string) => l;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,44 +15,92 @@
|
|||||||
<div class="pv-title-block">
|
<div class="pv-title-block">
|
||||||
<h1 class="pv-name">{{ persona.name }}</h1>
|
<h1 class="pv-name">{{ persona.name }}</h1>
|
||||||
<p *ngIf="subtitle" class="pv-subtitle">{{ subtitle }}</p>
|
<p *ngIf="subtitle" class="pv-subtitle">{{ subtitle }}</p>
|
||||||
|
|
||||||
|
<!-- Badges des NUMBER isoles (rendu compact, evite la grosse card pour 1 valeur) -->
|
||||||
|
<div *ngIf="heroBadges.length" class="pv-hero-badges">
|
||||||
|
<span *ngFor="let b of heroBadges" class="pv-hero-badge">
|
||||||
|
<span class="pv-hero-badge-label">{{ b.label }}</span>
|
||||||
|
<span class="pv-hero-badge-value">{{ b.value }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats numeriques en bandeau si presentes -->
|
<!-- Sections rendues dans l'ordre du template -->
|
||||||
<div *ngIf="textFields.length && hasAnyNumber(textFields)" class="pv-stat-band">
|
|
||||||
<div *ngFor="let f of textFields" class="pv-stat" [class.pv-stat-number]="f.isNumber">
|
|
||||||
<ng-container *ngIf="f.isNumber">
|
|
||||||
<span class="pv-stat-label">{{ f.name }}</span>
|
|
||||||
<span class="pv-stat-value">{{ f.value }}</span>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sections texte -->
|
|
||||||
<div class="pv-sections">
|
<div class="pv-sections">
|
||||||
<ng-container *ngFor="let f of textFields; let first = first">
|
<ng-container *ngFor="let s of orderedSections">
|
||||||
<section *ngIf="!f.isNumber" class="pv-section">
|
|
||||||
<h2 class="pv-section-title">{{ f.name }}</h2>
|
<!-- TEXT -->
|
||||||
|
<section *ngIf="s.kind === 'TEXT'" class="pv-section">
|
||||||
|
<h2 class="pv-section-title">{{ s.name }}</h2>
|
||||||
<div class="pv-section-body">
|
<div class="pv-section-body">
|
||||||
<p [class.with-dropcap]="first" class="pv-paragraph">
|
<p [class.with-dropcap]="s.name === firstTextSectionName" class="pv-paragraph">
|
||||||
{{ firstParagraph(f.value) }}
|
{{ firstParagraph(s.value) }}
|
||||||
</p>
|
</p>
|
||||||
<p *ngIf="restAfterFirstParagraph(f.value)" class="pv-paragraph">
|
<p *ngIf="restAfterFirstParagraph(s.value)" class="pv-paragraph">
|
||||||
{{ restAfterFirstParagraph(f.value) }}
|
{{ restAfterFirstParagraph(s.value) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Galeries d'images templates -->
|
<!-- NUMBER_GROUP : NUMBER consecutifs - table si <=6, sinon liste 2 cols -->
|
||||||
<section *ngFor="let img of imageFields" class="pv-section pv-section-images">
|
<section *ngIf="s.kind === 'NUMBER_GROUP'" class="pv-section pv-section-number">
|
||||||
<h2 class="pv-section-title">{{ img.field.name }}</h2>
|
<ng-container *ngIf="s.entries.length <= 6; else numGroupList">
|
||||||
<app-image-gallery [imageIds]="img.ids" [layout]="img.field.layout || 'GALLERY'" [editable]="false">
|
<div class="pv-kv-table" [style.--cols]="s.entries.length">
|
||||||
|
<div class="pv-kv-row pv-kv-row-labels">
|
||||||
|
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.label }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pv-kv-row pv-kv-row-values">
|
||||||
|
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #numGroupList>
|
||||||
|
<div class="pv-kv-list">
|
||||||
|
<div class="pv-kv-list-row" *ngFor="let e of s.entries">
|
||||||
|
<span class="pv-kv-list-label">{{ e.label }}</span>
|
||||||
|
<span class="pv-kv-list-dots"></span>
|
||||||
|
<span class="pv-kv-list-value">{{ e.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- KEY_VALUE_LIST : table style Foundry si <=6, sinon liste 2 cols (skills) -->
|
||||||
|
<section *ngIf="s.kind === 'KEY_VALUE_LIST'" class="pv-section pv-section-kv">
|
||||||
|
<h2 class="pv-section-title">{{ s.name }}</h2>
|
||||||
|
<ng-container *ngIf="s.entries.length <= 6; else kvList">
|
||||||
|
<div class="pv-kv-table" [style.--cols]="s.entries.length">
|
||||||
|
<div class="pv-kv-row pv-kv-row-labels">
|
||||||
|
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.label }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="pv-kv-row pv-kv-row-values">
|
||||||
|
<div class="pv-kv-cell" *ngFor="let e of s.entries">{{ e.value || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #kvList>
|
||||||
|
<div class="pv-kv-list">
|
||||||
|
<div class="pv-kv-list-row" *ngFor="let e of s.entries">
|
||||||
|
<span class="pv-kv-list-label">{{ e.label }}</span>
|
||||||
|
<span class="pv-kv-list-dots"></span>
|
||||||
|
<span class="pv-kv-list-value">{{ e.value || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- IMAGE : galerie -->
|
||||||
|
<section *ngIf="s.kind === 'IMAGE'" class="pv-section pv-section-images">
|
||||||
|
<h2 class="pv-section-title">{{ s.name }}</h2>
|
||||||
|
<app-image-gallery [imageIds]="s.ids" [layout]="s.layout" [editable]="false">
|
||||||
</app-image-gallery>
|
</app-image-gallery>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<!-- Etat vide -->
|
<!-- Etat vide -->
|
||||||
<div *ngIf="textFields.length === 0 && imageFields.length === 0" class="pv-empty">
|
<div *ngIf="orderedSections.length === 0" class="pv-empty">
|
||||||
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
|
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
|
||||||
<p>Cette fiche est encore vide.</p>
|
<p>Cette fiche est encore vide.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,41 +92,145 @@
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bandeau de stats (NUMBER) ---------------------------------------------
|
// Badges compacts pour les NUMBER isoles (Niveau, etc.) — evite la grosse card.
|
||||||
|
.pv-hero-badges {
|
||||||
.pv-stat-band {
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 8px;
|
||||||
padding: 12px 32px;
|
|
||||||
margin: 16px 32px 0;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pv-stat {
|
.pv-hero-badge {
|
||||||
display: none;
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(209, 168, 120, 0.08);
|
||||||
|
border: 1px solid rgba(209, 168, 120, 0.3);
|
||||||
|
border-radius: 100px;
|
||||||
|
|
||||||
&.pv-stat-number {
|
.pv-hero-badge-label {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pv-stat-label {
|
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.1em;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
.pv-hero-badge-value {
|
||||||
.pv-stat-value {
|
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #f3f4f6;
|
font-size: 1.05rem;
|
||||||
font-family: 'Cinzel', Georgia, serif;
|
color: #d1a878;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Table compacte (NUMBER_GROUP / KEY_VALUE_LIST) ------------------------
|
||||||
|
// Style Foundry : 2 rangees (labels uppercase / valeurs serif), separateurs
|
||||||
|
// fins, sans gros ornements. --cols est la variable CSS pour le nombre
|
||||||
|
// d'entrees, posee inline dans le HTML.
|
||||||
|
|
||||||
|
.pv-kv-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-kv-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-kv-row-labels {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
.pv-kv-cell {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 6px 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-kv-row-values {
|
||||||
|
.pv-kv-cell {
|
||||||
|
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #d1a878;
|
||||||
|
padding: 8px 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-kv-cell {
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
// Sur mobile : si plus de 4 colonnes, on retombe en wrap (sacrifice de la
|
||||||
|
// structure tableau pour eviter le scroll horizontal sur les stats blocks).
|
||||||
|
.pv-kv-row {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Liste 2 colonnes (KV avec >6 entrees, type "skills" Foundry) ---------
|
||||||
|
|
||||||
|
.pv-kv-list {
|
||||||
|
column-count: 2;
|
||||||
|
column-gap: 32px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-kv-list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
break-inside: avoid; // empeche un row de se couper entre 2 colonnes
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-kv-list-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #d6d8de;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointilles entre label et valeur (style fiche papier)
|
||||||
|
.pv-kv-list-dots {
|
||||||
|
flex: 1;
|
||||||
|
border-bottom: 1px dotted rgba(255, 255, 255, 0.12);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-kv-list-value {
|
||||||
|
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #d1a878;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.pv-kv-list {
|
||||||
|
column-count: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +248,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pv-section-kv,
|
||||||
|
.pv-section-number {
|
||||||
|
.pv-kv-table {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pv-section-title {
|
.pv-section-title {
|
||||||
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Component, Input } from '@angular/core';
|
import { Component, Input } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||||
import { TemplateField } from '../../services/template.model';
|
import { TemplateField, ImageLayout } from '../../services/template.model';
|
||||||
|
|
||||||
|
/** Section rendue dans la vue, dans l'ordre du template. Discriminee par `kind`. */
|
||||||
|
export type RenderedSection =
|
||||||
|
| { kind: 'TEXT'; name: string; value: string }
|
||||||
|
| { kind: 'NUMBER_GROUP'; entries: { label: string; value: string }[] }
|
||||||
|
| { kind: 'KEY_VALUE_LIST'; name: string; entries: { label: string; value: string }[] }
|
||||||
|
| { kind: 'IMAGE'; name: string; ids: string[]; layout: ImageLayout };
|
||||||
import { ImageService } from '../../services/image.service';
|
import { ImageService } from '../../services/image.service';
|
||||||
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
|
||||||
|
|
||||||
@@ -22,6 +29,7 @@ export interface PersonaLike {
|
|||||||
headerImageId?: string | null;
|
headerImageId?: string | null;
|
||||||
values?: Record<string, string>;
|
values?: Record<string, string>;
|
||||||
imageValues?: Record<string, string[]>;
|
imageValues?: Record<string, string[]>;
|
||||||
|
keyValueValues?: Record<string, Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -46,30 +54,69 @@ export class PersonaViewComponent {
|
|||||||
return this.imageService.contentUrl(id);
|
return this.imageService.contentUrl(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Champs TEXT/NUMBER non vides, dans l'ordre du template. */
|
/**
|
||||||
get textFields(): { name: string; value: string; isNumber: boolean }[] {
|
* Decompose la fiche en (heroBadges, sections) en un seul passage :
|
||||||
if (!this.persona?.values) return [];
|
* - NUMBER consecutifs groupes : 2+ → NUMBER_GROUP en section ; 1 isole → badge hero.
|
||||||
return this.templateFields
|
* - TEXT / KEY_VALUE_LIST / IMAGE : sections dans l'ordre du template.
|
||||||
.filter(f => (f.type === 'TEXT' || f.type === 'NUMBER'))
|
* Le calcul est fait par rendered() et cache via le get pour eviter les
|
||||||
.map(f => ({
|
* recalculs multiples par cycle de change detection.
|
||||||
name: f.name,
|
*/
|
||||||
value: this.persona!.values?.[f.name] ?? '',
|
private rendered(): { heroBadges: { label: string; value: string }[]; sections: RenderedSection[] } {
|
||||||
isNumber: f.type === 'NUMBER'
|
const sections: RenderedSection[] = [];
|
||||||
}))
|
const heroBadges: { label: string; value: string }[] = [];
|
||||||
.filter(x => x.value && x.value.trim().length > 0);
|
let numberBuffer: { label: string; value: string }[] = [];
|
||||||
|
|
||||||
|
const flushNumberBuffer = () => {
|
||||||
|
if (numberBuffer.length === 1) {
|
||||||
|
heroBadges.push(numberBuffer[0]);
|
||||||
|
} else if (numberBuffer.length > 1) {
|
||||||
|
sections.push({ kind: 'NUMBER_GROUP', entries: numberBuffer });
|
||||||
|
}
|
||||||
|
numberBuffer = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const f of this.templateFields) {
|
||||||
|
if (f.type === 'NUMBER') {
|
||||||
|
const value = this.persona?.values?.[f.name] ?? '';
|
||||||
|
if (value.trim()) numberBuffer.push({ label: f.name, value });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushNumberBuffer();
|
||||||
|
if (f.type === 'TEXT') {
|
||||||
|
const value = this.persona?.values?.[f.name] ?? '';
|
||||||
|
if (value.trim()) sections.push({ kind: 'TEXT', name: f.name, value });
|
||||||
|
} else if (f.type === 'KEY_VALUE_LIST') {
|
||||||
|
const inner = this.persona?.keyValueValues?.[f.name] ?? {};
|
||||||
|
const labels = f.labels ?? [];
|
||||||
|
const entries = labels.map(label => ({ label, value: inner[label] ?? '' }));
|
||||||
|
if (entries.some(e => e.value.trim())) {
|
||||||
|
sections.push({ kind: 'KEY_VALUE_LIST', name: f.name, entries });
|
||||||
|
}
|
||||||
|
} else if (f.type === 'IMAGE') {
|
||||||
|
const ids = this.persona?.imageValues?.[f.name] ?? [];
|
||||||
|
if (ids.length > 0) {
|
||||||
|
sections.push({ kind: 'IMAGE', name: f.name, ids, layout: f.layout ?? 'GALLERY' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushNumberBuffer();
|
||||||
|
return { heroBadges, sections };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Champs IMAGE non vides, dans l'ordre du template. */
|
get heroBadges(): { label: string; value: string }[] {
|
||||||
get imageFields(): { field: TemplateField; ids: string[] }[] {
|
return this.rendered().heroBadges;
|
||||||
if (!this.persona?.imageValues) return [];
|
|
||||||
return this.templateFields
|
|
||||||
.filter(f => f.type === 'IMAGE')
|
|
||||||
.map(f => ({ field: f, ids: this.persona!.imageValues?.[f.name] ?? [] }))
|
|
||||||
.filter(x => x.ids.length > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAnyNumber(fields: { isNumber: boolean }[]): boolean {
|
get orderedSections(): RenderedSection[] {
|
||||||
return fields.some(f => f.isNumber);
|
return this.rendered().sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pour la drop cap : seul le 1er TEXT la recoit. */
|
||||||
|
get firstTextSectionName(): string | null {
|
||||||
|
for (const s of this.orderedSections) {
|
||||||
|
if (s.kind === 'TEXT') return s.name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Premier paragraphe d'un texte (utilise pour la drop cap). */
|
/** Premier paragraphe d'un texte (utilise pour la drop cap). */
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tfe-list">
|
<div class="tfe-list">
|
||||||
<div class="tfe-row" *ngFor="let f of fields; let i = index" [class.invalid]="isDuplicate(f, i) || !f.name.trim()">
|
<div class="tfe-item" *ngFor="let f of fields; let i = index">
|
||||||
|
<div class="tfe-row" [class.invalid]="isDuplicate(f, i) || !f.name.trim()">
|
||||||
<div class="tfe-row-controls">
|
<div class="tfe-row-controls">
|
||||||
<button type="button" class="btn-arrow" (click)="moveUp(i)" [disabled]="i === 0" title="Monter">
|
<button type="button" class="btn-arrow" (click)="moveUp(i)" [disabled]="i === 0" title="Monter">
|
||||||
<lucide-icon [img]="ArrowUp" [size]="14"></lucide-icon>
|
<lucide-icon [img]="ArrowUp" [size]="14"></lucide-icon>
|
||||||
@@ -46,6 +47,40 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sous-editeur des labels pour KEY_VALUE_LIST -->
|
||||||
|
<div class="tfe-labels" *ngIf="f.type === 'KEY_VALUE_LIST'">
|
||||||
|
<div class="tfe-labels-header">
|
||||||
|
<lucide-icon [img]="ListOrdered" [size]="12"></lucide-icon>
|
||||||
|
<span>Labels (cles fixes pour toutes les fiches)</span>
|
||||||
|
</div>
|
||||||
|
<div class="tfe-labels-list">
|
||||||
|
<div class="tfe-label-row" *ngFor="let lbl of f.labels; let li = index; trackBy: trackByIndex">
|
||||||
|
<button type="button" class="btn-arrow-mini" (click)="moveLabelUp(f, li)" [disabled]="li === 0" title="Monter">
|
||||||
|
<lucide-icon [img]="ArrowUp" [size]="11"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-arrow-mini" (click)="moveLabelDown(f, li)" [disabled]="li === (f.labels?.length || 0) - 1" title="Descendre">
|
||||||
|
<lucide-icon [img]="ArrowDown" [size]="11"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="tfe-label-input"
|
||||||
|
[ngModel]="lbl"
|
||||||
|
(ngModelChange)="updateLabelAt(f, li, $event)"
|
||||||
|
[name]="'lbl-' + i + '-' + li"
|
||||||
|
placeholder="Ex: FOR, DEX..."
|
||||||
|
/>
|
||||||
|
<button type="button" class="btn-remove-mini" (click)="removeLabel(f, li)" title="Retirer ce label">
|
||||||
|
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="chip chip-mini" (click)="addLabel(f)">
|
||||||
|
<lucide-icon [img]="Plus" [size]="11"></lucide-icon>
|
||||||
|
Ajouter un label
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div *ngIf="fields.length === 0" class="tfe-empty">
|
<div *ngIf="fields.length === 0" class="tfe-empty">
|
||||||
Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.
|
Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.
|
||||||
</div>
|
</div>
|
||||||
@@ -75,5 +110,9 @@
|
|||||||
<lucide-icon [img]="ImageIcon" [size]="12"></lucide-icon>
|
<lucide-icon [img]="ImageIcon" [size]="12"></lucide-icon>
|
||||||
Image(s)
|
Image(s)
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="chip chip-custom" (click)="addBlank('KEY_VALUE_LIST')">
|
||||||
|
<lucide-icon [img]="ListOrdered" [size]="12"></lucide-icon>
|
||||||
|
Liste cle/valeur
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,3 +137,88 @@
|
|||||||
.chip-custom {
|
.chip-custom {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip-mini {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sous-editeur labels (KEY_VALUE_LIST) ---
|
||||||
|
|
||||||
|
.tfe-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-labels {
|
||||||
|
margin-left: 32px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-labels-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted, #aaa);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-labels-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-label-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto 1fr auto;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-arrow-mini,
|
||||||
|
.btn-remove-mini {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 100ms;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--color-text, #fff);
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-mini:hover {
|
||||||
|
border-color: rgba(255, 100, 100, 0.4);
|
||||||
|
color: rgba(255, 100, 100, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-label-input {
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--color-text, #fff);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash } from 'lucide-angular';
|
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash, ListOrdered, X } from 'lucide-angular';
|
||||||
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
|
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +29,8 @@ export class TemplateFieldsEditorComponent {
|
|||||||
readonly Type = Type;
|
readonly Type = Type;
|
||||||
readonly ImageIcon = ImageIcon;
|
readonly ImageIcon = ImageIcon;
|
||||||
readonly Hash = Hash;
|
readonly Hash = Hash;
|
||||||
|
readonly ListOrdered = ListOrdered;
|
||||||
|
readonly X = X;
|
||||||
|
|
||||||
/** Liste des champs (binding parent). */
|
/** Liste des champs (binding parent). */
|
||||||
@Input() fields: TemplateField[] = [];
|
@Input() fields: TemplateField[] = [];
|
||||||
@@ -47,7 +49,8 @@ export class TemplateFieldsEditorComponent {
|
|||||||
readonly typeOptions: { value: FieldType; label: string }[] = [
|
readonly typeOptions: { value: FieldType; label: string }[] = [
|
||||||
{ value: 'TEXT', label: 'Texte' },
|
{ value: 'TEXT', label: 'Texte' },
|
||||||
{ value: 'NUMBER', label: 'Nombre' },
|
{ value: 'NUMBER', label: 'Nombre' },
|
||||||
{ value: 'IMAGE', label: 'Image(s)' }
|
{ value: 'IMAGE', label: 'Image(s)' },
|
||||||
|
{ value: 'KEY_VALUE_LIST', label: 'Liste cle/valeur' }
|
||||||
];
|
];
|
||||||
|
|
||||||
readonly layoutOptions: { value: ImageLayout; label: string }[] = [
|
readonly layoutOptions: { value: ImageLayout; label: string }[] = [
|
||||||
@@ -75,9 +78,53 @@ export class TemplateFieldsEditorComponent {
|
|||||||
|
|
||||||
addBlank(type: FieldType): void {
|
addBlank(type: FieldType): void {
|
||||||
const layout: ImageLayout | null = type === 'IMAGE' ? 'GALLERY' : null;
|
const layout: ImageLayout | null = type === 'IMAGE' ? 'GALLERY' : null;
|
||||||
this.emit([...this.fields, { name: '', type, layout }]);
|
const labels: string[] | null = type === 'KEY_VALUE_LIST' ? [] : null;
|
||||||
|
this.emit([...this.fields, { name: '', type, layout, labels }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sous-editeur labels (KEY_VALUE_LIST) ---
|
||||||
|
|
||||||
|
addLabel(field: TemplateField): void {
|
||||||
|
const labels = field.labels ? [...field.labels] : [];
|
||||||
|
labels.push('');
|
||||||
|
field.labels = labels;
|
||||||
|
this.onFieldChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeLabel(field: TemplateField, index: number): void {
|
||||||
|
if (!field.labels) return;
|
||||||
|
const labels = [...field.labels];
|
||||||
|
labels.splice(index, 1);
|
||||||
|
field.labels = labels;
|
||||||
|
this.onFieldChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLabelAt(field: TemplateField, index: number, value: string): void {
|
||||||
|
if (!field.labels) return;
|
||||||
|
const labels = [...field.labels];
|
||||||
|
labels[index] = value;
|
||||||
|
field.labels = labels;
|
||||||
|
this.onFieldChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
moveLabelUp(field: TemplateField, index: number): void {
|
||||||
|
if (!field.labels || index <= 0) return;
|
||||||
|
const labels = [...field.labels];
|
||||||
|
[labels[index - 1], labels[index]] = [labels[index], labels[index - 1]];
|
||||||
|
field.labels = labels;
|
||||||
|
this.onFieldChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
moveLabelDown(field: TemplateField, index: number): void {
|
||||||
|
if (!field.labels || index >= field.labels.length - 1) return;
|
||||||
|
const labels = [...field.labels];
|
||||||
|
[labels[index], labels[index + 1]] = [labels[index + 1], labels[index]];
|
||||||
|
field.labels = labels;
|
||||||
|
this.onFieldChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByIndex = (i: number) => i;
|
||||||
|
|
||||||
remove(index: number): void {
|
remove(index: number): void {
|
||||||
const next = [...this.fields];
|
const next = [...this.fields];
|
||||||
next.splice(index, 1);
|
next.splice(index, 1);
|
||||||
@@ -100,10 +147,11 @@ export class TemplateFieldsEditorComponent {
|
|||||||
|
|
||||||
/** Notifie les changements internes (input/select sur un champ existant). */
|
/** Notifie les changements internes (input/select sur un champ existant). */
|
||||||
onFieldChanged(): void {
|
onFieldChanged(): void {
|
||||||
// Quand le type passe a IMAGE, layout = GALLERY ; sinon null.
|
|
||||||
for (const f of this.fields) {
|
for (const f of this.fields) {
|
||||||
if (f.type === 'IMAGE' && !f.layout) f.layout = 'GALLERY';
|
if (f.type === 'IMAGE' && !f.layout) f.layout = 'GALLERY';
|
||||||
if (f.type !== 'IMAGE') f.layout = null;
|
if (f.type !== 'IMAGE') f.layout = null;
|
||||||
|
if (f.type === 'KEY_VALUE_LIST' && !f.labels) f.labels = [];
|
||||||
|
if (f.type !== 'KEY_VALUE_LIST') f.labels = null;
|
||||||
}
|
}
|
||||||
this.emit([...this.fields]);
|
this.emit([...this.fields]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user