12 Commits

Author SHA1 Message Date
70351e9d9a Remplace docker SDK par appels HTTP directs (zero deps)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m34s
2026-04-24 07:39:49 +02:00
ff4905126d Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
0e5b5a7de4 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
c8c032336b Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
dda27e55fc Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
83ac67471e Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
e3c8232e38 Version 0.6.1
All checks were successful
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m19s
Build & Push Images / build (web) (push) Successful in 1m31s
2026-04-23 14:36:09 +02:00
a4df9fc759 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
f1989c1d77 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
All checks were successful
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m24s
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
8efdf5d0e0 Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).
Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
2026-04-23 14:06:50 +02:00
96bc5de942 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
84ccdd53ad Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00
103 changed files with 3271 additions and 270 deletions

View File

@@ -40,7 +40,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.5.0",
version="0.6.1",
)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.5.0</version>
<version>0.6.1</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>

View File

@@ -1,9 +1,13 @@
package com.loremind.application.campaigncontext;
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.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -17,11 +21,20 @@ import java.util.Optional;
public class ArcService {
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.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) {
Arc arc = Arc.builder()
.name(name)
@@ -59,7 +72,31 @@ public class ArcService {
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) {
for (Chapter chapter : chapterRepository.findByArcId(id)) {
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(chapter.getId());
}
arcRepository.deleteById(id);
}

View File

@@ -1,8 +1,15 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
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.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -16,9 +23,22 @@ import java.util.Optional;
public class CampaignService {
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.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) {}
/**
* 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) {
Campaign campaign = Campaign.builder()
.name(data.name())
@@ -71,7 +97,48 @@ public class CampaignService {
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) {
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);
}

View File

@@ -2,8 +2,10 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -17,11 +19,16 @@ import java.util.Optional;
public class ChapterService {
private final ChapterRepository chapterRepository;
private final SceneRepository sceneRepository;
public ChapterService(ChapterRepository chapterRepository) {
public ChapterService(ChapterRepository chapterRepository, SceneRepository sceneRepository) {
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) {
Chapter chapter = Chapter.builder()
.name(name)
@@ -58,7 +65,17 @@ public class ChapterService {
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) {
for (var scene : sceneRepository.findByChapterId(id)) {
sceneRepository.deleteById(scene.getId());
}
chapterRepository.deleteById(id);
}

View File

@@ -1,9 +1,13 @@
package com.loremind.application.lorecontext;
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.PageRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -16,11 +20,20 @@ import java.util.Optional;
public class LoreNodeService {
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
public LoreNodeService(LoreNodeRepository loreNodeRepository) {
public LoreNodeService(LoreNodeRepository loreNodeRepository, PageRepository pageRepository) {
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
* souhaitées (pattern Parameter Object) : évite les signatures qui gonflent
@@ -68,7 +81,64 @@ public class LoreNodeService {
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) {
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);
}
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;
}
}

View File

@@ -1,10 +1,17 @@
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.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.LoreRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
import com.loremind.domain.lorecontext.ports.TemplateRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@@ -26,15 +33,28 @@ public class LoreService {
private final LoreRepository loreRepository;
private final LoreNodeRepository loreNodeRepository;
private final PageRepository pageRepository;
private final TemplateRepository templateRepository;
private final CampaignRepository campaignRepository;
public LoreService(LoreRepository loreRepository,
LoreNodeRepository loreNodeRepository,
PageRepository pageRepository) {
PageRepository pageRepository,
TemplateRepository templateRepository,
CampaignRepository campaignRepository) {
this.loreRepository = loreRepository;
this.loreNodeRepository = loreNodeRepository;
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) {
Lore lore = Lore.builder()
.name(name)
@@ -83,7 +103,54 @@ public class LoreService {
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) {
// 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);
}
private int countCampaignsReferencingLore(String id) {
int count = 0;
for (Campaign campaign : campaignRepository.findAll()) {
if (id.equals(campaign.getLoreId())) count++;
}
return count;
}
}

View File

@@ -68,4 +68,12 @@ public class ArcController {
arcService.deleteArc(id);
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));
}
}

View File

@@ -74,4 +74,16 @@ public class CampaignController {
campaignService.deleteCampaign(id);
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));
}
}

View File

@@ -68,4 +68,12 @@ public class ChapterController {
chapterService.deleteChapter(id);
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));
}
}

View File

@@ -0,0 +1,30 @@
package com.loremind.infrastructure.web.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Expose la configuration publique consommee par le frontend au demarrage.
* Activer le mode demo via la variable d'env DEMO_MODE=true : le front
* masque alors Settings / Export VTT, et les endpoints sensibles sont
* verrouilles cote serveur (cf. SettingsController).
*/
@RestController
@RequestMapping("/api/config")
public class ConfigController {
private final boolean demoMode;
public ConfigController(@Value("${app.demo-mode:false}") boolean demoMode) {
this.demoMode = demoMode;
}
@GetMapping
public Map<String, Object> getPublicConfig() {
return Map.of("demoMode", demoMode);
}
}

View File

@@ -69,4 +69,17 @@ public class LoreController {
loreService.deleteLore(id);
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));
}
}

View File

@@ -97,4 +97,16 @@ public class LoreNodeController {
loreNodeService.deleteLoreNode(id);
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));
}
}

View File

@@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
@@ -32,20 +34,25 @@ public class SettingsController {
private final RestTemplate restTemplate;
private final String brainBaseUrl;
private final boolean demoMode;
public SettingsController(RestTemplate restTemplate,
@Value("${brain.base-url}") String brainBaseUrl) {
@Value("${brain.base-url}") String brainBaseUrl,
@Value("${app.demo-mode:false}") boolean demoMode) {
this.restTemplate = restTemplate;
this.brainBaseUrl = brainBaseUrl;
this.demoMode = demoMode;
}
@GetMapping
public ResponseEntity<Map<String, Object>> getSettings() {
guardDemoMode();
return forward(HttpMethod.GET, "/settings", null);
}
@PutMapping
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
guardDemoMode();
return forward(HttpMethod.PUT, "/settings", patch);
}
@@ -64,6 +71,12 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/onemin", null);
}
private void guardDemoMode() {
if (demoMode) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Settings disabled in demo mode");
}
}
@SuppressWarnings({"rawtypes", "unchecked"})
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
HttpHeaders headers = new HttpHeaders();

View File

@@ -21,13 +21,13 @@ spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# Configuration CORS pour autoriser le Frontend Angular
spring.web.cors.allowed-origins=http://localhost:4200
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200}
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
spring.web.cors.allowed-headers=*
spring.web.cors.allow-credentials=true
# Configuration du Brain (service IA Python)
brain.base-url=http://localhost:8000
brain.base-url=${BRAIN_BASE_URL:http://localhost:8000}
brain.timeout-seconds=120
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
@@ -50,3 +50,7 @@ minio.bucket=${MINIO_BUCKET:loremind-images}
# Limites d'upload d'images (MB)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.
app.demo-mode=${DEMO_MODE:false}

View File

@@ -1,7 +1,11 @@
package com.loremind.application.campaigncontext;
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.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@@ -26,6 +31,10 @@ public class ArcServiceTest {
@Mock
private ArcRepository arcRepository;
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks
private ArcService arcService;
@@ -159,15 +168,48 @@ public class ArcServiceTest {
}
@Test
void testDeleteArc() {
// Arrange
doNothing().when(arcRepository).deleteById("arc-1");
// Act
void testDeleteArc_EmptyArc() {
// Aucun chapitre : Mockito renvoie List.of() par défaut.
arcService.deleteArc("arc-1");
// Assert
verify(arcRepository, times(1)).deleteById("arc-1");
verify(arcRepository).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

View File

@@ -1,7 +1,15 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
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.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.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -27,6 +35,14 @@ public class CampaignServiceTest {
@Mock
private CampaignRepository campaignRepository;
@Mock
private ArcRepository arcRepository;
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@Mock
private CharacterRepository characterRepository;
@InjectMocks
private CampaignService campaignService;
@@ -53,7 +69,10 @@ public class CampaignServiceTest {
"lore-123",
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
Campaign result = campaignService.createCampaign(data);
@@ -73,7 +92,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
Campaign result = campaignService.createCampaign(data);
@@ -93,7 +113,8 @@ public class CampaignServiceTest {
" ",
null
);
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// Act
Campaign result = campaignService.createCampaign(data);
@@ -191,15 +212,75 @@ public class CampaignServiceTest {
}
@Test
void testDeleteCampaign() {
// Arrange
doNothing().when(campaignRepository).deleteById("campaign-1");
void testDeleteCampaign_EmptyCampaign() {
// Arrange : aucune dépendance ; Mockito renvoie List.of() par défaut.
// Act
campaignService.deleteCampaign("campaign-1");
// Assert
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

View File

@@ -1,7 +1,9 @@
package com.loremind.application.campaigncontext;
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.SceneRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@@ -26,6 +29,8 @@ public class ChapterServiceTest {
@Mock
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@InjectMocks
private ChapterService chapterService;
@@ -157,15 +162,36 @@ public class ChapterServiceTest {
}
@Test
void testDeleteChapter() {
// Arrange
doNothing().when(chapterRepository).deleteById("chapter-1");
// Act
void testDeleteChapter_EmptyChapter() {
// Aucune scène : Mockito renvoie List.of() par défaut.
chapterService.deleteChapter("chapter-1");
// Assert
verify(chapterRepository, times(1)).deleteById("chapter-1");
verify(chapterRepository).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

View File

@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
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 com.loremind.domain.generationcontext.CampaignStructuralContext;
import org.junit.jupiter.api.BeforeEach;
@@ -40,6 +41,8 @@ public class CampaignStructuralContextBuilderTest {
private ChapterRepository chapterRepository;
@Mock
private SceneRepository sceneRepository;
@Mock
private CharacterRepository characterRepository;
@InjectMocks
private CampaignStructuralContextBuilder builder;

View File

@@ -1,7 +1,9 @@
package com.loremind.application.lorecontext;
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.PageRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@@ -26,6 +29,7 @@ import static org.mockito.Mockito.*;
public class LoreNodeServiceTest {
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@InjectMocks private LoreNodeService loreNodeService;
@@ -118,8 +122,66 @@ public class LoreNodeServiceTest {
}
@Test
void testDelete() {
void testDelete_LeafFolder() {
// Aucun descendant, aucune page : seul le dossier est supprimé.
loreNodeService.deleteLoreNode("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());
}
}

View File

@@ -1,9 +1,15 @@
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.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.LoreRepository;
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.Test;
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
@@ -30,6 +37,8 @@ public class LoreServiceTest {
@Mock private LoreRepository loreRepository;
@Mock private LoreNodeRepository loreNodeRepository;
@Mock private PageRepository pageRepository;
@Mock private TemplateRepository templateRepository;
@Mock private CampaignRepository campaignRepository;
@InjectMocks private LoreService loreService;
@@ -134,8 +143,67 @@ public class LoreServiceTest {
}
@Test
void testDeleteLore_DelegatesToRepository() {
void testDeleteLore_EmptyLore() {
// Aucun dossier / page / template / campagne : seul le Lore est supprimé.
loreService.deleteLore("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());
}
}

27
demo/.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Copie en .env sur le serveur (jamais commite).
# Registre et tag des images core / brain a spawner par session.
REGISTRY=git.igmlcreation.fr
TAG=latest
# Secret partage entre core et brain (genere aleatoirement au build de chaque
# session, mais un defaut est utile pour les checks de sante au boot).
BRAIN_INTERNAL_SECRET_DEFAULT=change-me-on-server
# Capacite
MAX_SESSIONS=10
SESSION_TTL_MINUTES=20
# Rate limiting : 1 creation de session par IP par fenetre (secondes).
RATE_LIMIT_WINDOW_SECONDS=60
# Limites par conteneur de session (Docker API)
CORE_MEMORY_MB=700
BRAIN_MEMORY_MB=300
POSTGRES_MEMORY_MB=200
# Nom du reseau Docker externe Traefik (doit exister avant docker compose up)
TRAEFIK_NETWORK=traefik
# Domaine expose par Traefik
DEMO_HOST=loremind-demo.igmlcreation.fr

2
demo/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
*.log

46
demo/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Demo publique LoreMind
Deploiement d'une instance de demo ephemere pour `loremind-demo.igmlcreation.fr`.
## Principe
Chaque visiteur recoit un environnement isole spawne a la volee, detruit apres
un court delai d'inactivite. Les donnees ne sont jamais persistees.
Le mode demo (variable d'env `DEMO_MODE=true` sur le core) masque les ecrans
de configuration qui n'ont pas de sens en vitrine.
## Deploiement
Prerequis :
- Reseau Traefik existant cote host
- Images `core` et `brain` pushees au registre
```bash
cp .env.example .env
# Ajuster .env
docker compose -f docker-compose.infra.yml up -d --build
```
Premier build : 5-10 min. Suivants : incrementaux.
## Mise a jour
```bash
docker compose -f docker-compose.infra.yml pull
docker compose -f docker-compose.infra.yml up -d --build
```
Les sessions en cours sont tuees au redemarrage.
## Observations
- `docker logs loremind-demo-orchestrator -f`
- `docker ps --filter "name=demo-"`
## Desactiver
```bash
docker compose -f docker-compose.infra.yml down
docker ps -q --filter "name=demo-" | xargs -r docker rm -f
```

View File

@@ -0,0 +1,78 @@
# ==========================================================================
# LoreMind Demo - Infra permanente
# --------------------------------------------------------------------------
# - dockerproxy : expose un subset restreint de l'API Docker a l'orchestrateur
# (lecture seule sauf containers/images/networks). Remplace le mount direct
# de /var/run/docker.sock : meme avec RCE sur l'orchestrateur, un attaquant
# ne peut pas exec sur l'hote, creer des volumes, ni lire le daemon.
# - orchestrator : sert l'Angular et proxy les /api/* vers les sessions.
#
# Les conteneurs de session sont crees dynamiquement par l'orchestrateur.
# ==========================================================================
services:
dockerproxy:
image: tecnativa/docker-socket-proxy:latest
container_name: loremind-demo-dockerproxy
restart: unless-stopped
environment:
# Minimum requis par l'orchestrateur.
CONTAINERS: 1
IMAGES: 1
NETWORKS: 1
POST: 1
# Tout le reste reste a 0 (defaut) : pas d'EXEC, VOLUMES, BUILD, AUTH,
# SYSTEM, INFO, SWARM, SECRETS, CONFIGS, NODES, etc.
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- socket-proxy
# Pas de ports exposes : accessible uniquement via le reseau socket-proxy.
orchestrator:
container_name: loremind-demo-orchestrator
depends_on:
- dockerproxy
build:
context: ../
dockerfile: demo/orchestrator/Dockerfile
restart: unless-stopped
environment:
# L'orchestrateur parle a dockerproxy au lieu du socket direct.
DOCKER_HOST: tcp://dockerproxy:2375
REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
TAG: ${TAG:-latest}
MAX_SESSIONS: ${MAX_SESSIONS:-10}
SESSION_TTL_MINUTES: ${SESSION_TTL_MINUTES:-20}
CORE_MEMORY_MB: ${CORE_MEMORY_MB:-700}
BRAIN_MEMORY_MB: ${BRAIN_MEMORY_MB:-300}
POSTGRES_MEMORY_MB: ${POSTGRES_MEMORY_MB:-200}
SESSIONS_NETWORK: loremind-demo-sessions
BRAIN_INTERNAL_SECRET_DEFAULT: ${BRAIN_INTERNAL_SECRET_DEFAULT:-change-me}
# Rate limit : 1 creation par IP par fenetre (en secondes).
RATE_LIMIT_WINDOW_SECONDS: ${RATE_LIMIT_WINDOW_SECONDS:-60}
networks:
- traefik
- sessions
- socket-proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.loremind-demo.rule=Host(`${DEMO_HOST:-loremind-demo.igmlcreation.fr}`)"
- "traefik.http.routers.loremind-demo.entrypoints=websecure"
- "traefik.http.routers.loremind-demo.tls.certresolver=letsencrypt"
- "traefik.http.services.loremind-demo.loadbalancer.server.port=80"
networks:
traefik:
external: true
name: ${TRAEFIK_NETWORK:-traefik}
sessions:
# Reseau interne pour les trios de session. Pas d'acces Internet direct
# (sauf via le DNS Docker), pas expose au host.
name: loremind-demo-sessions
driver: bridge
socket-proxy:
# Reseau prive entre dockerproxy et orchestrateur. Isole du reste.
name: loremind-demo-socket-proxy
driver: bridge
internal: true

View File

@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1.6
# Build context attendu : racine du repo LoreMind.
# Appele depuis demo/docker-compose.infra.yml avec context: ../
# --- Etage 1 : build Angular statique ---
FROM node:20-alpine AS web-build
WORKDIR /build
COPY web/package*.json ./
RUN npm ci
COPY web/ .
RUN npm run build -- --configuration production
# --- Etage 2 : build orchestrateur Go ---
# go 1.25+ requis par une dependance transitive de github.com/docker/docker
# (otelhttp v0.68+ impose cette version minimale).
FROM golang:1.25-alpine AS go-build
WORKDIR /src
COPY demo/orchestrator/ ./
# go mod tidy resout le go.sum au build pour eviter d'avoir a le committer.
RUN go mod tidy && CGO_ENABLED=0 go build -o /orchestrator .
# --- Etage final : runtime minimal ---
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=go-build /orchestrator /app/orchestrator
COPY --from=web-build /build/dist/web /app/static
COPY demo/orchestrator/preparing.html /app/preparing.html
EXPOSE 80
ENV STATIC_DIR=/app/static \
PREPARING_PAGE=/app/preparing.html
ENTRYPOINT ["/app/orchestrator"]

View File

@@ -0,0 +1,64 @@
package main
import (
"log"
"os"
"strconv"
"time"
)
// Config centralise les parametres lus depuis les variables d'env au boot.
type Config struct {
Registry string
Tag string
MaxSessions int
SessionTTL time.Duration
CoreMemoryBytes int64
BrainMemoryBytes int64
PostgresMemoryBytes int64
SessionsNetwork string
BrainSecretDefault string
StaticDir string
PreparingPage string
RateLimitWindow time.Duration
MaxBodyBytes int64
}
func loadConfig() *Config {
return &Config{
Registry: envStr("REGISTRY", "git.igmlcreation.fr"),
Tag: envStr("TAG", "latest"),
MaxSessions: envInt("MAX_SESSIONS", 10),
SessionTTL: time.Duration(envInt("SESSION_TTL_MINUTES", 20)) * time.Minute,
CoreMemoryBytes: int64(envInt("CORE_MEMORY_MB", 700)) * 1024 * 1024,
BrainMemoryBytes: int64(envInt("BRAIN_MEMORY_MB", 300)) * 1024 * 1024,
PostgresMemoryBytes: int64(envInt("POSTGRES_MEMORY_MB", 200)) * 1024 * 1024,
SessionsNetwork: envStr("SESSIONS_NETWORK", "loremind-demo-sessions"),
BrainSecretDefault: envStr("BRAIN_INTERNAL_SECRET_DEFAULT", "change-me"),
StaticDir: envStr("STATIC_DIR", "/app/static"),
PreparingPage: envStr("PREPARING_PAGE", "/app/preparing.html"),
RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second,
// 10 Mo : aligne avec la limite d'upload d'image cote core.
MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024,
}
}
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
v := os.Getenv(key)
if v == "" {
return def
}
i, err := strconv.Atoi(v)
if err != nil {
log.Printf("warning: env %s=%q not a number, using default %d", key, v, def)
return def
}
return i
}

335
demo/orchestrator/docker.go Normal file
View File

@@ -0,0 +1,335 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// DockerClient parle a l'API Engine Docker en HTTP brut via le dockerproxy.
// Pas de SDK externe : evite les conflits de versions transitives qui
// rendaient github.com/docker/docker v27/v28 ininstallable proprement.
//
// L'API Engine v1.43 est exposee par Docker Engine 24+ (et le dockerproxy
// la supporte sans config supplementaire).
type DockerClient struct {
baseURL string
http *http.Client
}
func newDockerClient() (*DockerClient, error) {
base := os.Getenv("DOCKER_HOST")
if base == "" {
return nil, fmt.Errorf("DOCKER_HOST non defini (attendu : tcp://dockerproxy:2375)")
}
// tcp://host:port -> http://host:port (le dockerproxy parle HTTP en clair).
base = strings.Replace(base, "tcp://", "http://", 1)
return &DockerClient{
baseURL: strings.TrimRight(base, "/") + "/v1.43",
http: &http.Client{Timeout: 60 * time.Second},
}, nil
}
// --- Types serialises vers l'API Engine ---
type containerSpec struct {
Image string `json:"Image"`
Env []string `json:"Env,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
HostConfig hostConfig `json:"HostConfig"`
NetworkingConfig networkingConfig `json:"NetworkingConfig"`
}
type hostConfig struct {
Memory int64 `json:"Memory,omitempty"`
NanoCPUs int64 `json:"NanoCPUs,omitempty"`
PidsLimit int64 `json:"PidsLimit,omitempty"`
Tmpfs map[string]string `json:"Tmpfs,omitempty"`
SecurityOpt []string `json:"SecurityOpt,omitempty"`
RestartPolicy restartPolicy `json:"RestartPolicy"`
}
type restartPolicy struct {
Name string `json:"Name"`
}
type networkingConfig struct {
EndpointsConfig map[string]endpointSettings `json:"EndpointsConfig,omitempty"`
}
type endpointSettings struct {
Aliases []string `json:"Aliases,omitempty"`
}
// runSpec : forme intermediate cote orchestrateur, mappee sur containerSpec
// au moment d'envoyer la requete.
type runSpec struct {
Name string
Image string
Env []string
Labels map[string]string
Memory int64
Tmpfs map[string]string
Net string
Alias string
}
// --- Operations de haut niveau ---
// SpawnTrio cree postgres + brain + core pour une session.
func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Config) error {
pgName := "demo-" + sessionID + "-postgres"
brainName := "demo-" + sessionID + "-brain"
coreName := "demo-" + sessionID + "-core"
pgPassword := randomHex(16)
brainSecret := randomHex(32)
adminPassword := randomHex(16)
labels := map[string]string{"demo-session": sessionID}
if err := d.runContainer(ctx, runSpec{
Name: pgName,
Image: "postgres:16-alpine",
Env: []string{"POSTGRES_DB=loremind", "POSTGRES_USER=loremind", "POSTGRES_PASSWORD=" + pgPassword},
Labels: copyLabels(labels, "postgres"),
Memory: cfg.PostgresMemoryBytes,
Tmpfs: map[string]string{"/var/lib/postgresql/data": "rw,size=200m"},
Net: cfg.SessionsNetwork,
Alias: pgName,
}); err != nil {
return fmt.Errorf("spawn postgres: %w", err)
}
if err := d.runContainer(ctx, runSpec{
Name: brainName,
Image: cfg.Registry + "/ietm64/brain:" + cfg.Tag,
Env: []string{
"INTERNAL_SHARED_SECRET=" + brainSecret,
// Pas de provider LLM configure en demo : les features IA echoueront
// proprement, la demo sert principalement a explorer l'edition.
"LLM_PROVIDER=ollama",
"OLLAMA_BASE_URL=http://localhost:1",
},
Labels: copyLabels(labels, "brain"),
Memory: cfg.BrainMemoryBytes,
Net: cfg.SessionsNetwork,
Alias: brainName,
}); err != nil {
return fmt.Errorf("spawn brain: %w", err)
}
if err := d.runContainer(ctx, runSpec{
Name: coreName,
Image: cfg.Registry + "/ietm64/core:" + cfg.Tag,
Env: []string{
"SPRING_DATASOURCE_URL=jdbc:postgresql://" + pgName + ":5432/loremind",
"SPRING_DATASOURCE_USERNAME=loremind",
"SPRING_DATASOURCE_PASSWORD=" + pgPassword,
"BRAIN_BASE_URL=http://" + brainName + ":8000",
"BRAIN_INTERNAL_SECRET=" + brainSecret,
"ADMIN_USERNAME=admin",
"ADMIN_PASSWORD=" + adminPassword,
"DEMO_MODE=true",
"CORS_ALLOWED_ORIGINS=*",
},
Labels: copyLabels(labels, "core"),
Memory: cfg.CoreMemoryBytes,
Net: cfg.SessionsNetwork,
Alias: coreName,
}); err != nil {
return fmt.Errorf("spawn core: %w", err)
}
return nil
}
func (d *DockerClient) runContainer(ctx context.Context, s runSpec) error {
// Pull best-effort : si l'image est deja locale, ContainerCreate la reprendra.
_ = d.pullImage(ctx, s.Image)
spec := containerSpec{
Image: s.Image,
Env: s.Env,
Labels: s.Labels,
HostConfig: hostConfig{
Memory: s.Memory,
NanoCPUs: 1_000_000_000, // 1 vCPU par conteneur
PidsLimit: 200, // anti fork-bomb
Tmpfs: s.Tmpfs,
SecurityOpt: []string{"no-new-privileges:true"},
RestartPolicy: restartPolicy{Name: "no"},
},
NetworkingConfig: networkingConfig{
EndpointsConfig: map[string]endpointSettings{
s.Net: {Aliases: []string{s.Alias}},
},
},
}
body, err := json.Marshal(spec)
if err != nil {
return err
}
createResp, err := d.do(ctx, "POST", "/containers/create?name="+url.QueryEscape(s.Name), body)
if err != nil {
return fmt.Errorf("create %s: %w", s.Name, err)
}
var created struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(createResp, &created); err != nil {
return fmt.Errorf("parse create %s: %w", s.Name, err)
}
if _, err := d.do(ctx, "POST", "/containers/"+created.ID+"/start", nil); err != nil {
return fmt.Errorf("start %s: %w", s.Name, err)
}
return nil
}
// pullImage drain le flux de progression. Erreur silencieuse : si le pull
// echoue (registre prive sans auth, image deja locale), runContainer aura un
// retour clair via ContainerCreate.
func (d *DockerClient) pullImage(ctx context.Context, img string) error {
req, err := http.NewRequestWithContext(ctx, "POST",
d.baseURL+"/images/create?fromImage="+url.QueryEscape(img), nil)
if err != nil {
return err
}
resp, err := d.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("pull %s: status %d", img, resp.StatusCode)
}
return nil
}
// WaitReady poll l'endpoint /api/config du core jusqu'a 200 ou timeout.
func (d *DockerClient) WaitReady(ctx context.Context, sessionID string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
target := "http://demo-" + sessionID + "-core:8080/api/config"
c := &http.Client{Timeout: 2 * time.Second}
for time.Now().Before(deadline) {
resp, err := c.Get(target)
if err == nil {
resp.Body.Close()
if resp.StatusCode == 200 {
return true
}
}
select {
case <-ctx.Done():
return false
case <-time.After(2 * time.Second):
}
}
return false
}
// KillTrio supprime tous les conteneurs labellises demo-session=<id>.
func (d *DockerClient) KillTrio(ctx context.Context, sessionID string) error {
containers, err := d.listContainersWithLabel(ctx, "demo-session="+sessionID)
if err != nil {
return err
}
for _, c := range containers {
_, _ = d.do(ctx, "DELETE", "/containers/"+c.ID+"?force=true", nil)
}
return nil
}
// ListSessionIDs : utilise au boot pour retrouver les conteneurs orphelins.
func (d *DockerClient) ListSessionIDs(ctx context.Context) ([]string, error) {
containers, err := d.listContainersWithLabel(ctx, "demo-session")
if err != nil {
return nil, err
}
seen := map[string]bool{}
for _, c := range containers {
if v, ok := c.Labels["demo-session"]; ok && v != "" {
seen[v] = true
}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
return out, nil
}
type containerInfo struct {
ID string `json:"Id"`
Labels map[string]string `json:"Labels"`
}
func (d *DockerClient) listContainersWithLabel(ctx context.Context, label string) ([]containerInfo, error) {
filters := map[string][]string{"label": {label}}
filtersJSON, _ := json.Marshal(filters)
q := url.Values{}
q.Set("all", "true")
q.Set("filters", string(filtersJSON))
body, err := d.do(ctx, "GET", "/containers/json?"+q.Encode(), nil)
if err != nil {
return nil, err
}
var list []containerInfo
if err := json.Unmarshal(body, &list); err != nil {
return nil, err
}
return list, nil
}
// do envoie une requete et renvoie le body. Une reponse 4xx/5xx est convertie
// en erreur avec le contenu pour faciliter le debug.
func (d *DockerClient) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, d.baseURL+path, rdr)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := d.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("%s %s: HTTP %d %s", method, path, resp.StatusCode, out)
}
return out, nil
}
// --- helpers ---
func copyLabels(base map[string]string, role string) map[string]string {
out := make(map[string]string, len(base)+1)
for k, v := range base {
out[k] = v
}
out["demo-role"] = role
return out
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

8
demo/orchestrator/go.mod Normal file
View File

@@ -0,0 +1,8 @@
module github.com/loremind/demo-orchestrator
go 1.23
// Aucune dependance externe : on parle a Docker Engine en HTTP brut
// (cf. docker.go) plutot que d'utiliser github.com/docker/docker, dont le
// graphe transitif est instable d'une version a l'autre (sockets.DialPipe,
// errors.As/Is, otelhttp...).

231
demo/orchestrator/main.go Normal file
View File

@@ -0,0 +1,231 @@
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const cookieName = "loremind-demo-session"
func main() {
cfg := loadConfig()
docker, err := newDockerClient()
if err != nil {
log.Fatalf("docker init: %v", err)
}
mgr := newManager(docker, cfg)
limiter := newRateLimiter(cfg.RateLimitWindow)
// Nettoyage des sessions residuelles au boot (redemarrage orchestrateur).
cleanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
mgr.CleanupOrphans(cleanCtx)
cancel()
go mgr.RunGC(context.Background())
mux := http.NewServeMux()
mux.HandleFunc("/_demo/ready", readyHandler(mgr))
mux.HandleFunc("/api/", apiHandler(mgr, cfg))
mux.HandleFunc("/", rootHandler(mgr, limiter, cfg))
srv := &http.Server{
Addr: ":80",
Handler: mux,
// Timeouts anti-slowloris. WriteTimeout laisse de la marge pour le
// streaming SSE (ai/chat/stream) qui peut durer plusieurs minutes.
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 10 * time.Minute,
IdleTimeout: 120 * time.Second,
// Headers max : 1 Mo (defaut Go), suffisant.
}
log.Printf("orchestrator listening on :80 (max sessions=%d, ttl=%s, rate window=%s)",
cfg.MaxSessions, cfg.SessionTTL, cfg.RateLimitWindow)
if err := srv.ListenAndServe(); err != nil {
log.Fatalf("http server: %v", err)
}
}
// rootHandler gere toutes les routes non-API : sert l'Angular statique si le
// visiteur a deja une session prete, sinon cree une session (sous rate limit)
// et renvoie la page de preparation.
func rootHandler(mgr *Manager, limiter *rateLimiter, cfg *Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := currentSession(r, mgr)
// Visiteur connu et session prete -> sert l'app normalement.
if sess != nil && sess.Status == StatusReady {
serveStatic(w, r, cfg.StaticDir)
return
}
// On ne spawn qu'a la navigation initiale (GET d'un document HTML).
// Les assets secondaires (JS/CSS/favicon) ne doivent pas declencher
// de nouvelle session.
if r.Method != http.MethodGet {
http.Error(w, "No session", http.StatusUnauthorized)
return
}
if !acceptsHTML(r) {
http.Error(w, "No session", http.StatusUnauthorized)
return
}
// Session inexistante (ou expiree) -> en creer une, sous rate limit.
if sess == nil {
ip := clientIP(r)
if !limiter.Allow(ip) {
http.Error(w, "Trop de tentatives. Merci d'attendre "+
strconv.Itoa(int(cfg.RateLimitWindow.Seconds()))+"s.",
http.StatusTooManyRequests)
return
}
newSess, err := mgr.Create(r.Context())
if err != nil {
if errors.Is(err, ErrCapacity) {
http.Error(w, "La demo est pleine (max "+
strconv.Itoa(cfg.MaxSessions)+
" sessions simultanees). Merci de reessayer plus tard.",
http.StatusServiceUnavailable)
return
}
http.Error(w, "Impossible de creer la session : "+err.Error(),
http.StatusInternalServerError)
return
}
sess = newSess
setCookie(w, sess.ID, cfg.SessionTTL)
}
servePreparingPage(w, cfg.PreparingPage)
}
}
// apiHandler proxifie /api/* vers le core de la session.
// Bride la taille des bodies a MaxBodyBytes pour limiter les DoS memoire.
func apiHandler(mgr *Manager, cfg *Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := currentSession(r, mgr)
if sess == nil {
http.Error(w, "No session", http.StatusUnauthorized)
return
}
if sess.Status != StatusReady {
http.Error(w, "Session not ready", http.StatusServiceUnavailable)
return
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodyBytes)
}
proxy := sessionProxy(sess)
proxy.ServeHTTP(w, r)
}
}
// sessionProxy renvoie (et cree si besoin) un reverse proxy cache dans la
// session via sync.Once : garantit une seule creation meme sous requetes
// concurrentes, sans mutex explicite.
func sessionProxy(sess *Session) *httputil.ReverseProxy {
sess.proxyOnce.Do(func() {
target, _ := url.Parse("http://" + sess.CoreHost + ":8080")
p := httputil.NewSingleHostReverseProxy(target)
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("proxy error session=%s: %v", sess.ID, err)
http.Error(w, "Upstream error", http.StatusBadGateway)
}
sess.proxy = p
})
return sess.proxy.(*httputil.ReverseProxy)
}
// readyHandler renvoie l'etat de la session en JSON pour le polling client.
// N'expose aucun ID de session ni d'information sur les autres sessions.
func readyHandler(mgr *Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := currentSession(r, mgr)
w.Header().Set("Content-Type", "application/json")
if sess == nil {
json.NewEncoder(w).Encode(map[string]any{"status": "none"})
return
}
json.NewEncoder(w).Encode(map[string]any{
"status": string(sess.Status),
"error": sess.Err,
})
}
}
// currentSession lit le cookie et retrouve la session en memoire.
// Si le cookie pointe vers une session disparue (redemarrage orchestrateur ou
// TTL expire), retourne nil -> le handler traitera comme un nouveau visiteur.
func currentSession(r *http.Request, mgr *Manager) *Session {
c, err := r.Cookie(cookieName)
if err != nil || c.Value == "" {
return nil
}
return mgr.Get(c.Value)
}
func setCookie(w http.ResponseWriter, id string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: id,
Path: "/",
HttpOnly: true,
Secure: true, // Traefik termine le TLS ; le browser ne doit envoyer ce cookie qu'en HTTPS.
SameSite: http.SameSiteLaxMode,
MaxAge: int(ttl.Seconds()),
})
}
// serveStatic sert les fichiers de l'Angular build avec fallback sur index.html
// pour que le routeur cote client fonctionne (SPA).
// Le check HasPrefix apres Join + Clean empeche les path traversals (..).
func serveStatic(w http.ResponseWriter, r *http.Request, dir string) {
reqPath := r.URL.Path
if reqPath == "/" || reqPath == "" {
reqPath = "/index.html"
}
fullPath := filepath.Join(dir, filepath.Clean(reqPath))
if !strings.HasPrefix(fullPath, dir) {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
http.ServeFile(w, r, fullPath)
return
}
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
}
// servePreparingPage sert la page de chargement statique. Le cookie vient
// d'etre pose, le JS de la page utilisera sessionId implicitement via le
// cookie pour poller /_demo/ready.
func servePreparingPage(w http.ResponseWriter, path string) {
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, "Preparing page not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func acceptsHTML(r *http.Request) bool {
accept := r.Header.Get("Accept")
return strings.Contains(accept, "text/html") || accept == "" || accept == "*/*"
}

View File

@@ -0,0 +1,127 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LoreMind — Demo en preparation</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #1a1625;
color: #e4def5;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
}
.card {
max-width: 440px;
padding: 2.5rem 2rem;
}
.logo {
font-size: 2rem;
color: #b794f4;
margin-bottom: 0.5rem;
}
.subtitle {
letter-spacing: 0.2em;
font-size: 0.75rem;
color: #9880c4;
margin-bottom: 2rem;
}
h1 {
font-size: 1.35rem;
font-weight: 500;
margin: 0 0 1rem;
}
p {
color: #aaa0c5;
line-height: 1.6;
font-size: 0.95rem;
}
.spinner {
width: 36px;
height: 36px;
margin: 1.5rem auto 0;
border: 3px solid rgba(183, 148, 244, 0.2);
border-top-color: #b794f4;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
display: none;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
border-radius: 6px;
font-size: 0.85rem;
}
.error.visible { display: block; }
</style>
</head>
<body>
<main class="card">
<div class="logo">✦ LoreMind</div>
<div class="subtitle">THE DIGITAL CODEX</div>
<h1>Preparation de votre demo…</h1>
<p>
Nous initialisons une instance isolee rien que pour vous.
Cela prend generalement 20 a 40 secondes.
</p>
<p style="font-size: 0.8rem; color: #7d6ba0; margin-top: 1rem;">
Votre session sera automatiquement reinitialisee au bout de 20 minutes.
</p>
<div class="spinner"></div>
<div id="err" class="error"></div>
</main>
<script>
(function () {
var errBox = document.getElementById('err');
var attempts = 0;
var maxAttempts = 90; // 90 * 2s = 3 min max
function poll() {
attempts++;
fetch('/_demo/ready', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.status === 'ready') {
window.location.href = '/';
return;
}
if (data.status === 'failed') {
errBox.textContent = 'Echec du demarrage : ' + (data.error || 'raison inconnue') + '. Rechargez la page pour reessayer.';
errBox.classList.add('visible');
return;
}
if (attempts >= maxAttempts) {
errBox.textContent = 'Timeout. Rechargez la page pour reessayer.';
errBox.classList.add('visible');
return;
}
setTimeout(poll, 2000);
})
.catch(function () {
if (attempts >= maxAttempts) {
errBox.textContent = 'Connexion perdue. Rechargez la page pour reessayer.';
errBox.classList.add('visible');
return;
}
setTimeout(poll, 2000);
});
}
poll();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,72 @@
package main
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// rateLimiter autorise au plus une action par IP dans une fenetre glissante.
// Pas de token bucket : pour un endpoint de creation de session, "1 par
// fenetre" est largement suffisant et plus simple a raisonner.
type rateLimiter struct {
mu sync.Mutex
lastSeen map[string]time.Time
window time.Duration
}
func newRateLimiter(window time.Duration) *rateLimiter {
rl := &rateLimiter{
lastSeen: make(map[string]time.Time),
window: window,
}
go rl.cleanupLoop()
return rl
}
// Allow renvoie true si l'IP n'a pas deja declenche d'action dans la fenetre.
// Sur true, l'horloge de l'IP est reinitialisee.
func (rl *rateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
if last, ok := rl.lastSeen[ip]; ok && now.Sub(last) < rl.window {
return false
}
rl.lastSeen[ip] = now
return true
}
// cleanupLoop purge les entrees plus anciennes que 2x la fenetre pour eviter
// la croissance non bornee de la map sous trafic varie.
func (rl *rateLimiter) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
cutoff := time.Now().Add(-2 * rl.window)
rl.mu.Lock()
for ip, t := range rl.lastSeen {
if t.Before(cutoff) {
delete(rl.lastSeen, ip)
}
}
rl.mu.Unlock()
}
}
// clientIP extrait l'IP reelle en prenant la derniere entree de X-Forwarded-For.
// Justification : Traefik APPEND l'IP du peer au header existant, donc la
// derniere valeur est celle que Traefik a observe directement (le vrai client).
// Prendre la premiere serait une faille : un attaquant peut preremplir le header.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[len(parts)-1])
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}

View File

@@ -0,0 +1,177 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log"
"sync"
"time"
)
// SessionStatus reflete l'etat du cycle de vie d'un trio de session.
type SessionStatus string
const (
StatusStarting SessionStatus = "starting"
StatusReady SessionStatus = "ready"
StatusFailed SessionStatus = "failed"
)
// Session represente une demo isolee pour un visiteur.
// CoreHost est le hostname Docker interne du conteneur core de cette session
// (ex: "demo-abc123-core"), vers lequel l'orchestrateur proxifie les /api/*.
type Session struct {
ID string
CreatedAt time.Time
Status SessionStatus
CoreHost string
Err string
// proxy et proxyOnce : reverse-proxy cache, cree au plus une fois via
// sync.Once (evite la race entre deux requetes concurrentes sur la meme
// session). proxy est typee any pour ne pas contraindre sessions.go a
// importer net/http/httputil.
proxy any
proxyOnce sync.Once
}
// Manager gere le cycle de vie des sessions (creation, acces, cleanup).
// Thread-safe : le mutex protege la map contre les acces concurrents (HTTP
// handlers + goroutine de GC).
type Manager struct {
mu sync.Mutex
sessions map[string]*Session
docker *DockerClient
cfg *Config
}
func newManager(docker *DockerClient, cfg *Config) *Manager {
return &Manager{
sessions: make(map[string]*Session),
docker: docker,
cfg: cfg,
}
}
// ErrCapacity est retournee quand MAX_SESSIONS est atteint.
var ErrCapacity = errors.New("demo at capacity")
// Create reserve un slot et lance le spawn des conteneurs en arriere-plan.
// Retourne immediatement avec Status=starting. L'etat bascule a "ready" quand
// les conteneurs sont up et que core repond a /api/config.
func (m *Manager) Create(ctx context.Context) (*Session, error) {
m.mu.Lock()
if len(m.sessions) >= m.cfg.MaxSessions {
m.mu.Unlock()
return nil, ErrCapacity
}
id := newShortID()
sess := &Session{
ID: id,
CreatedAt: time.Now(),
Status: StatusStarting,
CoreHost: "demo-" + id + "-core",
}
m.sessions[id] = sess
m.mu.Unlock()
// Spawn asynchrone : l'utilisateur voit immediatement la page "preparation".
go func() {
spawnCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := m.docker.SpawnTrio(spawnCtx, id, m.cfg); err != nil {
log.Printf("session %s spawn failed: %v", id, err)
m.mu.Lock()
sess.Status = StatusFailed
sess.Err = err.Error()
m.mu.Unlock()
// Nettoyage best-effort des conteneurs partiellement crees.
_ = m.docker.KillTrio(context.Background(), id)
return
}
// Attente que core reponde (sinon proxy retourne 502 aux premieres requetes).
if m.docker.WaitReady(spawnCtx, id, 90*time.Second) {
m.mu.Lock()
sess.Status = StatusReady
m.mu.Unlock()
log.Printf("session %s ready", id)
} else {
log.Printf("session %s never became ready", id)
m.mu.Lock()
sess.Status = StatusFailed
sess.Err = "timeout waiting for core"
m.mu.Unlock()
_ = m.docker.KillTrio(context.Background(), id)
}
}()
return sess, nil
}
// Get renvoie la session associee a un ID, ou nil si elle n'existe plus.
func (m *Manager) Get(id string) *Session {
m.mu.Lock()
defer m.mu.Unlock()
return m.sessions[id]
}
// RunGC boucle toutes les minutes pour supprimer les sessions expirees.
// A lancer en goroutine au demarrage.
func (m *Manager) RunGC(ctx context.Context) {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.gcOnce()
}
}
}
func (m *Manager) gcOnce() {
cutoff := time.Now().Add(-m.cfg.SessionTTL)
m.mu.Lock()
var expired []string
for id, s := range m.sessions {
if s.CreatedAt.Before(cutoff) {
expired = append(expired, id)
}
}
for _, id := range expired {
delete(m.sessions, id)
}
m.mu.Unlock()
for _, id := range expired {
log.Printf("session %s expired, killing containers", id)
if err := m.docker.KillTrio(context.Background(), id); err != nil {
log.Printf("kill %s: %v", id, err)
}
}
}
// CleanupOrphans tue les conteneurs demo-* qui ne correspondent a aucune
// session en memoire. Appele au demarrage pour gerer un redemarrage brutal.
func (m *Manager) CleanupOrphans(ctx context.Context) {
ids, err := m.docker.ListSessionIDs(ctx)
if err != nil {
log.Printf("list orphans: %v", err)
return
}
for _, id := range ids {
log.Printf("cleaning orphan session %s", id)
_ = m.docker.KillTrio(ctx, id)
}
}
// newShortID genere un identifiant hexadecimal de 32 caracteres (128 bits).
// 128 bits d'entropie rendent les collisions et le brute-force statistiquement
// impossibles, meme si un attaquant pouvait tenter des millions de cookies.
func newShortID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

View File

@@ -60,7 +60,8 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "web:build"
"buildTarget": "web:build",
"proxyConfig": "proxy.conf.json"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.5.0",
"version": "0.6.1",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

8
web/proxy.conf.json Normal file
View File

@@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:8080",
"secure": false,
"changeOrigin": true,
"logLevel": "warn"
}
}

View File

@@ -1,10 +1,12 @@
import { Routes } from '@angular/router';
import { hiddenInDemoGuard } from './guards/demo-mode.guard';
export const routes: Routes = [
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },
{ 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/:templateId', loadComponent: () => import('./lore/template-edit/template-edit.component').then(m => m.TemplateEditComponent) },
@@ -29,6 +31,8 @@ export const routes: Routes = [
{ path: 'game-systems', loadComponent: () => import('./game-systems/game-systems.component').then(m => m.GameSystemsComponent) },
{ path: 'game-systems/create', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
{ path: 'game-systems/:id/edit', loadComponent: () => import('./game-systems/game-system-edit/game-system-edit.component').then(m => m.GameSystemEditComponent) },
{ path: 'settings', loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) },
// Routes masquees en mode demo : ajouter canActivate: [hiddenInDemoGuard]
// (a prevoir aussi sur la future route d'export VTT).
{ path: 'settings', canActivate: [hiddenInDemoGuard], loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) },
{ path: '', redirectTo: '/lore', pathMatch: 'full' }
];

View File

@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
@@ -33,6 +34,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -50,7 +52,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length;

View File

@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -68,6 +69,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -105,7 +107,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -10,6 +10,10 @@
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</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>
</header>

View File

@@ -3,8 +3,9 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
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 { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -27,6 +28,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
})
export class ArcViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
campaignId = '';
arcId = '';
@@ -41,6 +43,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -63,7 +66,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
@@ -101,6 +104,38 @@ export class ArcViewComponent implements OnInit, OnDestroy {
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 {
this.layoutService.hide();
}

View File

@@ -15,6 +15,24 @@
padding: 2rem;
width: 100%;
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 {
@@ -87,6 +105,14 @@
.modal-actions {
display: flex;
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 {

View File

@@ -70,7 +70,7 @@
</div>
</div>
<section class="detail-section characters-section">
<section class="detail-section characters-section" *ngIf="!editing">
<div class="section-header">
<h2>Personnages joueurs</h2>
<button class="btn-add" (click)="createCharacter()">
@@ -99,7 +99,7 @@
</div>
</section>
<section class="detail-section arcs-section">
<section class="detail-section arcs-section" *ngIf="!editing">
<div class="section-header">
<h2>Arcs narratifs</h2>
<button class="btn-add" (click)="createArc()">

View File

@@ -74,6 +74,15 @@
}
.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;
align-items: flex-start;
justify-content: space-between;

View File

@@ -77,8 +77,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
switchMap(id => forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
)
}))
).subscribe(({ campaign, allCampaigns, treeData }) => {
@@ -111,8 +111,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {} } as CampaignTreeData))
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [] } as CampaignTreeData))
)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
this.campaign = campaign;
@@ -257,23 +257,38 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
}
/**
* Suppression protégée : refus si la campagne contient des arcs.
* Les arcs contiennent potentiellement des chapitres/scènes construits longuement.
* Suppression en cascade : récupère d'abord le détail de ce qui sera effacé
* (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 {
if (!this.campaign) return;
if (this.arcs.length > 0) {
alert(
`Impossible de supprimer "${this.campaign.name}" : elle contient encore ${this.arcs.length} arc(s).\n` +
`Videz la campagne (arcs et chapitres) avant de la supprimer.`
);
return;
const campaign = this.campaign;
this.campaignService.getCampaignDeletionImpact(campaign.id!).subscribe({
next: impact => {
const parts: string[] = [];
if (impact.arcs > 0) parts.push(`${impact.arcs} arc${impact.arcs > 1 ? 's' : ''}`);
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 (impact.characters > 0) parts.push(`${impact.characters} personnage${impact.characters > 1 ? 's' : ''}`);
const lines = [`Supprimer définitivement la campagne "${campaign.name}" ?`];
if (parts.length) {
lines.push('');
lines.push(`Cette action supprimera aussi : ${parts.join(', ')}.`);
}
if (!confirm(`Supprimer définitivement la campagne "${this.campaign.name}" ?`)) return;
this.campaignService.deleteCampaign(this.campaign.id!).subscribe({
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')
});
}
ngOnDestroy(): void {

View File

@@ -1,8 +1,10 @@
import { Observable, forkJoin, of } from 'rxjs';
import { switchMap, map } from 'rxjs/operators';
import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service';
import { TreeItem } from '../services/layout.service';
import { Arc, Chapter, Scene } from '../services/campaign.model';
import { Character } from '../services/character.model';
/**
* Helper — charge l'arborescence complète d'une campagne (arcs -> chapitres -> scènes)
@@ -16,16 +18,21 @@ export interface CampaignTreeData {
arcs: Arc[];
chaptersByArc: Record<string, Chapter[]>;
scenesByChapter: Record<string, Scene[]>;
characters: Character[];
}
export function loadCampaignTreeData(
service: CampaignService,
campaignId: string
campaignId: string,
characterService: CharacterService
): Observable<CampaignTreeData> {
return service.getArcs(campaignId).pipe(
switchMap(arcs => {
return forkJoin({
arcs: service.getArcs(campaignId),
characters: characterService.getByCampaign(campaignId)
}).pipe(
switchMap(({ arcs, characters }) => {
if (arcs.length === 0) {
return of({ arcs, chaptersByArc: {}, scenesByChapter: {} });
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters });
}
const chapterCalls = arcs.map(a =>
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
@@ -40,7 +47,7 @@ export function loadCampaignTreeData(
});
if (allChapters.length === 0) {
return of({ arcs, chaptersByArc, scenesByChapter: {} });
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters });
}
const sceneCalls = allChapters.map(c =>
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
@@ -49,7 +56,7 @@ export function loadCampaignTreeData(
map(sceneResults => {
const scenesByChapter: Record<string, Scene[]> = {};
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
return { arcs, chaptersByArc, scenesByChapter };
return { arcs, chaptersByArc, scenesByChapter, characters };
})
);
})
@@ -67,9 +74,33 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
// IDs préfixés par type pour éviter les collisions dans LayoutService.expanded
// (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1
// peuvent coexister et se marchaient sur les pieds dans le Set<string> global).
const sortedCharacters = [...data.characters].sort(byName);
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
id: `character-${ch.id}`,
label: ch.name,
route: `/campaigns/${campaignId}/characters/${ch.id}/edit`
}));
const charactersNode: TreeItem = {
id: 'characters-root',
label: 'Personnages',
iconKey: 'users',
children: characterItems,
meta: characterItems.length ? String(characterItems.length) : undefined,
sectionHeaderBefore: 'Personnages',
// Note : si pas d'arcs, le filet au-dessus de "Personnages" est masqué par CSS
// (:first-child), ce qui est voulu — on ne veut pas de ligne seule en haut.
createActions: [{
id: 'new-character',
label: 'Nouveau PJ',
route: `/campaigns/${campaignId}/characters/create`,
actionIcon: 'plus'
}]
};
const sortedArcs = [...data.arcs].sort(byName);
return sortedArcs.map(arc => {
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byName);
const chapterItems: TreeItem[] = sortedChapters.map(ch => {
@@ -98,6 +129,8 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
label: arc.name,
children: chapterItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}`,
sectionHeaderBefore: idx === 0 ? 'Narration' : undefined,
createActions: [{
id: `new-chapter-${arc.id}`,
label: 'Nouveau chapitre',
@@ -106,4 +139,6 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
}]
};
});
return [...arcNodes, charactersNode];
}

View File

@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
@@ -32,6 +33,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -50,7 +52,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
this.arcName = currentArc?.name ?? '';

View File

@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -61,6 +62,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -98,7 +100,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
import { Campaign, Chapter, Scene } from '../../services/campaign.model';
@@ -48,6 +49,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
) {}
@@ -67,7 +69,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter;
this.scenes = scenes;

View File

@@ -15,6 +15,10 @@
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</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>
</header>

View File

@@ -3,8 +3,9 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
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 { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -27,6 +28,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Network = Network;
readonly Trash2 = Trash2;
campaignId = '';
arcId = '';
@@ -40,6 +42,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -66,7 +69,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
@@ -112,6 +115,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 {
this.layoutService.hide();
}

View File

@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { Campaign } from '../../services/campaign.model';
import { loadCampaignTreeData, buildCampaignTree } from '../campaign-tree.helper';
@@ -33,6 +34,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private layoutService: LayoutService
) {
this.form = this.fb.group({
@@ -52,7 +54,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
this.chapterName = currentChapter?.name ?? '';

View File

@@ -6,6 +6,7 @@ import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -65,6 +66,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -116,7 +118,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId),
chapterScenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;

View File

@@ -10,6 +10,10 @@
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</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>
</header>

View File

@@ -3,8 +3,9 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
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 { CharacterService } from '../../services/character.service';
import { PageService } from '../../services/page.service';
import { LayoutService, GlobalItem } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service';
@@ -26,6 +27,7 @@ import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.
})
export class SceneViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
campaignId = '';
arcId = '';
@@ -40,6 +42,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private router: Router,
private campaignService: CampaignService,
private characterService: CharacterService,
private pageService: PageService,
private layoutService: LayoutService,
private pageTitleService: PageTitleService
@@ -69,7 +72,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId)
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService)
}).pipe(
switchMap(data => {
const lid = data.campaign.loreId ?? null;
@@ -110,6 +113,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 {
this.layoutService.hide();
}

View File

@@ -0,0 +1,17 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { ConfigService } from '../services/config.service';
/**
* Bloque l'acces aux routes sensibles quand demoMode est actif et redirige
* vers la home. Defense UX ; le verrou serveur reste la source de verite.
*/
export const hiddenInDemoGuard: CanActivateFn = () => {
const config = inject(ConfigService);
const router = inject(Router);
if (config.demoMode) {
router.navigate(['/']);
return false;
}
return true;
};

View 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>

View 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;
}

View 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();
}
}

View File

@@ -39,7 +39,7 @@
</div>
<!-- ============ Grille des dossiers racine ============ -->
<section class="detail-section nodes-section">
<section class="detail-section nodes-section" *ngIf="!editing">
<div class="section-header">
<h2>Dossiers</h2>
<button class="btn-add" (click)="navigateToCreateNode()">

View File

@@ -15,11 +15,19 @@
}
.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;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
margin-bottom: 2.5rem;
margin-bottom: 1.5rem;
.header-texts { flex: 1; min-width: 0; }

View File

@@ -77,7 +77,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
}
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 ───────────────
@@ -110,25 +110,44 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
}
/**
* Suppression protégée : refus si le Lore contient encore des dossiers
* ou des pages. Protège contre un clic accidentel sur des données
* construites longuement. Logique côté frontend (pas d'appel HTTP
* supplémentaire) car les données sont déjà chargées.
* Suppression en cascade : récupère le détail de ce qui tombera (dossiers,
* pages, templates) et de ce qui sera détaché (campagnes conservées mais
* sans lien vers ce Lore), affiche le récapitulatif dans la confirmation,
* puis délègue au backend (transaction atomique).
*/
deleteLore(): void {
if (!this.lore) return;
if (this.allNodes.length > 0) {
alert(
`Impossible de supprimer "${this.lore.name}" : il contient encore ${this.allNodes.length} dossier(s).\n` +
`Videz le Lore (dossiers et pages) avant de le supprimer.`
);
return;
const lore = this.lore;
this.loreService.getLoreDeletionImpact(lore.id!).subscribe({
next: impact => {
const deleted: string[] = [];
if (impact.folders > 0) deleted.push(`${impact.folders} dossier${impact.folders > 1 ? 's' : ''}`);
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' : ''}`);
const lines = [`Supprimer définitivement le Lore "${lore.name}" ?`];
if (deleted.length) {
lines.push('');
lines.push(`Cette action supprimera aussi : ${deleted.join(', ')}.`);
}
if (!confirm(`Supprimer définitivement le Lore "${this.lore.name}" ?`)) return;
this.loreService.deleteLore(this.lore.id!).subscribe({
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')
});
}
ngOnDestroy(): void {

View File

@@ -49,15 +49,6 @@
</textarea>
</div>
<div class="field">
<label>Adresse</label>
<input
type="text"
formControlName="address"
placeholder="nom-du-dossier"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
<lucide-icon [img]="getIcon(selectedIcon)" [size]="16"></lucide-icon>

View File

@@ -9,6 +9,7 @@ import { PageService } from '../../services/page.service';
import { LayoutService } from '../../services/layout.service';
import { LoreNode } from '../../services/lore.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { popReturnTo } from '../return-stack.helper';
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
@Component({
@@ -42,15 +43,8 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
this.form = this.fb.group({
name: ['', Validators.required],
description: [''],
address: ['', Validators.required],
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 {
@@ -84,17 +78,35 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
this.loreService.createLoreNode({
name: raw.name,
description: raw.description,
address: raw.address,
icon: this.selectedIcon,
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null,
loreId: this.loreId
}).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
next: () => this.navigateBack(),
error: () => console.error('Erreur lors de la création du dossier')
});
}
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]);
}

View File

@@ -9,14 +9,6 @@
</div>
<div class="header-actions">
<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
type="submit"
class="btn-primary"
@@ -57,11 +49,6 @@
</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>
</div>

View File

@@ -129,26 +129,15 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
parentId: raw.parentId && raw.parentId !== '' ? raw.parentId : null
};
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')
});
}
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 {
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". */

View File

@@ -63,8 +63,9 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
}
/**
* Construit récursivement le TreeItem d'un dossier :
* ses sous-dossiers, puis ses pages, puis les actions "+ Nouveau dossier" et "+ Nouvelle page".
* Construit récursivement le TreeItem d'un dossier : ses sous-dossiers,
* 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 subFolders = childrenByParent.get(node.id!) ?? [];
@@ -84,7 +85,7 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
id: `folder-${node.id}`,
label: node.name,
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,
children,
createActions: [
@@ -116,20 +117,16 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
id: 'templates',
title: 'Templates',
initiallyOpen: true,
items: [
...templates.map(t => ({
headerAction: {
label: 'Nouveau template',
route: `/lore/${lore.id}/templates/create`
},
items: templates.map(t => ({
id: t.id!,
label: t.name,
meta: `${t.fieldCount ?? t.fields.length} champs`,
route: `/lore/${lore.id}/templates/${t.id}`
})),
{
id: 'create-template',
label: '+ Nouveau template',
isAction: true,
route: `/lore/${lore.id}/templates/create`
}
]
}))
};
return {

View File

@@ -35,7 +35,7 @@
<ng-template #emptyTemplates>
<p class="empty-hint">
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>
</ng-template>
</div>
@@ -43,11 +43,21 @@
<!-- Dossier de destination -->
<div class="field">
<label>Dossier de destination *</label>
<ng-container *ngIf="nodes.length; else emptyFolders">
<select formControlName="nodeId" [attr.disabled]="preselectedNodeId ? true : null">
<option value="" disabled>Sélectionnez un dossier</option>
<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>
<!-- Aide contextuelle -->

View File

@@ -92,9 +92,48 @@ export class PageCreateComponent implements OnInit, OnDestroy {
if (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 {
this.selectedTemplateId = template.id!;
// Si pas de noeud pré-choisi par l'URL, on pré-remplit avec le defaultNodeId du template.

View File

@@ -147,7 +147,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
for (const node of folderChain) {
items.push({
label: node.name,
route: ['/lore', this.loreId, 'folders', node.id, 'edit']
route: ['/lore', this.loreId, 'folders', node.id]
});
}

View File

@@ -12,6 +12,10 @@
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
Modifier
</button>
<button type="button" class="btn-danger" (click)="deletePage()" title="Supprimer la page">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
Supprimer
</button>
</div>
</header>

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin } from 'rxjs';
import { LucideAngularModule, Pencil } from 'lucide-angular';
import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.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 {
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
loreId = '';
pageId = '';
@@ -96,7 +97,7 @@ export class PageViewComponent implements OnInit, OnDestroy {
: undefined;
}
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 });
return items;
@@ -121,6 +122,26 @@ export class PageViewComponent implements OnInit, OnDestroy {
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 {
this.layoutService.hide();
}

View 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 };
}

View File

@@ -22,11 +22,23 @@
<div class="field">
<label>Dossier par défaut *</label>
<ng-container *ngIf="nodes.length; else emptyFolders">
<select formControlName="defaultNodeId">
<option value="" disabled>Sélectionnez un dossier</option>
<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>
@@ -85,7 +97,7 @@
type="text"
[(ngModel)]="newFieldName"
[ngModelOptions]="{ standalone: true }"
placeholder="Nom du champ..."
placeholder="+ Ajouter un champ"
(keydown.enter)="$event.preventDefault(); addField()" />
<select
class="type-select"

View File

@@ -247,3 +247,10 @@
&:hover { background: #363650; }
}
.empty-hint {
color: #9ca3af;
font-size: 0.88rem;
a { color: #a5b4fc; text-decoration: underline; }
}

View File

@@ -1,7 +1,7 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
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 { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service';
@@ -10,6 +10,7 @@ import { LayoutService } from '../../services/layout.service';
import { LoreNode } from '../../services/lore.model';
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { popReturnTo } from '../return-stack.helper';
/**
* Écran de création d'un Template (gabarit de Page).
@@ -20,7 +21,7 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
@Component({
selector: 'app-template-create',
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule, LucideAngularModule],
imports: [CommonModule, FormsModule, ReactiveFormsModule, RouterModule, LucideAngularModule],
templateUrl: './template-create.component.html',
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 => {
this.nodes = data.nodes;
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 {
const name = this.newFieldName.trim();
if (!name) return;
@@ -129,12 +175,28 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
defaultNodeId: raw.defaultNodeId,
fields: this.fields
}).subscribe({
next: () => this.router.navigate(['/lore', this.loreId]),
next: () => this.navigateBack(),
error: () => console.error('Erreur lors de la création du template')
});
}
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]);
}

View File

@@ -58,7 +58,10 @@
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
</button>
</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>
{{ f.name }}
</span>
@@ -79,8 +82,8 @@
<option value="MASONRY">Mosaique</option>
<option value="CAROUSEL">Carrousel</option>
</select>
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
<button type="button" class="btn-icon-danger" (click)="removeField(i)" aria-label="Supprimer">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</li>
</ul>

View File

@@ -107,9 +107,23 @@
align-items: center;
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 {
background: #1f1b3a;
background: #312b5c;
border-color: #3d3566;
color: #c7b8ff;
}
@@ -118,11 +132,33 @@
.btn-type-toggle {
width: auto;
padding: 0 0.7rem;
background: #2a2a3d;
color: #d1d5db;
font-size: 0.72rem;
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,
@@ -227,15 +263,14 @@
justify-content: center;
width: 36px;
height: 36px;
margin-right: 0.4rem;
background: transparent;
color: #6c63ff;
background: #6c63ff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
&:hover { background: #2a2a3d; }
&:hover { background: #5a52d6; }
}
.btn-primary, .btn-secondary, .btn-danger {

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
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 { TemplateService } from '../../services/template.service';
import { PageService } from '../../services/page.service';
@@ -26,7 +26,6 @@ import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.hel
})
export class TemplateEditComponent implements OnInit, OnDestroy {
readonly Plus = Plus;
readonly X = X;
readonly Trash2 = Trash2;
readonly Type = Type;
readonly ImageIcon = ImageIcon;
@@ -41,6 +40,17 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
fields: TemplateField[] = [];
newFieldName = '';
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(
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 };
});
this.originalFieldNames = new Set(this.fields.map(f => f.name));
this.form.patchValue({
name: template.name,
description: template.description,

View File

@@ -45,8 +45,8 @@ export type NarrativeEntityType = 'arc' | 'chapter' | 'scene' | 'character';
@Injectable({ providedIn: 'root' })
export class AiChatService {
private readonly loreEndpoint = 'http://localhost:8080/api/ai/chat/stream';
private readonly campaignEndpoint = 'http://localhost:8080/api/ai/chat/stream-campaign';
private readonly loreEndpoint = '/api/ai/chat/stream';
private readonly campaignEndpoint = '/api/ai/chat/stream-campaign';
/**
* Streame la réponse de l'IA pour un historique de messages donné (chat ancré Lore).

View File

@@ -3,6 +3,25 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
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.
* Port de sortie vers le Backend Java (Architecture Hexagonale).
@@ -11,7 +30,7 @@ import { Campaign, CampaignCreate, Arc, ArcCreate, Chapter, ChapterCreate, Scene
providedIn: 'root'
})
export class CampaignService {
private apiUrl = 'http://localhost:8080/api/campaigns';
private apiUrl = '/api/campaigns';
constructor(private http: HttpClient) {}
@@ -35,67 +54,79 @@ export class CampaignService {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
getCampaignDeletionImpact(id: string): Observable<CampaignDeletionImpact> {
return this.http.get<CampaignDeletionImpact>(`${this.apiUrl}/${id}/deletion-impact`);
}
// ========== ARC ==========
getArcs(campaignId: string): Observable<Arc[]> {
return this.http.get<Arc[]>(`http://localhost:8080/api/arcs/campaign/${campaignId}`);
return this.http.get<Arc[]>(`/api/arcs/campaign/${campaignId}`);
}
getArcById(id: string): Observable<Arc> {
return this.http.get<Arc>(`http://localhost:8080/api/arcs/${id}`);
return this.http.get<Arc>(`/api/arcs/${id}`);
}
createArc(payload: ArcCreate): Observable<Arc> {
return this.http.post<Arc>('http://localhost:8080/api/arcs', payload);
return this.http.post<Arc>('/api/arcs', payload);
}
updateArc(id: string, payload: ArcCreate): Observable<Arc> {
return this.http.put<Arc>(`http://localhost:8080/api/arcs/${id}`, payload);
return this.http.put<Arc>(`/api/arcs/${id}`, payload);
}
deleteArc(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/arcs/${id}`);
return this.http.delete<void>(`/api/arcs/${id}`);
}
getArcDeletionImpact(id: string): Observable<ArcDeletionImpact> {
return this.http.get<ArcDeletionImpact>(`/api/arcs/${id}/deletion-impact`);
}
// ========== CHAPTER ==========
getChapters(arcId: string): Observable<Chapter[]> {
return this.http.get<Chapter[]>(`http://localhost:8080/api/chapters/arc/${arcId}`);
return this.http.get<Chapter[]>(`/api/chapters/arc/${arcId}`);
}
getChapterById(id: string): Observable<Chapter> {
return this.http.get<Chapter>(`http://localhost:8080/api/chapters/${id}`);
return this.http.get<Chapter>(`/api/chapters/${id}`);
}
createChapter(payload: ChapterCreate): Observable<Chapter> {
return this.http.post<Chapter>('http://localhost:8080/api/chapters', payload);
return this.http.post<Chapter>('/api/chapters', payload);
}
updateChapter(id: string, payload: ChapterCreate): Observable<Chapter> {
return this.http.put<Chapter>(`http://localhost:8080/api/chapters/${id}`, payload);
return this.http.put<Chapter>(`/api/chapters/${id}`, payload);
}
deleteChapter(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/chapters/${id}`);
return this.http.delete<void>(`/api/chapters/${id}`);
}
getChapterDeletionImpact(id: string): Observable<ChapterDeletionImpact> {
return this.http.get<ChapterDeletionImpact>(`/api/chapters/${id}/deletion-impact`);
}
// ========== SCENE ==========
getScenes(chapterId: string): Observable<Scene[]> {
return this.http.get<Scene[]>(`http://localhost:8080/api/scenes/chapter/${chapterId}`);
return this.http.get<Scene[]>(`/api/scenes/chapter/${chapterId}`);
}
getSceneById(id: string): Observable<Scene> {
return this.http.get<Scene>(`http://localhost:8080/api/scenes/${id}`);
return this.http.get<Scene>(`/api/scenes/${id}`);
}
createScene(payload: SceneCreate): Observable<Scene> {
return this.http.post<Scene>('http://localhost:8080/api/scenes', payload);
return this.http.post<Scene>('/api/scenes', payload);
}
updateScene(id: string, payload: SceneCreate): Observable<Scene> {
return this.http.put<Scene>(`http://localhost:8080/api/scenes/${id}`, payload);
return this.http.put<Scene>(`/api/scenes/${id}`, payload);
}
deleteScene(id: string): Observable<void> {
return this.http.delete<void>(`http://localhost:8080/api/scenes/${id}`);
return this.http.delete<void>(`/api/scenes/${id}`);
}
search(q: string): Observable<Campaign[]> {

View File

@@ -8,7 +8,7 @@ import { Character, CharacterCreate } from './character.model';
*/
@Injectable({ providedIn: 'root' })
export class CharacterService {
private apiUrl = 'http://localhost:8080/api/characters';
private apiUrl = '/api/characters';
constructor(private http: HttpClient) {}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
/**
* Configuration publique chargee une seule fois au demarrage via APP_INITIALIZER.
* Le flag demoMode bascule l'UI en mode vitrine (Settings/Export masques).
*/
export interface PublicConfig {
demoMode: boolean;
}
@Injectable({ providedIn: 'root' })
export class ConfigService {
private config: PublicConfig = { demoMode: false };
constructor(private http: HttpClient) {}
async load(): Promise<void> {
try {
this.config = await firstValueFrom(this.http.get<PublicConfig>('/api/config'));
} catch {
// Si l'endpoint n'est pas joignable au boot, on reste sur le default
// (demoMode=false) pour ne pas bloquer l'app en dev.
}
}
get demoMode(): boolean {
return this.config.demoMode;
}
}

View File

@@ -17,7 +17,7 @@ import {
*/
@Injectable({ providedIn: 'root' })
export class ConversationService {
private readonly apiUrl = 'http://localhost:8080/api/conversations';
private readonly apiUrl = '/api/conversations';
constructor(private http: HttpClient) {}

View File

@@ -8,7 +8,7 @@ import { GameSystem, GameSystemCreate } from './game-system.model';
*/
@Injectable({ providedIn: 'root' })
export class GameSystemService {
private apiUrl = 'http://localhost:8080/api/game-systems';
private apiUrl = '/api/game-systems';
constructor(private http: HttpClient) {}

View File

@@ -9,8 +9,8 @@ import { Image } from './image.model';
*/
@Injectable({ providedIn: 'root' })
export class ImageService {
/** Base absolue du backend — utile pour construire des URLs complètes (<img src>). */
readonly apiBase = 'http://localhost:8080';
/** Base du backend (vide = même origine que le front, résolue par le navigateur). */
readonly apiBase = '';
private apiUrl = `${this.apiBase}/api/images`;
constructor(private http: HttpClient) {}

View File

@@ -11,6 +11,11 @@ export interface TreeItem {
iconKey?: string;
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
meta?: string;
/**
* Libellé de section affiché AU-DESSUS du nœud, avec un filet de séparation.
* Utilisé pour grouper visuellement des nœuds racines (ex: "Personnages" vs "Narration").
*/
sectionHeaderBefore?: string;
/**
* Actions de creation contextuelles (ex: "+ Nouveau chapitre" sur un arc).
* Affichees comme boutons icone au survol du noeud (repli visuel), et en
@@ -62,6 +67,8 @@ export interface BottomPanel {
title: string;
items: BottomPanelItem[];
initiallyOpen?: boolean;
/** Action "+" inline dans le header — créer un item sans déplier le panneau. */
headerAction?: { label: string; route: string };
}
export interface SecondarySidebarConfig {

View File

@@ -24,14 +24,12 @@ export interface LoreNode {
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
type?: string;
description?: string;
address?: string;
}
export interface LoreNodeCreate {
name: string;
icon: string;
description: string;
address: string;
parentId?: string | null;
loreId: string;
}

View File

@@ -3,6 +3,23 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
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.
* Port de sortie vers le Backend Java (Architecture Hexagonale).
@@ -11,8 +28,8 @@ import { Lore, LoreCreate, LoreNode, LoreNodeCreate } from './lore.model';
providedIn: 'root'
})
export class LoreService {
private apiUrl = 'http://localhost:8080/api/lores';
private nodesUrl = 'http://localhost:8080/api/lore-nodes';
private apiUrl = '/api/lores';
private nodesUrl = '/api/lore-nodes';
constructor(private http: HttpClient) {}
@@ -36,6 +53,10 @@ export class LoreService {
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[]> {
return this.http.get<LoreNode[]>(`${this.nodesUrl}?loreId=${loreId}`);
}
@@ -57,6 +78,10 @@ export class LoreService {
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[]> {
const params = new HttpParams().set('q', q);
return this.http.get<Lore[]>(`${this.apiUrl}/search`, { params });

View File

@@ -10,7 +10,7 @@ import { Page, PageCreate } from './page.model';
*/
@Injectable({ providedIn: 'root' })
export class PageService {
private apiUrl = 'http://localhost:8080/api/pages';
private apiUrl = '/api/pages';
constructor(private http: HttpClient) {}

View File

@@ -36,7 +36,7 @@ export interface OllamaModelInfo {
@Injectable({ providedIn: 'root' })
export class SettingsService {
private readonly apiUrl = 'http://localhost:8080/api/settings';
private readonly apiUrl = '/api/settings';
// HTTP Basic : le browser gere le prompt natif de credentials au premier 401.
// withCredentials=true pour que les creds soient renvoyees sur les appels

View File

@@ -9,7 +9,7 @@ import { Template, TemplateCreate } from './template.model';
*/
@Injectable({ providedIn: 'root' })
export class TemplateService {
private apiUrl = 'http://localhost:8080/api/templates';
private apiUrl = '/api/templates';
constructor(private http: HttpClient) {}

View File

@@ -17,7 +17,7 @@ export interface BreadcrumbItem {
* Utilisation type :
* <app-breadcrumb [items]="[
* { label: 'Mon Univers', route: ['/lore', loreId] },
* { label: 'PNJ', route: ['/lore', loreId, 'folders', nodeId, 'edit'] },
* { label: 'PNJ', route: ['/lore', loreId, 'folders', nodeId] },
* { label: 'Aldric' }
* ]"></app-breadcrumb>
*/

View File

@@ -131,7 +131,7 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
title: n.name,
subtitle: '',
tag: 'Dossier',
route: ['/lore', n.loreId, 'folders', n.id, 'edit']
route: ['/lore', n.loreId, 'folders', n.id]
}));
const templateResults: SearchResult[] = templates.map(t => ({
id: t.id,

View File

@@ -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()">
<lucide-icon [img]="isCollapsed ? PanelLeftOpen : PanelLeftClose" [size]="16"></lucide-icon>
@@ -27,6 +29,9 @@
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
<ng-template #treeNode let-item let-level="level">
<div class="tree-section-header" *ngIf="level === 0 && item.sectionHeaderBefore">
{{ item.sectionHeaderBefore }}
</div>
<div class="tree-item" [style.padding-left.px]="level * 12">
<div class="tree-row">
<button
@@ -41,7 +46,10 @@
</button>
<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
*ngIf="iconFor(item) as icon"
[img]="icon"
@@ -61,35 +69,21 @@
[title]="a.label"
[attr.aria-label]="a.label"
(click)="runCreateAction($event, a)">
<lucide-icon [img]="iconForAction(a)" [size]="12"></lucide-icon>
<lucide-icon [img]="iconForAction(a)" [size]="16"></lucide-icon>
</button>
</span>
</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 *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></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>
</ng-template>
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
<section class="bottom-panel" *ngIf="bottomPanel">
<div class="panel-header-row">
<button class="panel-header" (click)="togglePanel()">
<span class="panel-title">{{ bottomPanel.title }}</span>
<lucide-icon
@@ -97,6 +91,16 @@
[size]="14">
</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">
<li *ngFor="let item of bottomPanel.items">
<button
@@ -112,4 +116,9 @@
</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>

View File

@@ -8,15 +8,33 @@
padding: 1.25rem 0.75rem;
gap: 0.75rem;
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 {
width: 44px;
width: 44px !important;
padding: 1.25rem 0.5rem;
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 {
display: flex;
align-items: center;
@@ -92,6 +110,23 @@
padding: 0.25rem 0;
}
// En-tête de section — groupe visuellement les nœuds racines (ex: Personnages / Narration).
// Un filet au-dessus crée la séparation ; pas de filet pour la première section
// (le titre suffit) — on cible ça via :not(:first-child).
.tree-section-header {
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9ca3af;
padding: 0.6rem 0.5rem 0.3rem;
}
.tree > .tree-section-header:not(:first-child) {
border-top: 1px solid #374151;
margin-top: 0.35rem;
padding-top: 0.6rem;
}
.tree-btn {
display: flex;
align-items: center;
@@ -111,6 +146,17 @@
&.action { color: #6b7280; font-style: italic; }
&.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 {
margin-left: auto;
font-size: 0.72rem;
@@ -142,7 +188,7 @@
.node-actions {
display: inline-flex;
align-items: center;
gap: 0.1rem;
gap: 0.2rem;
margin-left: auto;
flex-shrink: 0;
opacity: 0;
@@ -160,17 +206,17 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: transparent;
width: 28px;
height: 28px;
background: rgba(55, 65, 81, 0.6);
border: none;
border-radius: 4px;
border-radius: 6px;
cursor: pointer;
color: #9ca3af;
color: #d1d5db;
padding: 0;
transition: background 0.12s, color 0.12s;
&:hover { background: #2a2a3d; color: #c7d2fe; }
&:hover { background: #4338ca; color: #ffffff; }
}
.chevron-btn {
@@ -214,11 +260,36 @@
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 {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: #a5b4fc;

View File

@@ -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 { Router } from '@angular/router';
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',
styleUrls: ['./secondary-sidebar.component.scss']
})
export class SecondarySidebarComponent {
export class SecondarySidebarComponent implements OnDestroy {
@Input() title = '';
@Input() createActions: SidebarAction[] = [];
@Input() bottomPanel: BottomPanel | null = null;
@@ -31,6 +31,17 @@ export class SecondarySidebarComponent {
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[] = [];
@Input() set items(value: TreeItem[]) {
@@ -39,7 +50,65 @@ export class SecondarySidebarComponent {
}
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 {
if (action.route) { this.router.navigate([action.route]); }
@@ -80,6 +149,12 @@ export class SecondarySidebarComponent {
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. */
iconFor(item: TreeItem): LucideIconData | null {
return item.iconKey ? resolveIcon(item.iconKey) : null;
@@ -108,12 +183,25 @@ export class SecondarySidebarComponent {
return !!item.children && item.children.length > 0;
}
/**
* 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).
*/
/** True si le chevron doit s'afficher — seulement quand le noeud a de vrais enfants. */
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'
});
}
/**

View File

@@ -53,18 +53,18 @@
<lucide-icon [img]="Dices" [size]="16"></lucide-icon>
<span>Systèmes de JDR</span>
</button>
<button class="tool-btn">
<button class="tool-btn" *ngIf="!config.demoMode">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Export VTT</span>
</button>
<button class="tool-btn" [class.active]="currentRoute.startsWith('/settings')" (click)="navigateTo('/settings')">
<button class="tool-btn" *ngIf="!config.demoMode" [class.active]="currentRoute.startsWith('/settings')" (click)="navigateTo('/settings')">
<lucide-icon [img]="Settings" [size]="16"></lucide-icon>
<span>Paramètres</span>
</button>
</div>
<div class="sidebar-footer">
<span class="version">Version 0.4.0</span>
<span class="version">Version {{ appVersion }}</span>
</div>
</aside>

View File

@@ -4,6 +4,10 @@ import { Router } from '@angular/router';
import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular';
import { LayoutService } from '../services/layout.service';
import { GlobalSearchService } from '../services/global-search.service';
import { ConfigService } from '../services/config.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({
selector: 'app-sidebar',
@@ -22,11 +26,13 @@ export class SidebarComponent {
readonly Dices = Dices;
readonly layoutConfig$ = this.layoutService.secondarySidebar$;
readonly appVersion = packageJson.version;
constructor(
private router: Router,
private layoutService: LayoutService,
private globalSearch: GlobalSearchService
private globalSearch: GlobalSearchService,
public config: ConfigService
) {
this.router.events.subscribe(() => {
this.currentRoute = this.router.url;

View File

@@ -3,6 +3,8 @@ import { AppComponent } from './app/app.component';
import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router';
import { routes } from './app/app.routes';
import { provideHttpClient } from '@angular/common/http';
import { APP_INITIALIZER } from '@angular/core';
import { ConfigService } from './app/services/config.service';
// withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular
// telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la
@@ -13,5 +15,11 @@ bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules)),
provideHttpClient(),
{
provide: APP_INITIALIZER,
useFactory: (config: ConfigService) => () => config.load(),
deps: [ConfigService],
multi: true,
},
],
}).catch((err: Error) => console.error(err));

Some files were not shown because too many files have changed in this diff Show More