2 Commits

Author SHA1 Message Date
7c4a42327d Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m42s
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
52e389db24 Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
83 changed files with 2613 additions and 267 deletions

View File

@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -22,8 +24,17 @@ public class CharacterService {
/** /**
* Parameter Object pour la création / mise à jour d'un Character. * Parameter Object pour la création / mise à jour d'un Character.
* `order` est fourni par le controller ; si absent, le service le calcule. * `order` est fourni par le controller ; si absent, le service le calcule.
* Les maps {@code values}/{@code imageValues} peuvent etre null (interpretees vides).
*/ */
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {} public record CharacterData(
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
String campaignId,
Integer order
) {}
public Character createCharacter(CharacterData data) { public Character createCharacter(CharacterData data) {
int order = data.order() != null int order = data.order() != null
@@ -31,7 +42,10 @@ public class CharacterService {
: nextOrderFor(data.campaignId()); : nextOrderFor(data.campaignId());
Character character = Character.builder() Character character = Character.builder()
.name(data.name()) .name(data.name())
.markdownContent(data.markdownContent()) .portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.campaignId(data.campaignId()) .campaignId(data.campaignId())
.order(order) .order(order)
.build(); .build();
@@ -50,7 +64,10 @@ public class CharacterService {
Character existing = characterRepository.findById(id) Character existing = characterRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
existing.setName(data.name()); existing.setName(data.name());
existing.setMarkdownContent(data.markdownContent()); existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }

View File

@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -19,11 +21,15 @@ public class NpcService {
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
} }
/** public record NpcData(
* Parameter Object pour la création / mise à jour d'un Npc. String name,
* `order` est fourni par le controller ; si absent, le service le calcule. String portraitImageId,
*/ String headerImageId,
public record NpcData(String name, String markdownContent, String campaignId, Integer order) {} Map<String, String> values,
Map<String, List<String>> imageValues,
String campaignId,
Integer order
) {}
public Npc createNpc(NpcData data) { public Npc createNpc(NpcData data) {
int order = data.order() != null int order = data.order() != null
@@ -31,7 +37,10 @@ public class NpcService {
: nextOrderFor(data.campaignId()); : nextOrderFor(data.campaignId());
Npc npc = Npc.builder() Npc npc = Npc.builder()
.name(data.name()) .name(data.name())
.markdownContent(data.markdownContent()) .portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.campaignId(data.campaignId()) .campaignId(data.campaignId())
.order(order) .order(order)
.build(); .build();
@@ -50,7 +59,10 @@ public class NpcService {
Npc existing = npcRepository.findById(id) Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
existing.setName(data.name()); existing.setName(data.name());
existing.setMarkdownContent(data.markdownContent()); existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
} }
@@ -61,7 +73,6 @@ public class NpcService {
npcRepository.deleteById(id); npcRepository.deleteById(id);
} }
/** Renvoie la prochaine position libre — append en fin de liste. */
private int nextOrderFor(String campaignId) { private int nextOrderFor(String campaignId) {
return npcRepository.findByCampaignId(campaignId).stream() return npcRepository.findByCampaignId(campaignId).stream()
.mapToInt(Npc::getOrder) .mapToInt(Npc::getOrder)

View File

@@ -2,6 +2,7 @@ package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@@ -18,11 +19,14 @@ public class GameSystemService {
/** /**
* Parameter Object pour la création / mise à jour d'un GameSystem. * Parameter Object pour la création / mise à jour d'un GameSystem.
* Les templates peuvent etre null (interpretes comme listes vides).
*/ */
public record GameSystemData( public record GameSystemData(
String name, String name,
String description, String description,
String rulesMarkdown, String rulesMarkdown,
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
String author, String author,
boolean isPublic boolean isPublic
) {} ) {}
@@ -35,6 +39,8 @@ public class GameSystemService {
.author(normalize(data.author())) .author(normalize(data.author()))
.isPublic(data.isPublic()) .isPublic(data.isPublic())
.build(); .build();
gameSystem.replaceCharacterTemplate(data.characterTemplate());
gameSystem.replaceNpcTemplate(data.npcTemplate());
return gameSystemRepository.save(gameSystem); return gameSystemRepository.save(gameSystem);
} }
@@ -52,6 +58,8 @@ public class GameSystemService {
existing.setName(data.name()); existing.setName(data.name());
existing.setDescription(data.description()); existing.setDescription(data.description());
existing.setRulesMarkdown(data.rulesMarkdown()); existing.setRulesMarkdown(data.rulesMarkdown());
existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate());
existing.setAuthor(normalize(data.author())); existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic()); existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing); return gameSystemRepository.save(existing);

View File

@@ -104,23 +104,32 @@ public class CampaignStructuralContextBuilder {
* sans injecter toute sa fiche. * sans injecter toute sa fiche.
*/ */
private CharacterSummary toCharacterSummary(Character c) { private CharacterSummary toCharacterSummary(Character c) {
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent())); return new CharacterSummary(c.getName(), extractSnippet(c.getValues()));
} }
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */ /** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
private NpcSummary toNpcSummary(Npc n) { private NpcSummary toNpcSummary(Npc n) {
return new NpcSummary(n.getName(), extractSnippet(n.getMarkdownContent())); return new NpcSummary(n.getName(), extractSnippet(n.getValues()));
} }
private static String extractSnippet(String markdown) { /**
if (markdown == null || markdown.isBlank()) return ""; * Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
String firstLine = markdown.lines() * du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
.map(String::strip) */
.filter(l -> !l.isEmpty() && !l.startsWith("#")) private static String extractSnippet(java.util.Map<String, String> values) {
.findFirst() if (values == null || values.isEmpty()) return "";
.orElse(""); for (String value : values.values()) {
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine; if (value == null || value.isBlank()) continue;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + ""; String firstLine = value.lines()
.map(String::strip)
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
.findFirst()
.orElse("");
if (firstLine.isEmpty()) continue;
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "";
}
return "";
} }
private ArcSummary toArcSummary(Arc arc) { private ArcSummary toArcSummary(Arc arc) {

View File

@@ -130,13 +130,19 @@ public class NarrativeEntityContextBuilder {
private NarrativeEntityContext fromCharacter(Character c) { private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", c.getMarkdownContent()); if (c.getValues() != null) {
// Champs templates exposes individuellement — meilleur pour le LLM que
// l'ancien blob markdown monolithique.
c.getValues().forEach((k, v) -> putField(fields, k, v));
}
return new NarrativeEntityContext("character", c.getName(), fields); return new NarrativeEntityContext("character", c.getName(), fields);
} }
private NarrativeEntityContext fromNpc(Npc n) { private NarrativeEntityContext fromNpc(Npc n) {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
putField(fields, "fiche complète (markdown)", n.getMarkdownContent()); if (n.getValues() != null) {
n.getValues().forEach((k, v) -> putField(fields, k, v));
}
return new NarrativeEntityContext("npc", n.getName(), fields); return new NarrativeEntityContext("npc", n.getName(), fields);
} }

View File

@@ -1,7 +1,7 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository; import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;

View File

@@ -4,18 +4,26 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** /**
* Fiche de personnage joueur (PJ) d'une campagne. * Fiche de personnage joueur (PJ) d'une campagne.
* <p> * <p>
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats, * Champs universels hard-codes : {@code name}, {@code portraitImageId},
* backstory, équipement). Évolution prévue vers un système templaté par * {@code headerImageId}. Tout le reste est piloté par le template PJ du
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D). * GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
* <p> * <p>
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée * Les valeurs des champs templates sont stockées dans deux maps :
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents). * - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
* Évolution prévue : système de templating partagé PJ/PNJ piloté par * parsé à l'usage cote presentation)
* GameSystem pour adapter les blocs aux différents systèmes de JDR. * - {@code imageValues} : champs IMAGE (liste ordonnée d'IDs d'images par champ)
* <p>
* Le champ historique {@code markdownContent} a été supprimé (refonte 2026-04-30).
* Le contenu pre-existant est migré dans {@code values["Notes"]} par défaut.
* <p>
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} (invariants divergents).
*/ */
@Data @Data
@Builder @Builder
@@ -24,11 +32,24 @@ public class Character {
private String id; private String id;
private String name; private String name;
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
private String portraitImageId;
/** ID de l'image header/banniere (champ universel hard-codé). Nullable. */
private String headerImageId;
/** /**
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création, * Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
* renseigné progressivement par le MJ. * (sensible a la casse cote stockage mais comparaison case-insensitive
* dans le domaine GameSystem). Jamais null apres construction.
*/ */
private String markdownContent; private Map<String, String> values;
/**
* Valeurs des champs IMAGE du template PJ. Cle = nom du champ, valeur =
* liste ordonnee d'IDs d'images. Jamais null apres construction.
*/
private Map<String, List<String>> imageValues;
/** Référence vers la Campaign parente. */ /** Référence vers la Campaign parente. */
private String campaignId; private String campaignId;
@@ -38,4 +59,15 @@ public class Character {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/** Garantit que les maps ne sont jamais null cote consommateur. */
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
} }

View File

@@ -4,21 +4,22 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** /**
* Fiche de personnage non-joueur (PNJ) d'une campagne. * Fiche de personnage non-joueur (PNJ) d'une campagne.
* <p> * <p>
* MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé — * Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
* un PNJ a vocation à porter à terme des invariants métier propres (faction, * à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
* statut vivant/mort/disparu, visibilité côté joueurs, relations inter-PNJ)
* qui n'ont aucun sens sur un PJ. Mutualiser via un enum aurait pollué l'entité
* PJ avec des champs inutiles ({@code if (type == NPC)} partout = anti-pattern).
* <p> * <p>
* Contenu markdown libre comme les PJ. Évolution prévue : templating partagé * Mêmes champs universels hard-codés et meme structure de templating que Character,
* PJ/PNJ piloté par GameSystem. * pilotée par le template PNJ du GameSystem
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
* <p> * <p>
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent * Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent gérés
* gérés via le système Page/Template du LoreContext. * via le système Page/Template du LoreContext.
*/ */
@Data @Data
@Builder @Builder
@@ -27,10 +28,19 @@ public class Npc {
private String id; private String id;
private String name; private String name;
/** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */ /** ID de l'image portrait (champ universel hard-code). Nullable. */
private String markdownContent; private String portraitImageId;
/** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */ /** ID de l'image header/banniere (champ universel hard-code). Nullable. */
private String headerImageId;
/** Valeurs TEXT/NUMBER du template PNJ. Jamais null apres construction. */
private Map<String, String> values;
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
private Map<String, List<String>> imageValues;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId; private String campaignId;
/** Ordre d'affichage dans la liste des PNJ de la campagne. */ /** Ordre d'affichage dans la liste des PNJ de la campagne. */
@@ -38,4 +48,14 @@ public class Npc {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
}
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
}

View File

@@ -1,9 +1,13 @@
package com.loremind.domain.gamesystemcontext; package com.loremind.domain.gamesystemcontext;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** /**
* Entité de domaine représentant un GameSystem (système de JDR). * Entité de domaine représentant un GameSystem (système de JDR).
@@ -12,6 +16,10 @@ import java.time.LocalDateTime;
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites * d'un markdown monolithique structuré par titres H2. Les sections sont extraites
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector). * à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
* <p> * <p>
* Porte aussi deux templates piloтant la structure des fiches PJ et PNJ d'une
* campagne adossée à ce système. Les fiches markdown libres ont laissé place à
* un système de champs typés (TEXT/IMAGE/NUMBER) défini ici.
* <p>
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace * {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour * de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
* éviter une migration ultérieure. * éviter une migration ultérieure.
@@ -27,6 +35,21 @@ public class GameSystem {
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */ /** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
private String rulesMarkdown; private String rulesMarkdown;
/**
* Template de fiche PJ : champs typés affichés pour chaque personnage joueur.
* Hors champs universels hard-codés (nom, portrait, header). Jamais null après
* persistance — un template vide est représenté par une liste vide.
*/
private List<TemplateField> characterTemplate;
/**
* Template de fiche PNJ. Mêmes règles que {@link #characterTemplate}.
* Distinct du template PJ car les invariants métier divergent (un PNJ peut
* n'avoir qu'un nom + une motivation, un PJ porte généralement une feuille
* de stats complète).
*/
private List<TemplateField> npcTemplate;
/** Auteur déclaré — futur marketplace. Nullable. */ /** Auteur déclaré — futur marketplace. Nullable. */
private String author; private String author;
@@ -35,4 +58,88 @@ public class GameSystem {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
// --- Méthodes métier : templates PJ/PNJ --------------------------------
/**
* Ajoute un champ au template PJ. Refuse les doublons de nom (insensible à la casse)
* pour éviter les collisions de clés dans {@code Character.values}.
*/
public void addCharacterField(TemplateField field) {
characterTemplate = appendField(characterTemplate, field);
}
/** Pendant PNJ de {@link #addCharacterField}. */
public void addNpcField(TemplateField field) {
npcTemplate = appendField(npcTemplate, field);
}
/**
* Retire un champ du template PJ par nom (insensible à la casse).
* No-op silencieux si le champ n'existe pas — appelant n'a pas à pré-vérifier.
*/
public void removeCharacterField(String fieldName) {
characterTemplate = removeFieldByName(characterTemplate, fieldName);
}
public void removeNpcField(String fieldName) {
npcTemplate = removeFieldByName(npcTemplate, fieldName);
}
/**
* Remplace intégralement le template PJ. Utilisé pour le réordonnancement
* et l'édition en bloc côté UI. Valide l'unicité des noms.
*/
public void replaceCharacterTemplate(List<TemplateField> fields) {
characterTemplate = validateAndCopy(fields);
}
public void replaceNpcTemplate(List<TemplateField> fields) {
npcTemplate = validateAndCopy(fields);
}
// --- Helpers privés ----------------------------------------------------
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
if (field == null || field.getName() == null || field.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
List<TemplateField> next = current == null ? new ArrayList<>() : new ArrayList<>(current);
if (containsName(next, field.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + field.getName());
}
next.add(field);
return next;
}
private static List<TemplateField> removeFieldByName(List<TemplateField> current, String fieldName) {
if (current == null || fieldName == null) return current;
List<TemplateField> next = new ArrayList<>(current);
next.removeIf(f -> equalsIgnoreCase(f.getName(), fieldName));
return next;
}
private static List<TemplateField> validateAndCopy(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateField> copy = new ArrayList<>(fields.size());
for (TemplateField f : fields) {
if (f == null || f.getName() == null || f.getName().isBlank()) {
throw new IllegalArgumentException("Field name is required");
}
if (containsName(copy, f.getName())) {
throw new IllegalArgumentException("Duplicate field name: " + f.getName());
}
copy.add(f);
}
return copy;
}
private static boolean containsName(List<TemplateField> fields, String name) {
return fields.stream().anyMatch(f -> equalsIgnoreCase(f.getName(), name));
}
private static boolean equalsIgnoreCase(String a, String b) {
if (a == null || b == null) return a == b;
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
}
} }

View File

@@ -1,15 +0,0 @@
package com.loremind.domain.lorecontext;
/**
* Type d'un champ dynamique d'un Template.
* <p>
* - TEXT : valeur textuelle libre (stockee dans Page.values : Map<String, String>)
* - IMAGE : galerie d'images, represente comme une liste d'IDs d'images
* (stockee dans Page.imageValues : Map<String, List<String>>)
* <p>
* Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK...
*/
public enum FieldType {
TEXT,
IMAGE
}

View File

@@ -1,5 +1,7 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.lorecontext;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.shared.template;
/**
* Type d'un champ dynamique de template (kernel partage).
* <p>
* - TEXT : valeur textuelle libre (Map<String, String>)
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
* <p>
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, KEY_VALUE_LIST, REFERENCE...
*/
public enum FieldType {
TEXT,
IMAGE,
NUMBER
}

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.shared.template;
/** /**
* Variante de rendu pour un champ de type IMAGE. * Variante de rendu pour un champ de type IMAGE.
@@ -8,7 +8,7 @@ package com.loremind.domain.lorecontext;
* - MASONRY : mosaique hauteurs variables facon Pinterest * - MASONRY : mosaique hauteurs variables facon Pinterest
* - CAROUSEL : defilement horizontal * - CAROUSEL : defilement horizontal
* <p> * <p>
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT. * Uniquement significatif quand {@link FieldType} = IMAGE. Ignore sinon.
*/ */
public enum ImageLayout { public enum ImageLayout {
GALLERY, GALLERY,

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.shared.template;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -6,15 +6,15 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Value Object d'un champ de Template. * Value Object d'un champ de Template (kernel partage).
* <p> * <p>
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible). * Un champ a un nom (affiche dans l'UI) et un type. Le type pilote
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET * le rendu cote front et la logique metier (seuls les champs TEXT sont
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation). * envoyes a l'IA pour generation).
* <p> * <p>
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu * Pour les champs IMAGE, {@link #layout} precise la variante de rendu
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY. * (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
* Ignore pour les champs TEXT. * Ignore pour les autres types.
*/ */
@Data @Data
@Builder @Builder
@@ -47,4 +47,9 @@ public class TemplateField {
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);
} }
/** Raccourci : construit un champ de type NUMBER. */
public static TemplateField number(String name) {
return new TemplateField(name, FieldType.NUMBER, null);
}
} }

View File

@@ -0,0 +1,93 @@
package com.loremind.infrastructure.persistence;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* Backfill one-shot des fiches Character / Npc post-refonte 2026-04-30.
* <p>
* Avant la refonte, les fiches stockaient leur contenu dans la colonne
* {@code markdown_content}. Apres la refonte, le contenu est dans
* {@code field_values} (JSON Map<String,String>). La colonne
* {@code markdown_content} subsiste car Hibernate ddl-auto=update ne drop pas.
* <p>
* Ce backfill copie {@code markdown_content} dans {@code field_values["Notes"]}
* pour toutes les fiches qui ont un markdown non vide ET un field_values vide.
* Idempotent : si field_values contient deja des donnees, on ne touche pas.
* <p>
* La colonne {@code markdown_content} n'est PAS supprimee apres backfill —
* permet un rollback applicatif au cas ou. Suppression definitive a faire dans
* une release ulterieure quand la confiance est etablie.
*/
@Component
public class CharacterNpcMarkdownBackfill {
private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class);
private final JdbcTemplate jdbc;
private final ObjectMapper mapper = new ObjectMapper();
public CharacterNpcMarkdownBackfill(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@EventListener(ApplicationReadyEvent.class)
public void backfillIfNeeded() {
if (!hasMarkdownContentColumn("characters")) {
log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre).");
return;
}
int chars = backfillTable("characters");
int npcs = backfillTable("npcs");
if (chars + npcs > 0) {
log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs);
}
}
private boolean hasMarkdownContentColumn(String table) {
try {
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_name = ? AND column_name = 'markdown_content'",
Integer.class, table);
return count != null && count > 0;
} catch (Exception e) {
log.warn("Backfill : impossible de verifier la colonne markdown_content sur {}: {}",
table, e.getMessage());
return false;
}
}
private int backfillTable(String table) {
// Selection : fiches avec markdown non vide ET field_values vide ou absent.
// field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data).
String selectSql = "SELECT id, markdown_content FROM " + table
+ " WHERE markdown_content IS NOT NULL "
+ " AND markdown_content <> '' "
+ " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')";
var rows = jdbc.queryForList(selectSql);
int migrated = 0;
for (var row : rows) {
Long id = ((Number) row.get("id")).longValue();
String markdown = (String) row.get("markdown_content");
String json;
try {
json = mapper.writeValueAsString(Map.of("Notes", markdown));
} catch (Exception e) {
log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", table, id, e.getMessage());
continue;
}
jdbc.update("UPDATE " + table + " SET field_values = ? WHERE id = ?", json, id);
migrated++;
}
return migrated;
}
}

View File

@@ -2,6 +2,8 @@ package com.loremind.infrastructure.persistence;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository; import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
@@ -23,6 +25,10 @@ import java.util.List;
* <p> * <p>
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé, * Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur). * il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
* <p>
* Backfill 2026-04-30 : pour les GameSystems existants (avant la refonte
* template-based), on remplit aussi les templates PJ/PNJ par defaut s'ils
* sont vides — sinon les fiches restent inutilisables.
*/ */
@Component @Component
public class GameSystemSeeder { public class GameSystemSeeder {
@@ -37,15 +43,37 @@ public class GameSystemSeeder {
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
public void seedIfEmpty() { public void seedIfEmpty() {
if (!gameSystemRepository.findAll().isEmpty()) { List<GameSystem> existing = gameSystemRepository.findAll();
log.debug("GameSystem seed skipped — table non vide."); if (existing.isEmpty()) {
log.info("Seed initial des GameSystems (table vide)...");
for (GameSystem gs : defaultSystems()) {
gameSystemRepository.save(gs);
}
log.info("GameSystems seedés : {}", defaultSystems().size());
return; return;
} }
log.info("Seed initial des GameSystems (table vide)..."); log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
for (GameSystem gs : defaultSystems()) { backfillEmptyTemplates(existing);
gameSystemRepository.save(gs); }
/**
* Backfill idempotent : pour chaque GameSystem existant ou les deux templates
* sont vides (PJ ET PNJ), injecte le template generique. Si l'utilisateur a
* deja personnalise au moins un des deux, on ne touche a rien.
*/
private void backfillEmptyTemplates(List<GameSystem> systems) {
int patched = 0;
for (GameSystem gs : systems) {
boolean charEmpty = gs.getCharacterTemplate() == null || gs.getCharacterTemplate().isEmpty();
boolean npcEmpty = gs.getNpcTemplate() == null || gs.getNpcTemplate().isEmpty();
if (charEmpty && npcEmpty) {
gs.replaceCharacterTemplate(genericCharacterTemplate());
gs.replaceNpcTemplate(genericNpcTemplate());
gameSystemRepository.save(gs);
patched++;
}
} }
log.info("GameSystems seedés : {}", defaultSystems().size()); if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
} }
private List<GameSystem> defaultSystems() { private List<GameSystem> defaultSystems() {
@@ -56,6 +84,8 @@ public class GameSystemSeeder {
.author("LoreMind seed") .author("LoreMind seed")
.isPublic(false) .isPublic(false)
.rulesMarkdown(NIMBLE_RULES) .rulesMarkdown(NIMBLE_RULES)
.characterTemplate(nimbleCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build(), .build(),
GameSystem.builder() GameSystem.builder()
.name("D&D 5e SRD (extrait)") .name("D&D 5e SRD (extrait)")
@@ -63,6 +93,8 @@ public class GameSystemSeeder {
.author("LoreMind seed") .author("LoreMind seed")
.isPublic(false) .isPublic(false)
.rulesMarkdown(DND_SRD_RULES) .rulesMarkdown(DND_SRD_RULES)
.characterTemplate(dndCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build(), .build(),
GameSystem.builder() GameSystem.builder()
.name("Homebrew Exemple") .name("Homebrew Exemple")
@@ -70,10 +102,70 @@ public class GameSystemSeeder {
.author("LoreMind seed") .author("LoreMind seed")
.isPublic(false) .isPublic(false)
.rulesMarkdown(HOMEBREW_EXAMPLE) .rulesMarkdown(HOMEBREW_EXAMPLE)
.characterTemplate(genericCharacterTemplate())
.npcTemplate(genericNpcTemplate())
.build() .build()
); );
} }
// --- Templates par defaut ---------------------------------------------
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
private static List<TemplateField> genericCharacterTemplate() {
return List.of(
TemplateField.text("Histoire"),
TemplateField.text("Personnalite"),
TemplateField.text("Apparence"),
TemplateField.image("Galerie", ImageLayout.GALLERY),
TemplateField.text("Notes")
);
}
/** Template generique PNJ — focus besoins MJ. */
private static List<TemplateField> genericNpcTemplate() {
return List.of(
TemplateField.text("Apparence"),
TemplateField.text("Motivation"),
TemplateField.text("Faction"),
TemplateField.text("Notes MJ")
);
}
private static List<TemplateField> nimbleCharacterTemplate() {
return List.of(
TemplateField.text("Classe"),
TemplateField.number("Blessures graves max"),
TemplateField.text("Capacites de classe"),
TemplateField.text("Equipement"),
TemplateField.text("Histoire"),
TemplateField.text("Objectifs personnels"),
TemplateField.image("Galerie", ImageLayout.GALLERY)
);
}
private static List<TemplateField> dndCharacterTemplate() {
return List.of(
TemplateField.text("Classe"),
TemplateField.text("Race"),
TemplateField.text("Historique"),
TemplateField.text("Alignement"),
TemplateField.number("Niveau"),
TemplateField.number("PV max"),
TemplateField.number("CA"),
TemplateField.number("FOR"),
TemplateField.number("DEX"),
TemplateField.number("CON"),
TemplateField.number("INT"),
TemplateField.number("SAG"),
TemplateField.number("CHA"),
TemplateField.text("Competences"),
TemplateField.text("Equipement"),
TemplateField.text("Sorts"),
TemplateField.text("Histoire"),
TemplateField.image("Galerie", ImageLayout.GALLERY)
);
}
private static final String NIMBLE_RULES = """ private static final String NIMBLE_RULES = """
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé). Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).

View File

@@ -3,9 +3,9 @@ package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.ImageLayout; import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import jakarta.persistence.AttributeConverter; import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter; import jakarta.persistence.Converter;

View File

@@ -1,5 +1,7 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -7,11 +9,18 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** /**
* Entité JPA pour les fiches de personnages (PJ) d'une campagne. * Entité JPA pour les fiches de personnages (PJ).
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte : * <p>
* on reste dans le Campaign Context, mais l'agrégat Character est autonome). * Refonte 2026-04-30 : ancien champ markdownContent migré vers {@code values["Notes"]}
* via Hibernate au demarrage. Hibernate ddl-auto=update ajoute les nouvelles colonnes
* sans dropper {@code markdown_content} — les donnees existantes sont conservees mais
* plus mappees au domaine. Migration manuelle prevue (script SQL one-shot) si le
* deploiement passe en bluegreen.
*/ */
@Entity @Entity
@Table(name = "characters") @Table(name = "characters")
@@ -28,8 +37,21 @@ public class CharacterJpaEntity {
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@Column(name = "markdown_content", columnDefinition = "TEXT") @Column(name = "portrait_image_id")
private String markdownContent; private String portraitImageId;
@Column(name = "header_image_id")
private String headerImageId;
/** Valeurs TEXT/NUMBER serialisees JSON. */
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
/** Valeurs IMAGE serialisees JSON. */
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
@Column(name = "campaign_id", nullable = false) @Column(name = "campaign_id", nullable = false)
private Long campaignId; private Long campaignId;
@@ -47,6 +69,8 @@ public class CharacterJpaEntity {
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
} }
@PreUpdate @PreUpdate

View File

@@ -1,5 +1,7 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -7,6 +9,8 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/** /**
* Entité JPA pour la persistance des GameSystems (systèmes de JDR). * Entité JPA pour la persistance des GameSystems (systèmes de JDR).
@@ -32,6 +36,16 @@ public class GameSystemJpaEntity {
@Column(name = "rules_markdown", columnDefinition = "TEXT") @Column(name = "rules_markdown", columnDefinition = "TEXT")
private String rulesMarkdown; private String rulesMarkdown;
/** Template PJ serialise en JSON via {@link TemplateFieldListJsonConverter}. */
@Convert(converter = TemplateFieldListJsonConverter.class)
@Column(name = "character_template", columnDefinition = "TEXT")
private List<TemplateField> characterTemplate;
/** Template PNJ serialise en JSON. */
@Convert(converter = TemplateFieldListJsonConverter.class)
@Column(name = "npc_template", columnDefinition = "TEXT")
private List<TemplateField> npcTemplate;
@Column @Column
private String author; private String author;
@@ -48,6 +62,8 @@ public class GameSystemJpaEntity {
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (characterTemplate == null) characterTemplate = new ArrayList<>();
if (npcTemplate == null) npcTemplate = new ArrayList<>();
} }
@PreUpdate @PreUpdate

View File

@@ -1,5 +1,7 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -7,10 +9,13 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** /**
* Entité JPA pour les fiches de PNJ d'une campagne. * Entité JPA pour les fiches de PNJ. Memes regles que CharacterJpaEntity
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte). * (cf. note de refonte 2026-04-30 sur la migration markdownContent).
*/ */
@Entity @Entity
@Table(name = "npcs") @Table(name = "npcs")
@@ -27,8 +32,19 @@ public class NpcJpaEntity {
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@Column(name = "markdown_content", columnDefinition = "TEXT") @Column(name = "portrait_image_id")
private String markdownContent; private String portraitImageId;
@Column(name = "header_image_id")
private String headerImageId;
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
@Column(name = "campaign_id", nullable = false) @Column(name = "campaign_id", nullable = false)
private Long campaignId; private Long campaignId;
@@ -46,6 +62,8 @@ public class NpcJpaEntity {
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
} }
@PreUpdate @PreUpdate

View File

@@ -1,6 +1,6 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter; import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;

View File

@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository; import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -52,7 +53,10 @@ public class PostgresCharacterRepository implements CharacterRepository {
return Character.builder() return Character.builder()
.id(e.getId().toString()) .id(e.getId().toString())
.name(e.getName()) .name(e.getName())
.markdownContent(e.getMarkdownContent()) .portraitImageId(e.getPortraitImageId())
.headerImageId(e.getHeaderImageId())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString()) .campaignId(e.getCampaignId().toString())
.order(e.getOrder()) .order(e.getOrder())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -65,7 +69,10 @@ public class PostgresCharacterRepository implements CharacterRepository {
return CharacterJpaEntity.builder() return CharacterJpaEntity.builder()
.id(id) .id(id)
.name(c.getName()) .name(c.getName())
.markdownContent(c.getMarkdownContent()) .portraitImageId(c.getPortraitImageId())
.headerImageId(c.getHeaderImageId())
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
.campaignId(Long.parseLong(c.getCampaignId())) .campaignId(Long.parseLong(c.getCampaignId()))
.order(c.getOrder()) .order(c.getOrder())
.createdAt(c.getCreatedAt()) .createdAt(c.getCreatedAt())

View File

@@ -61,6 +61,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.name(e.getName()) .name(e.getName())
.description(e.getDescription()) .description(e.getDescription())
.rulesMarkdown(e.getRulesMarkdown()) .rulesMarkdown(e.getRulesMarkdown())
.characterTemplate(e.getCharacterTemplate() != null
? new java.util.ArrayList<>(e.getCharacterTemplate())
: new java.util.ArrayList<>())
.npcTemplate(e.getNpcTemplate() != null
? new java.util.ArrayList<>(e.getNpcTemplate())
: new java.util.ArrayList<>())
.author(e.getAuthor()) .author(e.getAuthor())
.isPublic(e.isPublic()) .isPublic(e.isPublic())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -75,6 +81,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.name(g.getName()) .name(g.getName())
.description(g.getDescription()) .description(g.getDescription())
.rulesMarkdown(g.getRulesMarkdown()) .rulesMarkdown(g.getRulesMarkdown())
.characterTemplate(g.getCharacterTemplate() != null
? new java.util.ArrayList<>(g.getCharacterTemplate())
: new java.util.ArrayList<>())
.npcTemplate(g.getNpcTemplate() != null
? new java.util.ArrayList<>(g.getNpcTemplate())
: new java.util.ArrayList<>())
.author(g.getAuthor()) .author(g.getAuthor())
.isPublic(g.isPublic()) .isPublic(g.isPublic())
.createdAt(g.getCreatedAt()) .createdAt(g.getCreatedAt())

View File

@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository; import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -52,7 +53,10 @@ public class PostgresNpcRepository implements NpcRepository {
return Npc.builder() return Npc.builder()
.id(e.getId().toString()) .id(e.getId().toString())
.name(e.getName()) .name(e.getName())
.markdownContent(e.getMarkdownContent()) .portraitImageId(e.getPortraitImageId())
.headerImageId(e.getHeaderImageId())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString()) .campaignId(e.getCampaignId().toString())
.order(e.getOrder()) .order(e.getOrder())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -65,7 +69,10 @@ public class PostgresNpcRepository implements NpcRepository {
return NpcJpaEntity.builder() return NpcJpaEntity.builder()
.id(id) .id(id)
.name(n.getName()) .name(n.getName())
.markdownContent(n.getMarkdownContent()) .portraitImageId(n.getPortraitImageId())
.headerImageId(n.getHeaderImageId())
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId())) .campaignId(Long.parseLong(n.getCampaignId()))
.order(n.getOrder()) .order(n.getOrder())
.createdAt(n.getCreatedAt()) .createdAt(n.getCreatedAt())

View File

@@ -24,9 +24,7 @@ public class CharacterController {
@PostMapping @PostMapping
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) { public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
Character created = characterService.createCharacter( Character created = characterService.createCharacter(toData(dto, null));
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
);
return ResponseEntity.ok(characterMapper.toDTO(created)); return ResponseEntity.ok(characterMapper.toDTO(created));
} }
@@ -47,10 +45,7 @@ public class CharacterController {
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) { public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
Character updated = characterService.updateCharacter( Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));
id,
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
);
return ResponseEntity.ok(characterMapper.toDTO(updated)); return ResponseEntity.ok(characterMapper.toDTO(updated));
} }
@@ -59,4 +54,16 @@ public class CharacterController {
characterService.deleteCharacter(id); characterService.deleteCharacter(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
private CharacterService.CharacterData toData(CharacterDTO dto, Integer order) {
return new CharacterService.CharacterData(
dto.getName(),
dto.getPortraitImageId(),
dto.getHeaderImageId(),
dto.getValues(),
dto.getImageValues(),
dto.getCampaignId(),
order
);
}
} }

View File

@@ -2,11 +2,16 @@ package com.loremind.infrastructure.web.controller;
import com.loremind.application.gamesystemcontext.GameSystemService; import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO; import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper; import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -16,10 +21,14 @@ public class GameSystemController {
private final GameSystemService gameSystemService; private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper; private final GameSystemMapper gameSystemMapper;
private final TemplateFieldMapper templateFieldMapper;
public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) { public GameSystemController(GameSystemService gameSystemService,
GameSystemMapper gameSystemMapper,
TemplateFieldMapper templateFieldMapper) {
this.gameSystemService = gameSystemService; this.gameSystemService = gameSystemService;
this.gameSystemMapper = gameSystemMapper; this.gameSystemMapper = gameSystemMapper;
this.templateFieldMapper = templateFieldMapper;
} }
@PostMapping @PostMapping
@@ -63,13 +72,28 @@ public class GameSystemController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
}
private GameSystemService.GameSystemData toData(GameSystemDTO dto) { private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
return new GameSystemService.GameSystemData( return new GameSystemService.GameSystemData(
dto.getName(), dto.getName(),
dto.getDescription(), dto.getDescription(),
dto.getRulesMarkdown(), dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()),
dto.getAuthor(), dto.getAuthor(),
dto.isPublic() dto.isPublic()
); );
} }
private List<TemplateField> toDomainFields(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
return out;
}
} }

View File

@@ -24,9 +24,7 @@ public class NpcController {
@PostMapping @PostMapping
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) { public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
Npc created = npcService.createNpc( Npc created = npcService.createNpc(toData(dto, null));
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
);
return ResponseEntity.ok(npcMapper.toDTO(created)); return ResponseEntity.ok(npcMapper.toDTO(created));
} }
@@ -47,10 +45,7 @@ public class NpcController {
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) { public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
Npc updated = npcService.updateNpc( Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
id,
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
);
return ResponseEntity.ok(npcMapper.toDTO(updated)); return ResponseEntity.ok(npcMapper.toDTO(updated));
} }
@@ -59,4 +54,16 @@ public class NpcController {
npcService.deleteNpc(id); npcService.deleteNpc(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
private NpcService.NpcData toData(NpcDTO dto, Integer order) {
return new NpcService.NpcData(
dto.getName(),
dto.getPortraitImageId(),
dto.getHeaderImageId(),
dto.getValues(),
dto.getImageValues(),
dto.getCampaignId(),
order
);
}
} }

View File

@@ -2,7 +2,7 @@ package com.loremind.infrastructure.web.controller;
import com.loremind.application.lorecontext.TemplateService; import com.loremind.application.lorecontext.TemplateService;
import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO; import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper; import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import com.loremind.infrastructure.web.mapper.TemplateMapper; import com.loremind.infrastructure.web.mapper.TemplateMapper;

View File

@@ -2,15 +2,25 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data; import lombok.Data;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** /**
* DTO pour les fiches de personnages (PJ) d'une campagne. * DTO pour les fiches de personnages (PJ) d'une campagne.
* Reflete la refonte template-based : champs universels hard-codes (name,
* portrait, header) + maps {@code values}/{@code imageValues} pour les
* champs templates pilotes par le GameSystem.
*/ */
@Data @Data
public class CharacterDTO { public class CharacterDTO {
private String id; private String id;
private String name; private String name;
private String markdownContent; private String portraitImageId;
private String headerImageId;
private Map<String, String> values = new HashMap<>();
private Map<String, List<String>> imageValues = new HashMap<>();
private String campaignId; private String campaignId;
private int order; private int order;
} }

View File

@@ -2,15 +2,22 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data; import lombok.Data;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** /**
* DTO pour les fiches de PNJ d'une campagne. * DTO pour les fiches de PNJ d'une campagne. Meme structure que CharacterDTO.
*/ */
@Data @Data
public class NpcDTO { public class NpcDTO {
private String id; private String id;
private String name; private String name;
private String markdownContent; private String portraitImageId;
private String headerImageId;
private Map<String, String> values = new HashMap<>();
private Map<String, List<String>> imageValues = new HashMap<>();
private String campaignId; private String campaignId;
private int order; private int order;
} }

View File

@@ -1,9 +1,14 @@
package com.loremind.infrastructure.web.dto.gamesystemcontext; package com.loremind.infrastructure.web.dto.gamesystemcontext;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import lombok.Data; import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/** /**
* DTO pour l'entité GameSystem (système de JDR). * DTO pour l'entité GameSystem (système de JDR).
* Expose les templates PJ/PNJ comme listes de TemplateFieldDTO pour le wire.
*/ */
@Data @Data
public class GameSystemDTO { public class GameSystemDTO {
@@ -12,6 +17,8 @@ public class GameSystemDTO {
private String name; private String name;
private String description; private String description;
private String rulesMarkdown; private String rulesMarkdown;
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
private String author; private String author;
private boolean isPublic; private boolean isPublic;
} }

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.web.dto.lorecontext; package com.loremind.infrastructure.web.dto.lorecontext;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import lombok.Data; import lombok.Data;
import java.util.List; import java.util.List;

View File

@@ -1,4 +1,4 @@
package com.loremind.infrastructure.web.dto.lorecontext; package com.loremind.infrastructure.web.dto.shared;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
@@ -7,7 +7,7 @@ import lombok.NoArgsConstructor;
/** /**
* DTO pour un champ de Template. * DTO pour un champ de Template.
* <p> * <p>
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}. * Miroir wire-friendly de {@link com.loremind.domain.shared.template.TemplateField}.
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular. * Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote * Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
* le rendu visuel des champs image cote front. * le rendu visuel des champs image cote front.

View File

@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Character;
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO; import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.HashMap;
@Component @Component
public class CharacterMapper { public class CharacterMapper {
@@ -12,7 +14,10 @@ public class CharacterMapper {
CharacterDTO dto = new CharacterDTO(); CharacterDTO dto = new CharacterDTO();
dto.setId(c.getId()); dto.setId(c.getId());
dto.setName(c.getName()); dto.setName(c.getName());
dto.setMarkdownContent(c.getMarkdownContent()); dto.setPortraitImageId(c.getPortraitImageId());
dto.setHeaderImageId(c.getHeaderImageId());
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
dto.setCampaignId(c.getCampaignId()); dto.setCampaignId(c.getCampaignId());
dto.setOrder(c.getOrder()); dto.setOrder(c.getOrder());
return dto; return dto;
@@ -23,7 +28,10 @@ public class CharacterMapper {
return Character.builder() return Character.builder()
.id(dto.getId()) .id(dto.getId())
.name(dto.getName()) .name(dto.getName())
.markdownContent(dto.getMarkdownContent()) .portraitImageId(dto.getPortraitImageId())
.headerImageId(dto.getHeaderImageId())
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.order(dto.getOrder()) .order(dto.getOrder())
.build(); .build();

View File

@@ -1,12 +1,23 @@
package com.loremind.infrastructure.web.mapper; package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO; import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
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;
@Component @Component
public class GameSystemMapper { public class GameSystemMapper {
private final TemplateFieldMapper fieldMapper;
public GameSystemMapper(TemplateFieldMapper fieldMapper) {
this.fieldMapper = fieldMapper;
}
public GameSystemDTO toDTO(GameSystem g) { public GameSystemDTO toDTO(GameSystem g) {
if (g == null) return null; if (g == null) return null;
GameSystemDTO dto = new GameSystemDTO(); GameSystemDTO dto = new GameSystemDTO();
@@ -14,6 +25,8 @@ public class GameSystemMapper {
dto.setName(g.getName()); dto.setName(g.getName());
dto.setDescription(g.getDescription()); dto.setDescription(g.getDescription());
dto.setRulesMarkdown(g.getRulesMarkdown()); dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
dto.setAuthor(g.getAuthor()); dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic()); dto.setPublic(g.isPublic());
return dto; return dto;
@@ -26,8 +39,24 @@ public class GameSystemMapper {
.name(dto.getName()) .name(dto.getName())
.description(dto.getDescription()) .description(dto.getDescription())
.rulesMarkdown(dto.getRulesMarkdown()) .rulesMarkdown(dto.getRulesMarkdown())
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
.npcTemplate(toDomainList(dto.getNpcTemplate()))
.author(dto.getAuthor()) .author(dto.getAuthor())
.isPublic(dto.isPublic()) .isPublic(dto.isPublic())
.build(); .build();
} }
private List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
for (TemplateField f : fields) out.add(fieldMapper.toDTO(f));
return out;
}
private List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d));
return out;
}
} }

View File

@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Npc;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO; import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.HashMap;
@Component @Component
public class NpcMapper { public class NpcMapper {
@@ -12,7 +14,10 @@ public class NpcMapper {
NpcDTO dto = new NpcDTO(); NpcDTO dto = new NpcDTO();
dto.setId(n.getId()); dto.setId(n.getId());
dto.setName(n.getName()); dto.setName(n.getName());
dto.setMarkdownContent(n.getMarkdownContent()); dto.setPortraitImageId(n.getPortraitImageId());
dto.setHeaderImageId(n.getHeaderImageId());
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
dto.setCampaignId(n.getCampaignId()); dto.setCampaignId(n.getCampaignId());
dto.setOrder(n.getOrder()); dto.setOrder(n.getOrder());
return dto; return dto;
@@ -23,7 +28,10 @@ public class NpcMapper {
return Npc.builder() return Npc.builder()
.id(dto.getId()) .id(dto.getId())
.name(dto.getName()) .name(dto.getName())
.markdownContent(dto.getMarkdownContent()) .portraitImageId(dto.getPortraitImageId())
.headerImageId(dto.getHeaderImageId())
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.order(dto.getOrder()) .order(dto.getOrder())
.build(); .build();

View File

@@ -1,9 +1,9 @@
package com.loremind.infrastructure.web.mapper; package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.ImageLayout; import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO; import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**

View File

@@ -1,9 +1,9 @@
package com.loremind.infrastructure.web.mapper; package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO; import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
import com.loremind.infrastructure.web.dto.lorecontext.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.ArrayList;

View File

@@ -11,6 +11,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -38,7 +39,7 @@ public class NpcServiceTest {
testNpc = Npc.builder() testNpc = Npc.builder()
.id("npc-1") .id("npc-1")
.name("Borin le forgeron") .name("Borin le forgeron")
.markdownContent("# Borin\nForgeron nain") .values(new java.util.HashMap<>(Map.of("Notes", "Forgeron nain")))
.campaignId("camp-1") .campaignId("camp-1")
.order(1) .order(1)
.build(); .build();
@@ -49,7 +50,8 @@ public class NpcServiceTest {
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
Npc result = npcService.createNpc( Npc result = npcService.createNpc(
new NpcService.NpcData("Borin le forgeron", "# Borin", "camp-1", 5)); new NpcService.NpcData("Borin le forgeron", null, null,
Map.of("Notes", "Borin"), null, "camp-1", 5));
assertNotNull(result); assertNotNull(result);
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class); ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
@@ -65,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, "camp-1", null)); npcService.createNpc(new NpcService.NpcData("Nouveau", 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());
@@ -77,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, "camp-1", null)); npcService.createNpc(new NpcService.NpcData("Premier", 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());
@@ -121,10 +123,11 @@ public class NpcServiceTest {
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0)); when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
Npc result = npcService.updateNpc("npc-1", Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin renommé", "# v2", "camp-1", 7)); new NpcService.NpcData("Borin renommé", null, null,
Map.of("Notes", "v2"), null, "camp-1", 7));
assertEquals("Borin renommé", result.getName()); assertEquals("Borin renommé", result.getName());
assertEquals("# v2", result.getMarkdownContent()); assertEquals("v2", result.getValues().get("Notes"));
assertEquals(7, result.getOrder()); assertEquals(7, result.getOrder());
} }
@@ -134,7 +137,8 @@ public class NpcServiceTest {
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0)); when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
Npc result = npcService.updateNpc("npc-1", Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin", "# txt", "camp-1", null)); new NpcService.NpcData("Borin", null, null,
Map.of("Notes", "txt"), null, "camp-1", null));
// testNpc avait order=1 → préservé // testNpc avait order=1 → préservé
assertEquals(1, result.getOrder()); assertEquals(1, result.getOrder());
@@ -146,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, "camp-1", null))); new NpcService.NpcData("x", 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());
} }

View File

@@ -153,19 +153,19 @@ public class CampaignStructuralContextBuilderTest {
void testBuild_ProjectsCharactersAndNpcsWithSnippets() { void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1) Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
.name("Aragorn") .name("Aragorn")
.markdownContent("# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.") .values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")))
.build(); .build();
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2) Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
.name("Legolas") .name("Legolas")
.markdownContent(null) // pas de snippet → string vide .values(null) // pas de snippet → string vide
.build(); .build();
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2) Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
.name("Borin le forgeron") .name("Borin le forgeron")
.markdownContent("# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.") .values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")))
.build(); .build();
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1) Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
.name("Dame Elara") .name("Dame Elara")
.markdownContent("") .values(new java.util.HashMap<>(java.util.Map.of("Histoire", "")))
.build(); .build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
@@ -196,7 +196,7 @@ public class CampaignStructuralContextBuilderTest {
// Snippet > 160 chars : doit être tronqué à 159 + "…" // Snippet > 160 chars : doit être tronqué à 159 + "…"
String longLine = "x".repeat(200); String longLine = "x".repeat(200);
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1) Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
.name("Verbeux").markdownContent(longLine).build(); .name("Verbeux").values(new java.util.HashMap<>(java.util.Map.of("Histoire", longLine))).build();
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign)); when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());

View File

@@ -3,12 +3,12 @@ package com.loremind.application.generationcontext;
import com.loremind.domain.generationcontext.GenerationContext; import com.loremind.domain.generationcontext.GenerationContext;
import com.loremind.domain.generationcontext.GenerationResult; import com.loremind.domain.generationcontext.GenerationResult;
import com.loremind.domain.generationcontext.ports.AiProvider; import com.loremind.domain.generationcontext.ports.AiProvider;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.Lore; import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.LoreNode; import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.lorecontext.Page; import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.LoreRepository; import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;

View File

@@ -115,8 +115,13 @@ public class NarrativeEntityContextBuilderTest {
@Test @Test
void testBuild_Character_MarkdownProjected() { void testBuild_Character_MarkdownProjected() {
// Refonte 2026-04-30 : les valeurs templates sont projetees individuellement
// dans la map fields (cle = nom du champ template).
Character c = Character.builder() Character c = Character.builder()
.id("c-1").name("Aragorn").markdownContent("# Aragorn\nRôdeur") .id("c-1").name("Aragorn")
.values(new java.util.HashMap<>(java.util.Map.of(
"Histoire", "# Aragorn\nRôdeur",
"Race", "Humain")))
.build(); .build();
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c)); when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
@@ -124,14 +129,17 @@ public class NarrativeEntityContextBuilderTest {
assertEquals("character", ctx.entityType()); assertEquals("character", ctx.entityType());
assertEquals("Aragorn", ctx.title()); assertEquals("Aragorn", ctx.title());
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("fiche complète (markdown)")); assertEquals("# Aragorn\nRôdeur", ctx.fields().get("Histoire"));
assertEquals("Humain", ctx.fields().get("Race"));
} }
@Test @Test
void testBuild_Npc_MarkdownProjected() { void testBuild_Npc_MarkdownProjected() {
Npc n = Npc.builder() Npc n = Npc.builder()
.id("n-1").name("Borin le forgeron") .id("n-1").name("Borin le forgeron")
.markdownContent("# Borin\n**Faction :** Clan Feuillefer") .values(new java.util.HashMap<>(java.util.Map.of(
"Faction", "Clan Feuillefer",
"Histoire", "# Borin")))
.build(); .build();
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n)); when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
@@ -139,13 +147,14 @@ public class NarrativeEntityContextBuilderTest {
assertEquals("npc", ctx.entityType()); assertEquals("npc", ctx.entityType());
assertEquals("Borin le forgeron", ctx.title()); assertEquals("Borin le forgeron", ctx.title());
assertEquals("# Borin\n**Faction :** Clan Feuillefer", assertEquals("Clan Feuillefer", ctx.fields().get("Faction"));
ctx.fields().get("fiche complète (markdown)")); assertEquals("# Borin", ctx.fields().get("Histoire"));
} }
@Test @Test
void testBuild_Npc_NormalizesCase() { void testBuild_Npc_NormalizesCase() {
Npc n = Npc.builder().id("n-1").name("Elara").markdownContent("desc").build(); Npc n = Npc.builder().id("n-1").name("Elara")
.values(new java.util.HashMap<>(java.util.Map.of("Notes", "desc"))).build();
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n)); when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1"); NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");

View File

@@ -5,10 +5,10 @@ import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.LoreStructuralContext; import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.Page; import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository; import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;

View File

@@ -1,7 +1,7 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.TemplateRepository; import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -0,0 +1,139 @@
package com.loremind.domain.gamesystemcontext;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests unitaires du domaine GameSystem ciblant la gestion des templates PJ/PNJ.
* Le ruleset markdown est testé ailleurs via GameSystemContextSelector.
*/
class GameSystemTest {
// --- addCharacterField --------------------------------------------------
@Test
void addCharacterField_appendsField() {
GameSystem gs = GameSystem.builder().build();
gs.addCharacterField(TemplateField.text("Histoire"));
gs.addCharacterField(TemplateField.image("Portrait", ImageLayout.HERO));
assertEquals(2, gs.getCharacterTemplate().size());
assertEquals("Histoire", gs.getCharacterTemplate().get(0).getName());
assertEquals(FieldType.IMAGE, gs.getCharacterTemplate().get(1).getType());
}
@Test
void addCharacterField_rejectsDuplicateNameCaseInsensitive() {
GameSystem gs = GameSystem.builder().build();
gs.addCharacterField(TemplateField.text("Histoire"));
// Doublon de cle dans Character.values garanti casse-insensible :
// "Histoire" et "histoire" produiraient la meme cle JSON.
assertThrows(IllegalArgumentException.class,
() -> gs.addCharacterField(TemplateField.number("HISTOIRE")));
}
@Test
void addCharacterField_rejectsBlankName() {
GameSystem gs = GameSystem.builder().build();
assertThrows(IllegalArgumentException.class,
() -> gs.addCharacterField(new TemplateField(" ", FieldType.TEXT)));
}
// --- removeCharacterField ----------------------------------------------
@Test
void removeCharacterField_removesByNameCaseInsensitive() {
GameSystem gs = GameSystem.builder()
.characterTemplate(new ArrayList<>(List.of(
TemplateField.text("Histoire"),
TemplateField.text("Notes")
)))
.build();
gs.removeCharacterField("HISTOIRE");
assertEquals(1, gs.getCharacterTemplate().size());
assertEquals("Notes", gs.getCharacterTemplate().get(0).getName());
}
@Test
void removeCharacterField_silentNoOpWhenMissing() {
GameSystem gs = GameSystem.builder()
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Histoire"))))
.build();
gs.removeCharacterField("absent");
assertEquals(1, gs.getCharacterTemplate().size());
}
// --- replaceCharacterTemplate ------------------------------------------
@Test
void replaceCharacterTemplate_overwritesEntireList() {
GameSystem gs = GameSystem.builder()
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Old"))))
.build();
gs.replaceCharacterTemplate(List.of(
TemplateField.text("A"),
TemplateField.number("B")));
assertEquals(2, gs.getCharacterTemplate().size());
assertEquals("A", gs.getCharacterTemplate().get(0).getName());
assertEquals("B", gs.getCharacterTemplate().get(1).getName());
}
@Test
void replaceCharacterTemplate_rejectsDuplicates() {
GameSystem gs = GameSystem.builder().build();
assertThrows(IllegalArgumentException.class,
() -> gs.replaceCharacterTemplate(List.of(
TemplateField.text("a"),
TemplateField.text("A"))));
}
@Test
void replaceCharacterTemplate_nullBecomesEmptyList() {
GameSystem gs = GameSystem.builder().build();
gs.replaceCharacterTemplate(null);
assertTrue(gs.getCharacterTemplate().isEmpty());
}
@Test
void replaceCharacterTemplate_isolatesInternalListFromCallerMutations() {
// Garantie d'encapsulation : muter la liste passee ne doit pas affecter le GameSystem.
List<TemplateField> external = new ArrayList<>(List.of(TemplateField.text("A")));
GameSystem gs = GameSystem.builder().build();
gs.replaceCharacterTemplate(external);
external.add(TemplateField.text("B"));
assertEquals(1, gs.getCharacterTemplate().size());
}
// --- Templates NPC : meme logique, sanity check minimal ----------------
@Test
void npcTemplate_followsSameRulesAsCharacterTemplate() {
GameSystem gs = GameSystem.builder().build();
gs.addNpcField(TemplateField.text("Motivation"));
assertThrows(IllegalArgumentException.class,
() -> gs.addNpcField(TemplateField.text("motivation")));
gs.removeNpcField("Motivation");
assertTrue(gs.getNpcTemplate().isEmpty());
}
}

View File

@@ -1,5 +1,7 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.lorecontext;
import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.shared.template.TemplateField;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;

View File

@@ -1,4 +1,4 @@
package com.loremind.domain.lorecontext; package com.loremind.domain.shared.template;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -1,8 +1,8 @@
package com.loremind.infrastructure.persistence.converter; package com.loremind.infrastructure.persistence.converter;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.ImageLayout; import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;

View File

@@ -1,10 +1,10 @@
package com.loremind.infrastructure.persistence.postgres; package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.lorecontext.FieldType; import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.lorecontext.ImageLayout; import com.loremind.domain.shared.template.ImageLayout;
import com.loremind.domain.lorecontext.Lore; import com.loremind.domain.lorecontext.Lore;
import com.loremind.domain.lorecontext.Template; import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.TemplateField; import com.loremind.domain.shared.template.TemplateField;
import com.loremind.domain.lorecontext.ports.LoreRepository; import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository; import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;

View File

@@ -0,0 +1,108 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests d'integration du GameSystemController centres sur la persistance
* des templates PJ/PNJ via l'API REST. Le CRUD de base est suppose stable.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class GameSystemControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Test
void create_persistsCharacterAndNpcTemplates() throws Exception {
GameSystemDTO dto = new GameSystemDTO();
dto.setName("Nimble Test");
dto.setRulesMarkdown("## Combat\n- d20");
dto.setCharacterTemplate(List.of(
new TemplateFieldDTO("Histoire", "TEXT", null),
new TemplateFieldDTO("PV", "NUMBER", null),
new TemplateFieldDTO("Portrait", "IMAGE", "HERO")));
dto.setNpcTemplate(List.of(
new TemplateFieldDTO("Motivation", "TEXT", null)));
mockMvc.perform(post("/api/game-systems")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.characterTemplate.length()").value(3))
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"))
.andExpect(jsonPath("$.characterTemplate[2].layout").value("HERO"))
.andExpect(jsonPath("$.npcTemplate.length()").value(1))
.andExpect(jsonPath("$.npcTemplate[0].name").value("Motivation"));
}
@Test
void update_replacesTemplates() throws Exception {
// Creation initiale avec un seul champ.
GameSystemDTO dto = new GameSystemDTO();
dto.setName("RuleSet");
dto.setCharacterTemplate(List.of(new TemplateFieldDTO("Old", "TEXT", null)));
MvcResult posted = mockMvc.perform(post("/api/game-systems")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andReturn();
GameSystemDTO created = objectMapper.readValue(
posted.getResponse().getContentAsString(), GameSystemDTO.class);
// Replace template integralement.
created.setCharacterTemplate(List.of(
new TemplateFieldDTO("Histoire", "TEXT", null),
new TemplateFieldDTO("Niveau", "NUMBER", null)));
mockMvc.perform(put("/api/game-systems/{id}", created.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(created)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.characterTemplate.length()").value(2))
.andExpect(jsonPath("$.characterTemplate[0].name").value("Histoire"))
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"));
// Verification que le GET independant retourne bien les nouveaux champs (pas de cache stale).
mockMvc.perform(get("/api/game-systems/{id}", created.getId()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Old')]").doesNotExist())
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Histoire')]").exists());
}
@Test
void create_rejectsDuplicateFieldNames() throws Exception {
GameSystemDTO dto = new GameSystemDTO();
dto.setName("BadRules");
dto.setCharacterTemplate(List.of(
new TemplateFieldDTO("Nom", "TEXT", null),
new TemplateFieldDTO("nom", "NUMBER", null)));
mockMvc.perform(post("/api/game-systems")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().is4xxClientError());
}
}

View File

@@ -6,7 +6,7 @@ import com.loremind.domain.lorecontext.Template;
import com.loremind.domain.lorecontext.ports.LoreRepository; import com.loremind.domain.lorecontext.ports.LoreRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository; import com.loremind.domain.lorecontext.ports.TemplateRepository;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO; import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO; import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;

View File

@@ -18,8 +18,10 @@ export const routes: Routes = [
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) }, { path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) }, { path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },

View File

@@ -90,7 +90,7 @@
</div> </div>
<div class="characters-grid" *ngIf="characters.length > 0"> <div class="characters-grid" *ngIf="characters.length > 0">
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)"> <div class="character-card" *ngFor="let character of characters" (click)="viewCharacter(character)">
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon> <lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
<div class="character-info"> <div class="character-info">
<span class="character-name">{{ character.name }}</span> <span class="character-name">{{ character.name }}</span>
@@ -123,7 +123,7 @@
</div> </div>
<div class="characters-grid" *ngIf="npcs.length > 0"> <div class="characters-grid" *ngIf="npcs.length > 0">
<div class="character-card" *ngFor="let npc of npcs" (click)="editNpc(npc)"> <div class="character-card" *ngFor="let npc of npcs" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon> <lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info"> <div class="character-info">
<span class="character-name">{{ npc.name }}</span> <span class="character-name">{{ npc.name }}</span>

View File

@@ -194,6 +194,17 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']); this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
} }
/** Ouvre la vue lecture seule (style WorldAnvil) — clic sur la carte. */
viewCharacter(character: Character): void {
if (!this.campaign || !character.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id]);
}
viewNpc(npc: Npc): void {
if (!this.campaign || !npc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id]);
}
createArc(): void { createArc(): void {
if (!this.campaign) return; if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']); this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
@@ -205,19 +216,23 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
} }
/** /**
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre). * Extrait une ligne de resume pour la fiche PJ/PNJ — 1re valeur de template
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte). * non-vide (apres refonte 2026-04-30, remplace l'ancien parsing markdown).
*/ */
personaSnippet(p: { markdownContent?: string | null }): string { personaSnippet(p: { values?: Record<string, string> }): string {
if (!p.markdownContent) return '(Fiche vide)'; const values = p.values ?? {};
const firstMeaningful = p.markdownContent for (const v of Object.values(values)) {
.split('\n') if (!v) continue;
.map(l => l.trim()) const firstMeaningful = v
.find(l => l && !l.startsWith('#')); .split('\n')
if (!firstMeaningful) return '(Fiche vide)'; .map(l => l.trim())
return firstMeaningful.length > 80 .find(l => l && !l.startsWith('#'));
? firstMeaningful.substring(0, 77) + '…' if (!firstMeaningful) continue;
: firstMeaningful; return firstMeaningful.length > 80
? firstMeaningful.substring(0, 77) + '…'
: firstMeaningful;
}
return '(Fiche vide)';
} }
/** Alias gardé pour compatibilité avec les anciens templates. */ /** Alias gardé pour compatibilité avec les anciens templates. */

View File

@@ -35,18 +35,35 @@
/> />
</div> </div>
<div class="field content-field"> <div class="field-row image-row">
<label>Fiche (markdown)</label> <div class="field portrait-field">
<p class="hint"> <label>Portrait</label>
Tout en markdown libre : stats, classe, backstory, équipement, objectifs personnels… <app-single-image-picker
L'IA lira ces infos pour rester cohérente quand vous générez des scènes impliquant ce PJ. [imageId]="portraitImageId"
</p> aspectRatio="1 / 1"
<textarea hint="Carre conseille (400×400)."
[(ngModel)]="markdownContent" (imageIdChange)="portraitImageId = $event">
name="markdownContent" </app-single-image-picker>
rows="22" </div>
placeholder="# Thorin Grand-Hache&#10;&#10;**Race :** Nain&#10;**Classe :** Guerrier niveau 4&#10;**PV :** 35 / 35&#10;&#10;## Stats&#10;- Force : 16&#10;- Dextérité : 12&#10;...&#10;&#10;## Backstory&#10;Originaire des montagnes du Nord, Thorin a fui son clan après..." <div class="field header-field">
></textarea> <label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event">
</app-dynamic-fields-form>
</div> </div>
<div class="actions"> <div class="actions">

View File

@@ -4,22 +4,28 @@ import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { Character } from '../../../services/character.model'; import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; 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';
/** /**
* Éditeur plein écran d'une fiche de personnage (PJ). * Editeur plein ecran d'une fiche de personnage (PJ).
* Double rôle création/édition : * Refonte 2026-04-30 : remplace le markdown libre par un formulaire dynamique
* - `/campaigns/:campaignId/characters/create` → POST * pilote par le characterTemplate du GameSystem associe a la campagne.
* - `/campaigns/:campaignId/characters/:characterId/edit` → PUT
* *
* MVP : name + markdown libre. Évolution prévue vers un template dérivé * Comportements :
* du GameSystem de la campagne (stats structurées). * - Si la campagne n'a pas de GameSystem ou si son template est vide, affiche
* uniquement les champs universels (nom, portrait, header).
* - Le picker d'images dedie portrait/header est hors scope MVP — pour l'instant
* saisie manuelle d'IDs d'images.
*/ */
@Component({ @Component({
selector: 'app-character-edit', selector: 'app-character-edit',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent], imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './character-edit.component.html', templateUrl: './character-edit.component.html',
styleUrls: ['./character-edit.component.scss'] styleUrls: ['./character-edit.component.scss']
}) })
@@ -30,12 +36,11 @@ export class CharacterEditComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
/** État drawer chat IA focalisé sur ce PJ. */
chatOpen = false; chatOpen = false;
readonly chatQuickSuggestions = [ readonly chatQuickSuggestions = [
'Propose une backstory cohérente avec l\'univers', 'Propose une backstory coherente avec l\'univers',
'Suggère 3 objectifs personnels pour ce personnage', 'Suggere 3 objectifs personnels pour ce personnage',
'Aide-moi à équilibrer les stats de combat' 'Aide-moi a equilibrer les stats de combat'
]; ];
toggleChat(): void { this.chatOpen = !this.chatOpen; } toggleChat(): void { this.chatOpen = !this.chatOpen; }
@@ -44,13 +49,19 @@ export class CharacterEditComponent implements OnInit {
characterId: string | null = null; characterId: string | null = null;
name = ''; name = '';
markdownContent = ''; portraitImageId: string | null = null;
headerImageId: string | null = null;
values: Record<string, string> = {};
imageValues: Record<string, string[]> = {};
templateFields: TemplateField[] = [];
private order = 0; private order = 0;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private service: CharacterService private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -58,11 +69,18 @@ export class CharacterEditComponent implements OnInit {
this.campaignId = params.get('campaignId'); this.campaignId = params.get('campaignId');
this.characterId = params.get('characterId'); this.characterId = params.get('characterId');
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
}
if (this.characterId) { if (this.characterId) {
this.service.getById(this.characterId).subscribe({ this.service.getById(this.characterId).subscribe({
next: (c) => { next: (c) => {
this.name = c.name; this.name = c.name;
this.markdownContent = c.markdownContent ?? ''; this.portraitImageId = c.portraitImageId ?? null;
this.headerImageId = c.headerImageId ?? null;
this.values = c.values ?? {};
this.imageValues = c.imageValues ?? {};
this.order = c.order ?? 0; this.order = c.order ?? 0;
}, },
error: () => this.back() error: () => this.back()
@@ -70,21 +88,35 @@ export class CharacterEditComponent implements OnInit {
} }
} }
private loadTemplateForCampaign(campaignId: string): void {
this.campaignService.getCampaignById(campaignId).subscribe({
next: (campaign) => {
if (!campaign.gameSystemId) {
this.templateFields = [];
return;
}
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
next: (gs) => { this.templateFields = gs.characterTemplate ?? []; },
error: () => { this.templateFields = []; }
});
},
error: () => { this.templateFields = []; }
});
}
submit(): void { submit(): void {
if (!this.name.trim() || !this.campaignId) return; if (!this.name.trim() || !this.campaignId) return;
const payload = {
name: this.name.trim(),
portraitImageId: this.portraitImageId,
headerImageId: this.headerImageId,
values: this.values,
imageValues: this.imageValues,
campaignId: this.campaignId
};
const req = this.characterId const req = this.characterId
? this.service.update(this.characterId, { ? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
id: this.characterId, : this.service.create(payload);
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId,
order: this.order
})
: this.service.create({
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId
});
req.subscribe({ req.subscribe({
next: () => this.back(), next: () => this.back(),
error: () => console.error('Erreur sauvegarde Character') error: () => console.error('Erreur sauvegarde Character')
@@ -93,7 +125,7 @@ export class CharacterEditComponent implements OnInit {
deleteCharacter(): void { deleteCharacter(): void {
if (!this.characterId) return; if (!this.characterId) return;
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return; if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
this.service.delete(this.characterId).subscribe({ this.service.delete(this.characterId).subscribe({
next: () => this.back(), next: () => this.back(),
error: () => console.error('Erreur suppression Character') error: () => console.error('Erreur suppression Character')

View File

@@ -0,0 +1,28 @@
<div class="cv-page">
<div class="cv-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="characterId">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
</button>
</div>
<app-persona-view [persona]="character" [templateFields]="templateFields"></app-persona-view>
</div>
<app-ai-chat-drawer
*ngIf="characterId && campaignId"
[campaignId]="campaignId"
entityType="character"
[entityId]="characterId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,53 @@
.cv-page {
padding: 16px 0 48px;
min-height: 100vh;
}
.cv-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 16px;
max-width: 1100px;
margin: 0 auto;
.spacer { flex: 1; }
}
.btn-back,
.btn-edit,
.btn-ai {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #d1d5db;
font-size: 0.85rem;
cursor: pointer;
transition: all 120ms;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.btn-edit {
border-color: rgba(209, 168, 120, 0.4);
color: #d1a878;
&:hover {
background: rgba(209, 168, 120, 0.15);
}
}
.btn-ai {
&.active {
background: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe;
}
}

View File

@@ -0,0 +1,80 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template.model';
import { Character } from '../../../services/character.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Vue lecture seule "WorldAnvil" d'une fiche PJ.
* Route : /campaigns/:campaignId/characters/:characterId
*/
@Component({
selector: 'app-character-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './character-view.component.html',
styleUrls: ['./character-view.component.scss']
})
export class CharacterViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
characterId: string | null = null;
character: Character | null = null;
templateFields: TemplateField[] = [];
chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; }
constructor(
private route: ActivatedRoute,
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.characterId = params.get('characterId');
if (this.characterId) {
this.service.getById(this.characterId).subscribe({
next: c => { this.character = c; },
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.characterTemplate ?? [];
});
}
});
}
}
edit(): void {
if (this.campaignId && this.characterId) {
this.router.navigate(['/campaigns', this.campaignId, 'characters', this.characterId, 'edit']);
}
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -35,18 +35,35 @@
/> />
</div> </div>
<div class="field content-field"> <div class="field-row image-row">
<label>Fiche (markdown)</label> <div class="field portrait-field">
<p class="hint"> <label>Portrait</label>
Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ… <app-single-image-picker
À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes. [imageId]="portraitImageId"
</p> aspectRatio="1 / 1"
<textarea hint="Carre conseille (400×400)."
[(ngModel)]="markdownContent" (imageIdChange)="portraitImageId = $event">
name="markdownContent" </app-single-image-picker>
rows="22" </div>
placeholder="# Borin le forgeron&#10;&#10;**Race :** Nain&#10;**Faction :** Clan Feuillefer&#10;**Statut :** Vivant&#10;&#10;## Apparence&#10;Barbe rousse tressée, tablier de cuir brûlé...&#10;&#10;## Motivations&#10;Venger son clan décimé par les orcs il y a 10 hivers.&#10;&#10;## Notes MJ (secret)&#10;Connaît l'emplacement du marteau de Durin..." <div class="field header-field">
></textarea> <label>Bandeau / Header</label>
<app-single-image-picker
[imageId]="headerImageId"
aspectRatio="3 / 1"
hint="Format paysage conseille (1200×400)."
(imageIdChange)="headerImageId = $event">
</app-single-image-picker>
</div>
</div>
<div class="template-fields">
<app-dynamic-fields-form
[fields]="templateFields"
[values]="values"
[imageValues]="imageValues"
(valuesChange)="values = $event"
(imageValuesChange)="imageValues = $event">
</app-dynamic-fields-form>
</div> </div>
<div class="actions"> <div class="actions">

View File

@@ -4,21 +4,23 @@ import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template.model';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component'; 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';
/** /**
* Éditeur plein écran d'une fiche de PNJ. * Editeur plein ecran d'une fiche de PNJ.
* Double rôle création/édition : * Refonte 2026-04-30 : meme principe que CharacterEditComponent — markdown
* - `/campaigns/:campaignId/npcs/create` → POST * libre remplace par un formulaire dynamique pilote par le npcTemplate du
* - `/campaigns/:campaignId/npcs/:npcId/edit` → PUT * GameSystem associe a la campagne.
*
* MVP : name + markdown libre. L'Assistant IA est branché en mode édition
* (focus entityType="npc") pour proposer apparence, motivations, secrets...
*/ */
@Component({ @Component({
selector: 'app-npc-edit', selector: 'app-npc-edit',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent], imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
templateUrl: './npc-edit.component.html', templateUrl: './npc-edit.component.html',
styleUrls: ['./npc-edit.component.scss'] styleUrls: ['./npc-edit.component.scss']
}) })
@@ -29,12 +31,11 @@ export class NpcEditComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
/** État drawer chat IA focalisé sur ce PNJ. */
chatOpen = false; chatOpen = false;
readonly chatQuickSuggestions = [ readonly chatQuickSuggestions = [
'Propose une apparence et une posture marquantes', 'Propose une apparence et une posture marquantes',
'Suggère 2 motivations et un secret pour ce PNJ', 'Suggere 2 motivations et un secret pour ce PNJ',
'Imagine 3 répliques signatures qui le caractérisent' 'Imagine 3 repliques signatures qui le caracterisent'
]; ];
toggleChat(): void { this.chatOpen = !this.chatOpen; } toggleChat(): void { this.chatOpen = !this.chatOpen; }
@@ -43,13 +44,19 @@ export class NpcEditComponent implements OnInit {
npcId: string | null = null; npcId: string | null = null;
name = ''; name = '';
markdownContent = ''; portraitImageId: string | null = null;
headerImageId: string | null = null;
values: Record<string, string> = {};
imageValues: Record<string, string[]> = {};
templateFields: TemplateField[] = [];
private order = 0; private order = 0;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private service: NpcService private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -57,11 +64,18 @@ export class NpcEditComponent implements OnInit {
this.campaignId = params.get('campaignId'); this.campaignId = params.get('campaignId');
this.npcId = params.get('npcId'); this.npcId = params.get('npcId');
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
}
if (this.npcId) { if (this.npcId) {
this.service.getById(this.npcId).subscribe({ this.service.getById(this.npcId).subscribe({
next: (n) => { next: (n) => {
this.name = n.name; this.name = n.name;
this.markdownContent = n.markdownContent ?? ''; this.portraitImageId = n.portraitImageId ?? null;
this.headerImageId = n.headerImageId ?? null;
this.values = n.values ?? {};
this.imageValues = n.imageValues ?? {};
this.order = n.order ?? 0; this.order = n.order ?? 0;
}, },
error: () => this.back() error: () => this.back()
@@ -69,21 +83,35 @@ export class NpcEditComponent implements OnInit {
} }
} }
private loadTemplateForCampaign(campaignId: string): void {
this.campaignService.getCampaignById(campaignId).subscribe({
next: (campaign) => {
if (!campaign.gameSystemId) {
this.templateFields = [];
return;
}
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
next: (gs) => { this.templateFields = gs.npcTemplate ?? []; },
error: () => { this.templateFields = []; }
});
},
error: () => { this.templateFields = []; }
});
}
submit(): void { submit(): void {
if (!this.name.trim() || !this.campaignId) return; if (!this.name.trim() || !this.campaignId) return;
const payload = {
name: this.name.trim(),
portraitImageId: this.portraitImageId,
headerImageId: this.headerImageId,
values: this.values,
imageValues: this.imageValues,
campaignId: this.campaignId
};
const req = this.npcId const req = this.npcId
? this.service.update(this.npcId, { ? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
id: this.npcId, : this.service.create(payload);
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId,
order: this.order
})
: this.service.create({
name: this.name.trim(),
markdownContent: this.markdownContent || null,
campaignId: this.campaignId
});
req.subscribe({ req.subscribe({
next: () => this.back(), next: () => this.back(),
error: () => console.error('Erreur sauvegarde Npc') error: () => console.error('Erreur sauvegarde Npc')
@@ -92,7 +120,7 @@ export class NpcEditComponent implements OnInit {
deleteNpc(): void { deleteNpc(): void {
if (!this.npcId) return; if (!this.npcId) return;
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return; if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
this.service.delete(this.npcId).subscribe({ this.service.delete(this.npcId).subscribe({
next: () => this.back(), next: () => this.back(),
error: () => console.error('Erreur suppression Npc') error: () => console.error('Erreur suppression Npc')

View File

@@ -0,0 +1,28 @@
<div class="nv-page">
<div class="nv-toolbar">
<button class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour
</button>
<span class="spacer"></span>
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="npcId">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Assistant IA
</button>
<button class="btn-edit" (click)="edit()">
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
Editer
</button>
</div>
<app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view>
</div>
<app-ai-chat-drawer
*ngIf="npcId && campaignId"
[campaignId]="campaignId"
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -0,0 +1,51 @@
.nv-page {
padding: 16px 0 48px;
min-height: 100vh;
}
.nv-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 32px 16px;
max-width: 1100px;
margin: 0 auto;
.spacer { flex: 1; }
}
.btn-back,
.btn-edit,
.btn-ai {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #d1d5db;
font-size: 0.85rem;
cursor: pointer;
transition: all 120ms;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
.btn-edit {
border-color: rgba(209, 168, 120, 0.4);
color: #d1a878;
&:hover {
background: rgba(209, 168, 120, 0.15);
}
}
.btn-ai.active {
background: rgba(168, 85, 247, 0.2);
border-color: rgba(168, 85, 247, 0.5);
color: #d8b4fe;
}

View File

@@ -0,0 +1,80 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
import { NpcService } from '../../../services/npc.service';
import { CampaignService } from '../../../services/campaign.service';
import { GameSystemService } from '../../../services/game-system.service';
import { TemplateField } from '../../../services/template.model';
import { Npc } from '../../../services/npc.model';
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
/**
* Vue lecture seule "WorldAnvil" d'une fiche PNJ.
* Route : /campaigns/:campaignId/npcs/:npcId
*/
@Component({
selector: 'app-npc-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
templateUrl: './npc-view.component.html',
styleUrls: ['./npc-view.component.scss']
})
export class NpcViewComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Edit3 = Edit3;
readonly Sparkles = Sparkles;
campaignId: string | null = null;
npcId: string | null = null;
npc: Npc | null = null;
templateFields: TemplateField[] = [];
chatOpen = false;
toggleChat(): void { this.chatOpen = !this.chatOpen; }
constructor(
private route: ActivatedRoute,
private router: Router,
private service: NpcService,
private campaignService: CampaignService,
private gameSystemService: GameSystemService
) {}
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.npcId = params.get('npcId');
if (this.npcId) {
this.service.getById(this.npcId).subscribe({
next: n => { this.npc = n; },
error: () => this.back()
});
}
if (this.campaignId) {
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
if (camp.gameSystemId) {
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
this.templateFields = gs.npcTemplate ?? [];
});
}
});
}
}
edit(): void {
if (this.campaignId && this.npcId) {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', this.npcId, 'edit']);
}
}
back(): void {
if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);
}
}
}

View File

@@ -90,6 +90,32 @@
</div> </div>
</div> </div>
<!-- Templates de fiches PJ/PNJ -->
<div class="templates-area">
<h2 class="sections-title">Fiches de personnages</h2>
<p class="sections-hint">
Definissez la structure des fiches PJ et PNJ pour ce systeme. Les champs
universels (nom, portrait, header) sont automatiques — ne rajoutez ici
que les champs specifiques au systeme (Histoire, PV, Stats…).
</p>
<app-template-fields-editor
label="Champs de la fiche PJ"
hint="Affiches lors de la creation/edition d'un personnage joueur."
[fields]="characterTemplate"
[suggestions]="characterFieldSuggestions"
(fieldsChange)="characterTemplate = $event">
</app-template-fields-editor>
<app-template-fields-editor
label="Champs de la fiche PNJ"
hint="Affiches lors de la creation/edition d'un personnage non-joueur."
[fields]="npcTemplate"
[suggestions]="npcFieldSuggestions"
(fieldsChange)="npcTemplate = $event">
</app-template-fields-editor>
</div>
<div class="actions"> <div class="actions">
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()"> <button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
<lucide-icon [img]="Save" [size]="16"></lucide-icon> <lucide-icon [img]="Save" [size]="16"></lucide-icon>

View File

@@ -5,6 +5,13 @@
margin: 0 auto; margin: 0 auto;
} }
.templates-area {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.gse-header { .gse-header {
margin-bottom: 2rem; margin-bottom: 2rem;

View File

@@ -4,6 +4,8 @@ import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular'; import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
import { GameSystemService } from '../../services/game-system.service'; import { GameSystemService } from '../../services/game-system.service';
import { TemplateField } from '../../services/template.model';
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
/** /**
* Éditeur plein écran d'un GameSystem. Rôle double création/édition : * Éditeur plein écran d'un GameSystem. Rôle double création/édition :
@@ -31,10 +33,16 @@ const SUGGESTED_SECTIONS = [
'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression' 'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression'
]; ];
/** Suggestions de champs pour la fiche PJ — generiques (extension par template). */
const CHARACTER_FIELD_SUGGESTIONS = ['Histoire', 'Personnalite', 'Apparence', 'Notes'];
/** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */
const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ'];
@Component({ @Component({
selector: 'app-game-system-edit', selector: 'app-game-system-edit',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule], imports: [CommonModule, FormsModule, LucideAngularModule, TemplateFieldsEditorComponent],
templateUrl: './game-system-edit.component.html', templateUrl: './game-system-edit.component.html',
styleUrls: ['./game-system-edit.component.scss'] styleUrls: ['./game-system-edit.component.scss']
}) })
@@ -53,8 +61,12 @@ export class GameSystemEditComponent implements OnInit {
description = ''; description = '';
author = ''; author = '';
sections: RuleSection[] = []; sections: RuleSection[] = [];
characterTemplate: TemplateField[] = [];
npcTemplate: TemplateField[] = [];
readonly suggestedSections = SUGGESTED_SECTIONS; readonly suggestedSections = SUGGESTED_SECTIONS;
readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS;
readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS;
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
@@ -71,6 +83,8 @@ export class GameSystemEditComponent implements OnInit {
this.description = gs.description ?? ''; this.description = gs.description ?? '';
this.author = gs.author ?? ''; this.author = gs.author ?? '';
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? ''); this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : [];
this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : [];
}, },
error: () => this.back() error: () => this.back()
}); });
@@ -104,11 +118,17 @@ export class GameSystemEditComponent implements OnInit {
submit(): void { submit(): void {
if (!this.name.trim()) return; if (!this.name.trim()) return;
if (this.hasInvalidTemplateFields()) {
console.warn('Champs templates invalides (noms vides ou doublons) — sauvegarde bloquee.');
return;
}
const payload = { const payload = {
name: this.name.trim(), name: this.name.trim(),
description: this.description.trim() || null, description: this.description.trim() || null,
author: this.author.trim() || null, author: this.author.trim() || null,
rulesMarkdown: this.serializeMarkdown(), rulesMarkdown: this.serializeMarkdown(),
characterTemplate: this.characterTemplate,
npcTemplate: this.npcTemplate,
isPublic: false isPublic: false
}; };
const req = this.id const req = this.id
@@ -124,6 +144,22 @@ export class GameSystemEditComponent implements OnInit {
this.router.navigate(['/game-systems']); this.router.navigate(['/game-systems']);
} }
/** Validation cote front : nom vide ou doublons (case-insensitive). */
private hasInvalidTemplateFields(): boolean {
return this.hasInvalidList(this.characterTemplate) || this.hasInvalidList(this.npcTemplate);
}
private hasInvalidList(fields: TemplateField[]): boolean {
const seen = new Set<string>();
for (const f of fields) {
const name = f.name?.trim().toLowerCase();
if (!name) return true;
if (seen.has(name)) return true;
seen.add(name);
}
return false;
}
// --- Parse / Serialize markdown ------------------------------------------ // --- Parse / Serialize markdown ------------------------------------------
/** /**

View File

@@ -1,18 +1,27 @@
/** /**
* Fiche de personnage joueur (PJ) d'une campagne. * Fiche de personnage joueur (PJ) d'une campagne.
* MVP : markdownContent libre. Évolution prévue vers des fiches templatées * Refonte 2026-04-30 : abandon du markdownContent au profit d'un systeme
* par GameSystem (stats structurées selon le JDR joué). * template-based pilote par le GameSystem de la campagne.
* - portraitImageId / headerImageId : champs universels hard-codes
* - values : Map<champ template TEXT/NUMBER, valeur>
* - imageValues : Map<champ template IMAGE, liste d'IDs d'images>
*/ */
export interface Character { export interface Character {
id?: string; id?: string;
name: string; name: string;
markdownContent?: string | null; portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
campaignId: string; campaignId: string;
order?: number; order?: number;
} }
export interface CharacterCreate { export interface CharacterCreate {
name: string; name: string;
markdownContent?: string | null; portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
campaignId: string; campaignId: string;
} }

View File

@@ -1,24 +1,30 @@
import { TemplateField } from './template.model';
/** /**
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java). * Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
* *
* rulesMarkdown : markdown monolithique, sections découpées par titres H2 * rulesMarkdown : markdown monolithique, sections decoupees par titres H2.
* (## Combat, ## Classes, etc.). Le découpage et la sélection des sections * characterTemplate / npcTemplate : champs templates pilotant le rendu des
* à injecter dans le prompt IA sont faits côté backend Java. * fiches PJ / PNJ d'une campagne adossee a ce systeme (cf. refonte 2026-04-30).
*/ */
export interface GameSystem { export interface GameSystem {
id?: string; id?: string;
name: string; name: string;
description?: string | null; description?: string | null;
rulesMarkdown?: string | null; rulesMarkdown?: string | null;
characterTemplate?: TemplateField[];
npcTemplate?: TemplateField[];
author?: string | null; author?: string | null;
isPublic?: boolean; isPublic?: boolean;
} }
/** Payload de création/mise à jour (sans id). */ /** Payload de creation/mise a jour (sans id). */
export interface GameSystemCreate { export interface GameSystemCreate {
name: string; name: string;
description?: string | null; description?: string | null;
rulesMarkdown?: string | null; rulesMarkdown?: string | null;
characterTemplate?: TemplateField[];
npcTemplate?: TemplateField[];
author?: string | null; author?: string | null;
isPublic: boolean; isPublic: boolean;
} }

View File

@@ -1,18 +1,23 @@
/** /**
* Fiche de personnage non-joueur (PNJ) d'une campagne. * Fiche de personnage non-joueur (PNJ) d'une campagne.
* MVP : markdownContent libre (description, motivation, stats, notes MJ). * Refonte 2026-04-30 : meme structure que Character (template-based).
* Évolution prévue : templating partagé PJ/PNJ piloté par GameSystem.
*/ */
export interface Npc { export interface Npc {
id?: string; id?: string;
name: string; name: string;
markdownContent?: string | null; portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
campaignId: string; campaignId: string;
order?: number; order?: number;
} }
export interface NpcCreate { export interface NpcCreate {
name: string; name: string;
markdownContent?: string | null; portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
campaignId: string; campaignId: string;
} }

View File

@@ -1,11 +1,12 @@
// Interfaces TypeScript pour TemplateDTO (Backend Java). // Interfaces TypeScript pour TemplateDTO (Backend Java).
/** /**
* Type d'un champ de Template. Miroir de com.loremind.domain.lorecontext.FieldType. * Type d'un champ de Template. Miroir de com.loremind.domain.shared.template.FieldType.
* - '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)
*/ */
export type FieldType = 'TEXT' | 'IMAGE'; export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER';
/** /**
* Variante de rendu pour un champ IMAGE. Miroir de * Variante de rendu pour un champ IMAGE. Miroir de

View File

@@ -0,0 +1,39 @@
<div class="dff" *ngIf="fields?.length; else emptyTpl">
<div class="dff-field" *ngFor="let f of fields; trackBy: trackByName">
<label>{{ f.name }}</label>
<ng-container [ngSwitch]="f.type">
<textarea
*ngSwitchCase="'TEXT'"
rows="4"
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="Renseignez {{ f.name }}…">
</textarea>
<input
*ngSwitchCase="'NUMBER'"
type="number"
[ngModel]="values[f.name] || ''"
(ngModelChange)="onTextChange(f, $event)"
[name]="'val-' + f.name"
placeholder="0"
/>
<app-image-gallery
*ngSwitchCase="'IMAGE'"
[editable]="true"
[layout]="f.layout || 'GALLERY'"
[imageIds]="imagesFor(f)"
(imageIdsChange)="onImageIdsChange(f, $event)">
</app-image-gallery>
</ng-container>
</div>
</div>
<ng-template #emptyTpl>
<div class="dff-empty">
Aucun champ defini dans le template de ce systeme. Editez le GameSystem pour ajouter des champs.
</div>
</ng-template>

View File

@@ -0,0 +1,50 @@
.dff {
display: flex;
flex-direction: column;
gap: 14px;
}
.dff-field {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text, #fff);
}
input,
textarea {
padding: 8px 10px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: var(--color-text, #fff);
font-size: 0.95rem;
font-family: inherit;
resize: vertical;
}
}
.image-mvp {
display: flex;
flex-direction: column;
gap: 4px;
.hint {
font-size: 0.75rem;
color: var(--color-text-muted, #888);
font-style: italic;
}
}
.dff-empty {
padding: 24px;
text-align: center;
color: var(--color-text-muted, #888);
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.1);
border-radius: 6px;
}

View File

@@ -0,0 +1,48 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TemplateField } from '../../services/template.model';
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
/**
* Formulaire dynamique pilote par une liste de TemplateField.
*
* Inputs :
* - fields : structure (provient du GameSystem.characterTemplate / npcTemplate)
* - values : Record<champName, string> pour les types TEXT et NUMBER
* - imageValues : Record<champName, string[]> pour le type IMAGE
*
* Pour les champs IMAGE, delegue au composant <app-image-gallery editable>
* qui gere l'upload, la suppression et le respect du layout.
*/
@Component({
selector: 'app-dynamic-fields-form',
standalone: true,
imports: [CommonModule, FormsModule, ImageGalleryComponent],
templateUrl: './dynamic-fields-form.component.html',
styleUrls: ['./dynamic-fields-form.component.scss']
})
export class DynamicFieldsFormComponent {
@Input() fields: TemplateField[] = [];
@Input() values: Record<string, string> = {};
@Input() imageValues: Record<string, string[]> = {};
@Output() valuesChange = new EventEmitter<Record<string, string>>();
@Output() imageValuesChange = new EventEmitter<Record<string, string[]>>();
onTextChange(field: TemplateField, value: string): void {
this.values = { ...this.values, [field.name]: value };
this.valuesChange.emit(this.values);
}
onImageIdsChange(field: TemplateField, ids: string[]): void {
this.imageValues = { ...this.imageValues, [field.name]: ids };
this.imageValuesChange.emit(this.imageValues);
}
imagesFor(field: TemplateField): string[] {
return this.imageValues[field.name] ?? [];
}
trackByName = (_: number, f: TemplateField) => f.name;
}

View File

@@ -0,0 +1,60 @@
<div class="pv" *ngIf="persona">
<!-- Bandeau / Header -->
<div class="pv-banner" *ngIf="persona.headerImageId">
<img [src]="contentUrl(persona.headerImageId)" alt="" />
<div class="pv-banner-fade"></div>
</div>
<!-- En-tete : portrait + titre -->
<div class="pv-hero" [class.no-banner]="!persona.headerImageId">
<div class="pv-portrait" *ngIf="persona.portraitImageId">
<img [src]="contentUrl(persona.portraitImageId)" alt="" />
</div>
<div class="pv-title-block">
<h1 class="pv-name">{{ persona.name }}</h1>
<p *ngIf="subtitle" class="pv-subtitle">{{ subtitle }}</p>
</div>
</div>
<!-- Stats numeriques en bandeau si presentes -->
<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">
<ng-container *ngFor="let f of textFields; let first = first">
<section *ngIf="!f.isNumber" class="pv-section">
<h2 class="pv-section-title">{{ f.name }}</h2>
<div class="pv-section-body">
<p [class.with-dropcap]="first" class="pv-paragraph">
{{ firstParagraph(f.value) }}
</p>
<p *ngIf="restAfterFirstParagraph(f.value)" class="pv-paragraph">
{{ restAfterFirstParagraph(f.value) }}
</p>
</div>
</section>
</ng-container>
<!-- Galeries d'images templates -->
<section *ngFor="let img of imageFields" class="pv-section pv-section-images">
<h2 class="pv-section-title">{{ img.field.name }}</h2>
<app-image-gallery [imageIds]="img.ids" [layout]="img.field.layout || 'GALLERY'" [editable]="false">
</app-image-gallery>
</section>
<!-- Etat vide -->
<div *ngIf="textFields.length === 0 && imageFields.length === 0" class="pv-empty">
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
<p>Cette fiche est encore vide.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,228 @@
// Vue WorldAnvil-style : bandeau, portrait latteral, sections elegantes, drop cap.
.pv {
max-width: 1100px;
margin: 0 auto;
color: #e5e7eb;
}
// --- Bandeau ----------------------------------------------------------------
.pv-banner {
position: relative;
height: 280px;
overflow: hidden;
border-radius: 8px 8px 0 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.pv-banner-fade {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
to bottom,
transparent 60%,
rgba(15, 17, 23, 0.85) 100%
);
}
// --- Hero (portrait + titre) ------------------------------------------------
.pv-hero {
display: grid;
grid-template-columns: 220px 1fr;
gap: 28px;
padding: 20px 32px;
margin-top: -90px;
position: relative;
z-index: 1;
&.no-banner {
margin-top: 0;
padding-top: 32px;
}
}
.pv-portrait {
width: 220px;
height: 220px;
border-radius: 6px;
overflow: hidden;
border: 3px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
background: #1a1d24;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.pv-title-block {
display: flex;
flex-direction: column;
justify-content: flex-end;
padding-bottom: 8px;
}
.pv-name {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 3rem;
font-weight: 600;
letter-spacing: 0.04em;
margin: 0;
color: #f3f4f6;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
}
.pv-subtitle {
margin: 8px 0 0;
font-size: 1.05rem;
font-style: italic;
color: #b5b9c4;
letter-spacing: 0.05em;
}
// --- Bandeau de stats (NUMBER) ---------------------------------------------
.pv-stat-band {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 12px 32px;
margin: 16px 32px 0;
background: rgba(255, 255, 255, 0.04);
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.pv-stat {
display: none;
&.pv-stat-number {
display: flex;
flex-direction: column;
align-items: center;
min-width: 60px;
}
.pv-stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #9ca3af;
}
.pv-stat-value {
font-size: 1.25rem;
font-weight: 700;
color: #f3f4f6;
font-family: 'Cinzel', Georgia, serif;
}
}
// --- Sections ---------------------------------------------------------------
.pv-sections {
padding: 32px 32px 48px;
}
.pv-section {
margin-bottom: 36px;
&:last-child {
margin-bottom: 0;
}
}
.pv-section-title {
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 1.4rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #d1a878;
margin: 0 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(209, 168, 120, 0.25);
position: relative;
// Petit ornement central
&::after {
content: '';
position: absolute;
left: 50%;
bottom: -3px;
width: 6px;
height: 6px;
background: #d1a878;
border-radius: 50%;
transform: translateX(-50%);
}
}
.pv-section-body {
font-size: 1rem;
line-height: 1.65;
color: #d6d8de;
}
.pv-paragraph {
margin: 0 0 14px;
white-space: pre-wrap;
&.with-dropcap::first-letter {
float: left;
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
font-size: 3.5rem;
line-height: 0.9;
font-weight: 700;
color: #d1a878;
padding: 4px 8px 0 0;
margin-top: 4px;
}
}
// --- Etat vide --------------------------------------------------------------
.pv-empty {
text-align: center;
padding: 64px 24px;
color: #6b7280;
font-style: italic;
p {
margin: 12px 0 0;
}
}
// --- Responsive -------------------------------------------------------------
@media (max-width: 720px) {
.pv-hero {
grid-template-columns: 1fr;
margin-top: -60px;
}
.pv-portrait {
width: 160px;
height: 160px;
}
.pv-name {
font-size: 2.2rem;
}
.pv-banner {
height: 180px;
}
}

View File

@@ -0,0 +1,88 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { TemplateField } from '../../services/template.model';
import { ImageService } from '../../services/image.service';
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
/**
* Affichage type "WorldAnvil" d'une fiche PJ ou PNJ.
*
* Layout :
* - Bandeau (headerImageId) en haut, pleine largeur
* - Bloc 2 colonnes : portrait a gauche, infos textuelles a droite
* - Sections suivantes pour chaque champ template TEXT/NUMBER/IMAGE
* - Drop cap sur la 1re lettre du 1er paragraphe TEXT
*
* Composant pur de presentation : ne fetche rien, recoit (persona, templateFields).
*/
export interface PersonaLike {
name: string;
portraitImageId?: string | null;
headerImageId?: string | null;
values?: Record<string, string>;
imageValues?: Record<string, string[]>;
}
@Component({
selector: 'app-persona-view',
standalone: true,
imports: [CommonModule, LucideAngularModule, ImageGalleryComponent],
templateUrl: './persona-view.component.html',
styleUrls: ['./persona-view.component.scss']
})
export class PersonaViewComponent {
readonly BookOpen = BookOpen;
@Input() persona: PersonaLike | null = null;
@Input() templateFields: TemplateField[] = [];
/** Sous-titre optionnel sous le nom (ex: "Champion d'Aerimor"). */
@Input() subtitle?: string;
constructor(private imageService: ImageService) {}
contentUrl(id: string): string {
return this.imageService.contentUrl(id);
}
/** Champs TEXT/NUMBER non vides, dans l'ordre du template. */
get textFields(): { name: string; value: string; isNumber: boolean }[] {
if (!this.persona?.values) return [];
return this.templateFields
.filter(f => (f.type === 'TEXT' || f.type === 'NUMBER'))
.map(f => ({
name: f.name,
value: this.persona!.values?.[f.name] ?? '',
isNumber: f.type === 'NUMBER'
}))
.filter(x => x.value && x.value.trim().length > 0);
}
/** Champs IMAGE non vides, dans l'ordre du template. */
get imageFields(): { field: TemplateField; ids: string[] }[] {
if (!this.persona?.imageValues) return [];
return this.templateFields
.filter(f => f.type === 'IMAGE')
.map(f => ({ field: f, ids: this.persona!.imageValues?.[f.name] ?? [] }))
.filter(x => x.ids.length > 0);
}
hasAnyNumber(fields: { isNumber: boolean }[]): boolean {
return fields.some(f => f.isNumber);
}
/** Premier paragraphe d'un texte (utilise pour la drop cap). */
firstParagraph(text: string): string {
if (!text) return '';
const paragraphs = text.split(/\n\s*\n/);
return paragraphs[0]?.trim() ?? '';
}
/** Reste du texte apres le 1er paragraphe. */
restAfterFirstParagraph(text: string): string {
if (!text) return '';
const paragraphs = text.split(/\n\s*\n/);
return paragraphs.slice(1).join('\n\n').trim();
}
}

View File

@@ -0,0 +1,14 @@
<div class="sip">
<div class="sip-frame" [style.aspectRatio]="aspectRatio">
<ng-container *ngIf="imageId; else uploadTpl">
<img [src]="contentUrl(imageId)" alt="" />
<button type="button" class="sip-remove" (click)="remove()" title="Retirer l'image">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</ng-container>
<ng-template #uploadTpl>
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</ng-template>
</div>
<small *ngIf="hint" class="sip-hint">{{ hint }}</small>
</div>

View File

@@ -0,0 +1,48 @@
.sip {
display: flex;
flex-direction: column;
gap: 4px;
}
.sip-frame {
position: relative;
width: 100%;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.sip-remove {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
transition: background 120ms;
&:hover {
background: rgba(220, 38, 38, 0.9);
}
}
.sip-hint {
font-size: 0.75rem;
font-style: italic;
color: var(--color-text-muted, #888);
}

View File

@@ -0,0 +1,60 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
/**
* Picker d'image unique : preview + upload + suppression.
*
* Usage :
* <app-single-image-picker [imageId]="portraitId" (imageIdChange)="portraitId = $event">
* </app-single-image-picker>
*
* Comportements :
* - Si imageId est defini : affiche la miniature avec un bouton X pour retirer
* - Sinon : affiche le bouton d'upload (compact mode)
*
* Le composant ne supprime pas l'image cote backend — il decouple juste le
* lien (passe imageId a null). L'image reste accessible via d'autres entites.
*/
@Component({
selector: 'app-single-image-picker',
standalone: true,
imports: [CommonModule, LucideAngularModule, ImageUploaderComponent],
templateUrl: './single-image-picker.component.html',
styleUrls: ['./single-image-picker.component.scss']
})
export class SingleImagePickerComponent {
readonly X = X;
readonly ImageIcon = ImageIcon;
@Input() imageId: string | null = null;
/** Texte d'aide affiche sous le picker (ex: "Format conseille : 400×400"). */
@Input() hint?: string;
/** Aspect ratio de la preview (CSS aspect-ratio property). */
@Input() aspectRatio = '1 / 1';
@Output() imageIdChange = new EventEmitter<string | null>();
constructor(private imageService: ImageService) {}
contentUrl(id: string): string {
return this.imageService.contentUrl(id);
}
onUploaded(img: Image): void {
if (img?.id) {
this.imageId = img.id;
this.imageIdChange.emit(this.imageId);
}
}
remove(): void {
this.imageId = null;
this.imageIdChange.emit(null);
}
}

View File

@@ -0,0 +1,79 @@
<div class="tfe">
<div class="tfe-header">
<h3 class="tfe-label">{{ label }}</h3>
<p *ngIf="hint" class="tfe-hint">{{ hint }}</p>
</div>
<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-row-controls">
<button type="button" class="btn-arrow" (click)="moveUp(i)" [disabled]="i === 0" title="Monter">
<lucide-icon [img]="ArrowUp" [size]="14"></lucide-icon>
</button>
<button type="button" class="btn-arrow" (click)="moveDown(i)" [disabled]="i === fields.length - 1" title="Descendre">
<lucide-icon [img]="ArrowDown" [size]="14"></lucide-icon>
</button>
</div>
<input
type="text"
class="tfe-name"
[(ngModel)]="f.name"
[name]="'name-' + i"
(ngModelChange)="onFieldChanged()"
placeholder="Nom du champ (ex: Histoire, PV...)"
/>
<select
class="tfe-type"
[(ngModel)]="f.type"
[name]="'type-' + i"
(ngModelChange)="onFieldChanged()">
<option *ngFor="let opt of typeOptions" [value]="opt.value">{{ opt.label }}</option>
</select>
<select
class="tfe-layout"
*ngIf="f.type === 'IMAGE'"
[(ngModel)]="f.layout"
[name]="'layout-' + i"
(ngModelChange)="onFieldChanged()">
<option *ngFor="let opt of layoutOptions" [value]="opt.value">{{ opt.label }}</option>
</select>
<button type="button" class="btn-remove" (click)="remove(i)" title="Supprimer ce champ">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<div *ngIf="fields.length === 0" class="tfe-empty">
Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.
</div>
</div>
<div class="tfe-add">
<span class="tfe-add-label">Ajouter :</span>
<button
type="button"
class="chip"
*ngFor="let s of suggestions"
[class.disabled]="isSuggestionUsed(s)"
[disabled]="isSuggestionUsed(s)"
(click)="addSuggestion(s)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
{{ s }}
</button>
<button type="button" class="chip chip-custom" (click)="addBlank('TEXT')">
<lucide-icon [img]="Type" [size]="12"></lucide-icon>
Texte
</button>
<button type="button" class="chip chip-custom" (click)="addBlank('NUMBER')">
<lucide-icon [img]="Hash" [size]="12"></lucide-icon>
Nombre
</button>
<button type="button" class="chip chip-custom" (click)="addBlank('IMAGE')">
<lucide-icon [img]="ImageIcon" [size]="12"></lucide-icon>
Image(s)
</button>
</div>
</div>

View File

@@ -0,0 +1,139 @@
.tfe {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.tfe-header {
.tfe-label {
margin: 0 0 4px;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #fff);
}
.tfe-hint {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted, #aaa);
}
}
.tfe-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.tfe-row {
display: grid;
grid-template-columns: auto 1fr 130px auto auto;
gap: 8px;
align-items: center;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
transition: border-color 120ms;
&.invalid {
border-color: rgba(255, 100, 100, 0.5);
}
}
.tfe-row-controls {
display: flex;
flex-direction: column;
gap: 2px;
}
.btn-arrow,
.btn-remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: var(--color-text-muted, #aaa);
cursor: pointer;
transition: all 120ms;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: var(--color-text, #fff);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.btn-remove:hover {
border-color: rgba(255, 100, 100, 0.4);
color: rgba(255, 100, 100, 0.9);
}
.tfe-name,
.tfe-type,
.tfe-layout {
padding: 6px 8px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: var(--color-text, #fff);
font-size: 0.9rem;
}
.tfe-empty {
padding: 12px;
text-align: center;
font-size: 0.85rem;
font-style: italic;
color: var(--color-text-muted, #888);
}
.tfe-add {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
.tfe-add-label {
font-size: 0.85rem;
color: var(--color-text-muted, #aaa);
margin-right: 4px;
}
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: var(--color-text-muted, #ccc);
font-size: 0.8rem;
cursor: pointer;
transition: all 120ms;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--color-text, #fff);
}
&.disabled,
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.chip-custom {
border-style: dashed;
}

View File

@@ -0,0 +1,115 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash } from 'lucide-angular';
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
/**
* Editeur reutilisable d'une liste de TemplateField.
* Pilote l'ajout / suppression / reordonnancement / changement de type / renommage.
*
* Emet `fieldsChange` a chaque modification pour permettre un binding 2-way :
* <app-template-fields-editor [fields]="myFields" (fieldsChange)="myFields = $event">
*
* Validation locale : duplicats de noms (case-insensitive) marques visuellement,
* mais c'est le parent qui decide du blocage du submit.
*/
@Component({
selector: 'app-template-fields-editor',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './template-fields-editor.component.html',
styleUrls: ['./template-fields-editor.component.scss']
})
export class TemplateFieldsEditorComponent {
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ArrowUp = ArrowUp;
readonly ArrowDown = ArrowDown;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
readonly Hash = Hash;
/** Liste des champs (binding parent). */
@Input() fields: TemplateField[] = [];
/** Suggestions de noms de champs (chips ajout rapide). */
@Input() suggestions: string[] = [];
/** Label de la section (ex: "Champs de la fiche PJ"). */
@Input() label = 'Champs du template';
/** Hint affichee sous le label. */
@Input() hint?: string;
@Output() fieldsChange = new EventEmitter<TemplateField[]>();
readonly typeOptions: { value: FieldType; label: string }[] = [
{ value: 'TEXT', label: 'Texte' },
{ value: 'NUMBER', label: 'Nombre' },
{ value: 'IMAGE', label: 'Image(s)' }
];
readonly layoutOptions: { value: ImageLayout; label: string }[] = [
{ value: 'GALLERY', label: 'Galerie' },
{ value: 'HERO', label: 'Bandeau' },
{ value: 'MASONRY', label: 'Mosaique' },
{ value: 'CAROUSEL', label: 'Carrousel' }
];
isDuplicate(field: TemplateField, index: number): boolean {
if (!field.name?.trim()) return false;
const lower = field.name.trim().toLowerCase();
return this.fields.some((f, i) => i !== index && f.name?.trim().toLowerCase() === lower);
}
isSuggestionUsed(name: string): boolean {
const lower = name.toLowerCase();
return this.fields.some(f => f.name?.trim().toLowerCase() === lower);
}
addSuggestion(name: string): void {
if (this.isSuggestionUsed(name)) return;
this.emit([...this.fields, { name, type: 'TEXT', layout: null }]);
}
addBlank(type: FieldType): void {
const layout: ImageLayout | null = type === 'IMAGE' ? 'GALLERY' : null;
this.emit([...this.fields, { name: '', type, layout }]);
}
remove(index: number): void {
const next = [...this.fields];
next.splice(index, 1);
this.emit(next);
}
moveUp(index: number): void {
if (index <= 0) return;
const next = [...this.fields];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
this.emit(next);
}
moveDown(index: number): void {
if (index >= this.fields.length - 1) return;
const next = [...this.fields];
[next[index], next[index + 1]] = [next[index + 1], next[index]];
this.emit(next);
}
/** Notifie les changements internes (input/select sur un champ existant). */
onFieldChanged(): void {
// Quand le type passe a IMAGE, layout = GALLERY ; sinon null.
for (const f of this.fields) {
if (f.type === 'IMAGE' && !f.layout) f.layout = 'GALLERY';
if (f.type !== 'IMAGE') f.layout = null;
}
this.emit([...this.fields]);
}
private emit(fields: TemplateField[]): void {
this.fields = fields;
this.fieldsChange.emit(fields);
}
}