Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1989c1d77 | |||
| 8efdf5d0e0 | |||
| 96bc5de942 | |||
| 84ccdd53ad |
@@ -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.5.0",
|
version="0.6.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.5.0</version>
|
<version>0.6.0</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -17,11 +21,20 @@ import java.util.Optional;
|
|||||||
public class ArcService {
|
public class ArcService {
|
||||||
|
|
||||||
private final ArcRepository arcRepository;
|
private final ArcRepository arcRepository;
|
||||||
|
private final ChapterRepository chapterRepository;
|
||||||
|
private final SceneRepository sceneRepository;
|
||||||
|
|
||||||
public ArcService(ArcRepository arcRepository) {
|
public ArcService(ArcRepository arcRepository,
|
||||||
|
ChapterRepository chapterRepository,
|
||||||
|
SceneRepository sceneRepository) {
|
||||||
this.arcRepository = arcRepository;
|
this.arcRepository = arcRepository;
|
||||||
|
this.chapterRepository = chapterRepository;
|
||||||
|
this.sceneRepository = sceneRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compte des entités qui seront supprimées en cascade avec l'arc. */
|
||||||
|
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) {
|
||||||
Arc arc = Arc.builder()
|
Arc arc = Arc.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
@@ -59,7 +72,31 @@ public class ArcService {
|
|||||||
return arcRepository.save(arc);
|
return arcRepository.save(arc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'impact d'une suppression en cascade : chapitres + scènes
|
||||||
|
* qui disparaîtront avec l'arc.
|
||||||
|
*/
|
||||||
|
public DeletionImpact getDeletionImpact(String id) {
|
||||||
|
List<Chapter> chapters = chapterRepository.findByArcId(id);
|
||||||
|
int sceneTotal = 0;
|
||||||
|
for (Chapter chapter : chapters) {
|
||||||
|
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
|
||||||
|
}
|
||||||
|
return new DeletionImpact(chapters.size(), sceneTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime l'arc et toutes ses entités dépendantes (chapitres → scènes).
|
||||||
|
* Transactionnel : atomique.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
public void deleteArc(String id) {
|
public void deleteArc(String id) {
|
||||||
|
for (Chapter chapter : chapterRepository.findByArcId(id)) {
|
||||||
|
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
|
||||||
|
sceneRepository.deleteById(scene.getId());
|
||||||
|
}
|
||||||
|
chapterRepository.deleteById(chapter.getId());
|
||||||
|
}
|
||||||
arcRepository.deleteById(id);
|
arcRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -16,9 +23,22 @@ import java.util.Optional;
|
|||||||
public class CampaignService {
|
public class CampaignService {
|
||||||
|
|
||||||
private final CampaignRepository campaignRepository;
|
private final CampaignRepository campaignRepository;
|
||||||
|
private final ArcRepository arcRepository;
|
||||||
|
private final ChapterRepository chapterRepository;
|
||||||
|
private final SceneRepository sceneRepository;
|
||||||
|
private final CharacterRepository characterRepository;
|
||||||
|
|
||||||
public CampaignService(CampaignRepository campaignRepository) {
|
public CampaignService(
|
||||||
|
CampaignRepository campaignRepository,
|
||||||
|
ArcRepository arcRepository,
|
||||||
|
ChapterRepository chapterRepository,
|
||||||
|
SceneRepository sceneRepository,
|
||||||
|
CharacterRepository characterRepository) {
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignRepository = campaignRepository;
|
||||||
|
this.arcRepository = arcRepository;
|
||||||
|
this.chapterRepository = chapterRepository;
|
||||||
|
this.sceneRepository = sceneRepository;
|
||||||
|
this.characterRepository = characterRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +50,12 @@ public class CampaignService {
|
|||||||
*/
|
*/
|
||||||
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
|
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
|
||||||
|
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
|
||||||
|
*/
|
||||||
|
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
|
||||||
|
|
||||||
public Campaign createCampaign(CampaignData data) {
|
public Campaign createCampaign(CampaignData data) {
|
||||||
Campaign campaign = Campaign.builder()
|
Campaign campaign = Campaign.builder()
|
||||||
.name(data.name())
|
.name(data.name())
|
||||||
@@ -71,7 +97,48 @@ public class CampaignService {
|
|||||||
return (id == null || id.isBlank()) ? null : id;
|
return (id == null || id.isBlank()) ? null : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
|
||||||
|
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
|
||||||
|
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
|
||||||
|
*/
|
||||||
|
public DeletionImpact getDeletionImpact(String id) {
|
||||||
|
List<Arc> arcs = arcRepository.findByCampaignId(id);
|
||||||
|
int chapterTotal = 0;
|
||||||
|
int sceneTotal = 0;
|
||||||
|
for (Arc arc : arcs) {
|
||||||
|
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
|
||||||
|
chapterTotal += chapters.size();
|
||||||
|
for (Chapter chapter : chapters) {
|
||||||
|
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int characterTotal = characterRepository.findByCampaignId(id).size();
|
||||||
|
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
|
||||||
|
* scènes, plus les personnages). L'opération est transactionnelle : soit
|
||||||
|
* tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
|
||||||
|
* de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
public void deleteCampaign(String id) {
|
public void deleteCampaign(String id) {
|
||||||
|
List<Arc> arcs = arcRepository.findByCampaignId(id);
|
||||||
|
for (Arc arc : arcs) {
|
||||||
|
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
|
||||||
|
for (Chapter chapter : chapters) {
|
||||||
|
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
|
||||||
|
sceneRepository.deleteById(scene.getId());
|
||||||
|
}
|
||||||
|
chapterRepository.deleteById(chapter.getId());
|
||||||
|
}
|
||||||
|
arcRepository.deleteById(arc.getId());
|
||||||
|
}
|
||||||
|
for (var character : characterRepository.findByCampaignId(id)) {
|
||||||
|
characterRepository.deleteById(character.getId());
|
||||||
|
}
|
||||||
campaignRepository.deleteById(id);
|
campaignRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.loremind.application.campaigncontext;
|
|||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -17,11 +19,16 @@ import java.util.Optional;
|
|||||||
public class ChapterService {
|
public class ChapterService {
|
||||||
|
|
||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
|
private final SceneRepository sceneRepository;
|
||||||
|
|
||||||
public ChapterService(ChapterRepository chapterRepository) {
|
public ChapterService(ChapterRepository chapterRepository, SceneRepository sceneRepository) {
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
|
this.sceneRepository = sceneRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compte des scènes qui seront supprimées en cascade avec le chapitre. */
|
||||||
|
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) {
|
||||||
Chapter chapter = Chapter.builder()
|
Chapter chapter = Chapter.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
@@ -58,7 +65,17 @@ public class ChapterService {
|
|||||||
return chapterRepository.save(chapter);
|
return chapterRepository.save(chapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compte des scènes qui tomberont avec le chapitre. */
|
||||||
|
public DeletionImpact getDeletionImpact(String id) {
|
||||||
|
return new DeletionImpact(sceneRepository.findByChapterId(id).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supprime le chapitre et toutes ses scènes. Transactionnel : atomique. */
|
||||||
|
@Transactional
|
||||||
public void deleteChapter(String id) {
|
public void deleteChapter(String id) {
|
||||||
|
for (var scene : sceneRepository.findByChapterId(id)) {
|
||||||
|
sceneRepository.deleteById(scene.getId());
|
||||||
|
}
|
||||||
chapterRepository.deleteById(id);
|
chapterRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.LoreNode;
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -16,11 +20,20 @@ import java.util.Optional;
|
|||||||
public class LoreNodeService {
|
public class LoreNodeService {
|
||||||
|
|
||||||
private final LoreNodeRepository loreNodeRepository;
|
private final LoreNodeRepository loreNodeRepository;
|
||||||
|
private final PageRepository pageRepository;
|
||||||
|
|
||||||
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
|
public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
|
||||||
this.loreNodeRepository = loreNodeRepository;
|
this.loreNodeRepository = loreNodeRepository;
|
||||||
|
this.pageRepository = pageRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte des entités qui seront supprimées en cascade si le dossier est effacé :
|
||||||
|
* le dossier lui-même n'est pas compté, seuls les descendants (sous-dossiers
|
||||||
|
* récursifs + pages de l'ensemble du sous-arbre).
|
||||||
|
*/
|
||||||
|
public record DeletionImpact(int folders, int pages) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
|
* Crée un LoreNode (dossier) à partir d'un "objet changes" porteur des valeurs
|
||||||
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
|
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
|
||||||
@@ -68,7 +81,64 @@ public class LoreNodeService {
|
|||||||
return loreNodeRepository.save(existing);
|
return loreNodeRepository.save(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'impact d'une suppression en cascade : nombre de sous-dossiers
|
||||||
|
* (récursif, sans compter la racine) et de pages dans l'ensemble du sous-arbre.
|
||||||
|
*/
|
||||||
|
public DeletionImpact getDeletionImpact(String id) {
|
||||||
|
List<LoreNode> descendants = collectDescendants(id);
|
||||||
|
int pageTotal = pageRepository.findByNodeId(id).size();
|
||||||
|
for (LoreNode descendant : descendants) {
|
||||||
|
pageTotal += pageRepository.findByNodeId(descendant.getId()).size();
|
||||||
|
}
|
||||||
|
return new DeletionImpact(descendants.size(), pageTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime le dossier et tout son sous-arbre (sous-dossiers récursifs + pages).
|
||||||
|
* Suppression en profondeur d'abord (feuilles → racine) pour limiter les
|
||||||
|
* références orphelines en cours de transaction. Les FKs applicatives n'ayant
|
||||||
|
* pas de CASCADE en DB, on orchestre la descente ici.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
public void deleteLoreNode(String id) {
|
public void deleteLoreNode(String id) {
|
||||||
|
List<LoreNode> descendants = collectDescendants(id);
|
||||||
|
// Descendants retournés en ordre BFS (haut → bas) : on inverse pour
|
||||||
|
// supprimer les feuilles en premier, puis on finit par la racine.
|
||||||
|
for (int i = descendants.size() - 1; i >= 0; i--) {
|
||||||
|
String descendantId = descendants.get(i).getId();
|
||||||
|
deletePagesOfNode(descendantId);
|
||||||
|
loreNodeRepository.deleteById(descendantId);
|
||||||
|
}
|
||||||
|
deletePagesOfNode(id);
|
||||||
loreNodeRepository.deleteById(id);
|
loreNodeRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void deletePagesOfNode(String nodeId) {
|
||||||
|
for (Page page : pageRepository.findByNodeId(nodeId)) {
|
||||||
|
pageRepository.deleteById(page.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les descendants (hors racine) d'un dossier, en ordre BFS.
|
||||||
|
* Parcours itératif pour éviter tout risque de débordement de pile sur
|
||||||
|
* une arborescence profonde malicieuse.
|
||||||
|
*/
|
||||||
|
private List<LoreNode> collectDescendants(String rootId) {
|
||||||
|
List<LoreNode> result = new ArrayList<>();
|
||||||
|
List<String> frontier = new ArrayList<>();
|
||||||
|
frontier.add(rootId);
|
||||||
|
while (!frontier.isEmpty()) {
|
||||||
|
List<String> nextFrontier = new ArrayList<>();
|
||||||
|
for (String parentId : frontier) {
|
||||||
|
for (LoreNode child : loreNodeRepository.findByParentId(parentId)) {
|
||||||
|
result.add(child);
|
||||||
|
nextFrontier.add(child.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = nextFrontier;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
import com.loremind.domain.lorecontext.Lore;
|
||||||
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
|
import com.loremind.domain.lorecontext.Page;
|
||||||
|
import com.loremind.domain.lorecontext.Template;
|
||||||
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;
|
||||||
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -26,15 +33,28 @@ public class LoreService {
|
|||||||
private final LoreRepository loreRepository;
|
private final LoreRepository loreRepository;
|
||||||
private final LoreNodeRepository loreNodeRepository;
|
private final LoreNodeRepository loreNodeRepository;
|
||||||
private final PageRepository pageRepository;
|
private final PageRepository pageRepository;
|
||||||
|
private final TemplateRepository templateRepository;
|
||||||
|
private final CampaignRepository campaignRepository;
|
||||||
|
|
||||||
public LoreService(LoreRepository loreRepository,
|
public LoreService(LoreRepository loreRepository,
|
||||||
LoreNodeRepository loreNodeRepository,
|
LoreNodeRepository loreNodeRepository,
|
||||||
PageRepository pageRepository) {
|
PageRepository pageRepository,
|
||||||
|
TemplateRepository templateRepository,
|
||||||
|
CampaignRepository campaignRepository) {
|
||||||
this.loreRepository = loreRepository;
|
this.loreRepository = loreRepository;
|
||||||
this.loreNodeRepository = loreNodeRepository;
|
this.loreNodeRepository = loreNodeRepository;
|
||||||
this.pageRepository = pageRepository;
|
this.pageRepository = pageRepository;
|
||||||
|
this.templateRepository = templateRepository;
|
||||||
|
this.campaignRepository = campaignRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte des entités qui seront supprimées / détachées en cascade si le Lore
|
||||||
|
* est effacé. `detachedCampaigns` : campagnes qui perdront leur référence à
|
||||||
|
* ce Lore (leur loreId sera nullé) mais resteront présentes.
|
||||||
|
*/
|
||||||
|
public record DeletionImpact(int folders, int pages, int templates, int detachedCampaigns) {}
|
||||||
|
|
||||||
public Lore createLore(String name, String description) {
|
public Lore createLore(String name, String description) {
|
||||||
Lore lore = Lore.builder()
|
Lore lore = Lore.builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
@@ -83,7 +103,54 @@ public class LoreService {
|
|||||||
return loreRepository.save(lore);
|
return loreRepository.save(lore);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule l'impact d'une suppression de Lore en cascade : dossiers + pages
|
||||||
|
* + templates supprimés, et campagnes qui seront détachées (loreId → null
|
||||||
|
* sans être supprimées, car une campagne peut vivre sans univers).
|
||||||
|
*/
|
||||||
|
public DeletionImpact getDeletionImpact(String id) {
|
||||||
|
int folders = (int) loreNodeRepository.countByLoreId(id);
|
||||||
|
int pages = (int) pageRepository.countByLoreId(id);
|
||||||
|
int templates = templateRepository.findByLoreId(id).size();
|
||||||
|
int detached = countCampaignsReferencingLore(id);
|
||||||
|
return new DeletionImpact(folders, pages, templates, detached);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime le Lore et toutes ses entités dépendantes (dossiers, pages, templates).
|
||||||
|
* Les campagnes qui référençaient ce Lore sont conservées — leur loreId est
|
||||||
|
* mis à null (une campagne peut légitimement exister sans univers associé).
|
||||||
|
* Opération transactionnelle : atomique.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
public void deleteLore(String id) {
|
public void deleteLore(String id) {
|
||||||
|
// Pages d'abord : elles référencent nodeId ET loreId, on les supprime
|
||||||
|
// globalement via loreId pour éviter d'en rater une rattachée à un
|
||||||
|
// node orphelin (ne devrait pas arriver, mais ceinture+bretelles).
|
||||||
|
for (Page page : pageRepository.findByLoreId(id)) {
|
||||||
|
pageRepository.deleteById(page.getId());
|
||||||
|
}
|
||||||
|
for (LoreNode node : loreNodeRepository.findByLoreId(id)) {
|
||||||
|
loreNodeRepository.deleteById(node.getId());
|
||||||
|
}
|
||||||
|
for (Template template : templateRepository.findByLoreId(id)) {
|
||||||
|
templateRepository.deleteById(template.getId());
|
||||||
|
}
|
||||||
|
// Détache les campagnes : on garde la campagne, on nulle juste la référence.
|
||||||
|
for (Campaign campaign : campaignRepository.findAll()) {
|
||||||
|
if (id.equals(campaign.getLoreId())) {
|
||||||
|
campaign.setLoreId(null);
|
||||||
|
campaignRepository.save(campaign);
|
||||||
|
}
|
||||||
|
}
|
||||||
loreRepository.deleteById(id);
|
loreRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int countCampaignsReferencingLore(String id) {
|
||||||
|
int count = 0;
|
||||||
|
for (Campaign campaign : campaignRepository.findAll()) {
|
||||||
|
if (id.equals(campaign.getLoreId())) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,4 +68,12 @@ public class ArcController {
|
|||||||
arcService.deleteArc(id);
|
arcService.deleteArc(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
public ResponseEntity<ArcService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||||
|
if (!arcService.arcExists(id)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(arcService.getDeletionImpact(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,4 +74,16 @@ public class CampaignController {
|
|||||||
campaignService.deleteCampaign(id);
|
campaignService.deleteCampaign(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
|
||||||
|
* l'UI pour afficher "X arcs, Y chapitres, Z scènes..." dans la confirmation.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
public ResponseEntity<CampaignService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||||
|
if (!campaignService.campaignExists(id)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(campaignService.getDeletionImpact(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,4 +68,12 @@ public class ChapterController {
|
|||||||
chapterService.deleteChapter(id);
|
chapterService.deleteChapter(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
public ResponseEntity<ChapterService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||||
|
if (!chapterService.chapterExists(id)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(chapterService.getDeletionImpact(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,4 +69,17 @@ public class LoreController {
|
|||||||
loreService.deleteLore(id);
|
loreService.deleteLore(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récapitulatif des entités qui seront supprimées / détachées en cascade.
|
||||||
|
* Utilisé par l'UI pour afficher "X dossiers, Y pages, Z templates,
|
||||||
|
* N campagne(s) détachée(s)" dans la confirmation.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
public ResponseEntity<LoreService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||||
|
if (loreService.getLoreById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(loreService.getDeletionImpact(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,4 +97,16 @@ public class LoreNodeController {
|
|||||||
loreNodeService.deleteLoreNode(id);
|
loreNodeService.deleteLoreNode(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récapitulatif des entités qui seront supprimées en cascade : utilisé par
|
||||||
|
* l'UI pour afficher "X sous-dossiers, Y pages..." dans la confirmation.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/deletion-impact")
|
||||||
|
public ResponseEntity<LoreNodeService.DeletionImpact> getDeletionImpact(@PathVariable String id) {
|
||||||
|
if (loreNodeService.getLoreNodeById(id).isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(loreNodeService.getDeletionImpact(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
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.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -14,6 +18,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +31,10 @@ public class ArcServiceTest {
|
|||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private ArcRepository arcRepository;
|
private ArcRepository arcRepository;
|
||||||
|
@Mock
|
||||||
|
private ChapterRepository chapterRepository;
|
||||||
|
@Mock
|
||||||
|
private SceneRepository sceneRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ArcService arcService;
|
private ArcService arcService;
|
||||||
@@ -159,15 +168,48 @@ public class ArcServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteArc() {
|
void testDeleteArc_EmptyArc() {
|
||||||
// Arrange
|
// Aucun chapitre : Mockito renvoie List.of() par défaut.
|
||||||
doNothing().when(arcRepository).deleteById("arc-1");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
arcService.deleteArc("arc-1");
|
arcService.deleteArc("arc-1");
|
||||||
|
|
||||||
// Assert
|
verify(arcRepository).deleteById("arc-1");
|
||||||
verify(arcRepository, times(1)).deleteById("arc-1");
|
verify(chapterRepository, never()).deleteById(anyString());
|
||||||
|
verify(sceneRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteArc_CascadesChaptersAndScenes() {
|
||||||
|
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("C").build();
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chap-1").name("S2").build();
|
||||||
|
|
||||||
|
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
|
||||||
|
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1, s2));
|
||||||
|
|
||||||
|
arcService.deleteArc("arc-1");
|
||||||
|
|
||||||
|
verify(sceneRepository).deleteById("s-1");
|
||||||
|
verify(sceneRepository).deleteById("s-2");
|
||||||
|
verify(chapterRepository).deleteById("chap-1");
|
||||||
|
verify(arcRepository).deleteById("arc-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact() {
|
||||||
|
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
|
||||||
|
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
|
||||||
|
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
|
||||||
|
|
||||||
|
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
|
||||||
|
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
|
||||||
|
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
|
||||||
|
|
||||||
|
ArcService.DeletionImpact impact = arcService.getDeletionImpact("arc-1");
|
||||||
|
|
||||||
|
assertEquals(2, impact.chapters());
|
||||||
|
assertEquals(3, impact.scenes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
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.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -27,6 +35,14 @@ public class CampaignServiceTest {
|
|||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private CampaignRepository campaignRepository;
|
private CampaignRepository campaignRepository;
|
||||||
|
@Mock
|
||||||
|
private ArcRepository arcRepository;
|
||||||
|
@Mock
|
||||||
|
private ChapterRepository chapterRepository;
|
||||||
|
@Mock
|
||||||
|
private SceneRepository sceneRepository;
|
||||||
|
@Mock
|
||||||
|
private CharacterRepository characterRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private CampaignService campaignService;
|
private CampaignService campaignService;
|
||||||
@@ -53,7 +69,10 @@ public class CampaignServiceTest {
|
|||||||
"lore-123",
|
"lore-123",
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
// Le repo renvoie la Campaign telle que passée — on teste la normalisation
|
||||||
|
// du loreId dans le service, pas le comportement du repo.
|
||||||
|
when(campaignRepository.save(any(Campaign.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
Campaign result = campaignService.createCampaign(data);
|
Campaign result = campaignService.createCampaign(data);
|
||||||
@@ -73,7 +92,8 @@ public class CampaignServiceTest {
|
|||||||
null,
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
when(campaignRepository.save(any(Campaign.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
Campaign result = campaignService.createCampaign(data);
|
Campaign result = campaignService.createCampaign(data);
|
||||||
@@ -93,7 +113,8 @@ public class CampaignServiceTest {
|
|||||||
" ",
|
" ",
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
|
when(campaignRepository.save(any(Campaign.class)))
|
||||||
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
Campaign result = campaignService.createCampaign(data);
|
Campaign result = campaignService.createCampaign(data);
|
||||||
@@ -191,15 +212,75 @@ public class CampaignServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteCampaign() {
|
void testDeleteCampaign_EmptyCampaign() {
|
||||||
// Arrange
|
// Arrange : aucune dépendance ; Mockito renvoie List.of() par défaut.
|
||||||
doNothing().when(campaignRepository).deleteById("campaign-1");
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
campaignService.deleteCampaign("campaign-1");
|
campaignService.deleteCampaign("campaign-1");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
verify(campaignRepository, times(1)).deleteById("campaign-1");
|
verify(campaignRepository, times(1)).deleteById("campaign-1");
|
||||||
|
verify(arcRepository, never()).deleteById(anyString());
|
||||||
|
verify(chapterRepository, never()).deleteById(anyString());
|
||||||
|
verify(sceneRepository, never()).deleteById(anyString());
|
||||||
|
verify(characterRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteCampaign_CascadesArcsChaptersScenes() {
|
||||||
|
// Arrange : campagne avec 1 arc → 1 chapitre → 2 scènes.
|
||||||
|
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
|
||||||
|
Chapter chapter = Chapter.builder().id("chap-1").arcId("arc-1").name("Chap 1").build();
|
||||||
|
Scene scene1 = Scene.builder().id("scene-1").chapterId("chap-1").name("Scene 1").build();
|
||||||
|
Scene scene2 = Scene.builder().id("scene-2").chapterId("chap-1").name("Scene 2").build();
|
||||||
|
|
||||||
|
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
|
||||||
|
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(chapter));
|
||||||
|
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(scene1, scene2));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
campaignService.deleteCampaign("campaign-1");
|
||||||
|
|
||||||
|
// Assert : tout disparaît, dans l'ordre feuilles → racine.
|
||||||
|
verify(sceneRepository).deleteById("scene-1");
|
||||||
|
verify(sceneRepository).deleteById("scene-2");
|
||||||
|
verify(chapterRepository).deleteById("chap-1");
|
||||||
|
verify(arcRepository).deleteById("arc-1");
|
||||||
|
verify(campaignRepository).deleteById("campaign-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteCampaign_CascadesCharacters() {
|
||||||
|
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
|
||||||
|
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
|
||||||
|
|
||||||
|
campaignService.deleteCampaign("campaign-1");
|
||||||
|
|
||||||
|
verify(characterRepository).deleteById("char-1");
|
||||||
|
verify(campaignRepository).deleteById("campaign-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact() {
|
||||||
|
Arc arc = Arc.builder().id("arc-1").campaignId("campaign-1").name("Arc 1").build();
|
||||||
|
Chapter c1 = Chapter.builder().id("chap-1").arcId("arc-1").name("C1").build();
|
||||||
|
Chapter c2 = Chapter.builder().id("chap-2").arcId("arc-1").name("C2").build();
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
|
||||||
|
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
|
||||||
|
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
|
||||||
|
|
||||||
|
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
|
||||||
|
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
|
||||||
|
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
|
||||||
|
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
|
||||||
|
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
|
||||||
|
|
||||||
|
CampaignService.DeletionImpact impact = campaignService.getDeletionImpact("campaign-1");
|
||||||
|
|
||||||
|
assertEquals(1, impact.arcs());
|
||||||
|
assertEquals(2, impact.chapters());
|
||||||
|
assertEquals(3, impact.scenes());
|
||||||
|
assertEquals(1, impact.characters());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.loremind.application.campaigncontext;
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
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.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -14,6 +16,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +29,8 @@ public class ChapterServiceTest {
|
|||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private ChapterRepository chapterRepository;
|
private ChapterRepository chapterRepository;
|
||||||
|
@Mock
|
||||||
|
private SceneRepository sceneRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ChapterService chapterService;
|
private ChapterService chapterService;
|
||||||
@@ -157,15 +162,36 @@ public class ChapterServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteChapter() {
|
void testDeleteChapter_EmptyChapter() {
|
||||||
// Arrange
|
// Aucune scène : Mockito renvoie List.of() par défaut.
|
||||||
doNothing().when(chapterRepository).deleteById("chapter-1");
|
|
||||||
|
|
||||||
// Act
|
|
||||||
chapterService.deleteChapter("chapter-1");
|
chapterService.deleteChapter("chapter-1");
|
||||||
|
|
||||||
// Assert
|
verify(chapterRepository).deleteById("chapter-1");
|
||||||
verify(chapterRepository, times(1)).deleteById("chapter-1");
|
verify(sceneRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteChapter_CascadesScenes() {
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
|
||||||
|
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
|
||||||
|
|
||||||
|
chapterService.deleteChapter("chapter-1");
|
||||||
|
|
||||||
|
verify(sceneRepository).deleteById("s-1");
|
||||||
|
verify(sceneRepository).deleteById("s-2");
|
||||||
|
verify(chapterRepository).deleteById("chapter-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact() {
|
||||||
|
Scene s1 = Scene.builder().id("s-1").chapterId("chapter-1").name("S1").build();
|
||||||
|
Scene s2 = Scene.builder().id("s-2").chapterId("chapter-1").name("S2").build();
|
||||||
|
when(sceneRepository.findByChapterId("chapter-1")).thenReturn(List.of(s1, s2));
|
||||||
|
|
||||||
|
ChapterService.DeletionImpact impact = chapterService.getDeletionImpact("chapter-1");
|
||||||
|
|
||||||
|
assertEquals(2, impact.scenes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.SceneBranch;
|
|||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -40,6 +41,8 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
private ChapterRepository chapterRepository;
|
private ChapterRepository chapterRepository;
|
||||||
@Mock
|
@Mock
|
||||||
private SceneRepository sceneRepository;
|
private SceneRepository sceneRepository;
|
||||||
|
@Mock
|
||||||
|
private CharacterRepository characterRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private CampaignStructuralContextBuilder builder;
|
private CampaignStructuralContextBuilder builder;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.LoreNode;
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||||
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
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.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -15,6 +17,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +29,7 @@ import static org.mockito.Mockito.*;
|
|||||||
public class LoreNodeServiceTest {
|
public class LoreNodeServiceTest {
|
||||||
|
|
||||||
@Mock private LoreNodeRepository loreNodeRepository;
|
@Mock private LoreNodeRepository loreNodeRepository;
|
||||||
|
@Mock private PageRepository pageRepository;
|
||||||
|
|
||||||
@InjectMocks private LoreNodeService loreNodeService;
|
@InjectMocks private LoreNodeService loreNodeService;
|
||||||
|
|
||||||
@@ -118,8 +122,66 @@ public class LoreNodeServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDelete() {
|
void testDelete_LeafFolder() {
|
||||||
|
// Aucun descendant, aucune page : seul le dossier est supprimé.
|
||||||
loreNodeService.deleteLoreNode("n-1");
|
loreNodeService.deleteLoreNode("n-1");
|
||||||
verify(loreNodeRepository).deleteById("n-1");
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
verify(pageRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDelete_CascadesPagesOfRoot() {
|
||||||
|
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
|
||||||
|
Page p2 = Page.builder().id("p-2").nodeId("n-1").title("P2").build();
|
||||||
|
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1, p2));
|
||||||
|
|
||||||
|
loreNodeService.deleteLoreNode("n-1");
|
||||||
|
|
||||||
|
verify(pageRepository).deleteById("p-1");
|
||||||
|
verify(pageRepository).deleteById("p-2");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDelete_CascadesSubfoldersRecursive() {
|
||||||
|
// n-1 → n-1a → n-1a1 ; chaque feuille a une page.
|
||||||
|
LoreNode mid = LoreNode.builder().id("n-1a").parentId("n-1").loreId("lore-1").name("mid").build();
|
||||||
|
LoreNode leaf = LoreNode.builder().id("n-1a1").parentId("n-1a").loreId("lore-1").name("leaf").build();
|
||||||
|
Page pageOnLeaf = Page.builder().id("p-leaf").nodeId("n-1a1").title("P").build();
|
||||||
|
|
||||||
|
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(mid));
|
||||||
|
when(loreNodeRepository.findByParentId("n-1a")).thenReturn(List.of(leaf));
|
||||||
|
when(pageRepository.findByNodeId("n-1a1")).thenReturn(List.of(pageOnLeaf));
|
||||||
|
|
||||||
|
loreNodeService.deleteLoreNode("n-1");
|
||||||
|
|
||||||
|
// Feuilles d'abord (pages puis dossier leaf), puis mid, puis la racine.
|
||||||
|
verify(pageRepository).deleteById("p-leaf");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1a1");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1a");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact_CountsSubfoldersAndPages() {
|
||||||
|
LoreNode sub1 = LoreNode.builder().id("s-1").parentId("n-1").loreId("lore-1").name("s1").build();
|
||||||
|
LoreNode sub2 = LoreNode.builder().id("s-2").parentId("n-1").loreId("lore-1").name("s2").build();
|
||||||
|
LoreNode subsub = LoreNode.builder().id("s-1a").parentId("s-1").loreId("lore-1").name("s1a").build();
|
||||||
|
Page p1 = Page.builder().id("p-1").nodeId("n-1").title("P1").build();
|
||||||
|
Page p2 = Page.builder().id("p-2").nodeId("s-1").title("P2").build();
|
||||||
|
Page p3 = Page.builder().id("p-3").nodeId("s-1a").title("P3").build();
|
||||||
|
|
||||||
|
when(loreNodeRepository.findByParentId("n-1")).thenReturn(List.of(sub1, sub2));
|
||||||
|
when(loreNodeRepository.findByParentId("s-1")).thenReturn(List.of(subsub));
|
||||||
|
when(pageRepository.findByNodeId("n-1")).thenReturn(List.of(p1));
|
||||||
|
when(pageRepository.findByNodeId("s-1")).thenReturn(List.of(p2));
|
||||||
|
when(pageRepository.findByNodeId("s-2")).thenReturn(List.of());
|
||||||
|
when(pageRepository.findByNodeId("s-1a")).thenReturn(List.of(p3));
|
||||||
|
|
||||||
|
LoreNodeService.DeletionImpact impact = loreNodeService.getDeletionImpact("n-1");
|
||||||
|
|
||||||
|
// 3 sous-dossiers (sub1, sub2, subsub) — on ne compte pas la racine n-1.
|
||||||
|
assertEquals(3, impact.folders());
|
||||||
|
assertEquals(3, impact.pages());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
import com.loremind.domain.lorecontext.Lore;
|
||||||
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
|
import com.loremind.domain.lorecontext.Page;
|
||||||
|
import com.loremind.domain.lorecontext.Template;
|
||||||
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;
|
||||||
|
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;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -17,6 +23,7 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +37,8 @@ public class LoreServiceTest {
|
|||||||
@Mock private LoreRepository loreRepository;
|
@Mock private LoreRepository loreRepository;
|
||||||
@Mock private LoreNodeRepository loreNodeRepository;
|
@Mock private LoreNodeRepository loreNodeRepository;
|
||||||
@Mock private PageRepository pageRepository;
|
@Mock private PageRepository pageRepository;
|
||||||
|
@Mock private TemplateRepository templateRepository;
|
||||||
|
@Mock private CampaignRepository campaignRepository;
|
||||||
|
|
||||||
@InjectMocks private LoreService loreService;
|
@InjectMocks private LoreService loreService;
|
||||||
|
|
||||||
@@ -134,8 +143,67 @@ public class LoreServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testDeleteLore_DelegatesToRepository() {
|
void testDeleteLore_EmptyLore() {
|
||||||
|
// Aucun dossier / page / template / campagne : seul le Lore est supprimé.
|
||||||
loreService.deleteLore("lore-1");
|
loreService.deleteLore("lore-1");
|
||||||
verify(loreRepository).deleteById("lore-1");
|
verify(loreRepository).deleteById("lore-1");
|
||||||
|
verify(loreNodeRepository, never()).deleteById(anyString());
|
||||||
|
verify(pageRepository, never()).deleteById(anyString());
|
||||||
|
verify(templateRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteLore_CascadesFoldersPagesTemplates() {
|
||||||
|
LoreNode node = LoreNode.builder().id("n-1").loreId("lore-1").name("F").build();
|
||||||
|
Page page = Page.builder().id("p-1").loreId("lore-1").nodeId("n-1").title("P").build();
|
||||||
|
Template template = Template.builder().id("t-1").loreId("lore-1").name("T").build();
|
||||||
|
|
||||||
|
when(pageRepository.findByLoreId("lore-1")).thenReturn(List.of(page));
|
||||||
|
when(loreNodeRepository.findByLoreId("lore-1")).thenReturn(List.of(node));
|
||||||
|
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(template));
|
||||||
|
|
||||||
|
loreService.deleteLore("lore-1");
|
||||||
|
|
||||||
|
verify(pageRepository).deleteById("p-1");
|
||||||
|
verify(loreNodeRepository).deleteById("n-1");
|
||||||
|
verify(templateRepository).deleteById("t-1");
|
||||||
|
verify(loreRepository).deleteById("lore-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteLore_DetachesCampaignsInsteadOfDeleting() {
|
||||||
|
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C1").build();
|
||||||
|
Campaign other = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
|
||||||
|
Campaign orphan = Campaign.builder().id("c-3").loreId(null).name("C3").build();
|
||||||
|
when(campaignRepository.findAll()).thenReturn(List.of(attached, other, orphan));
|
||||||
|
when(campaignRepository.save(any(Campaign.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
loreService.deleteLore("lore-1");
|
||||||
|
|
||||||
|
// Seule la campagne attachée est re-sauvegardée (avec loreId=null).
|
||||||
|
ArgumentCaptor<Campaign> captor = ArgumentCaptor.forClass(Campaign.class);
|
||||||
|
verify(campaignRepository, times(1)).save(captor.capture());
|
||||||
|
assertEquals("c-1", captor.getValue().getId());
|
||||||
|
assertNull(captor.getValue().getLoreId());
|
||||||
|
// Aucune campagne n'est supprimée.
|
||||||
|
verify(campaignRepository, never()).deleteById(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetDeletionImpact() {
|
||||||
|
Template t1 = Template.builder().id("t-1").loreId("lore-1").name("T").build();
|
||||||
|
Campaign attached = Campaign.builder().id("c-1").loreId("lore-1").name("C").build();
|
||||||
|
Campaign unrelated = Campaign.builder().id("c-2").loreId("lore-other").name("C2").build();
|
||||||
|
when(loreNodeRepository.countByLoreId("lore-1")).thenReturn(4L);
|
||||||
|
when(pageRepository.countByLoreId("lore-1")).thenReturn(12L);
|
||||||
|
when(templateRepository.findByLoreId("lore-1")).thenReturn(List.of(t1));
|
||||||
|
when(campaignRepository.findAll()).thenReturn(List.of(attached, unrelated));
|
||||||
|
|
||||||
|
LoreService.DeletionImpact impact = loreService.getDeletionImpact("lore-1");
|
||||||
|
|
||||||
|
assertEquals(4, impact.folders());
|
||||||
|
assertEquals(12, impact.pages());
|
||||||
|
assertEquals(1, impact.templates());
|
||||||
|
assertEquals(1, impact.detachedCampaigns());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const routes: Routes = [
|
|||||||
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
|
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
|
||||||
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
||||||
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
|
||||||
|
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },
|
||||||
{ path: 'lore/:loreId/folders/:folderId/edit', loadComponent: () => import('./lore/lore-node-edit/lore-node-edit.component').then(m => m.LoreNodeEditComponent) },
|
{ path: 'lore/:loreId/folders/:folderId/edit', loadComponent: () => import('./lore/lore-node-edit/lore-node-edit.component').then(m => m.LoreNodeEditComponent) },
|
||||||
{ path: 'lore/:loreId/templates/create', loadComponent: () => import('./lore/template-create/template-create.component').then(m => m.TemplateCreateComponent) },
|
{ path: 'lore/:loreId/templates/create', loadComponent: () => import('./lore/template-create/template-create.component').then(m => m.TemplateCreateComponent) },
|
||||||
{ path: 'lore/:loreId/templates/:templateId', loadComponent: () => import('./lore/template-edit/template-edit.component').then(m => m.TemplateEditComponent) },
|
{ path: 'lore/:loreId/templates/:templateId', loadComponent: () => import('./lore/template-edit/template-edit.component').then(m => m.TemplateEditComponent) },
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-danger" (click)="deleteArc()" title="Supprimer l'arc et tout son contenu">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
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 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
@@ -27,6 +27,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;
|
||||||
|
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
arcId = '';
|
arcId = '';
|
||||||
@@ -101,6 +102,38 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'edit']);
|
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'edit']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppression en cascade : récupère d'abord le compte de chapitres / scènes
|
||||||
|
* qui tomberont avec l'arc, l'annonce dans la confirmation, puis délègue au
|
||||||
|
* backend (transaction atomique).
|
||||||
|
*/
|
||||||
|
deleteArc(): void {
|
||||||
|
if (!this.arc) return;
|
||||||
|
const arc = this.arc;
|
||||||
|
this.campaignService.getArcDeletionImpact(arc.id!).subscribe({
|
||||||
|
next: impact => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
||||||
|
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const lines = [`Supprimer l'arc "${arc.name}" ?`];
|
||||||
|
if (parts.length) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Cette action est irréversible.');
|
||||||
|
|
||||||
|
if (!confirm(lines.join('\n'))) return;
|
||||||
|
this.campaignService.deleteArc(arc.id!).subscribe({
|
||||||
|
next: () => this.router.navigate(['/campaigns', this.campaignId]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression de l\'arc')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
this.layoutService.hide();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,24 @@
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
|
// Le contenu peut dépasser la hauteur de l'écran (formulaire long) :
|
||||||
|
// on borne la modale et on fait scroller l'intérieur en flex-column.
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header { flex-shrink: 0; }
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
// Marge interne pour que la scrollbar ne colle pas aux inputs.
|
||||||
|
margin-right: -0.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -87,6 +105,14 @@
|
|||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
// Actions collées en bas du scroll : visibles même si on n'a pas défilé
|
||||||
|
// jusqu'en bas du formulaire.
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: #111827;
|
||||||
|
padding-top: 1rem;
|
||||||
|
margin-top: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
|
|||||||
@@ -74,6 +74,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-header {
|
.detail-header {
|
||||||
|
// Sticky : Modifier/Supprimer restent accessibles pendant le scroll de la
|
||||||
|
// campagne (potentiellement très longue avec arcs / chapitres / scènes).
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #0a0a14;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -257,22 +257,37 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suppression protégée : refus si la campagne contient des arcs.
|
* Suppression en cascade : récupère d'abord le détail de ce qui sera effacé
|
||||||
* Les arcs contiennent potentiellement des chapitres/scènes construits longuement.
|
* (arcs / chapitres / scènes / personnages), affiche le récapitulatif dans
|
||||||
|
* la confirmation, puis supprime. Le cascade est orchestré côté backend dans
|
||||||
|
* une seule transaction.
|
||||||
*/
|
*/
|
||||||
deleteCampaign(): void {
|
deleteCampaign(): void {
|
||||||
if (!this.campaign) return;
|
if (!this.campaign) return;
|
||||||
if (this.arcs.length > 0) {
|
const campaign = this.campaign;
|
||||||
alert(
|
this.campaignService.getCampaignDeletionImpact(campaign.id!).subscribe({
|
||||||
`Impossible de supprimer "${this.campaign.name}" : elle contient encore ${this.arcs.length} arc(s).\n` +
|
next: impact => {
|
||||||
`Videz la campagne (arcs et chapitres) avant de la supprimer.`
|
const parts: string[] = [];
|
||||||
);
|
if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 1 ? 's' : ''}`);
|
||||||
return;
|
if (impact.chapters > 0) parts.push(`${impact.chapters} chapitre${impact.chapters > 1 ? 's' : ''}`);
|
||||||
}
|
if (impact.scenes > 0) parts.push(`${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}`);
|
||||||
if (!confirm(`Supprimer définitivement la campagne "${this.campaign.name}" ?`)) return;
|
if (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
|
||||||
this.campaignService.deleteCampaign(this.campaign.id!).subscribe({
|
|
||||||
next: () => this.router.navigate(['/campaigns']),
|
const lines = [`Supprimer définitivement la campagne "${campaign.name}" ?`];
|
||||||
error: () => console.error('Erreur lors de la suppression de la campagne')
|
if (parts.length) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Cette action est irréversible.');
|
||||||
|
|
||||||
|
if (!confirm(lines.join('\n'))) return;
|
||||||
|
this.campaignService.deleteCampaign(campaign.id!).subscribe({
|
||||||
|
next: () => this.router.navigate(['/campaigns']),
|
||||||
|
error: () => console.error('Erreur lors de la suppression de la campagne')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => console.error('Impossible de récupérer les dépendances de la campagne')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-danger" (click)="deleteChapter()" title="Supprimer le chapitre et ses scènes">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
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 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
@@ -27,6 +27,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
|||||||
export class ChapterViewComponent implements OnInit, OnDestroy {
|
export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Network = Network;
|
readonly Network = Network;
|
||||||
|
readonly Trash2 = Trash2;
|
||||||
|
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
arcId = '';
|
arcId = '';
|
||||||
@@ -112,6 +113,33 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppression en cascade : récupère le compte de scènes qui tomberont avec
|
||||||
|
* le chapitre, l'annonce dans la confirmation, puis délègue au backend.
|
||||||
|
*/
|
||||||
|
deleteChapter(): void {
|
||||||
|
if (!this.chapter) return;
|
||||||
|
const chapter = this.chapter;
|
||||||
|
this.campaignService.getChapterDeletionImpact(chapter.id!).subscribe({
|
||||||
|
next: impact => {
|
||||||
|
const lines = [`Supprimer le chapitre "${chapter.name}" ?`];
|
||||||
|
if (impact.scenes > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Cette action supprimera aussi : ${impact.scenes} scène${impact.scenes > 1 ? 's' : ''}.`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Cette action est irréversible.');
|
||||||
|
|
||||||
|
if (!confirm(lines.join('\n'))) return;
|
||||||
|
this.campaignService.deleteChapter(chapter.id!).subscribe({
|
||||||
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression du chapitre')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
this.layoutService.hide();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-danger" (click)="deleteScene()" title="Supprimer la scène">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
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 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||||
import { CampaignService } from '../../services/campaign.service';
|
import { CampaignService } from '../../services/campaign.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
import { LayoutService, GlobalItem } from '../../services/layout.service';
|
||||||
@@ -26,6 +26,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;
|
||||||
|
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
arcId = '';
|
arcId = '';
|
||||||
@@ -110,6 +111,19 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Suppression simple — une scène n'a pas d'enfants. Retour au chapitre parent. */
|
||||||
|
deleteScene(): void {
|
||||||
|
if (!this.scene) return;
|
||||||
|
const scene = this.scene;
|
||||||
|
if (!confirm(`Supprimer la scène "${scene.name}" ?\n\nCette action est irréversible.`)) return;
|
||||||
|
this.campaignService.deleteScene(scene.id!).subscribe({
|
||||||
|
next: () => this.router.navigate([
|
||||||
|
'/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId
|
||||||
|
]),
|
||||||
|
error: () => console.error('Erreur lors de la suppression de la scène')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
this.layoutService.hide();
|
||||||
}
|
}
|
||||||
|
|||||||
85
web/src/app/lore/folder-view/folder-view.component.html
Normal file
85
web/src/app/lore/folder-view/folder-view.component.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<div class="folder-view" *ngIf="node">
|
||||||
|
|
||||||
|
<!-- Fil d'Ariane : Lore → ancêtres → dossier courant -->
|
||||||
|
<nav class="breadcrumb" aria-label="Fil d'Ariane">
|
||||||
|
<button type="button" class="crumb" (click)="navigateToLoreRoot()" *ngIf="lore">
|
||||||
|
{{ lore.name }}
|
||||||
|
</button>
|
||||||
|
<ng-container *ngFor="let ancestor of ancestors">
|
||||||
|
<lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon>
|
||||||
|
<button type="button" class="crumb" (click)="navigateToSubfolder(ancestor.id!)">
|
||||||
|
{{ ancestor.name }}
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
<lucide-icon [img]="ChevronRight" [size]="12" class="crumb-sep"></lucide-icon>
|
||||||
|
<span class="crumb current">{{ node.name }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Header : icône + nom + actions -->
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="header-texts">
|
||||||
|
<h1>
|
||||||
|
<lucide-icon [img]="folderIcon" [size]="24" class="title-icon"></lucide-icon>
|
||||||
|
{{ node.name }}
|
||||||
|
</h1>
|
||||||
|
<p class="description">
|
||||||
|
{{ subfolders.length }} sous-dossier(s) · {{ pages.length }} page(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button type="button" class="btn-secondary" (click)="navigateToEdit()" title="Modifier le dossier">
|
||||||
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-danger" (click)="delete()" title="Supprimer le dossier et tout son contenu">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sous-dossiers -->
|
||||||
|
<section class="detail-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Sous-dossiers</h2>
|
||||||
|
<button class="btn-add" (click)="navigateToCreateSubfolder()">
|
||||||
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||||
|
Nouveau sous-dossier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="items-grid" *ngIf="subfolders.length > 0">
|
||||||
|
<div class="node-card" *ngFor="let sub of subfolders" (click)="navigateToSubfolder(sub.id!)">
|
||||||
|
<lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
|
||||||
|
<span class="node-name">{{ sub.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" *ngIf="subfolders.length === 0">
|
||||||
|
<p>Aucun sous-dossier.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pages -->
|
||||||
|
<section class="detail-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Pages</h2>
|
||||||
|
<button class="btn-add" (click)="navigateToCreatePage()">
|
||||||
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||||
|
Nouvelle page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="items-grid" *ngIf="pages.length > 0">
|
||||||
|
<div class="node-card" *ngFor="let page of pages" (click)="navigateToPage(page.id!)">
|
||||||
|
<lucide-icon [img]="FileText" [size]="24" class="node-icon"></lucide-icon>
|
||||||
|
<span class="node-name">{{ page.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" *ngIf="pages.length === 0">
|
||||||
|
<p>Aucune page dans ce dossier.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
154
web/src/app/lore/folder-view/folder-view.component.scss
Normal file
154
web/src/app/lore/folder-view/folder-view.component.scss
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
.folder-view {
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
|
||||||
|
.crumb {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
&:hover { color: #c7d2fe; background: #1f2937; }
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
&.current:hover { background: transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb-sep { color: #4b5563; flex-shrink: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
// Sticky pour que Modifier/Supprimer restent accessibles même en scrollant
|
||||||
|
// une longue liste de sous-dossiers/pages.
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #0a0a14;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
|
||||||
|
.header-texts { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
.title-icon { color: #6c63ff; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary, .btn-danger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.btn-secondary { background: #1f2937; color: #d1d5db; &:hover { background: #374151; } }
|
||||||
|
.btn-danger { background: #3a1e1e; color: #f87171; &:hover { background: #5a2e2e; } }
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
background: #0d1117;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
|
||||||
|
h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #6c63ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover { background: #5b52e0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card {
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, transform 0.2s;
|
||||||
|
|
||||||
|
&:hover { border-color: #6c63ff; transform: translateY(-2px); }
|
||||||
|
|
||||||
|
.node-icon { color: #6c63ff; }
|
||||||
|
.node-name { color: white; font-size: 0.9rem; font-weight: 600; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
}
|
||||||
179
web/src/app/lore/folder-view/folder-view.component.ts
Normal file
179
web/src/app/lore/folder-view/folder-view.component.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { forkJoin } from 'rxjs';
|
||||||
|
import { LucideAngularModule, LucideIconData, Folder, FileText, Pencil, Trash2, Plus, ChevronRight } from 'lucide-angular';
|
||||||
|
import { LoreService } from '../../services/lore.service';
|
||||||
|
import { TemplateService } from '../../services/template.service';
|
||||||
|
import { PageService } from '../../services/page.service';
|
||||||
|
import { LayoutService } from '../../services/layout.service';
|
||||||
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
|
import { Lore, LoreNode } from '../../services/lore.model';
|
||||||
|
import { Page } from '../../services/page.model';
|
||||||
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
import { resolveIcon } from '../lore-icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue "détail" d'un dossier : affiche son contenu (sous-dossiers + pages) et
|
||||||
|
* expose les actions Modifier / Supprimer dans le header.
|
||||||
|
*
|
||||||
|
* L'édition du nom/icône/parent se fait dans l'écran séparé folder-edit
|
||||||
|
* (/folders/:folderId/edit). La suppression avec cascade déclenche le même
|
||||||
|
* dialogue d'impact que les autres écrans.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-folder-view',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, LucideAngularModule],
|
||||||
|
templateUrl: './folder-view.component.html',
|
||||||
|
styleUrls: ['./folder-view.component.scss']
|
||||||
|
})
|
||||||
|
export class FolderViewComponent implements OnInit, OnDestroy {
|
||||||
|
readonly Folder = Folder;
|
||||||
|
readonly FileText = FileText;
|
||||||
|
readonly Pencil = Pencil;
|
||||||
|
readonly Trash2 = Trash2;
|
||||||
|
readonly Plus = Plus;
|
||||||
|
readonly ChevronRight = ChevronRight;
|
||||||
|
|
||||||
|
loreId = '';
|
||||||
|
folderId = '';
|
||||||
|
lore: Lore | null = null;
|
||||||
|
node: LoreNode | null = null;
|
||||||
|
subfolders: LoreNode[] = [];
|
||||||
|
pages: Page[] = [];
|
||||||
|
/** Chaîne des dossiers ancêtres (du plus proche du racine vers le parent direct). */
|
||||||
|
ancestors: LoreNode[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private loreService: LoreService,
|
||||||
|
private templateService: TemplateService,
|
||||||
|
private pageService: PageService,
|
||||||
|
private layoutService: LayoutService,
|
||||||
|
private pageTitleService: PageTitleService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loreId = this.route.snapshot.paramMap.get('loreId')!;
|
||||||
|
// Réagit aux changements de :folderId pour que la navigation d'un dossier
|
||||||
|
// à un autre via la sidebar ne démonte/remonte pas le composant à blanc.
|
||||||
|
this.route.paramMap.subscribe(pm => {
|
||||||
|
const next = pm.get('folderId')!;
|
||||||
|
if (next !== this.folderId) {
|
||||||
|
this.folderId = next;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.folderId = this.route.snapshot.paramMap.get('folderId')!;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
forkJoin({
|
||||||
|
sidebar: loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService),
|
||||||
|
node: this.loreService.getLoreNodeById(this.folderId)
|
||||||
|
}).subscribe(({ sidebar, node }) => {
|
||||||
|
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||||
|
this.lore = sidebar.lore;
|
||||||
|
this.node = node;
|
||||||
|
this.pageTitleService.set(node.name);
|
||||||
|
this.subfolders = sidebar.nodes.filter(n => n.parentId === this.folderId);
|
||||||
|
this.pages = sidebar.pages.filter(p => p.nodeId === this.folderId);
|
||||||
|
this.ancestors = this.buildAncestors(node, sidebar.nodes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remonte la chaîne parentId → parent en partant du dossier courant,
|
||||||
|
* sans s'inclure soi-même. Ordre : racine → parent direct.
|
||||||
|
* Garde-fou sur la longueur au cas où une boucle existerait en BDD
|
||||||
|
* (ne devrait pas, mais ceinture+bretelles).
|
||||||
|
*/
|
||||||
|
private buildAncestors(current: LoreNode, allNodes: LoreNode[]): LoreNode[] {
|
||||||
|
const byId = new Map(allNodes.map(n => [n.id!, n]));
|
||||||
|
const chain: LoreNode[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let parentId = current.parentId ?? null;
|
||||||
|
while (parentId && !seen.has(parentId) && chain.length < 32) {
|
||||||
|
const parent = byId.get(parentId);
|
||||||
|
if (!parent) break;
|
||||||
|
chain.push(parent);
|
||||||
|
seen.add(parent.id!);
|
||||||
|
parentId = parent.parentId ?? null;
|
||||||
|
}
|
||||||
|
return chain.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icône du dossier courant, résolue depuis la clé lucide stockée sur le node. */
|
||||||
|
get folderIcon(): LucideIconData {
|
||||||
|
return resolveIcon(this.node?.icon ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToSubfolder(id: string): void {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'folders', id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToLoreRoot(): void {
|
||||||
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToPage(id: string): void {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'pages', id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToCreateSubfolder(): void {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'folders', this.folderId, 'create']);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToCreatePage(): void {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'nodes', this.folderId, 'pages', 'create']);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateToEdit(): void {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'folders', this.folderId, 'edit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppression en cascade avec dialogue d'impact. On délègue au backend (transaction
|
||||||
|
* atomique), et au retour on remonte soit au dossier parent soit au Lore racine.
|
||||||
|
*/
|
||||||
|
delete(): void {
|
||||||
|
if (!this.node) return;
|
||||||
|
const node = this.node;
|
||||||
|
this.loreService.getLoreNodeDeletionImpact(this.folderId).subscribe({
|
||||||
|
next: impact => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (impact.folders > 0) parts.push(`${impact.folders} sous-dossier${impact.folders > 1 ? 's' : ''}`);
|
||||||
|
if (impact.pages > 0) parts.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||||
|
|
||||||
|
const lines = [`Supprimer le dossier "${node.name}" ?`];
|
||||||
|
if (parts.length) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Cette action est irréversible.');
|
||||||
|
|
||||||
|
if (!confirm(lines.join('\n'))) return;
|
||||||
|
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
||||||
|
next: () => {
|
||||||
|
// Remonte au dossier parent si présent, sinon au Lore.
|
||||||
|
if (node.parentId) {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'folders', node.parentId]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => console.error('Erreur lors de la suppression du dossier')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.layoutService.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,11 +15,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-header {
|
.detail-header {
|
||||||
|
// Sticky : les actions Modifier/Supprimer du Lore restent accessibles
|
||||||
|
// quand on scrolle la grille de dossiers.
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #0a0a14;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
.header-texts { flex: 1; min-width: 0; }
|
.header-texts { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
navigateToFolder(nodeId: string): void {
|
navigateToFolder(nodeId: string): void {
|
||||||
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId, 'edit']);
|
this.router.navigate(['/lore', this.lore!.id, 'folders', nodeId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────── Édition / suppression du Lore ───────────────
|
// ─────────────── Édition / suppression du Lore ───────────────
|
||||||
@@ -110,24 +110,43 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suppression protégée : refus si le Lore contient encore des dossiers
|
* Suppression en cascade : récupère le détail de ce qui tombera (dossiers,
|
||||||
* ou des pages. Protège contre un clic accidentel sur des données
|
* pages, templates) et de ce qui sera détaché (campagnes conservées mais
|
||||||
* construites longuement. Logique côté frontend (pas d'appel HTTP
|
* sans lien vers ce Lore), affiche le récapitulatif dans la confirmation,
|
||||||
* supplémentaire) car les données sont déjà chargées.
|
* puis délègue au backend (transaction atomique).
|
||||||
*/
|
*/
|
||||||
deleteLore(): void {
|
deleteLore(): void {
|
||||||
if (!this.lore) return;
|
if (!this.lore) return;
|
||||||
if (this.allNodes.length > 0) {
|
const lore = this.lore;
|
||||||
alert(
|
this.loreService.getLoreDeletionImpact(lore.id!).subscribe({
|
||||||
`Impossible de supprimer "${this.lore.name}" : il contient encore ${this.allNodes.length} dossier(s).\n` +
|
next: impact => {
|
||||||
`Videz le Lore (dossiers et pages) avant de le supprimer.`
|
const deleted: string[] = [];
|
||||||
);
|
if (impact.folders > 0) deleted.push(`${impact.folders} dossier${impact.folders > 1 ? 's' : ''}`);
|
||||||
return;
|
if (impact.pages > 0) deleted.push(`${impact.pages} page${impact.pages > 1 ? 's' : ''}`);
|
||||||
}
|
if (impact.templates > 0) deleted.push(`${impact.templates} template${impact.templates > 1 ? 's' : ''}`);
|
||||||
if (!confirm(`Supprimer définitivement le Lore "${this.lore.name}" ?`)) return;
|
|
||||||
this.loreService.deleteLore(this.lore.id!).subscribe({
|
const lines = [`Supprimer définitivement le Lore "${lore.name}" ?`];
|
||||||
next: () => this.router.navigate(['/lore']),
|
if (deleted.length) {
|
||||||
error: () => console.error('Erreur lors de la suppression du Lore')
|
lines.push('');
|
||||||
|
lines.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
|
||||||
|
}
|
||||||
|
if (impact.detachedCampaigns > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push(
|
||||||
|
`${impact.detachedCampaigns} campagne${impact.detachedCampaigns > 1 ? 's' : ''} ${impact.detachedCampaigns > 1 ? 'seront conservées' : 'sera conservée'} ` +
|
||||||
|
`mais ${impact.detachedCampaigns > 1 ? 'perdront' : 'perdra'} leur lien vers cet univers.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Cette action est irréversible.');
|
||||||
|
|
||||||
|
if (!confirm(lines.join('\n'))) return;
|
||||||
|
this.loreService.deleteLore(lore.id!).subscribe({
|
||||||
|
next: () => this.router.navigate(['/lore']),
|
||||||
|
error: () => console.error('Erreur lors de la suppression du Lore')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => console.error('Impossible de récupérer les dépendances du Lore')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,15 +49,6 @@
|
|||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label>Adresse</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
formControlName="address"
|
|
||||||
placeholder="nom-du-dossier"
|
|
||||||
/>
|
|
||||||
</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">
|
||||||
<lucide-icon [img]="getIcon(selectedIcon)" [size]="16"></lucide-icon>
|
<lucide-icon [img]="getIcon(selectedIcon)" [size]="16"></lucide-icon>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageService } from '../../services/page.service';
|
|||||||
import { LayoutService } from '../../services/layout.service';
|
import { LayoutService } from '../../services/layout.service';
|
||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
import { popReturnTo } from '../return-stack.helper';
|
||||||
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -42,15 +43,8 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
name: ['', Validators.required],
|
name: ['', Validators.required],
|
||||||
description: [''],
|
description: [''],
|
||||||
address: ['', Validators.required],
|
|
||||||
parentId: [''] // '' = racine
|
parentId: [''] // '' = racine
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-génère l'adresse depuis le nom
|
|
||||||
this.form.get('name')!.valueChanges.subscribe(name => {
|
|
||||||
const slug = (name as string).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
||||||
this.form.get('address')!.setValue(slug, { emitEvent: false });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -84,17 +78,35 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.loreService.createLoreNode({
|
this.loreService.createLoreNode({
|
||||||
name: raw.name,
|
name: raw.name,
|
||||||
description: raw.description,
|
description: raw.description,
|
||||||
address: raw.address,
|
|
||||||
icon: this.selectedIcon,
|
icon: this.selectedIcon,
|
||||||
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null,
|
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null,
|
||||||
loreId: this.loreId
|
loreId: this.loreId
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.navigateBack(),
|
||||||
error: () => console.error('Erreur lors de la création du dossier')
|
error: () => console.error('Erreur lors de la création du dossier')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
|
this.navigateBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirige vers l'écran d'origine en dépilant le premier élément du query-param
|
||||||
|
* `returnTo` (pile séparée par des virgules). Supporte `page-create` et
|
||||||
|
* `template-create`, en transmettant le reste de la pile à l'écran suivant.
|
||||||
|
*/
|
||||||
|
private navigateBack(): void {
|
||||||
|
const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
|
||||||
|
const qp = rest ? { returnTo: rest } : {};
|
||||||
|
if (next === 'page-create') {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], { queryParams: qp });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next === 'template-create') {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'templates', 'create'], { queryParams: qp });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.router.navigate(['/lore', this.loreId]);
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-danger"
|
|
||||||
[disabled]="!canDelete"
|
|
||||||
[title]="canDelete ? 'Supprimer le dossier' : 'Impossible : le dossier contient des éléments'"
|
|
||||||
(click)="delete()">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
@@ -57,11 +49,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box" *ngIf="!canDelete">
|
|
||||||
⚠️ Pour supprimer ce dossier, videz-le d'abord : déplacez ou supprimez ses
|
|
||||||
{{ childFolderCount }} sous-dossier(s) et ses {{ pageCount }} page(s).
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -129,26 +129,15 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
|||||||
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null
|
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null
|
||||||
};
|
};
|
||||||
this.loreService.updateLoreNode(this.folderId, updated).subscribe({
|
this.loreService.updateLoreNode(this.folderId, updated).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.router.navigate(['/lore', this.loreId, 'folders', this.folderId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde du dossier')
|
error: () => console.error('Erreur lors de la sauvegarde du dossier')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get canDelete(): boolean {
|
|
||||||
return this.childFolderCount === 0 && this.pageCount === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(): void {
|
|
||||||
if (!this.canDelete || !this.node) return;
|
|
||||||
if (!confirm(`Supprimer le dossier "${this.node.name}" ?`)) return;
|
|
||||||
this.loreService.deleteLoreNode(this.folderId).subscribe({
|
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
|
||||||
error: () => console.error('Erreur lors de la suppression du dossier')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
this.router.navigate(['/lore', this.loreId]);
|
// Retour vers la vue détail du dossier plutôt que la racine du Lore :
|
||||||
|
// l'édition est un sous-écran du détail.
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'folders', this.folderId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retourne l'icône lucide à afficher dans l'aperçu du bouton "Sauvegarder". */
|
/** Retourne l'icône lucide à afficher dans l'aperçu du bouton "Sauvegarder". */
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit récursivement le TreeItem d'un dossier :
|
* Construit récursivement le TreeItem d'un dossier : ses sous-dossiers,
|
||||||
* ses sous-dossiers, puis ses pages, puis les actions "+ Nouveau dossier" et "+ Nouvelle page".
|
* ses pages, et deux actions révélées au survol de la ligne (pas dans la
|
||||||
|
* hiérarchie) — "Nouveau sous-dossier" et "Nouvelle page".
|
||||||
*/
|
*/
|
||||||
const buildFolderItem = (node: LoreNode): TreeItem => {
|
const buildFolderItem = (node: LoreNode): TreeItem => {
|
||||||
const subFolders = childrenByParent.get(node.id!) ?? [];
|
const subFolders = childrenByParent.get(node.id!) ?? [];
|
||||||
@@ -84,7 +85,7 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
|||||||
id: `folder-${node.id}`,
|
id: `folder-${node.id}`,
|
||||||
label: node.name,
|
label: node.name,
|
||||||
iconKey: node.icon ?? undefined,
|
iconKey: node.icon ?? undefined,
|
||||||
route: `/lore/${lore.id}/folders/${node.id}/edit`,
|
route: `/lore/${lore.id}/folders/${node.id}`,
|
||||||
meta: nodePages.length > 0 ? String(nodePages.length) : undefined,
|
meta: nodePages.length > 0 ? String(nodePages.length) : undefined,
|
||||||
children,
|
children,
|
||||||
createActions: [
|
createActions: [
|
||||||
@@ -116,20 +117,16 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
|
|||||||
id: 'templates',
|
id: 'templates',
|
||||||
title: 'Templates',
|
title: 'Templates',
|
||||||
initiallyOpen: true,
|
initiallyOpen: true,
|
||||||
items: [
|
headerAction: {
|
||||||
...templates.map(t => ({
|
label: 'Nouveau template',
|
||||||
id: t.id!,
|
route: `/lore/${lore.id}/templates/create`
|
||||||
label: t.name,
|
},
|
||||||
meta: `${t.fieldCount ?? t.fields.length} champs`,
|
items: templates.map(t => ({
|
||||||
route: `/lore/${lore.id}/templates/${t.id}`
|
id: t.id!,
|
||||||
})),
|
label: t.name,
|
||||||
{
|
meta: `${t.fieldCount ?? t.fields.length} champs`,
|
||||||
id: 'create-template',
|
route: `/lore/${lore.id}/templates/${t.id}`
|
||||||
label: '+ Nouveau template',
|
}))
|
||||||
isAction: true,
|
|
||||||
route: `/lore/${lore.id}/templates/create`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<ng-template #emptyTemplates>
|
<ng-template #emptyTemplates>
|
||||||
<p class="empty-hint">
|
<p class="empty-hint">
|
||||||
Aucun template défini pour ce Lore.
|
Aucun template défini pour ce Lore.
|
||||||
<a [routerLink]="['/lore', loreId, 'templates', 'create']">Créer un template</a> d'abord.
|
<a [routerLink]="['/lore', loreId, 'templates', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">Créer un template</a> d'abord.
|
||||||
</p>
|
</p>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,11 +43,21 @@
|
|||||||
<!-- Dossier de destination -->
|
<!-- Dossier de destination -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Dossier de destination *</label>
|
<label>Dossier de destination *</label>
|
||||||
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
|
|
||||||
<option value="" disabled>Sélectionnez un dossier</option>
|
<ng-container *ngIf="nodes.length; else emptyFolders">
|
||||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
|
||||||
</select>
|
<option value="" disabled>Sélectionnez un dossier</option>
|
||||||
<p class="hint">La page sera créée dans ce dossier</p>
|
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">La page sera créée dans ce dossier</p>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #emptyFolders>
|
||||||
|
<p class="empty-hint">
|
||||||
|
Aucun dossier dans ce Lore.
|
||||||
|
<a [routerLink]="['/lore', loreId, 'nodes', 'create']" [queryParams]="{ returnTo: 'page-create' }" (click)="saveDraft()">Créer un dossier</a> d'abord.
|
||||||
|
</p>
|
||||||
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aide contextuelle -->
|
<!-- Aide contextuelle -->
|
||||||
|
|||||||
@@ -92,9 +92,48 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
|||||||
if (this.preselectedNodeId) {
|
if (this.preselectedNodeId) {
|
||||||
this.form.patchValue({ nodeId: this.preselectedNodeId });
|
this.form.patchValue({ nodeId: this.preselectedNodeId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.restoreDraft();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clé sessionStorage pour le brouillon — scopée au lore courant. */
|
||||||
|
private get draftKey(): string {
|
||||||
|
return `page-create-draft:${this.loreId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde le titre et le template sélectionné avant un détour de navigation
|
||||||
|
* (création de template ou de dossier), pour pouvoir les restaurer au retour.
|
||||||
|
* NodeId volontairement omis : il peut référencer un dossier qui n'existait
|
||||||
|
* pas encore et serait invalide après un aller-retour.
|
||||||
|
*/
|
||||||
|
saveDraft(): void {
|
||||||
|
const draft = {
|
||||||
|
title: this.form.value.title ?? '',
|
||||||
|
selectedTemplateId: this.selectedTemplateId
|
||||||
|
};
|
||||||
|
if (!draft.title && !draft.selectedTemplateId) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(this.draftKey, JSON.stringify(draft));
|
||||||
|
} catch { /* quota dépassé ou storage indisponible : on ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreDraft(): void {
|
||||||
|
let raw: string | null = null;
|
||||||
|
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
|
||||||
|
if (!raw) return;
|
||||||
|
sessionStorage.removeItem(this.draftKey);
|
||||||
|
try {
|
||||||
|
const draft = JSON.parse(raw) as { title?: string; selectedTemplateId?: string | null };
|
||||||
|
if (draft.title) this.form.patchValue({ title: draft.title });
|
||||||
|
if (draft.selectedTemplateId && this.templates.some(t => t.id === draft.selectedTemplateId)) {
|
||||||
|
const tpl = this.templates.find(t => t.id === draft.selectedTemplateId)!;
|
||||||
|
this.selectTemplate(tpl);
|
||||||
|
}
|
||||||
|
} catch { /* JSON corrompu : on ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
selectTemplate(template: Template): void {
|
selectTemplate(template: Template): void {
|
||||||
this.selectedTemplateId = template.id!;
|
this.selectedTemplateId = template.id!;
|
||||||
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
|
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
|||||||
for (const node of folderChain) {
|
for (const node of folderChain) {
|
||||||
items.push({
|
items.push({
|
||||||
label: node.name,
|
label: node.name,
|
||||||
route: ['/lore', this.loreId, 'folders', node.id, 'edit']
|
route: ['/lore', this.loreId, 'folders', node.id]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-danger" (click)="deletePage()" title="Supprimer la page">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } 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';
|
||||||
import { LucideAngularModule, Pencil } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -34,6 +34,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
|
|||||||
})
|
})
|
||||||
export class PageViewComponent implements OnInit, OnDestroy {
|
export class PageViewComponent implements OnInit, OnDestroy {
|
||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
|
readonly Trash2 = Trash2;
|
||||||
|
|
||||||
loreId = '';
|
loreId = '';
|
||||||
pageId = '';
|
pageId = '';
|
||||||
@@ -96,7 +97,7 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
for (const node of folderChain) {
|
for (const node of folderChain) {
|
||||||
items.push({ label: node.name, route: ['/lore', this.loreId, 'folders', node.id, 'edit'] });
|
items.push({ label: node.name, route: ['/lore', this.loreId, 'folders', node.id] });
|
||||||
}
|
}
|
||||||
items.push({ label: this.page.title });
|
items.push({ label: this.page.title });
|
||||||
return items;
|
return items;
|
||||||
@@ -121,6 +122,26 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate(['/lore', this.loreId, 'pages', this.pageId, 'edit']);
|
this.router.navigate(['/lore', this.loreId, 'pages', this.pageId, 'edit']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppression simple : pas d'enfants. On remonte au dossier parent
|
||||||
|
* si on peut, sinon à la racine du Lore.
|
||||||
|
*/
|
||||||
|
deletePage(): void {
|
||||||
|
if (!this.page) return;
|
||||||
|
const page = this.page;
|
||||||
|
if (!confirm(`Supprimer la page "${page.title}" ?\n\nCette action est irréversible.`)) return;
|
||||||
|
this.pageService.delete(page.id!).subscribe({
|
||||||
|
next: () => {
|
||||||
|
if (page.nodeId) {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'folders', page.nodeId]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => console.error('Erreur lors de la suppression de la page')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.layoutService.hide();
|
this.layoutService.hide();
|
||||||
}
|
}
|
||||||
|
|||||||
22
web/src/app/lore/return-stack.helper.ts
Normal file
22
web/src/app/lore/return-stack.helper.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Gère la pile de retours partagée par les écrans de création imbriqués
|
||||||
|
* (page-create ↔ template-create ↔ node-create).
|
||||||
|
*
|
||||||
|
* La pile est encodée dans le query-param `returnTo` sous forme de chaîne
|
||||||
|
* séparée par des virgules, ex : `"template-create,page-create"`. Chaque
|
||||||
|
* écran dépile le premier élément pour savoir où revenir, et propage le
|
||||||
|
* reste comme nouveau `returnTo`.
|
||||||
|
*/
|
||||||
|
export interface PoppedReturn {
|
||||||
|
/** Nom de l'écran vers lequel revenir, ou null si la pile est vide. */
|
||||||
|
next: string | null;
|
||||||
|
/** Reste de la pile à transmettre à l'écran de retour, ou null si vide. */
|
||||||
|
rest: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function popReturnTo(raw: string | null | undefined): PoppedReturn {
|
||||||
|
const parts = (raw ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const next = parts.shift() ?? null;
|
||||||
|
const rest = parts.length ? parts.join(',') : null;
|
||||||
|
return { next, rest };
|
||||||
|
}
|
||||||
@@ -22,11 +22,23 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Dossier par défaut *</label>
|
<label>Dossier par défaut *</label>
|
||||||
<select formControlName="defaultNodeId">
|
|
||||||
<option value="" disabled>Sélectionnez un dossier</option>
|
<ng-container *ngIf="nodes.length; else emptyFolders">
|
||||||
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
<select formControlName="defaultNodeId">
|
||||||
</select>
|
<option value="" disabled>Sélectionnez un dossier</option>
|
||||||
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier</p>
|
<option *ngFor="let node of nodes" [value]="node.id">{{ node.name }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">Les pages créées avec ce template seront placées dans ce dossier</p>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #emptyFolders>
|
||||||
|
<p class="empty-hint">
|
||||||
|
Aucun dossier dans ce Lore.
|
||||||
|
<a [routerLink]="['/lore', loreId, 'nodes', 'create']"
|
||||||
|
[queryParams]="{ returnTo: nodeCreateReturnTo }"
|
||||||
|
(click)="saveDraft()">Créer un dossier</a> d'abord.
|
||||||
|
</p>
|
||||||
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +97,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="newFieldName"
|
[(ngModel)]="newFieldName"
|
||||||
[ngModelOptions]="{ standalone: true }"
|
[ngModelOptions]="{ standalone: true }"
|
||||||
placeholder="Nom du champ..."
|
placeholder="+ Ajouter un champ"
|
||||||
(keydown.enter)="$event.preventDefault(); addField()" />
|
(keydown.enter)="$event.preventDefault(); addField()" />
|
||||||
<select
|
<select
|
||||||
class="type-select"
|
class="type-select"
|
||||||
|
|||||||
@@ -247,3 +247,10 @@
|
|||||||
|
|
||||||
&:hover { background: #363650; }
|
&:hover { background: #363650; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
|
||||||
|
a { color: #a5b4fc; text-decoration: underline; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
@@ -10,6 +10,7 @@ import { LayoutService } from '../../services/layout.service';
|
|||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
|
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
import { popReturnTo } from '../return-stack.helper';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écran de création d'un Template (gabarit de Page).
|
* Écran de création d'un Template (gabarit de Page).
|
||||||
@@ -20,7 +21,7 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-template-create',
|
selector: 'app-template-create',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
|
imports: [CommonModule, FormsModule, ReactiveFormsModule, RouterModule, LucideAngularModule],
|
||||||
templateUrl: './template-create.component.html',
|
templateUrl: './template-create.component.html',
|
||||||
styleUrls: ['./template-create.component.scss']
|
styleUrls: ['./template-create.component.scss']
|
||||||
})
|
})
|
||||||
@@ -69,9 +70,54 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService).subscribe(data => {
|
loadLoreSidebarData(this.loreId, this.loreService, this.templateService, this.pageService).subscribe(data => {
|
||||||
this.nodes = data.nodes;
|
this.nodes = data.nodes;
|
||||||
this.layoutService.show(buildLoreSidebarConfig(data));
|
this.layoutService.show(buildLoreSidebarConfig(data));
|
||||||
|
this.restoreDraft();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clé sessionStorage pour le brouillon de template — scopée au lore. */
|
||||||
|
private get draftKey(): string {
|
||||||
|
return `template-create-draft:${this.loreId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde le formulaire courant avant un détour (création de dossier).
|
||||||
|
* defaultNodeId volontairement omis : il référence potentiellement un dossier
|
||||||
|
* qui n'existe pas encore.
|
||||||
|
*/
|
||||||
|
saveDraft(): void {
|
||||||
|
const draft = {
|
||||||
|
name: this.form.value.name ?? '',
|
||||||
|
description: this.form.value.description ?? '',
|
||||||
|
fields: this.fields
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(this.draftKey, JSON.stringify(draft));
|
||||||
|
} catch { /* storage indisponible : on ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreDraft(): void {
|
||||||
|
let raw: string | null = null;
|
||||||
|
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
|
||||||
|
if (!raw) return;
|
||||||
|
sessionStorage.removeItem(this.draftKey);
|
||||||
|
try {
|
||||||
|
const draft = JSON.parse(raw) as { name?: string; description?: string; fields?: TemplateField[] };
|
||||||
|
if (draft.name) this.form.patchValue({ name: draft.name });
|
||||||
|
if (draft.description) this.form.patchValue({ description: draft.description });
|
||||||
|
if (Array.isArray(draft.fields) && draft.fields.length) this.fields = draft.fields;
|
||||||
|
} catch { /* JSON corrompu : on ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le `returnTo` à passer à l'écran de création de dossier :
|
||||||
|
* on empile 'template-create' par-dessus la pile courante, pour que node-create
|
||||||
|
* revienne ici puis remonte à l'écran d'origine le cas échéant.
|
||||||
|
*/
|
||||||
|
get nodeCreateReturnTo(): string {
|
||||||
|
const current = this.route.snapshot.queryParamMap.get('returnTo');
|
||||||
|
return current ? `template-create,${current}` : 'template-create';
|
||||||
|
}
|
||||||
|
|
||||||
addField(): void {
|
addField(): void {
|
||||||
const name = this.newFieldName.trim();
|
const name = this.newFieldName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
@@ -129,12 +175,28 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
defaultNodeId: raw.defaultNodeId,
|
defaultNodeId: raw.defaultNodeId,
|
||||||
fields: this.fields
|
fields: this.fields
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/lore', this.loreId]),
|
next: () => this.navigateBack(),
|
||||||
error: () => console.error('Erreur lors de la création du template')
|
error: () => console.error('Erreur lors de la création du template')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
|
this.navigateBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirige vers l'écran d'origine en dépilant le premier élément du query-param
|
||||||
|
* `returnTo` (pile de retours séparés par des virgules, ex : `page-create` ou
|
||||||
|
* `template-create,page-create`). Sinon retombe sur la page détail du Lore.
|
||||||
|
*/
|
||||||
|
private navigateBack(): void {
|
||||||
|
const { next, rest } = popReturnTo(this.route.snapshot.queryParamMap.get('returnTo'));
|
||||||
|
if (next === 'page-create') {
|
||||||
|
this.router.navigate(['/lore', this.loreId, 'pages', 'create'], {
|
||||||
|
queryParams: rest ? { returnTo: rest } : {}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.router.navigate(['/lore', this.loreId]);
|
this.router.navigate(['/lore', this.loreId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,10 @@
|
|||||||
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
|
<span class="field-chip"
|
||||||
|
[class.field-chip-image]="f.type === 'IMAGE'"
|
||||||
|
[class.field-chip-existing]="f.type !== 'IMAGE' && isExistingField(f)"
|
||||||
|
[class.field-chip-new]="f.type !== 'IMAGE' && !isExistingField(f)">
|
||||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
||||||
{{ f.name }}
|
{{ f.name }}
|
||||||
</span>
|
</span>
|
||||||
@@ -79,8 +82,8 @@
|
|||||||
<option value="MASONRY">Mosaique</option>
|
<option value="MASONRY">Mosaique</option>
|
||||||
<option value="CAROUSEL">Carrousel</option>
|
<option value="CAROUSEL">Carrousel</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
|
<button type="button" class="btn-icon-danger" (click)="removeField(i)" aria-label="Supprimer">
|
||||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -107,9 +107,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
|
|
||||||
// Discriminant visuel pour les champs IMAGE (palette indigo).
|
// Champ existant, chargé depuis le backend — orange ambre.
|
||||||
|
&.field-chip-existing {
|
||||||
|
background: #5a3a1a;
|
||||||
|
border-color: #7a4f22;
|
||||||
|
color: #fde4c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Champ ajouté pendant cette session, pas encore sauvegardé — vert.
|
||||||
|
&.field-chip-new {
|
||||||
|
background: #2a5f3f;
|
||||||
|
border-color: #347a4f;
|
||||||
|
color: #d1fae5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Champ IMAGE (palette indigo) — prioritaire sur existing/new.
|
||||||
&.field-chip-image {
|
&.field-chip-image {
|
||||||
background: #1f1b3a;
|
background: #312b5c;
|
||||||
border-color: #3d3566;
|
border-color: #3d3566;
|
||||||
color: #c7b8ff;
|
color: #c7b8ff;
|
||||||
}
|
}
|
||||||
@@ -118,11 +132,33 @@
|
|||||||
.btn-type-toggle {
|
.btn-type-toggle {
|
||||||
width: auto;
|
width: auto;
|
||||||
padding: 0 0.7rem;
|
padding: 0 0.7rem;
|
||||||
|
background: #2a2a3d;
|
||||||
|
color: #d1d5db;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
color: #9ca3af;
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
&:hover { color: #a5b4fc; background: #1f1b3a; }
|
&:hover { background: #363650; color: white; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-danger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #3f1f1f;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover { background: #5a2a2a; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-select,
|
.type-select,
|
||||||
@@ -227,15 +263,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
margin-right: 0.4rem;
|
background: #6c63ff;
|
||||||
background: transparent;
|
color: white;
|
||||||
color: #6c63ff;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
||||||
&:hover { background: #2a2a3d; }
|
&:hover { background: #5a52d6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary, .btn-secondary, .btn-danger {
|
.btn-primary, .btn-secondary, .btn-danger {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
@@ -26,7 +26,6 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
|
|||||||
})
|
})
|
||||||
export class TemplateEditComponent implements OnInit, OnDestroy {
|
export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||||
readonly Plus = Plus;
|
readonly Plus = Plus;
|
||||||
readonly X = X;
|
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Type = Type;
|
readonly Type = Type;
|
||||||
readonly ImageIcon = ImageIcon;
|
readonly ImageIcon = ImageIcon;
|
||||||
@@ -41,6 +40,17 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
fields: TemplateField[] = [];
|
fields: TemplateField[] = [];
|
||||||
newFieldName = '';
|
newFieldName = '';
|
||||||
newFieldType: FieldType = 'TEXT';
|
newFieldType: FieldType = 'TEXT';
|
||||||
|
/**
|
||||||
|
* Noms des champs chargés depuis le backend — utilisés pour discriminer
|
||||||
|
* visuellement les champs existants (orange) des champs ajoutés dans cette
|
||||||
|
* session d'édition (vert). Non muté ensuite.
|
||||||
|
*/
|
||||||
|
private originalFieldNames = new Set<string>();
|
||||||
|
|
||||||
|
/** True si le champ est présent depuis le chargement du template. */
|
||||||
|
isExistingField(field: TemplateField): boolean {
|
||||||
|
return this.originalFieldNames.has(field.name);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
@@ -83,6 +93,7 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
? { name: f.name, type, layout: f.layout ?? 'GALLERY' }
|
? { name: f.name, type, layout: f.layout ?? 'GALLERY' }
|
||||||
: { name: f.name, type };
|
: { name: f.name, type };
|
||||||
});
|
});
|
||||||
|
this.originalFieldNames = new Set(this.fields.map(f => f.name));
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
name: template.name,
|
name: template.name,
|
||||||
description: template.description,
|
description: template.description,
|
||||||
|
|||||||
@@ -3,6 +3,25 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene, SceneCreate } from './campaign.model';
|
import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene, SceneCreate } from './campaign.model';
|
||||||
|
|
||||||
|
/** Compte des entités qui seront supprimées en cascade avec la campagne. */
|
||||||
|
export interface CampaignDeletionImpact {
|
||||||
|
arcs: number;
|
||||||
|
chapters: number;
|
||||||
|
scenes: number;
|
||||||
|
characters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compte des entités qui seront supprimées en cascade avec un arc. */
|
||||||
|
export interface ArcDeletionImpact {
|
||||||
|
chapters: number;
|
||||||
|
scenes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compte des scènes qui tomberont avec un chapitre. */
|
||||||
|
export interface ChapterDeletionImpact {
|
||||||
|
scenes: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service HTTP pour la gestion des Campagnes.
|
* Service HTTP pour la gestion des Campagnes.
|
||||||
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
||||||
@@ -35,6 +54,10 @@ export class CampaignService {
|
|||||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCampaignDeletionImpact(id: string): Observable<CampaignDeletionImpact> {
|
||||||
|
return this.http.get<CampaignDeletionImpact>(`${this.apiUrl}/${id}/deletion-impact`);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== ARC ==========
|
// ========== ARC ==========
|
||||||
getArcs(campaignId: string): Observable<Arc[]> {
|
getArcs(campaignId: string): Observable<Arc[]> {
|
||||||
return this.http.get<Arc[]>(`http://localhost:8080/api/arcs/campaign/${campaignId}`);
|
return this.http.get<Arc[]>(`http://localhost:8080/api/arcs/campaign/${campaignId}`);
|
||||||
@@ -56,6 +79,10 @@ export class CampaignService {
|
|||||||
return this.http.delete<void>(`http://localhost:8080/api/arcs/${id}`);
|
return this.http.delete<void>(`http://localhost:8080/api/arcs/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getArcDeletionImpact(id: string): Observable<ArcDeletionImpact> {
|
||||||
|
return this.http.get<ArcDeletionImpact>(`http://localhost:8080/api/arcs/${id}/deletion-impact`);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== CHAPTER ==========
|
// ========== CHAPTER ==========
|
||||||
getChapters(arcId: string): Observable<Chapter[]> {
|
getChapters(arcId: string): Observable<Chapter[]> {
|
||||||
return this.http.get<Chapter[]>(`http://localhost:8080/api/chapters/arc/${arcId}`);
|
return this.http.get<Chapter[]>(`http://localhost:8080/api/chapters/arc/${arcId}`);
|
||||||
@@ -77,6 +104,10 @@ export class CampaignService {
|
|||||||
return this.http.delete<void>(`http://localhost:8080/api/chapters/${id}`);
|
return this.http.delete<void>(`http://localhost:8080/api/chapters/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getChapterDeletionImpact(id: string): Observable<ChapterDeletionImpact> {
|
||||||
|
return this.http.get<ChapterDeletionImpact>(`http://localhost:8080/api/chapters/${id}/deletion-impact`);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== SCENE ==========
|
// ========== SCENE ==========
|
||||||
getScenes(chapterId: string): Observable<Scene[]> {
|
getScenes(chapterId: string): Observable<Scene[]> {
|
||||||
return this.http.get<Scene[]>(`http://localhost:8080/api/scenes/chapter/${chapterId}`);
|
return this.http.get<Scene[]>(`http://localhost:8080/api/scenes/chapter/${chapterId}`);
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface BottomPanel {
|
|||||||
title: string;
|
title: string;
|
||||||
items: BottomPanelItem[];
|
items: BottomPanelItem[];
|
||||||
initiallyOpen?: boolean;
|
initiallyOpen?: boolean;
|
||||||
|
/** Action "+" inline dans le header — créer un item sans déplier le panneau. */
|
||||||
|
headerAction?: { label: string; route: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SecondarySidebarConfig {
|
export interface SecondarySidebarConfig {
|
||||||
|
|||||||
@@ -24,14 +24,12 @@ export interface LoreNode {
|
|||||||
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
|
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
|
||||||
type?: string;
|
type?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
address?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoreNodeCreate {
|
export interface LoreNodeCreate {
|
||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
description: string;
|
description: string;
|
||||||
address: string;
|
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
loreId: string;
|
loreId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
|
import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
|
||||||
|
|
||||||
|
/** Compte des entités qui seront supprimées en cascade avec un dossier. */
|
||||||
|
export interface LoreNodeDeletionImpact {
|
||||||
|
/** Sous-dossiers (récursif, sans compter le dossier racine lui-même). */
|
||||||
|
folders: number;
|
||||||
|
/** Pages dans l'ensemble du sous-arbre. */
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compte des entités qui seront supprimées / détachées en cascade avec un Lore. */
|
||||||
|
export interface LoreDeletionImpact {
|
||||||
|
folders: number;
|
||||||
|
pages: number;
|
||||||
|
templates: number;
|
||||||
|
/** Campagnes qui perdront leur référence au Lore (mais resteront présentes). */
|
||||||
|
detachedCampaigns: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service HTTP pour la gestion des Lores.
|
* Service HTTP pour la gestion des Lores.
|
||||||
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
* Port de sortie vers le Backend Java (Architecture Hexagonale).
|
||||||
@@ -36,6 +53,10 @@ export class LoreService {
|
|||||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLoreDeletionImpact(id: string): Observable<LoreDeletionImpact> {
|
||||||
|
return this.http.get<LoreDeletionImpact>(`${this.apiUrl}/${id}/deletion-impact`);
|
||||||
|
}
|
||||||
|
|
||||||
getLoreNodes(loreId: string): Observable<LoreNode[]> {
|
getLoreNodes(loreId: string): Observable<LoreNode[]> {
|
||||||
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
|
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
|
||||||
}
|
}
|
||||||
@@ -57,6 +78,10 @@ export class LoreService {
|
|||||||
return this.http.delete<void>(`${this.nodesUrl}/${id}`);
|
return this.http.delete<void>(`${this.nodesUrl}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> {
|
||||||
|
return this.http.get<LoreNodeDeletionImpact>(`${this.nodesUrl}/${id}/deletion-impact`);
|
||||||
|
}
|
||||||
|
|
||||||
searchLores(q: string): Observable<Lore[]> {
|
searchLores(q: string): Observable<Lore[]> {
|
||||||
const params = new HttpParams().set('q', q);
|
const params = new HttpParams().set('q', q);
|
||||||
return this.http.get<Lore[]>(`${this.apiUrl}/search`, { params });
|
return this.http.get<Lore[]>(`${this.apiUrl}/search`, { params });
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface BreadcrumbItem {
|
|||||||
* Utilisation type :
|
* Utilisation type :
|
||||||
* <app-breadcrumb [items]="[
|
* <app-breadcrumb [items]="[
|
||||||
* { label: 'Mon Univers', route: ['/lore', loreId] },
|
* { label: 'Mon Univers', route: ['/lore', loreId] },
|
||||||
* { label: 'PNJ', route: ['/lore', loreId, 'folders', nodeId, 'edit'] },
|
* { label: 'PNJ', route: ['/lore', loreId, 'folders', nodeId] },
|
||||||
* { label: 'Aldric' }
|
* { label: 'Aldric' }
|
||||||
* ]"></app-breadcrumb>
|
* ]"></app-breadcrumb>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
|||||||
title: n.name,
|
title: n.name,
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
tag: 'Dossier',
|
tag: 'Dossier',
|
||||||
route: ['/lore', n.loreId, 'folders', n.id, 'edit']
|
route: ['/lore', n.loreId, 'folders', n.id]
|
||||||
}));
|
}));
|
||||||
const templateResults: SearchResult[] = templates.map(t => ({
|
const templateResults: SearchResult[] = templates.map(t => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<aside class="secondary-sidebar" [class.collapsed]="isCollapsed">
|
<aside class="secondary-sidebar"
|
||||||
|
[class.collapsed]="isCollapsed"
|
||||||
|
[style.width.px]="isCollapsed ? null : width">
|
||||||
|
|
||||||
<div class="collapse-toggle" (click)="toggleCollapse()">
|
<div class="collapse-toggle" (click)="toggleCollapse()">
|
||||||
<lucide-icon [img]="isCollapsed ? PanelLeftOpen : PanelLeftClose" [size]="16"></lucide-icon>
|
<lucide-icon [img]="isCollapsed ? PanelLeftOpen : PanelLeftClose" [size]="16"></lucide-icon>
|
||||||
@@ -41,7 +43,10 @@
|
|||||||
</button>
|
</button>
|
||||||
<span *ngIf="item.isAction || !isExpandable(item)" class="chevron-spacer"></span>
|
<span *ngIf="item.isAction || !isExpandable(item)" class="chevron-spacer"></span>
|
||||||
|
|
||||||
<button type="button" class="tree-btn" [class.action]="item.isAction" (click)="clickItem(item)">
|
<button type="button" class="tree-btn"
|
||||||
|
[class.action]="item.isAction"
|
||||||
|
[class.active]="isActive(item)"
|
||||||
|
(click)="clickItem(item)">
|
||||||
<lucide-icon
|
<lucide-icon
|
||||||
*ngIf="iconFor(item) as icon"
|
*ngIf="iconFor(item) as icon"
|
||||||
[img]="icon"
|
[img]="icon"
|
||||||
@@ -61,42 +66,38 @@
|
|||||||
[title]="a.label"
|
[title]="a.label"
|
||||||
[attr.aria-label]="a.label"
|
[attr.aria-label]="a.label"
|
||||||
(click)="runCreateAction($event, a)">
|
(click)="runCreateAction($event, a)">
|
||||||
<lucide-icon [img]="iconForAction(a)" [size]="12"></lucide-icon>
|
<lucide-icon [img]="iconForAction(a)" [size]="16"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tree-children" *ngIf="isExpanded(item.id) && (hasChildren(item) || item.createActions?.length)">
|
<div class="tree-children" *ngIf="isExpanded(item.id) && hasChildren(item)">
|
||||||
<ng-container *ngFor="let child of item.children">
|
<ng-container *ngFor="let child of item.children">
|
||||||
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
|
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<!-- Empty-state inline : createActions affichees en pleine largeur
|
|
||||||
UNIQUEMENT si le noeud n'a aucun vrai enfant (sinon le hover-reveal
|
|
||||||
sur le parent suffit, pas de pollution visuelle). -->
|
|
||||||
<ng-container *ngIf="!hasChildren(item) && item.createActions?.length">
|
|
||||||
<div class="tree-item empty-action" *ngFor="let a of item.createActions"
|
|
||||||
[style.padding-left.px]="(level + 1) * 12">
|
|
||||||
<div class="tree-row">
|
|
||||||
<span class="chevron-spacer"></span>
|
|
||||||
<button type="button" class="tree-btn action" (click)="runCreateAction($event, a)">
|
|
||||||
<lucide-icon [img]="iconForAction(a)" [size]="12" class="item-icon"></lucide-icon>
|
|
||||||
+ {{ a.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
|
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
|
||||||
<section class="bottom-panel" *ngIf="bottomPanel">
|
<section class="bottom-panel" *ngIf="bottomPanel">
|
||||||
<button class="panel-header" (click)="togglePanel()">
|
<div class="panel-header-row">
|
||||||
<span class="panel-title">{{ bottomPanel.title }}</span>
|
<button class="panel-header" (click)="togglePanel()">
|
||||||
<lucide-icon
|
<span class="panel-title">{{ bottomPanel.title }}</span>
|
||||||
[img]="panelOpen ? ChevronDown : ChevronRight"
|
<lucide-icon
|
||||||
[size]="14">
|
[img]="panelOpen ? ChevronDown : ChevronRight"
|
||||||
</lucide-icon>
|
[size]="14">
|
||||||
</button>
|
</lucide-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
*ngIf="bottomPanel.headerAction as action"
|
||||||
|
type="button"
|
||||||
|
class="panel-header-action"
|
||||||
|
[title]="action.label"
|
||||||
|
[attr.aria-label]="action.label"
|
||||||
|
(click)="runPanelHeaderAction($event, action)">
|
||||||
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ul class="panel-list" *ngIf="panelOpen">
|
<ul class="panel-list" *ngIf="panelOpen">
|
||||||
<li *ngFor="let item of bottomPanel.items">
|
<li *ngFor="let item of bottomPanel.items">
|
||||||
<button
|
<button
|
||||||
@@ -112,4 +113,9 @@
|
|||||||
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Poignée de redimensionnement sur le bord droit (masquée si replié) -->
|
||||||
|
<div class="resize-handle"
|
||||||
|
*ngIf="!isCollapsed"
|
||||||
|
(mousedown)="startResize($event)"
|
||||||
|
title="Glissez pour redimensionner"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -8,15 +8,33 @@
|
|||||||
padding: 1.25rem 0.75rem;
|
padding: 1.25rem 0.75rem;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
transition: width 0.25s ease;
|
position: relative;
|
||||||
|
// Pas de transition sur la largeur : sinon le drag de resize "traîne" derrière la souris.
|
||||||
|
// L'animation d'expand/collapse est gérée uniquement par la classe .collapsed ci-dessous.
|
||||||
|
|
||||||
&.collapsed {
|
&.collapsed {
|
||||||
width: 44px;
|
width: 44px !important;
|
||||||
padding: 1.25rem 0.5rem;
|
padding: 1.25rem 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: width 0.25s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -3px;
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 10;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active { background: #6c63ff; }
|
||||||
|
}
|
||||||
|
|
||||||
.collapse-toggle {
|
.collapse-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -111,6 +129,17 @@
|
|||||||
&.action { color: #6b7280; font-style: italic; }
|
&.action { color: #6b7280; font-style: italic; }
|
||||||
&.action:hover { color: #a5b4fc; background: #1f2937; }
|
&.action:hover { color: #a5b4fc; background: #1f2937; }
|
||||||
|
|
||||||
|
// Dossier / page / scène actuellement affichée : surligné avec un accent
|
||||||
|
// violet et une barre gauche pour repérer instantanément où on se trouve,
|
||||||
|
// utile quand plusieurs entrées partagent le même label.
|
||||||
|
&.active {
|
||||||
|
background: #1e1b4b;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: inset 3px 0 0 #6c63ff;
|
||||||
|
}
|
||||||
|
&.active:hover { background: #2a2558; }
|
||||||
|
|
||||||
.tree-item-meta {
|
.tree-item-meta {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -142,7 +171,7 @@
|
|||||||
.node-actions {
|
.node-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.1rem;
|
gap: 0.2rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -160,17 +189,17 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 28px;
|
||||||
height: 22px;
|
height: 28px;
|
||||||
background: transparent;
|
background: rgba(55, 65, 81, 0.6);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #9ca3af;
|
color: #d1d5db;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: background 0.12s, color 0.12s;
|
transition: background 0.12s, color 0.12s;
|
||||||
|
|
||||||
&:hover { background: #2a2a3d; color: #c7d2fe; }
|
&:hover { background: #4338ca; color: #ffffff; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.chevron-btn {
|
.chevron-btn {
|
||||||
@@ -214,11 +243,36 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: rgba(108, 99, 255, 0.15);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #c7d2fe;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
|
||||||
|
&:hover { background: #6c63ff; color: #ffffff; }
|
||||||
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #a5b4fc;
|
color: #a5b4fc;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, LucideIconData } from 'lucide-angular';
|
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, LucideIconData } from 'lucide-angular';
|
||||||
@@ -12,7 +12,7 @@ import { resolveIcon } from '../../lore/lore-icons';
|
|||||||
templateUrl: './secondary-sidebar.component.html',
|
templateUrl: './secondary-sidebar.component.html',
|
||||||
styleUrls: ['./secondary-sidebar.component.scss']
|
styleUrls: ['./secondary-sidebar.component.scss']
|
||||||
})
|
})
|
||||||
export class SecondarySidebarComponent {
|
export class SecondarySidebarComponent implements OnDestroy {
|
||||||
@Input() title = '';
|
@Input() title = '';
|
||||||
@Input() createActions: SidebarAction[] = [];
|
@Input() createActions: SidebarAction[] = [];
|
||||||
@Input() bottomPanel: BottomPanel | null = null;
|
@Input() bottomPanel: BottomPanel | null = null;
|
||||||
@@ -31,6 +31,17 @@ export class SecondarySidebarComponent {
|
|||||||
|
|
||||||
isCollapsed = false;
|
isCollapsed = false;
|
||||||
|
|
||||||
|
// --- Resize (étirement horizontal) -------------------------------------
|
||||||
|
/** Clé localStorage pour persister la largeur choisie par l'utilisateur. */
|
||||||
|
private static readonly WIDTH_STORAGE_KEY = 'secondary-sidebar-width';
|
||||||
|
private static readonly MIN_WIDTH = 180;
|
||||||
|
private static readonly MAX_WIDTH = 600;
|
||||||
|
private static readonly DEFAULT_WIDTH = 220;
|
||||||
|
|
||||||
|
/** Largeur courante en px (bindée en [style.width.px]). */
|
||||||
|
width = SecondarySidebarComponent.DEFAULT_WIDTH;
|
||||||
|
private isResizing = false;
|
||||||
|
|
||||||
private _items: TreeItem[] = [];
|
private _items: TreeItem[] = [];
|
||||||
|
|
||||||
@Input() set items(value: TreeItem[]) {
|
@Input() set items(value: TreeItem[]) {
|
||||||
@@ -39,7 +50,65 @@ export class SecondarySidebarComponent {
|
|||||||
}
|
}
|
||||||
get items(): TreeItem[] { return this._items; }
|
get items(): TreeItem[] { return this._items; }
|
||||||
|
|
||||||
constructor(private router: Router, private layoutService: LayoutService) {}
|
constructor(
|
||||||
|
private router: Router,
|
||||||
|
private layoutService: LayoutService,
|
||||||
|
private elementRef: ElementRef<HTMLElement>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SecondarySidebarComponent.WIDTH_STORAGE_KEY);
|
||||||
|
const parsed = stored ? parseInt(stored, 10) : NaN;
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
this.width = Math.min(
|
||||||
|
Math.max(parsed, SecondarySidebarComponent.MIN_WIDTH),
|
||||||
|
SecondarySidebarComponent.MAX_WIDTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch { /* storage indisponible : on garde la valeur par défaut */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Début du resize — on active le flag et on désactive la sélection texte le temps du drag. */
|
||||||
|
startResize(event: MouseEvent): void {
|
||||||
|
if (this.isCollapsed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.isResizing = true;
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:mousemove', ['$event'])
|
||||||
|
onResizeMove(event: MouseEvent): void {
|
||||||
|
if (!this.isResizing) return;
|
||||||
|
// La sidebar peut être précédée par la sidebar primaire : on calcule la largeur
|
||||||
|
// cible à partir du bord gauche du composant, pas de la fenêtre. Sinon le
|
||||||
|
// curseur et la poignée se désynchronisent.
|
||||||
|
const rect = this.elementRef.nativeElement.getBoundingClientRect();
|
||||||
|
const delta = event.clientX - rect.left;
|
||||||
|
const next = Math.min(
|
||||||
|
Math.max(delta, SecondarySidebarComponent.MIN_WIDTH),
|
||||||
|
SecondarySidebarComponent.MAX_WIDTH
|
||||||
|
);
|
||||||
|
this.width = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:mouseup')
|
||||||
|
onResizeEnd(): void {
|
||||||
|
if (!this.isResizing) return;
|
||||||
|
this.isResizing = false;
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SecondarySidebarComponent.WIDTH_STORAGE_KEY, String(this.width));
|
||||||
|
} catch { /* storage indisponible : on ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Sécurité : si le composant est détruit en plein drag, on restaure le curseur global.
|
||||||
|
if (this.isResizing) {
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
runAction(action: SidebarAction): void {
|
runAction(action: SidebarAction): void {
|
||||||
if (action.route) { this.router.navigate([action.route]); }
|
if (action.route) { this.router.navigate([action.route]); }
|
||||||
@@ -80,6 +149,12 @@ export class SecondarySidebarComponent {
|
|||||||
if (item.route) { this.router.navigate([item.route]); }
|
if (item.route) { this.router.navigate([item.route]); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clic sur le "+" du header : navigue sans toggler le panneau (stopPropagation). */
|
||||||
|
runPanelHeaderAction(event: Event, action: { route: string }): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.router.navigate([action.route]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Résout la clé d'icône d'un TreeItem en icône lucide pour le template. */
|
/** Résout la clé d'icône d'un TreeItem en icône lucide pour le template. */
|
||||||
iconFor(item: TreeItem): LucideIconData | null {
|
iconFor(item: TreeItem): LucideIconData | null {
|
||||||
return item.iconKey ? resolveIcon(item.iconKey) : null;
|
return item.iconKey ? resolveIcon(item.iconKey) : null;
|
||||||
@@ -108,12 +183,25 @@ export class SecondarySidebarComponent {
|
|||||||
return !!item.children && item.children.length > 0;
|
return !!item.children && item.children.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** True si le chevron doit s'afficher — seulement quand le noeud a de vrais enfants. */
|
||||||
* True si le chevron doit s'afficher : soit il y a des enfants, soit le
|
|
||||||
* noeud a des createActions (dans ce cas deplier revele l'empty-state).
|
|
||||||
*/
|
|
||||||
isExpandable(item: TreeItem): boolean {
|
isExpandable(item: TreeItem): boolean {
|
||||||
return this.hasChildren(item) || (item.createActions?.length ?? 0) > 0;
|
return this.hasChildren(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True si la route du node correspond exactement à l'URL courante. Utilisé
|
||||||
|
* pour surligner le dossier / page / scène en cours dans l'arbre — utile
|
||||||
|
* quand plusieurs entrées partagent le même label (ex : deux sous-dossiers
|
||||||
|
* "test" dans la même arborescence).
|
||||||
|
*/
|
||||||
|
isActive(item: TreeItem): boolean {
|
||||||
|
if (!item.route) return false;
|
||||||
|
return this.router.isActive(item.route, {
|
||||||
|
paths: 'exact',
|
||||||
|
queryParams: 'ignored',
|
||||||
|
fragment: 'ignored',
|
||||||
|
matrixParams: 'ignored'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<span class="version">Version 0.4.0</span>
|
<span class="version">Version {{ appVersion }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { Router } from '@angular/router';
|
|||||||
import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular';
|
import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular';
|
||||||
import { LayoutService } from '../services/layout.service';
|
import { LayoutService } from '../services/layout.service';
|
||||||
import { GlobalSearchService } from '../services/global-search.service';
|
import { GlobalSearchService } from '../services/global-search.service';
|
||||||
|
// Single source of truth pour la version affichée dans le footer :
|
||||||
|
// on lit directement package.json à la compilation (resolveJsonModule).
|
||||||
|
import packageJson from '../../../package.json';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-sidebar',
|
selector: 'app-sidebar',
|
||||||
@@ -22,6 +25,7 @@ export class SidebarComponent {
|
|||||||
readonly Dices = Dices;
|
readonly Dices = Dices;
|
||||||
|
|
||||||
readonly layoutConfig$ = this.layoutService.secondarySidebar$;
|
readonly layoutConfig$ = this.layoutService.secondarySidebar$;
|
||||||
|
readonly appVersion = packageJson.version;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
|||||||
@@ -63,8 +63,18 @@
|
|||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// Header de page "create" / "edit" (titre + éventuel sous-titre)
|
// Header de page "create" / "edit" (titre + éventuel sous-titre)
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
// Sticky : sur les formulaires longs, les boutons (Sauvegarder / Annuler /
|
||||||
|
// Supprimer) restent toujours accessibles sans devoir scroller. Le fond opaque
|
||||||
|
// masque le contenu qui scrolle dessous, et le border-bottom crée une
|
||||||
|
// séparation visuelle dès qu'il y a du contenu sous le header.
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 2rem;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #0a0a14;
|
||||||
|
padding: 1rem 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
|||||||
@@ -15,12 +15,19 @@
|
|||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
|
|
||||||
// En-tête : titre + sous-titre + boutons d'action (Modifier, Supprimer...)
|
// En-tête : titre + sous-titre + boutons d'action (Modifier, Supprimer...)
|
||||||
|
// Sticky pour que les actions restent accessibles sur les longues fiches.
|
||||||
.view-header {
|
.view-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: #0a0a14;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 2rem;
|
padding: 1rem 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022",
|
"module": "ES2022",
|
||||||
|
|||||||
Reference in New Issue
Block a user