Evolutions :
- Ajout d'icônes dans la scène, chapitre et arc - Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles - Changement sur le thème du graphe : mode sombre et plus blanc - Barre d'action en haut, même pour la partie scène - Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action Passage version 0.6.5
This commit is contained in:
@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.6.2",
|
version="0.6.5",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.6.2</version>
|
<version>0.6.5</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,16 @@ public class ArcService {
|
|||||||
public record DeletionImpact(int chapters, int scenes) {}
|
public record DeletionImpact(int chapters, int scenes) {}
|
||||||
|
|
||||||
public Arc createArc(String name, String description, String campaignId, int order) {
|
public Arc createArc(String name, String description, String campaignId, int order) {
|
||||||
|
return createArc(name, description, campaignId, order, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Arc createArc(String name, String description, String campaignId, int order, String icon) {
|
||||||
Arc arc = Arc.builder()
|
Arc arc = Arc.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.description(description)
|
.description(description)
|
||||||
.campaignId(campaignId)
|
.campaignId(campaignId)
|
||||||
.order(order)
|
.order(order)
|
||||||
|
.icon(icon)
|
||||||
.build();
|
.build();
|
||||||
return arcRepository.save(arc);
|
return arcRepository.save(arc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ public class ChapterService {
|
|||||||
public record DeletionImpact(int scenes) {}
|
public record DeletionImpact(int scenes) {}
|
||||||
|
|
||||||
public Chapter createChapter(String name, String description, String arcId, int order) {
|
public Chapter createChapter(String name, String description, String arcId, int order) {
|
||||||
|
return createChapter(name, description, arcId, order, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Chapter createChapter(String name, String description, String arcId, int order, String icon) {
|
||||||
Chapter chapter = Chapter.builder()
|
Chapter chapter = Chapter.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.description(description)
|
.description(description)
|
||||||
.arcId(arcId)
|
.arcId(arcId)
|
||||||
.order(order)
|
.order(order)
|
||||||
|
.icon(icon)
|
||||||
.build();
|
.build();
|
||||||
return chapterRepository.save(chapter);
|
return chapterRepository.save(chapter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,16 @@ public class SceneService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Scene createScene(String name, String description, String chapterId, int order) {
|
public Scene createScene(String name, String description, String chapterId, int order) {
|
||||||
|
return createScene(name, description, chapterId, order, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Scene createScene(String name, String description, String chapterId, int order, String icon) {
|
||||||
Scene scene = Scene.builder()
|
Scene scene = Scene.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.description(description)
|
.description(description)
|
||||||
.chapterId(chapterId)
|
.chapterId(chapterId)
|
||||||
.order(order)
|
.order(order)
|
||||||
|
.icon(icon)
|
||||||
.build();
|
.build();
|
||||||
return sceneRepository.save(scene);
|
return sceneRepository.save(scene);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class Arc {
|
|||||||
private String campaignId; // Référence vers la Campaign parente
|
private String campaignId; // Référence vers la Campaign parente
|
||||||
private int order; // Ordre de l'arc dans la campagne
|
private int order; // Ordre de l'arc dans la campagne
|
||||||
|
|
||||||
|
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
||||||
private String themes; // Thèmes principaux explorés dans cet arc
|
private String themes; // Thèmes principaux explorés dans cet arc
|
||||||
private String stakes; // Enjeux globaux pour les personnages
|
private String stakes; // Enjeux globaux pour les personnages
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class Chapter {
|
|||||||
private String arcId; // Référence vers l'Arc parent
|
private String arcId; // Référence vers l'Arc parent
|
||||||
private int order; // Ordre du chapitre dans l'arc
|
private int order; // Ordre du chapitre dans l'arc
|
||||||
|
|
||||||
|
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
// Champs narratifs enrichis (voir docs/maquettes/campagne/détail/)
|
||||||
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
|
private String gmNotes; // Notes privées du MJ (non exportées vers FoundryVTT)
|
||||||
private String playerObjectives; // Objectifs des joueurs dans ce chapitre
|
private String playerObjectives; // Objectifs des joueurs dans ce chapitre
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public class Scene {
|
|||||||
private String chapterId; // Référence vers le Chapter parent
|
private String chapterId; // Référence vers le Chapter parent
|
||||||
private int order; // Ordre de la scène dans le chapitre
|
private int order; // Ordre de la scène dans le chapitre
|
||||||
|
|
||||||
|
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// === Contexte et ambiance ===
|
// === Contexte et ambiance ===
|
||||||
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
|
private String location; // Lieu de la scène (ex: Taverne du Dragon d'Or)
|
||||||
private String timing; // Moment (ex: Soir, à la tombée de la nuit)
|
private String timing; // Moment (ex: Soir, à la tombée de la nuit)
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class ArcJpaEntity {
|
|||||||
@Column(name = "\"order\"", nullable = false)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String themes;
|
private String themes;
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class ChapterJpaEntity {
|
|||||||
@Column(name = "\"order\"", nullable = false)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate DDL (ddl-auto=update)
|
||||||
@Column(name = "gm_notes", columnDefinition = "TEXT")
|
@Column(name = "gm_notes", columnDefinition = "TEXT")
|
||||||
private String gmNotes;
|
private String gmNotes;
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class SceneJpaEntity {
|
|||||||
@Column(name = "\"order\"", nullable = false)
|
@Column(name = "\"order\"", nullable = false)
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
|
// Champs narratifs enrichis — ajoutés automatiquement par Hibernate (ddl-auto=update)
|
||||||
|
|
||||||
// Contexte et ambiance
|
// Contexte et ambiance
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
.description(jpaEntity.getDescription())
|
.description(jpaEntity.getDescription())
|
||||||
.campaignId(jpaEntity.getCampaignId().toString())
|
.campaignId(jpaEntity.getCampaignId().toString())
|
||||||
.order(jpaEntity.getOrder())
|
.order(jpaEntity.getOrder())
|
||||||
|
.icon(jpaEntity.getIcon())
|
||||||
.themes(jpaEntity.getThemes())
|
.themes(jpaEntity.getThemes())
|
||||||
.stakes(jpaEntity.getStakes())
|
.stakes(jpaEntity.getStakes())
|
||||||
.gmNotes(jpaEntity.getGmNotes())
|
.gmNotes(jpaEntity.getGmNotes())
|
||||||
@@ -99,6 +100,7 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
.description(arc.getDescription())
|
.description(arc.getDescription())
|
||||||
.campaignId(Long.parseLong(arc.getCampaignId()))
|
.campaignId(Long.parseLong(arc.getCampaignId()))
|
||||||
.order(arc.getOrder())
|
.order(arc.getOrder())
|
||||||
|
.icon(arc.getIcon())
|
||||||
.themes(arc.getThemes())
|
.themes(arc.getThemes())
|
||||||
.stakes(arc.getStakes())
|
.stakes(arc.getStakes())
|
||||||
.gmNotes(arc.getGmNotes())
|
.gmNotes(arc.getGmNotes())
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
.description(jpaEntity.getDescription())
|
.description(jpaEntity.getDescription())
|
||||||
.arcId(jpaEntity.getArcId().toString())
|
.arcId(jpaEntity.getArcId().toString())
|
||||||
.order(jpaEntity.getOrder())
|
.order(jpaEntity.getOrder())
|
||||||
|
.icon(jpaEntity.getIcon())
|
||||||
.gmNotes(jpaEntity.getGmNotes())
|
.gmNotes(jpaEntity.getGmNotes())
|
||||||
.playerObjectives(jpaEntity.getPlayerObjectives())
|
.playerObjectives(jpaEntity.getPlayerObjectives())
|
||||||
.narrativeStakes(jpaEntity.getNarrativeStakes())
|
.narrativeStakes(jpaEntity.getNarrativeStakes())
|
||||||
@@ -96,6 +97,7 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
.description(chapter.getDescription())
|
.description(chapter.getDescription())
|
||||||
.arcId(Long.parseLong(chapter.getArcId()))
|
.arcId(Long.parseLong(chapter.getArcId()))
|
||||||
.order(chapter.getOrder())
|
.order(chapter.getOrder())
|
||||||
|
.icon(chapter.getIcon())
|
||||||
.gmNotes(chapter.getGmNotes())
|
.gmNotes(chapter.getGmNotes())
|
||||||
.playerObjectives(chapter.getPlayerObjectives())
|
.playerObjectives(chapter.getPlayerObjectives())
|
||||||
.narrativeStakes(chapter.getNarrativeStakes())
|
.narrativeStakes(chapter.getNarrativeStakes())
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.description(jpaEntity.getDescription())
|
.description(jpaEntity.getDescription())
|
||||||
.chapterId(jpaEntity.getChapterId().toString())
|
.chapterId(jpaEntity.getChapterId().toString())
|
||||||
.order(jpaEntity.getOrder())
|
.order(jpaEntity.getOrder())
|
||||||
|
.icon(jpaEntity.getIcon())
|
||||||
.location(jpaEntity.getLocation())
|
.location(jpaEntity.getLocation())
|
||||||
.timing(jpaEntity.getTiming())
|
.timing(jpaEntity.getTiming())
|
||||||
.atmosphere(jpaEntity.getAtmosphere())
|
.atmosphere(jpaEntity.getAtmosphere())
|
||||||
@@ -104,6 +105,7 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.description(scene.getDescription())
|
.description(scene.getDescription())
|
||||||
.chapterId(Long.parseLong(scene.getChapterId()))
|
.chapterId(Long.parseLong(scene.getChapterId()))
|
||||||
.order(scene.getOrder())
|
.order(scene.getOrder())
|
||||||
|
.icon(scene.getIcon())
|
||||||
.location(scene.getLocation())
|
.location(scene.getLocation())
|
||||||
.timing(scene.getTiming())
|
.timing(scene.getTiming())
|
||||||
.atmosphere(scene.getAtmosphere())
|
.atmosphere(scene.getAtmosphere())
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class ArcController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
|
public ResponseEntity<ArcDTO> createArc(@RequestBody ArcDTO arcDTO) {
|
||||||
Arc arc = arcMapper.toDomain(arcDTO);
|
Arc arc = arcMapper.toDomain(arcDTO);
|
||||||
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder());
|
Arc createdArc = arcService.createArc(arc.getName(), arc.getDescription(), arc.getCampaignId(), arc.getOrder(), arc.getIcon());
|
||||||
return ResponseEntity.ok(arcMapper.toDTO(createdArc));
|
return ResponseEntity.ok(arcMapper.toDTO(createdArc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class ChapterController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
|
public ResponseEntity<ChapterDTO> createChapter(@RequestBody ChapterDTO chapterDTO) {
|
||||||
Chapter chapter = chapterMapper.toDomain(chapterDTO);
|
Chapter chapter = chapterMapper.toDomain(chapterDTO);
|
||||||
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder());
|
Chapter createdChapter = chapterService.createChapter(chapter.getName(), chapter.getDescription(), chapter.getArcId(), chapter.getOrder(), chapter.getIcon());
|
||||||
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
|
return ResponseEntity.ok(chapterMapper.toDTO(createdChapter));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class SceneController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
|
public ResponseEntity<SceneDTO> createScene(@RequestBody SceneDTO sceneDTO) {
|
||||||
Scene scene = sceneMapper.toDomain(sceneDTO);
|
Scene scene = sceneMapper.toDomain(sceneDTO);
|
||||||
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder());
|
Scene createdScene = sceneService.createScene(scene.getName(), scene.getDescription(), scene.getChapterId(), scene.getOrder(), scene.getIcon());
|
||||||
return ResponseEntity.ok(sceneMapper.toDTO(createdScene));
|
return ResponseEntity.ok(sceneMapper.toDTO(createdScene));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ public class ArcDTO {
|
|||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
private String themes;
|
private String themes;
|
||||||
private String stakes;
|
private String stakes;
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ public class ChapterDTO {
|
|||||||
private String arcId;
|
private String arcId;
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
private String gmNotes;
|
private String gmNotes;
|
||||||
private String playerObjectives;
|
private String playerObjectives;
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ public class SceneDTO {
|
|||||||
private String chapterId;
|
private String chapterId;
|
||||||
private int order;
|
private int order;
|
||||||
|
|
||||||
|
/** Cle d'icone (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||||
|
private String icon;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
private String location;
|
private String location;
|
||||||
private String timing;
|
private String timing;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class ArcMapper {
|
|||||||
dto.setDescription(arc.getDescription());
|
dto.setDescription(arc.getDescription());
|
||||||
dto.setCampaignId(arc.getCampaignId());
|
dto.setCampaignId(arc.getCampaignId());
|
||||||
dto.setOrder(arc.getOrder());
|
dto.setOrder(arc.getOrder());
|
||||||
|
dto.setIcon(arc.getIcon());
|
||||||
dto.setThemes(arc.getThemes());
|
dto.setThemes(arc.getThemes());
|
||||||
dto.setStakes(arc.getStakes());
|
dto.setStakes(arc.getStakes());
|
||||||
dto.setGmNotes(arc.getGmNotes());
|
dto.setGmNotes(arc.getGmNotes());
|
||||||
@@ -46,6 +47,7 @@ public class ArcMapper {
|
|||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
|
.icon(dto.getIcon())
|
||||||
.themes(dto.getThemes())
|
.themes(dto.getThemes())
|
||||||
.stakes(dto.getStakes())
|
.stakes(dto.getStakes())
|
||||||
.gmNotes(dto.getGmNotes())
|
.gmNotes(dto.getGmNotes())
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class ChapterMapper {
|
|||||||
dto.setDescription(chapter.getDescription());
|
dto.setDescription(chapter.getDescription());
|
||||||
dto.setArcId(chapter.getArcId());
|
dto.setArcId(chapter.getArcId());
|
||||||
dto.setOrder(chapter.getOrder());
|
dto.setOrder(chapter.getOrder());
|
||||||
|
dto.setIcon(chapter.getIcon());
|
||||||
dto.setGmNotes(chapter.getGmNotes());
|
dto.setGmNotes(chapter.getGmNotes());
|
||||||
dto.setPlayerObjectives(chapter.getPlayerObjectives());
|
dto.setPlayerObjectives(chapter.getPlayerObjectives());
|
||||||
dto.setNarrativeStakes(chapter.getNarrativeStakes());
|
dto.setNarrativeStakes(chapter.getNarrativeStakes());
|
||||||
@@ -44,6 +45,7 @@ public class ChapterMapper {
|
|||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.arcId(dto.getArcId())
|
.arcId(dto.getArcId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
|
.icon(dto.getIcon())
|
||||||
.gmNotes(dto.getGmNotes())
|
.gmNotes(dto.getGmNotes())
|
||||||
.playerObjectives(dto.getPlayerObjectives())
|
.playerObjectives(dto.getPlayerObjectives())
|
||||||
.narrativeStakes(dto.getNarrativeStakes())
|
.narrativeStakes(dto.getNarrativeStakes())
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public class SceneMapper {
|
|||||||
dto.setDescription(scene.getDescription());
|
dto.setDescription(scene.getDescription());
|
||||||
dto.setChapterId(scene.getChapterId());
|
dto.setChapterId(scene.getChapterId());
|
||||||
dto.setOrder(scene.getOrder());
|
dto.setOrder(scene.getOrder());
|
||||||
|
dto.setIcon(scene.getIcon());
|
||||||
dto.setLocation(scene.getLocation());
|
dto.setLocation(scene.getLocation());
|
||||||
dto.setTiming(scene.getTiming());
|
dto.setTiming(scene.getTiming());
|
||||||
dto.setAtmosphere(scene.getAtmosphere());
|
dto.setAtmosphere(scene.getAtmosphere());
|
||||||
@@ -59,6 +60,7 @@ public class SceneMapper {
|
|||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.chapterId(dto.getChapterId())
|
.chapterId(dto.getChapterId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
|
.icon(dto.getIcon())
|
||||||
.location(dto.getLocation())
|
.location(dto.getLocation())
|
||||||
.timing(dto.getTiming())
|
.timing(dto.getTiming())
|
||||||
.atmosphere(dto.getAtmosphere())
|
.atmosphere(dto.getAtmosphere())
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.1",
|
"version": "0.6.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.1",
|
"version": "0.6.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.6.2",
|
"version": "0.6.5",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
// padding-top: 0 — sinon le contenu defile dans la zone de padding
|
||||||
|
// au-dessus du `.page-header` sticky (top: 0 pin sur l'edge interne du
|
||||||
|
// padding-box). Chaque page-wrapper definit deja son propre padding-top
|
||||||
|
// qui devient l'unique source d'espacement haut.
|
||||||
|
padding: 0 2rem 2rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Icône</label>
|
||||||
|
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||||
Créer l'arc
|
Créer l'arc
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
|
|||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { Campaign } from '../../services/campaign.model';
|
import { Campaign } from '../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
|
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de création d'un nouvel Arc narratif (contexte Campagne).
|
* Écran de création d'un nouvel Arc narratif (contexte Campagne).
|
||||||
@@ -18,15 +20,17 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-arc-create',
|
selector: 'app-arc-create',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
|
||||||
templateUrl: './arc-create.component.html',
|
templateUrl: './arc-create.component.html',
|
||||||
styleUrls: ['./arc-create.component.scss']
|
styleUrls: ['./arc-create.component.scss']
|
||||||
})
|
})
|
||||||
export class ArcCreateComponent implements OnInit, OnDestroy {
|
export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||||
readonly BookOpen = BookOpen;
|
readonly BookOpen = BookOpen;
|
||||||
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
|
selectedIcon: string | null = null;
|
||||||
private existingArcCount = 0;
|
private existingArcCount = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -80,7 +84,8 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
|||||||
name: this.form.value.name,
|
name: this.form.value.name,
|
||||||
description: this.form.value.description,
|
description: this.form.value.description,
|
||||||
campaignId: this.campaignId,
|
campaignId: this.campaignId,
|
||||||
order: this.existingArcCount + 1
|
order: this.existingArcCount + 1,
|
||||||
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', created.id]),
|
||||||
error: () => console.error('Erreur lors de la création de l\'arc')
|
error: () => console.error('Erreur lors de la création de l\'arc')
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
Assistant IA
|
Assistant IA
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||||
|
<button type="button" class="btn-danger" (click)="delete()">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
|
||||||
|
Sauvegarder
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,6 +71,11 @@
|
|||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Icône</label>
|
||||||
|
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="arc-edit-themes">Thèmes principaux</label>
|
<label for="arc-edit-themes">Thèmes principaux</label>
|
||||||
@@ -136,17 +149,6 @@
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
|
||||||
Sauvegarder
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
|
||||||
<button type="button" class="btn-danger" (click)="delete()">
|
|
||||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
|
|||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'un Arc.
|
* Écran de détail/modification d'un Arc.
|
||||||
@@ -29,13 +31,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-arc-edit',
|
selector: 'app-arc-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
|
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
|
||||||
templateUrl: './arc-edit.component.html',
|
templateUrl: './arc-edit.component.html',
|
||||||
styleUrls: ['./arc-edit.component.scss']
|
styleUrls: ['./arc-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class ArcEditComponent implements OnInit, OnDestroy {
|
export class ArcEditComponent implements OnInit, OnDestroy {
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
|
selectedIcon: string | null = null;
|
||||||
|
|
||||||
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
||||||
chatOpen = false;
|
chatOpen = false;
|
||||||
@@ -122,6 +126,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
this.loreId = loreId;
|
this.loreId = loreId;
|
||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
|
||||||
|
this.selectedIcon = arc.icon ?? null;
|
||||||
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
|
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
|
||||||
this.mapImageIds = [...(arc.mapImageIds ?? [])];
|
this.mapImageIds = [...(arc.mapImageIds ?? [])];
|
||||||
this.pageTitleService.set(arc.name);
|
this.pageTitleService.set(arc.name);
|
||||||
@@ -167,7 +172,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
resolution: this.form.value.resolution,
|
resolution: this.form.value.resolution,
|
||||||
relatedPageIds: this.relatedPageIds,
|
relatedPageIds: this.relatedPageIds,
|
||||||
illustrationImageIds: this.illustrationImageIds,
|
illustrationImageIds: this.illustrationImageIds,
|
||||||
mapImageIds: this.mapImageIds
|
mapImageIds: this.mapImageIds,
|
||||||
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde')
|
error: () => console.error('Erreur lors de la sauvegarde')
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>{{ arc.name }}</h1>
|
<h1>
|
||||||
|
<lucide-icon *ngIf="arc.icon" [img]="resolveCampaignIcon(arc.icon)" [size]="22" class="title-icon"></lucide-icon>
|
||||||
|
{{ arc.name }}
|
||||||
|
</h1>
|
||||||
<p class="view-subtitle">Arc narratif</p>
|
<p class="view-subtitle">Arc narratif</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="view-actions">
|
<div class="view-actions">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||||
|
import { resolveCampaignIcon } from '../campaign-icons';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -29,6 +30,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
|||||||
export class ArcViewComponent implements OnInit, OnDestroy {
|
export class ArcViewComponent implements OnInit, OnDestroy {
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
|
readonly resolveCampaignIcon = resolveCampaignIcon;
|
||||||
|
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
arcId = '';
|
arcId = '';
|
||||||
|
|||||||
66
web/src/app/campaigns/campaign-icons.ts
Normal file
66
web/src/app/campaigns/campaign-icons.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Bookmark,
|
||||||
|
Theater, Drama, Compass, Flag, Calendar, Map, Castle, Tent,
|
||||||
|
Swords, Skull, Crown, Heart, Eye, Footprints, Dice5, Hourglass,
|
||||||
|
Flame, Snowflake, Cloud, Sun, Moon, Star, Zap, Target,
|
||||||
|
Music, MessageCircle, Lock, Key as KeyIcon, Coins, Gift,
|
||||||
|
LucideIconData
|
||||||
|
} from 'lucide-angular';
|
||||||
|
|
||||||
|
// Type local equivalent a IconOption de lore-icons. Duplique volontairement
|
||||||
|
// pour eviter une dependance circulaire (lore-icons importe ce fichier).
|
||||||
|
export interface CampaignIconOption {
|
||||||
|
key: string;
|
||||||
|
icon: LucideIconData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banque d'icones dediee aux entites narratives d'une campagne (arc, chapitre, scene).
|
||||||
|
*
|
||||||
|
* Pourquoi separe de LORE_ICON_OPTIONS ? Les icones lore sont plutot orientees
|
||||||
|
* "objets de monde" (chateau, foret, dragon...). Ici on est sur du sequencement
|
||||||
|
* narratif et de la mise en scene : ambiances, actes, decisions. Cles uniques
|
||||||
|
* (prefixees `c-` quand un nom existerait deja dans le lore) pour eviter les
|
||||||
|
* collisions avec LORE_ICON_OPTIONS — le resolver consulte les deux registres.
|
||||||
|
*/
|
||||||
|
export const CAMPAIGN_ICON_OPTIONS: CampaignIconOption[] = [
|
||||||
|
{ key: 'c-theater', icon: Theater },
|
||||||
|
{ key: 'c-drama', icon: Drama },
|
||||||
|
{ key: 'c-compass', icon: Compass },
|
||||||
|
{ key: 'c-flag', icon: Flag },
|
||||||
|
{ key: 'c-calendar', icon: Calendar },
|
||||||
|
{ key: 'c-map', icon: Map },
|
||||||
|
{ key: 'c-castle', icon: Castle },
|
||||||
|
{ key: 'c-tent', icon: Tent },
|
||||||
|
{ key: 'c-swords', icon: Swords },
|
||||||
|
{ key: 'c-skull', icon: Skull },
|
||||||
|
{ key: 'c-crown', icon: Crown },
|
||||||
|
{ key: 'c-heart', icon: Heart },
|
||||||
|
{ key: 'c-eye', icon: Eye },
|
||||||
|
{ key: 'c-footprints', icon: Footprints },
|
||||||
|
{ key: 'c-dice', icon: Dice5 },
|
||||||
|
{ key: 'c-hourglass', icon: Hourglass },
|
||||||
|
{ key: 'c-flame', icon: Flame },
|
||||||
|
{ key: 'c-snowflake', icon: Snowflake },
|
||||||
|
{ key: 'c-cloud', icon: Cloud },
|
||||||
|
{ key: 'c-sun', icon: Sun },
|
||||||
|
{ key: 'c-moon', icon: Moon },
|
||||||
|
{ key: 'c-star', icon: Star },
|
||||||
|
{ key: 'c-zap', icon: Zap },
|
||||||
|
{ key: 'c-target', icon: Target },
|
||||||
|
{ key: 'c-music', icon: Music },
|
||||||
|
{ key: 'c-message', icon: MessageCircle },
|
||||||
|
{ key: 'c-lock', icon: Lock },
|
||||||
|
{ key: 'c-key', icon: KeyIcon },
|
||||||
|
{ key: 'c-coins', icon: Coins },
|
||||||
|
{ key: 'c-gift', icon: Gift },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Icone par defaut quand une entite narrative n'en a pas. */
|
||||||
|
export const DEFAULT_CAMPAIGN_ICON: LucideIconData = Bookmark;
|
||||||
|
|
||||||
|
/** Resolveur dedie. Prefere passer par `resolveIcon` dans lore-icons qui consulte les deux. */
|
||||||
|
export function resolveCampaignIcon(key: string | null | undefined): LucideIconData {
|
||||||
|
if (!key) return DEFAULT_CAMPAIGN_ICON;
|
||||||
|
return CAMPAIGN_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_CAMPAIGN_ICON;
|
||||||
|
}
|
||||||
@@ -109,11 +109,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
|
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
|
||||||
id: `scene-${sc.id}`,
|
id: `scene-${sc.id}`,
|
||||||
label: sc.name,
|
label: sc.name,
|
||||||
|
iconKey: sc.icon ?? undefined,
|
||||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
|
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
id: `chapter-${ch.id}`,
|
id: `chapter-${ch.id}`,
|
||||||
label: ch.name,
|
label: ch.name,
|
||||||
|
iconKey: ch.icon ?? undefined,
|
||||||
children: sceneItems,
|
children: sceneItems,
|
||||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
|
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
|
||||||
createActions: [{
|
createActions: [{
|
||||||
@@ -127,6 +129,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
return {
|
return {
|
||||||
id: `arc-${arc.id}`,
|
id: `arc-${arc.id}`,
|
||||||
label: arc.name,
|
label: arc.name,
|
||||||
|
iconKey: arc.icon ?? undefined,
|
||||||
children: chapterItems,
|
children: chapterItems,
|
||||||
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
|
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
|
||||||
sectionHeaderBefore: idx === 0 ? 'Narration' : undefined,
|
sectionHeaderBefore: idx === 0 ? 'Narration' : undefined,
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Icône</label>
|
||||||
|
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||||
Créer le chapitre
|
Créer le chapitre
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
|
|||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { Campaign } from '../../services/campaign.model';
|
import { Campaign } from '../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
|
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de création d'un nouveau chapitre rattaché à un arc.
|
* Écran de création d'un nouveau chapitre rattaché à un arc.
|
||||||
@@ -17,11 +19,14 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chapter-create',
|
selector: 'app-chapter-create',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
|
||||||
templateUrl: './chapter-create.component.html',
|
templateUrl: './chapter-create.component.html',
|
||||||
styleUrls: ['./chapter-create.component.scss']
|
styleUrls: ['./chapter-create.component.scss']
|
||||||
})
|
})
|
||||||
export class ChapterCreateComponent implements OnInit, OnDestroy {
|
export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||||
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
|
selectedIcon: string | null = null;
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
arcId = '';
|
arcId = '';
|
||||||
@@ -82,7 +87,8 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
|||||||
name: this.form.value.name,
|
name: this.form.value.name,
|
||||||
description: this.form.value.description,
|
description: this.form.value.description,
|
||||||
arcId: this.arcId,
|
arcId: this.arcId,
|
||||||
order: this.existingChapterCount + 1
|
order: this.existingChapterCount + 1,
|
||||||
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', created.id]),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', created.id]),
|
||||||
error: () => console.error('Erreur lors de la création du chapitre')
|
error: () => console.error('Erreur lors de la création du chapitre')
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
Assistant IA
|
Assistant IA
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||||
|
<button type="button" class="btn-danger" (click)="delete()">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
|
||||||
|
Sauvegarder
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,6 +71,11 @@
|
|||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Icône</label>
|
||||||
|
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="chapter-edit-gm-notes">Notes du Maître de Jeu</label>
|
<label for="chapter-edit-gm-notes">Notes du Maître de Jeu</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -116,17 +129,6 @@
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
|
||||||
Sauvegarder
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
|
||||||
<button type="button" class="btn-danger" (click)="delete()">
|
|
||||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
|
|||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'un Chapitre.
|
* Écran de détail/modification d'un Chapitre.
|
||||||
@@ -27,13 +29,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chapter-edit',
|
selector: 'app-chapter-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
|
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
|
||||||
templateUrl: './chapter-edit.component.html',
|
templateUrl: './chapter-edit.component.html',
|
||||||
styleUrls: ['./chapter-edit.component.scss']
|
styleUrls: ['./chapter-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class ChapterEditComponent implements OnInit, OnDestroy {
|
export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
|
selectedIcon: string | null = null;
|
||||||
|
|
||||||
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
||||||
chatOpen = false;
|
chatOpen = false;
|
||||||
@@ -113,6 +117,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
this.loreId = loreId;
|
this.loreId = loreId;
|
||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
|
||||||
|
this.selectedIcon = chapter.icon ?? null;
|
||||||
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
|
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
|
||||||
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
|
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
@@ -153,7 +158,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
narrativeStakes: this.form.value.narrativeStakes,
|
narrativeStakes: this.form.value.narrativeStakes,
|
||||||
relatedPageIds: this.relatedPageIds,
|
relatedPageIds: this.relatedPageIds,
|
||||||
illustrationImageIds: this.illustrationImageIds,
|
illustrationImageIds: this.illustrationImageIds,
|
||||||
mapImageIds: this.mapImageIds
|
mapImageIds: this.mapImageIds,
|
||||||
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde')
|
error: () => console.error('Erreur lors de la sauvegarde')
|
||||||
|
|||||||
@@ -16,11 +16,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="graph-container" *ngIf="scenes.length > 0">
|
<div class="graph-container" *ngIf="scenes.length > 0">
|
||||||
<svg [attr.width]="svgWidth" [attr.height]="svgHeight" class="graph-svg">
|
<svg #svgEl
|
||||||
|
[attr.width]="svgWidth" [attr.height]="svgHeight"
|
||||||
|
class="graph-svg"
|
||||||
|
(pointermove)="onPointerMove($event)"
|
||||||
|
(pointerup)="onPointerUp($event)"
|
||||||
|
(pointercancel)="onPointerUp($event)">
|
||||||
<defs>
|
<defs>
|
||||||
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
|
<marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
|
||||||
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6b7280" />
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#b8c0cc" />
|
||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -28,20 +33,25 @@
|
|||||||
<g class="edge" *ngFor="let edge of edges">
|
<g class="edge" *ngFor="let edge of edges">
|
||||||
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
|
<line [attr.x1]="edge.x1" [attr.y1]="edge.y1"
|
||||||
[attr.x2]="edge.x2" [attr.y2]="edge.y2"
|
[attr.x2]="edge.x2" [attr.y2]="edge.y2"
|
||||||
stroke="#6b7280" stroke-width="2"
|
stroke="#b8c0cc" stroke-width="2"
|
||||||
marker-end="url(#arrowhead)" />
|
marker-end="url(#arrowhead)" />
|
||||||
<text *ngIf="edge.label"
|
<text *ngIf="edge.label"
|
||||||
[attr.x]="edge.labelX"
|
[attr.x]="edge.labelX"
|
||||||
[attr.y]="edge.labelY"
|
[attr.y]="edge.labelY"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
class="edge-label">
|
class="edge-label"
|
||||||
|
[class.dragging]="draggingLabelKey === edge.key"
|
||||||
|
(pointerdown)="onLabelPointerDown($event, edge)">
|
||||||
{{ edge.label }}
|
{{ edge.label }}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g class="nodes">
|
<g class="nodes">
|
||||||
<g class="node" *ngFor="let node of nodes" (click)="openScene(node.id)">
|
<g class="node"
|
||||||
|
[class.dragging]="draggingId === node.id"
|
||||||
|
*ngFor="let node of nodes"
|
||||||
|
(pointerdown)="onPointerDown($event, node)">
|
||||||
<title>{{ node.name }}</title>
|
<title>{{ node.name }}</title>
|
||||||
<rect [attr.x]="node.x" [attr.y]="node.y"
|
<rect [attr.x]="node.x" [attr.y]="node.y"
|
||||||
[attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT"
|
[attr.width]="NODE_WIDTH" [attr.height]="NODE_HEIGHT"
|
||||||
@@ -57,7 +67,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<small class="graph-hint">
|
<small class="graph-hint">
|
||||||
💡 Cliquez sur une scène pour l'ouvrir. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
|
💡 Cliquez sur une scène pour l'ouvrir, ou glissez-la pour réorganiser la carte. Les scènes non reliées au point d'entrée (scène d'ordre 1) apparaissent en bas.
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: #6b7280;
|
color: #9ca3af;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
}
|
}
|
||||||
@@ -20,15 +20,17 @@
|
|||||||
.graph-empty {
|
.graph-empty {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #6b7280;
|
color: #9ca3af;
|
||||||
background: #f9fafb;
|
background: #14141f;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px dashed #d1d5db;
|
border: 1px dashed #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-container {
|
.graph-container {
|
||||||
background: #fafafa;
|
// Fond legerement plus sombre que la couleur des noeuds : creuse l'image
|
||||||
border: 1px solid #e5e7eb;
|
// sans aller jusqu'au noir pur (qui « brulerait » par contraste).
|
||||||
|
background: #0d0d18;
|
||||||
|
border: 1px solid #374151;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -41,14 +43,27 @@
|
|||||||
.graph-svg {
|
.graph-svg {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
// Empeche le browser de clipper le contenu qui depasserait le viewport SVG
|
||||||
|
// pendant un drag — le scroll du conteneur prend le relais.
|
||||||
|
overflow: visible;
|
||||||
|
// Évite que le navigateur intercepte le drag pour faire de la sélection texte
|
||||||
|
// ou du panning natif sur les nœuds.
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node {
|
.node {
|
||||||
cursor: pointer;
|
cursor: grab;
|
||||||
|
// Ombre portee douce pour detacher chaque noeud du fond. Faible alpha pour
|
||||||
|
// rester subtil sur fond sombre, large diffusion pour rester organique.
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5));
|
||||||
|
|
||||||
.node-box {
|
.node-box {
|
||||||
fill: #ffffff;
|
// Indigo desature : reprend la palette accent (#6c63ff) en version assombrie
|
||||||
stroke: #1f2937;
|
// pour donner du caractere aux noeuds sans saturer la vue. Bordure assortie
|
||||||
|
// un peu plus claire pour bien dessiner le contour.
|
||||||
|
fill: #1f1d3a;
|
||||||
|
stroke: #4f4a7a;
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
transition: fill 0.15s ease, stroke 0.15s ease;
|
transition: fill 0.15s ease, stroke 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -56,31 +71,47 @@
|
|||||||
.node-label {
|
.node-label {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
fill: #1f2937;
|
fill: #f3f4f6;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .node-box {
|
&:hover .node-box {
|
||||||
fill: #eef2ff;
|
fill: #2c2952;
|
||||||
stroke: #4f46e5;
|
stroke: #8b80ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
.node-box {
|
||||||
|
fill: #2c2952;
|
||||||
|
stroke: #8b80ff;
|
||||||
|
filter: drop-shadow(0 4px 10px rgba(108, 99, 255, 0.35));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.edge-label {
|
.edge-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
fill: #4b5563;
|
fill: #e5e7eb;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
// Halo blanc autour du texte pour garantir la lisibilité même s'il passe
|
cursor: grab;
|
||||||
// sur une ligne ou un autre élément.
|
// Halo sombre autour du texte pour rester lisible quand un label passe
|
||||||
|
// par-dessus une arête ou un autre nœud. Aligne sur la couleur du fond.
|
||||||
paint-order: stroke;
|
paint-order: stroke;
|
||||||
stroke: #fafafa;
|
stroke: #0d0d18;
|
||||||
stroke-width: 3px;
|
stroke-width: 4px;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
|
|
||||||
|
&:hover { fill: #ffffff; }
|
||||||
|
&.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
fill: #ffffff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-hint {
|
.graph-hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
color: #6b7280;
|
color: #9ca3af;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
@@ -11,7 +11,7 @@ import { Campaign, Chapter, Scene } from '../../services/campaign.model';
|
|||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
|
|
||||||
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
|
interface GraphNode { id: string; name: string; displayName: string; x: number; y: number; }
|
||||||
interface GraphEdge { label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
|
interface GraphEdge { key: string; label: string; x1: number; y1: number; x2: number; y2: number; labelX: number; labelY: number; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vue graphique d'un chapitre : organigramme des scènes et branches narratives.
|
* Vue graphique d'un chapitre : organigramme des scènes et branches narratives.
|
||||||
@@ -45,6 +45,24 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
svgWidth = 600;
|
svgWidth = 600;
|
||||||
svgHeight = 400;
|
svgHeight = 400;
|
||||||
|
|
||||||
|
@ViewChild('svgEl') svgEl?: ElementRef<SVGSVGElement>;
|
||||||
|
|
||||||
|
// Etat de drag : id du noeud manipule, offset entre le pointeur et le coin
|
||||||
|
// haut-gauche du noeud (en coords SVG), et flag indiquant qu'un mouvement
|
||||||
|
// significatif a eu lieu (pour distinguer clic vs glisser).
|
||||||
|
draggingId: string | null = null;
|
||||||
|
draggingLabelKey: string | null = null;
|
||||||
|
private dragOffsetX = 0;
|
||||||
|
private dragOffsetY = 0;
|
||||||
|
private dragMoved = false;
|
||||||
|
private readonly DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
|
// Decalage manuel applique a chaque label d'arete, indexe par cle stable
|
||||||
|
// (sourceId|targetId|branchIdx). Persiste a travers les recalculs d'aretes
|
||||||
|
// pour que le label suive son arete quand on deplace un noeud, tout en
|
||||||
|
// conservant le repositionnement manuel de l'utilisateur.
|
||||||
|
private labelOffsets = new Map<string, { dx: number; dy: number }>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
@@ -153,7 +171,18 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
this.nodes = nodes;
|
||||||
|
this.recomputeEdges();
|
||||||
|
this.svgWidth = Math.max(rowWidth + 40, 600);
|
||||||
|
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalcule la geometrie des aretes a partir des positions courantes des noeuds.
|
||||||
|
* Appele apres le layout initial et apres chaque deplacement manuel d'un noeud.
|
||||||
|
*/
|
||||||
|
private recomputeEdges(): void {
|
||||||
|
const nodeMap = new Map(this.nodes.map(n => [n.id, n]));
|
||||||
const edges: GraphEdge[] = [];
|
const edges: GraphEdge[] = [];
|
||||||
for (const scene of this.scenes) {
|
for (const scene of this.scenes) {
|
||||||
const from = nodeMap.get(scene.id!);
|
const from = nodeMap.get(scene.id!);
|
||||||
@@ -171,19 +200,158 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
|||||||
const y2 = to.y;
|
const y2 = to.y;
|
||||||
// t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes.
|
// t ∈ [0.25, 0.55] : labels plutot pres de la source, echelonnes.
|
||||||
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
|
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
|
||||||
|
const key = `${scene.id}|${b.targetSceneId}|${idx}`;
|
||||||
|
const offset = this.labelOffsets.get(key) ?? { dx: 0, dy: 0 };
|
||||||
edges.push({
|
edges.push({
|
||||||
|
key,
|
||||||
label: b.label,
|
label: b.label,
|
||||||
x1, y1, x2, y2,
|
x1, y1, x2, y2,
|
||||||
labelX: x1 + (x2 - x1) * t,
|
labelX: x1 + (x2 - x1) * t + offset.dx,
|
||||||
labelY: y1 + (y2 - y1) * t - 4
|
labelY: y1 + (y2 - y1) * t - 4 + offset.dy
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.nodes = nodes;
|
|
||||||
this.edges = edges;
|
this.edges = edges;
|
||||||
this.svgWidth = Math.max(rowWidth + 40, 600);
|
}
|
||||||
this.svgHeight = (orphanLevel + 1) * (this.NODE_HEIGHT + this.V_SPACING) + 40;
|
|
||||||
|
/**
|
||||||
|
* Convertit des coordonnees ecran (PointerEvent) en coordonnees SVG via la CTM
|
||||||
|
* inverse. Necessaire car le SVG peut etre redimensionne par max-width.
|
||||||
|
*/
|
||||||
|
private toSvgCoords(evt: PointerEvent): { x: number; y: number } {
|
||||||
|
const svg = this.svgEl?.nativeElement;
|
||||||
|
if (!svg) return { x: evt.clientX, y: evt.clientY };
|
||||||
|
const pt = svg.createSVGPoint();
|
||||||
|
pt.x = evt.clientX;
|
||||||
|
pt.y = evt.clientY;
|
||||||
|
const ctm = svg.getScreenCTM();
|
||||||
|
if (!ctm) return { x: evt.clientX, y: evt.clientY };
|
||||||
|
const local = pt.matrixTransform(ctm.inverse());
|
||||||
|
return { x: local.x, y: local.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerDown(evt: PointerEvent, node: GraphNode): void {
|
||||||
|
// Bouton gauche uniquement.
|
||||||
|
if (evt.button !== 0) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
const { x, y } = this.toSvgCoords(evt);
|
||||||
|
this.draggingId = node.id;
|
||||||
|
this.dragOffsetX = x - node.x;
|
||||||
|
this.dragOffsetY = y - node.y;
|
||||||
|
this.dragMoved = false;
|
||||||
|
(evt.target as Element).setPointerCapture?.(evt.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove(evt: PointerEvent): void {
|
||||||
|
const { x, y } = this.toSvgCoords(evt);
|
||||||
|
|
||||||
|
if (this.draggingLabelKey) {
|
||||||
|
const edge = this.edges.find(e => e.key === this.draggingLabelKey);
|
||||||
|
if (!edge) return;
|
||||||
|
const newX = x - this.dragOffsetX;
|
||||||
|
const newY = y - this.dragOffsetY;
|
||||||
|
if (!this.dragMoved && Math.hypot(newX - edge.labelX, newY - edge.labelY) < this.DRAG_THRESHOLD) return;
|
||||||
|
this.dragMoved = true;
|
||||||
|
// Recalcule la position automatique courante puis stocke la difference,
|
||||||
|
// pour que l'offset reste valable meme apres deplacement d'un noeud.
|
||||||
|
const auto = this.autoLabelPosition(edge.key);
|
||||||
|
if (auto) {
|
||||||
|
this.labelOffsets.set(edge.key, { dx: newX - auto.x, dy: newY - auto.y });
|
||||||
|
}
|
||||||
|
edge.labelX = newX;
|
||||||
|
edge.labelY = newY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.draggingId) return;
|
||||||
|
const node = this.nodes.find(n => n.id === this.draggingId);
|
||||||
|
if (!node) return;
|
||||||
|
// Empeche le noeud de partir en coordonnees negatives : sinon il sort
|
||||||
|
// du viewport SVG et se fait clipper par le navigateur (le SVG a
|
||||||
|
// overflow: hidden par defaut quand on lui donne width/height explicites).
|
||||||
|
const newX = Math.max(0, x - this.dragOffsetX);
|
||||||
|
const newY = Math.max(0, y - this.dragOffsetY);
|
||||||
|
if (!this.dragMoved) {
|
||||||
|
const dx = newX - node.x;
|
||||||
|
const dy = newY - node.y;
|
||||||
|
if (Math.hypot(dx, dy) >= this.DRAG_THRESHOLD) this.dragMoved = true;
|
||||||
|
else return;
|
||||||
|
}
|
||||||
|
node.x = newX;
|
||||||
|
node.y = newY;
|
||||||
|
this.recomputeEdges();
|
||||||
|
this.fitSvgToNodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalcule la position "auto" (sans offset manuel) du label d'une arete
|
||||||
|
* a partir de sa cle. Utilise pour deriver le delta a stocker pendant le drag.
|
||||||
|
*/
|
||||||
|
private autoLabelPosition(key: string): { x: number; y: number } | null {
|
||||||
|
const [sourceId, targetId, idxStr] = key.split('|');
|
||||||
|
const idx = Number(idxStr);
|
||||||
|
const scene = this.scenes.find(s => s.id === sourceId);
|
||||||
|
if (!scene?.branches) return null;
|
||||||
|
const siblings = scene.branches.filter(b => this.nodes.some(n => n.id === b.targetSceneId));
|
||||||
|
const count = siblings.length;
|
||||||
|
if (idx >= count) return null;
|
||||||
|
const from = this.nodes.find(n => n.id === sourceId);
|
||||||
|
const to = this.nodes.find(n => n.id === targetId);
|
||||||
|
if (!from || !to) return null;
|
||||||
|
const x1 = from.x + this.NODE_WIDTH / 2;
|
||||||
|
const y1 = from.y + this.NODE_HEIGHT;
|
||||||
|
const x2 = to.x + this.NODE_WIDTH / 2;
|
||||||
|
const y2 = to.y;
|
||||||
|
const t = count === 1 ? 0.5 : 0.25 + (idx / (count - 1)) * 0.3;
|
||||||
|
return { x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t - 4 };
|
||||||
|
}
|
||||||
|
|
||||||
|
onLabelPointerDown(evt: PointerEvent, edge: GraphEdge): void {
|
||||||
|
if (evt.button !== 0) return;
|
||||||
|
// Empeche l'event de remonter au <g class="node"> ou au svg, sinon on
|
||||||
|
// declencherait aussi un drag de noeud.
|
||||||
|
evt.stopPropagation();
|
||||||
|
evt.preventDefault();
|
||||||
|
const { x, y } = this.toSvgCoords(evt);
|
||||||
|
this.draggingLabelKey = edge.key;
|
||||||
|
this.dragOffsetX = x - edge.labelX;
|
||||||
|
this.dragOffsetY = y - edge.labelY;
|
||||||
|
this.dragMoved = false;
|
||||||
|
(evt.target as Element).setPointerCapture?.(evt.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrandit le SVG si un noeud s'approche du bord droit ou bas, pour eviter
|
||||||
|
* que le contenu deplace soit rogne. On ne reduit jamais en-dessous de la
|
||||||
|
* taille initiale du layout pour rester stable visuellement.
|
||||||
|
*/
|
||||||
|
private fitSvgToNodes(): void {
|
||||||
|
const margin = 40;
|
||||||
|
let maxX = 600;
|
||||||
|
let maxY = 200;
|
||||||
|
for (const n of this.nodes) {
|
||||||
|
if (n.x + this.NODE_WIDTH + margin > maxX) maxX = n.x + this.NODE_WIDTH + margin;
|
||||||
|
if (n.y + this.NODE_HEIGHT + margin > maxY) maxY = n.y + this.NODE_HEIGHT + margin;
|
||||||
|
}
|
||||||
|
if (maxX > this.svgWidth) this.svgWidth = maxX;
|
||||||
|
if (maxY > this.svgHeight) this.svgHeight = maxY;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(evt: PointerEvent): void {
|
||||||
|
if (this.draggingLabelKey) {
|
||||||
|
this.draggingLabelKey = null;
|
||||||
|
this.dragMoved = false;
|
||||||
|
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.draggingId) return;
|
||||||
|
const id = this.draggingId;
|
||||||
|
const moved = this.dragMoved;
|
||||||
|
this.draggingId = null;
|
||||||
|
this.dragMoved = false;
|
||||||
|
(evt.target as Element).releasePointerCapture?.(evt.pointerId);
|
||||||
|
// Si le pointeur n'a pas reellement bouge, on traite comme un clic d'ouverture.
|
||||||
|
if (!moved) this.openScene(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private truncate(text: string): string {
|
private truncate(text: string): string {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>{{ chapter.name }}</h1>
|
<h1>
|
||||||
|
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
|
||||||
|
{{ chapter.name }}
|
||||||
|
</h1>
|
||||||
<p class="view-subtitle">Chapitre</p>
|
<p class="view-subtitle">Chapitre</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="view-actions">
|
<div class="view-actions">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
||||||
|
import { resolveCampaignIcon } from '../campaign-icons';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -29,6 +30,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Network = Network;
|
readonly Network = Network;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
|
readonly resolveCampaignIcon = resolveCampaignIcon;
|
||||||
|
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
arcId = '';
|
arcId = '';
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Icône</label>
|
||||||
|
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
||||||
Créer la scène
|
Créer la scène
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { CharacterService } from '../../services/character.service';
|
|||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
import { Campaign } from '../../services/campaign.model';
|
import { Campaign } from '../../services/campaign.model';
|
||||||
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
|
||||||
|
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de création d'une nouvelle scène rattachée à un chapitre.
|
* Écran de création d'une nouvelle scène rattachée à un chapitre.
|
||||||
@@ -17,11 +19,14 @@ import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-scene-create',
|
selector: 'app-scene-create',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule],
|
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, IconPickerComponent],
|
||||||
templateUrl: './scene-create.component.html',
|
templateUrl: './scene-create.component.html',
|
||||||
styleUrls: ['./scene-create.component.scss']
|
styleUrls: ['./scene-create.component.scss']
|
||||||
})
|
})
|
||||||
export class SceneCreateComponent implements OnInit, OnDestroy {
|
export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||||
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
|
selectedIcon: string | null = null;
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
arcId = '';
|
arcId = '';
|
||||||
@@ -84,7 +89,8 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
|||||||
name: this.form.value.name,
|
name: this.form.value.name,
|
||||||
description: this.form.value.description,
|
description: this.form.value.description,
|
||||||
chapterId: this.chapterId,
|
chapterId: this.chapterId,
|
||||||
order: this.existingSceneCount + 1
|
order: this.existingSceneCount + 1,
|
||||||
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
|
next: (created) => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', created.id]),
|
||||||
error: () => console.error('Erreur lors de la création de la scène')
|
error: () => console.error('Erreur lors de la création de la scène')
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
Assistant IA
|
Assistant IA
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||||
|
<button type="button" class="btn-danger" (click)="delete()">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-primary" (click)="submit()" [disabled]="form.invalid">
|
||||||
|
Sauvegarder
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,6 +71,11 @@
|
|||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Icône</label>
|
||||||
|
<app-icon-picker [options]="campaignIconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Section : Contexte et ambiance -->
|
<!-- Section : Contexte et ambiance -->
|
||||||
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
|
<app-expandable-section title="Contexte et ambiance" icon="📍" [initiallyOpen]="true">
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
@@ -218,17 +231,6 @@
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary" [disabled]="form.invalid">
|
|
||||||
Sauvegarder
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
|
||||||
<button type="button" class="btn-danger" (click)="delete()">
|
|
||||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { ExpandableSectionComponent } from '../../shared/expandable-section/expa
|
|||||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||||
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||||
|
import { IconPickerComponent } from '../../shared/icon-picker/icon-picker.component';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de détail/modification d'une Scène.
|
* Écran de détail/modification d'une Scène.
|
||||||
@@ -25,13 +27,15 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-scene-edit',
|
selector: 'app-scene-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent],
|
imports: [CommonModule, ReactiveFormsModule, LucideAngularModule, ExpandableSectionComponent, LoreLinkPickerComponent, AiChatDrawerComponent, ImageGalleryComponent, IconPickerComponent],
|
||||||
templateUrl: './scene-edit.component.html',
|
templateUrl: './scene-edit.component.html',
|
||||||
styleUrls: ['./scene-edit.component.scss']
|
styleUrls: ['./scene-edit.component.scss']
|
||||||
})
|
})
|
||||||
export class SceneEditComponent implements OnInit, OnDestroy {
|
export class SceneEditComponent implements OnInit, OnDestroy {
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
|
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||||
|
selectedIcon: string | null = null;
|
||||||
|
|
||||||
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
/** État drawer chat IA (b5.7 — intégration Campagne). */
|
||||||
chatOpen = false;
|
chatOpen = false;
|
||||||
@@ -131,6 +135,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
this.loreId = loreId;
|
this.loreId = loreId;
|
||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
|
||||||
|
this.selectedIcon = scene.icon ?? null;
|
||||||
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
|
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
|
||||||
this.mapImageIds = [...(scene.mapImageIds ?? [])];
|
this.mapImageIds = [...(scene.mapImageIds ?? [])];
|
||||||
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
|
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
|
||||||
@@ -184,7 +189,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
relatedPageIds: this.relatedPageIds,
|
relatedPageIds: this.relatedPageIds,
|
||||||
illustrationImageIds: this.illustrationImageIds,
|
illustrationImageIds: this.illustrationImageIds,
|
||||||
mapImageIds: this.mapImageIds,
|
mapImageIds: this.mapImageIds,
|
||||||
branches: this.branches
|
branches: this.branches,
|
||||||
|
icon: this.selectedIcon
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde')
|
error: () => console.error('Erreur lors de la sauvegarde')
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
<header class="view-header">
|
<header class="view-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>{{ scene.name }}</h1>
|
<h1>
|
||||||
|
<lucide-icon *ngIf="scene.icon" [img]="resolveCampaignIcon(scene.icon)" [size]="22" class="title-icon"></lucide-icon>
|
||||||
|
{{ scene.name }}
|
||||||
|
</h1>
|
||||||
<p class="view-subtitle">Scène</p>
|
<p class="view-subtitle">Scène</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="view-actions">
|
<div class="view-actions">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
|||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||||
|
import { resolveCampaignIcon } from '../campaign-icons';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { CharacterService } from '../../services/character.service';
|
import { CharacterService } from '../../services/character.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -28,6 +29,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
|||||||
export class SceneViewComponent implements OnInit, OnDestroy {
|
export class SceneViewComponent implements OnInit, OnDestroy {
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
|
readonly resolveCampaignIcon = resolveCampaignIcon;
|
||||||
|
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
arcId = '';
|
arcId = '';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
|
BookOpen, Scroll, Wand2, Sparkles, TreePine, Mountain,
|
||||||
Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData
|
Ship, Flame, Star, Moon, Key, Globe, Compass, LucideIconData
|
||||||
} from 'lucide-angular';
|
} from 'lucide-angular';
|
||||||
|
import { CAMPAIGN_ICON_OPTIONS } from '../campaigns/campaign-icons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registre partagé d'icônes disponibles pour les dossiers (LoreNode).
|
* Registre partagé d'icônes disponibles pour les dossiers (LoreNode).
|
||||||
@@ -46,8 +47,15 @@ export const LORE_ICON_OPTIONS: IconOption[] = [
|
|||||||
/** Icône par défaut pour un dossier sans icône. */
|
/** Icône par défaut pour un dossier sans icône. */
|
||||||
export const DEFAULT_FOLDER_ICON: LucideIconData = Folder;
|
export const DEFAULT_FOLDER_ICON: LucideIconData = Folder;
|
||||||
|
|
||||||
/** Résout une clé d'icône en LucideIconData. Fallback : icône dossier par défaut. */
|
/**
|
||||||
|
* Résout une clé d'icône en LucideIconData. Consulte LORE_ICON_OPTIONS puis
|
||||||
|
* CAMPAIGN_ICON_OPTIONS pour permettre à la sidebar partagée d'afficher
|
||||||
|
* indifféremment des icônes de dossiers (lore) ou d'arcs/chapitres/scènes
|
||||||
|
* (campagne). Fallback : icône dossier par défaut.
|
||||||
|
*/
|
||||||
export function resolveIcon(key: string | null | undefined): LucideIconData {
|
export function resolveIcon(key: string | null | undefined): LucideIconData {
|
||||||
if (!key) return DEFAULT_FOLDER_ICON;
|
if (!key) return DEFAULT_FOLDER_ICON;
|
||||||
return LORE_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_FOLDER_ICON;
|
const loreMatch = LORE_ICON_OPTIONS.find(o => o.key === key);
|
||||||
|
if (loreMatch) return loreMatch.icon;
|
||||||
|
return CAMPAIGN_ICON_OPTIONS.find(o => o.key === key)?.icon ?? DEFAULT_FOLDER_ICON;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface Arc {
|
|||||||
order?: number;
|
order?: number;
|
||||||
chapterCount?: number;
|
chapterCount?: number;
|
||||||
|
|
||||||
|
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS). */
|
||||||
|
icon?: string | null;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
themes?: string;
|
themes?: string;
|
||||||
stakes?: string;
|
stakes?: string;
|
||||||
@@ -52,6 +55,7 @@ export interface ArcCreate {
|
|||||||
description?: string;
|
description?: string;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
order: number;
|
order: number;
|
||||||
|
icon?: string | null;
|
||||||
|
|
||||||
themes?: string;
|
themes?: string;
|
||||||
stakes?: string;
|
stakes?: string;
|
||||||
@@ -70,6 +74,7 @@ export interface Chapter {
|
|||||||
description?: string;
|
description?: string;
|
||||||
arcId: string;
|
arcId: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
icon?: string | null;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
gmNotes?: string;
|
gmNotes?: string;
|
||||||
@@ -86,6 +91,7 @@ export interface ChapterCreate {
|
|||||||
description?: string;
|
description?: string;
|
||||||
arcId: string;
|
arcId: string;
|
||||||
order: number;
|
order: number;
|
||||||
|
icon?: string | null;
|
||||||
|
|
||||||
gmNotes?: string;
|
gmNotes?: string;
|
||||||
playerObjectives?: string;
|
playerObjectives?: string;
|
||||||
@@ -112,6 +118,7 @@ export interface Scene {
|
|||||||
description?: string; // = Description courte dans l'UI
|
description?: string; // = Description courte dans l'UI
|
||||||
chapterId: string;
|
chapterId: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
icon?: string | null;
|
||||||
|
|
||||||
// Champs narratifs enrichis
|
// Champs narratifs enrichis
|
||||||
location?: string;
|
location?: string;
|
||||||
@@ -136,6 +143,7 @@ export interface SceneCreate {
|
|||||||
description?: string;
|
description?: string;
|
||||||
chapterId: string;
|
chapterId: string;
|
||||||
order: number;
|
order: number;
|
||||||
|
icon?: string | null;
|
||||||
|
|
||||||
location?: string;
|
location?: string;
|
||||||
timing?: string;
|
timing?: string;
|
||||||
|
|||||||
31
web/src/app/shared/icon-picker/icon-picker.component.scss
Normal file
31
web/src/app/shared/icon-picker/icon-picker.component.scss
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.icon-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #1f2937;
|
||||||
|
border: 1px solid #374151;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover { background: #374151; color: white; }
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: #1e1b4b;
|
||||||
|
border-color: #6c63ff;
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
web/src/app/shared/icon-picker/icon-picker.component.ts
Normal file
54
web/src/app/shared/icon-picker/icon-picker.component.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { LucideAngularModule, LucideIconData } from 'lucide-angular';
|
||||||
|
|
||||||
|
export interface IconPickerOption {
|
||||||
|
key: string;
|
||||||
|
icon: LucideIconData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Petit composant reutilisable : grille d'icones cliquables avec selection unique.
|
||||||
|
* Utilise par les formulaires de creation / edition d'arcs, chapitres et scenes
|
||||||
|
* (ainsi que potentiellement les dossiers du Lore plus tard).
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* <app-icon-picker [options]="iconOptions" [(selected)]="selectedIcon"></app-icon-picker>
|
||||||
|
*
|
||||||
|
* Le composant est purement presentationnel : il n'interroge pas le registre
|
||||||
|
* d'icones lui-meme — l'appelant lui passe la banque a afficher.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-icon-picker',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, LucideAngularModule],
|
||||||
|
template: `
|
||||||
|
<div class="icon-grid">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn"
|
||||||
|
*ngFor="let option of options"
|
||||||
|
[class.selected]="selected === option.key"
|
||||||
|
[attr.aria-pressed]="selected === option.key"
|
||||||
|
[title]="option.key"
|
||||||
|
(click)="pick(option.key)">
|
||||||
|
<lucide-icon [img]="option.icon" [size]="18"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styleUrls: ['./icon-picker.component.scss']
|
||||||
|
})
|
||||||
|
export class IconPickerComponent {
|
||||||
|
@Input() options: IconPickerOption[] = [];
|
||||||
|
@Input() selected: string | null = null;
|
||||||
|
/** Permet le binding two-way `[(selected)]`. */
|
||||||
|
@Output() selectedChange = new EventEmitter<string | null>();
|
||||||
|
/** Si true, recliquer sur l'icone actuellement selectionnee la deselectionne. */
|
||||||
|
@Input() allowDeselect = true;
|
||||||
|
|
||||||
|
pick(key: string): void {
|
||||||
|
const next = this.allowDeselect && this.selected === key ? null : key;
|
||||||
|
this.selected = next;
|
||||||
|
this.selectedChange.emit(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,26 @@
|
|||||||
color: white;
|
color: white;
|
||||||
margin: 0 0 0.3rem;
|
margin: 0 0 0.3rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
|
||||||
|
// Pastille violette autour de l'icone. <lucide-icon> est inline par
|
||||||
|
// defaut : sans inline-flex le SVG se cale en haut-gauche de la boite
|
||||||
|
// au lieu d'etre centre. On fixe aussi la taille pour que la pastille
|
||||||
|
// soit toujours carree quelle que soit l'icone.
|
||||||
|
.title-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
color: #a5b4fc;
|
||||||
|
background: #1e1b4b;
|
||||||
|
border: 1px solid #4338ca;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.view-subtitle {
|
.view-subtitle {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
|||||||
Reference in New Issue
Block a user