Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m50s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 18s

Corrections fonctionnelles
- order initialisé à la création des pages et dossiers de lore (nextOrderFor) :
  un nouvel élément se place désormais en dernier de sa fratrie au lieu de 0
- storePortrait : type MIME canonique dérivé de l'extension du fichier
  (un data URL "image/jpg" n'est plus rejeté silencieusement)
- takeUntilDestroyed ajouté sur les abonnements paramMap de arc-view,
  folder-view et campaign-detail (fuites mémoire)

Factorisation (suppression de duplication)
- shared/folder-grouping.util.ts : groupByFolder + byOrder + byFolderName
  mutualisés entre npc-list, enemy-list, campaign-detail et la sidebar
  (folderChildren). Le tri des dossiers est désormais cohérent entre la
  sidebar et les vues cartes (insensible casse/accents partout)
- DataSyncService.onChange()/persist() remplacent le câblage
  changed$ + reorder recopié dans 5 vues
- campaign-detail : loadCampaignBundle()/applyCampaignBundle() éliminent
  le forkJoin dupliqué entre ngOnInit et reload
- ReorderSupport (domain/shared) : squelette générique remplaçant les
  8 boucles de réordonnancement copiées dans les services
- FoundryExportService : GameSystem résolu une seule fois dans buildBundle
- module Foundry : walkScalars mutualise la récursion de flattenStats et
  flattenStructure (comportements préservés) ; esc() de l'importer aligné
  sur foundry.utils.escapeHTML

Suppression de code mort
- characterService/characters retirés de loadCampaignTreeData et de ses
  16 appelants (arguments, injections, imports et littéraux CampaignTreeData)
- CSS orphelin : .tree-row.cdk-drop-list-receiving et .btn-back
- BottomPanel.initiallyOpen (jamais lu) retiré de l'interface et de l'appelant
- commentaire trompeur du PDF corrigé

Tests
- mise à jour de 3 tests obsolètes de campaign-tree.helper.spec qui
  vérifiaient encore l'ancien comportement (tri alphabétique, non-classés
  en enfants directs) → alignés sur le modèle actuel (tri par order +
  pseudo-dossier "Sans dossier")
This commit is contained in:
2026-06-26 09:10:20 +02:00
parent 3896c5b4cb
commit a9242ba1e1
94 changed files with 2234 additions and 546 deletions

View File

@@ -148,6 +148,23 @@
<artifactId>tink</artifactId> <artifactId>tink</artifactId>
<version>1.21.0</version> <version>1.21.0</version>
</dependency> </dependency>
<!-- openhtmltopdf — rendu XHTML+CSS -> PDF, 100 % JVM (pas de binaire natif :
compatible distribution bureau locale via jpackage). Sert l'export PDF
d'une campagne (livret). -->
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.0.10</version>
</dependency>
<!-- TwelveMonkeys imageio-webp — décodeur WebP pur Java pour ImageIO. Sans lui,
ImageIO (et donc openhtmltopdf/PDFBox) ne sait pas lire le WebP : les
portraits/illustrations WebP seraient absents du PDF. -->
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-webp</artifactId>
<version>3.12.0</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -2,6 +2,7 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -117,4 +118,16 @@ public class ArcService {
public boolean arcExists(String id) { public boolean arcExists(String id) {
return arcRepository.existsById(id); return arcRepository.existsById(id);
} }
/**
* Réordonne les arcs d'une campagne : {@code order} = position dans la liste fournie.
* Les ids inconnus sont ignorés. Transactionnel.
*/
@Transactional
public void reorderArcs(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> arcRepository.findById(id).orElse(null),
(arc, i) -> arc.setOrder(i),
arcRepository::save);
}
} }

View File

@@ -1,6 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
@@ -97,4 +98,19 @@ public class ChapterService {
public boolean chapterExists(String id) { public boolean chapterExists(String id) {
return chapterRepository.existsById(id); return chapterRepository.existsById(id);
} }
/**
* Réordonne (et déplace) les chapitres d'un arc : {@code order} = position. Si
* {@code arcId} est fourni, on réaffecte le chapitre à cet arc. Transactionnel.
*/
@Transactional
public void reorderChapters(String arcId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> chapterRepository.findById(id).orElse(null),
(chapter, i) -> {
if (arcId != null && !arcId.isBlank()) chapter.setArcId(arcId);
chapter.setOrder(i);
},
chapterRepository::save);
}
} }

View File

@@ -1,6 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy; import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.EnemyRepository; import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.images.Image; import com.loremind.domain.images.Image;
import com.loremind.application.images.ImageService; import com.loremind.application.images.ImageService;
@@ -91,6 +92,19 @@ public class EnemyService {
enemyRepository.deleteById(id); enemyRepository.deleteById(id);
} }
/**
* Réordonne (et reclasse) les ennemis d'un dossier : {@code order} = position, et
* le dossier de chaque ennemi est posé à {@code folder} (glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderEnemies(String folder, List<String> orderedIds) {
String f = normalize(folder);
ReorderSupport.reorder(orderedIds,
id -> enemyRepository.findById(id).orElse(null),
(enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); },
enemyRepository::save);
}
// --- Import de monstres depuis un compendium Foundry ------------------- // --- Import de monstres depuis un compendium Foundry -------------------
/** /**
@@ -130,9 +144,12 @@ public class EnemyService {
String ext = contentType.contains("png") ? "png" String ext = contentType.contains("png") ? "png"
: contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg" : contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg"
: contentType.contains("gif") ? "gif" : "webp"; : contentType.contains("gif") ? "gif" : "webp";
// Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non
// standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg").
String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext;
String filename = (name == null || name.isBlank() ? "monstre" : name.replaceAll("[^a-zA-Z0-9._-]", "_")) + "." + ext; String filename = (name == null || name.isBlank() ? "monstre" : name.replaceAll("[^a-zA-Z0-9._-]", "_")) + "." + ext;
try { try {
Image img = imageService.upload(filename, contentType, Image img = imageService.upload(filename, mimeType,
new ByteArrayInputStream(bytes), bytes.length); new ByteArrayInputStream(bytes), bytes.length);
return img.getId(); return img.getId();
} catch (RuntimeException e) { } catch (RuntimeException e) {

View File

@@ -2,6 +2,7 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -102,6 +103,19 @@ public class NpcService {
npcRepository.deleteById(id); npcRepository.deleteById(id);
} }
/**
* Réordonne (et reclasse) les PNJ d'un dossier : {@code order} = position, et le
* dossier de chaque PNJ est posé à {@code folder} (déplacement par glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderNpcs(String folder, List<String> orderedIds) {
String f = normalizeFolder(folder);
ReorderSupport.reorder(orderedIds,
id -> npcRepository.findById(id).orElse(null),
(npc, i) -> { npc.setFolder(f); npc.setOrder(i); },
npcRepository::save);
}
public List<Npc> searchNpcs(String query) { public List<Npc> searchNpcs(String query) {
if (query == null || query.isBlank()) return List.of(); if (query == null || query.isBlank()) return List.of();
return npcRepository.searchByName(query.trim()); return npcRepository.searchByName(query.trim());

View File

@@ -2,6 +2,7 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.RandomTable; import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.RandomTableEntry; import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator; import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
@@ -85,6 +86,15 @@ public class RandomTableService {
repository.deleteById(id); repository.deleteById(id);
} }
/** Réordonne les tables aléatoires d'une campagne : {@code order} = position. */
@org.springframework.transaction.annotation.Transactional
public void reorderTables(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> repository.findById(id).orElse(null),
(table, i) -> table.setOrder(i),
repository::save);
}
public List<RandomTable> searchTables(String query) { public List<RandomTable> searchTables(String query) {
if (query == null || query.isBlank()) return List.of(); if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim()); return repository.searchByName(query.trim());

View File

@@ -1,10 +1,12 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Scene; import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -92,6 +94,25 @@ public class SceneService {
return sceneRepository.existsById(id); return sceneRepository.existsById(id);
} }
/**
* Réordonne les scènes d'un chapitre : {@code order} = position. Si {@code chapterId}
* diffère (scène déplacée vers un autre chapitre), on réaffecte le chapitre et on
* vide ses branches (elles ne valent que dans le chapitre d'origine). Transactionnel.
*/
@Transactional
public void reorderScenes(String chapterId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> sceneRepository.findById(id).orElse(null),
(scene, i) -> {
if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) {
scene.setChapterId(chapterId);
scene.setBranches(new ArrayList<>());
}
scene.setOrder(i);
},
sceneRepository::save);
}
/** /**
* Vérifie les invariants du graphe narratif : * Vérifie les invariants du graphe narratif :
* 1. Pas d'auto-référence (scène qui pointe sur elle-même). * 1. Pas d'auto-référence (scène qui pointe sur elle-même).

View File

@@ -1,6 +1,7 @@
package com.loremind.application.lorecontext; package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode; import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.lorecontext.Page; import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository; import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository; import com.loremind.domain.lorecontext.ports.PageRepository;
@@ -45,10 +46,20 @@ public class LoreNodeService {
.icon(changes.getIcon()) .icon(changes.getIcon())
.parentId(changes.getParentId()) .parentId(changes.getParentId())
.loreId(changes.getLoreId()) .loreId(changes.getLoreId())
.order(nextOrderFor(changes.getLoreId(), changes.getParentId()))
.build(); .build();
return loreNodeRepository.save(loreNode); return loreNodeRepository.save(loreNode);
} }
/** Prochain ordre pour un dossier neuf : après ses frères (même lore + même parent). */
private int nextOrderFor(String loreId, String parentId) {
if (loreId == null) return 0;
return loreNodeRepository.findByLoreId(loreId).stream()
.filter(n -> java.util.Objects.equals(n.getParentId(), parentId))
.mapToInt(LoreNode::getOrder)
.max().orElse(-1) + 1;
}
public Optional<LoreNode> getLoreNodeById(String id) { public Optional<LoreNode> getLoreNodeById(String id) {
return loreNodeRepository.findById(id); return loreNodeRepository.findById(id);
} }
@@ -57,6 +68,37 @@ public class LoreNodeService {
return loreNodeRepository.findAll(); return loreNodeRepository.findAll();
} }
/**
* Réordonne (et déplace) des dossiers frères : {@code order} = position, et
* {@code parentId} = dossier parent cible (null = racine). Évite les cycles : un
* dossier n'est pas réaffecté sous lui-même ni sous un de ses descendants.
*/
@Transactional
public void reorderNodes(String parentId, List<String> orderedIds) {
String parent = (parentId == null || parentId.isBlank()) ? null : parentId;
ReorderSupport.reorder(orderedIds,
id -> loreNodeRepository.findById(id).orElse(null),
(node, i) -> {
if (parent == null || !isSelfOrDescendant(parent, node.getId())) {
node.setParentId(parent);
}
node.setOrder(i);
},
loreNodeRepository::save);
}
/** True si {@code candidateId} est {@code nodeId} ou un de ses descendants (anti-cycle). */
private boolean isSelfOrDescendant(String candidateId, String nodeId) {
String cur = candidateId;
int guard = 0;
while (cur != null && guard++ < 100) {
if (cur.equals(nodeId)) return true;
LoreNode n = loreNodeRepository.findById(cur).orElse(null);
cur = n != null ? n.getParentId() : null;
}
return false;
}
public List<LoreNode> getLoreNodesByLoreId(String loreId) { public List<LoreNode> getLoreNodesByLoreId(String loreId) {
return loreNodeRepository.findByLoreId(loreId); return loreNodeRepository.findByLoreId(loreId);
} }

View File

@@ -5,6 +5,7 @@ import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.loremind.domain.shared.CollectionUtils; import com.loremind.domain.shared.CollectionUtils;
import com.loremind.domain.shared.ReorderSupport;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@@ -33,6 +34,7 @@ public class PageService {
.nodeId(nodeId) .nodeId(nodeId)
.templateId(templateId) .templateId(templateId)
.title(title) .title(title)
.order(nextOrderFor(nodeId))
.values(new HashMap<>()) .values(new HashMap<>())
.tags(new ArrayList<>()) .tags(new ArrayList<>())
.relatedPageIds(new ArrayList<>()) .relatedPageIds(new ArrayList<>())
@@ -40,6 +42,14 @@ public class PageService {
return pageRepository.save(page); return pageRepository.save(page);
} }
/** Prochain ordre pour une page neuve : après les pages existantes du même dossier. */
private int nextOrderFor(String nodeId) {
if (nodeId == null) return 0;
return pageRepository.findByNodeId(nodeId).stream()
.mapToInt(Page::getOrder)
.max().orElse(-1) + 1;
}
public Optional<Page> getPageById(String id) { public Optional<Page> getPageById(String id) {
return pageRepository.findById(id); return pageRepository.findById(id);
} }
@@ -87,4 +97,19 @@ public class PageService {
public void deletePage(String id) { public void deletePage(String id) {
pageRepository.deleteById(id); pageRepository.deleteById(id);
} }
/**
* Réordonne (et déplace) les pages d'un dossier : {@code order} = position dans la
* liste, et {@code nodeId} = dossier cible (déplacement par glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderPages(String nodeId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> pageRepository.findById(id).orElse(null),
(page, i) -> {
if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId);
page.setOrder(i);
},
pageRepository::save);
}
} }

View File

@@ -24,6 +24,8 @@ public class LoreNode {
private String icon; private String icon;
private String parentId; // Auto-référence pour l'arborescence private String parentId; // Auto-référence pour l'arborescence
private String loreId; // Référence vers le Lore parent private String loreId; // Référence vers le Lore parent
/** Position du dossier parmi ses frères (glisser-déposer). */
private int order;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@@ -29,6 +29,9 @@ public class Page {
private String templateId; private String templateId;
private String title; private String title;
/** Position de la page dans son dossier (glisser-déposer). */
private int order;
/** Valeurs des champs dynamiques TEXT définis par le Template. */ /** Valeurs des champs dynamiques TEXT définis par le Template. */
private Map<String, String> values; private Map<String, String> values;

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.shared;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.ObjIntConsumer;
/**
* Squelette commun du réordonnancement par glisser-déposer : parcourt la liste
* ordonnée d'ids, charge chaque entité, lui applique sa position (et toute
* réaffectation de parent via {@code applyOrder}), puis la sauve. Les ids inconnus
* sont ignorés. Évite de recopier cette boucle dans chaque service ordonnable.
*/
public final class ReorderSupport {
private ReorderSupport() {}
/**
* @param orderedIds liste ordonnée des ids (null = no-op)
* @param finder id → entité (ou null si introuvable)
* @param applyOrder (entité, index) → pose l'ordre et l'éventuel parent
* @param saver persiste l'entité
*/
public static <T> void reorder(List<String> orderedIds,
Function<String, T> finder,
ObjIntConsumer<T> applyOrder,
Consumer<T> saver) {
if (orderedIds == null) return;
int i = 0;
for (String id : orderedIds) {
T entity = finder.apply(id);
if (entity != null) {
applyOrder.accept(entity, i);
saver.accept(entity);
}
i++;
}
}
}

View File

@@ -36,6 +36,10 @@ public class LoreNodeJpaEntity {
@Column(name = "lore_id", nullable = false) @Column(name = "lore_id", nullable = false)
private Long loreId; private Long loreId;
/** Position parmi les dossiers frères. Colonne quotée : `order` est un mot-clé SQL. */
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@@ -45,6 +45,10 @@ public class PageJpaEntity {
@Column(nullable = false) @Column(nullable = false)
private String title; private String title;
/** Position dans le dossier. Colonne quotée : `order` est un mot-clé SQL. */
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "values_json", columnDefinition = "TEXT") @Column(name = "values_json", columnDefinition = "TEXT")
@Convert(converter = StringMapJsonConverter.class) @Convert(converter = StringMapJsonConverter.class)
private Map<String, String> values; private Map<String, String> values;

View File

@@ -95,6 +95,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
.icon(jpaEntity.getIcon()) .icon(jpaEntity.getIcon())
.parentId(jpaEntity.getParentId() != null ? jpaEntity.getParentId().toString() : null) .parentId(jpaEntity.getParentId() != null ? jpaEntity.getParentId().toString() : null)
.loreId(jpaEntity.getLoreId().toString()) .loreId(jpaEntity.getLoreId().toString())
.order(jpaEntity.getOrder())
.createdAt(jpaEntity.getCreatedAt()) .createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt()) .updatedAt(jpaEntity.getUpdatedAt())
.build(); .build();
@@ -111,6 +112,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
.icon(loreNode.getIcon()) .icon(loreNode.getIcon())
.parentId(parentId) .parentId(parentId)
.loreId(Long.parseLong(loreNode.getLoreId())) .loreId(Long.parseLong(loreNode.getLoreId()))
.order(loreNode.getOrder())
.createdAt(loreNode.getCreatedAt()) .createdAt(loreNode.getCreatedAt())
.updatedAt(loreNode.getUpdatedAt()) .updatedAt(loreNode.getUpdatedAt())
.build(); .build();

View File

@@ -91,6 +91,7 @@ public class PostgresPageRepository implements PageRepository {
.nodeId(e.getNodeId() != null ? e.getNodeId().toString() : null) .nodeId(e.getNodeId() != null ? e.getNodeId().toString() : null)
.templateId(e.getTemplateId() != null ? e.getTemplateId().toString() : null) .templateId(e.getTemplateId() != null ? e.getTemplateId().toString() : null)
.title(e.getTitle()) .title(e.getTitle())
.order(e.getOrder())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>()) .values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>()) .imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>()) .keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
@@ -111,6 +112,7 @@ public class PostgresPageRepository implements PageRepository {
.templateId(p.getTemplateId() != null && !p.getTemplateId().isBlank() .templateId(p.getTemplateId() != null && !p.getTemplateId().isBlank()
? Long.parseLong(p.getTemplateId()) : null) ? Long.parseLong(p.getTemplateId()) : null)
.title(p.getTitle()) .title(p.getTitle())
.order(p.getOrder())
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>()) .values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>()) .imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
.keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>()) .keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>())

View File

@@ -99,9 +99,10 @@ public class FoundryExportService {
CampaignJpaEntity campaign = campaignRepo.findById(Long.parseLong(campaignId)) CampaignJpaEntity campaign = campaignRepo.findById(Long.parseLong(campaignId))
.orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId)); .orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId));
List<TemplateField> npcTemplate = resolveTemplate(campaign.getGameSystemId(), true); GameSystemJpaEntity gameSystem = resolveGameSystem(campaign.getGameSystemId());
List<TemplateField> enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false); List<TemplateField> npcTemplate = gameSystem != null ? gameSystem.getNpcTemplate() : null;
String foundryActorType = resolveActorType(campaign.getGameSystemId()); List<TemplateField> enemyTemplate = gameSystem != null ? gameSystem.getEnemyTemplate() : null;
String foundryActorType = gameSystem != null ? gameSystem.getFoundryActorType() : null;
AssetRegistry assets = new AssetRegistry(); AssetRegistry assets = new AssetRegistry();
@@ -275,23 +276,11 @@ public class FoundryExportService {
// ----- Resolution des champs PNJ/Ennemi via le template ----- // ----- Resolution des champs PNJ/Ennemi via le template -----
private List<TemplateField> resolveTemplate(String gameSystemId, boolean npc) { /** Résout le GameSystem UNE fois (templates PNJ/ennemi + type d'acteur en sont tirés). */
private GameSystemJpaEntity resolveGameSystem(String gameSystemId) {
if (gameSystemId == null || gameSystemId.isBlank()) return null; if (gameSystemId == null || gameSystemId.isBlank()) return null;
try { try {
return gameSystemRepo.findById(Long.parseLong(gameSystemId)) return gameSystemRepo.findById(Long.parseLong(gameSystemId)).orElse(null);
.map(gs -> npc ? gs.getNpcTemplate() : gs.getEnemyTemplate())
.orElse(null);
} catch (NumberFormatException ex) {
return null;
}
}
private String resolveActorType(String gameSystemId) {
if (gameSystemId == null || gameSystemId.isBlank()) return null;
try {
return gameSystemRepo.findById(Long.parseLong(gameSystemId))
.map(GameSystemJpaEntity::getFoundryActorType)
.orElse(null);
} catch (NumberFormatException ex) { } catch (NumberFormatException ex) {
return null; return null;
} }

View File

@@ -0,0 +1,611 @@
package com.loremind.infrastructure.transfer.pdf;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.*;
/**
* Export d'UNE campagne en livret PDF (joli document imprimable) : structure narrative
* (arcs/quetes/scenes), PNJ &amp; ennemis (bestiaire), lore, et battlemaps en illustration.
* <p>
* Rendu XHTML+CSS -&gt; PDF via openhtmltopdf (100 % JVM). Les images sont rincees,
* redimensionnees et re-encodees en JPEG (ImageIO + decodeur WebP TwelveMonkeys) puis
* embarquees en data-URI : garantit le rendu (WebP compris) et borne la taille du PDF.
*/
@Service
public class PdfExportService {
private static final Logger log = LoggerFactory.getLogger(PdfExportService.class);
/** Cotes max (px) avant re-encodage : portraits compacts, battlemaps/illustrations larges. */
private static final int PORTRAIT_MAX = 700;
private static final int ILLUSTRATION_MAX = 1500;
private final CampaignJpaRepository campaignRepo;
private final ArcJpaRepository arcRepo;
private final ChapterJpaRepository chapterRepo;
private final SceneJpaRepository sceneRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
private final GameSystemJpaRepository gameSystemRepo;
private final ImageJpaRepository imageRepo;
private final StoredFileJpaRepository storedFileRepo;
private final LoreNodeJpaRepository loreNodeRepo;
private final PageJpaRepository pageRepo;
private final TemplateJpaRepository templateRepo;
private final ImageStorage imageStorage;
private final FileStorage fileStorage;
public PdfExportService(CampaignJpaRepository campaignRepo, ArcJpaRepository arcRepo,
ChapterJpaRepository chapterRepo, SceneJpaRepository sceneRepo,
NpcJpaRepository npcRepo, EnemyJpaRepository enemyRepo,
GameSystemJpaRepository gameSystemRepo, ImageJpaRepository imageRepo,
StoredFileJpaRepository storedFileRepo,
LoreNodeJpaRepository loreNodeRepo, PageJpaRepository pageRepo,
TemplateJpaRepository templateRepo, ImageStorage imageStorage,
FileStorage fileStorage) {
this.campaignRepo = campaignRepo;
this.arcRepo = arcRepo;
this.chapterRepo = chapterRepo;
this.sceneRepo = sceneRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
this.gameSystemRepo = gameSystemRepo;
this.imageRepo = imageRepo;
this.storedFileRepo = storedFileRepo;
this.loreNodeRepo = loreNodeRepo;
this.pageRepo = pageRepo;
this.templateRepo = templateRepo;
this.imageStorage = imageStorage;
this.fileStorage = fileStorage;
}
/** Nom de la campagne (pour le nom de fichier). @throws NoSuchElementException si absente. */
public String campaignName(String campaignId) {
return campaign(campaignId).getName();
}
/** Construit le PDF complet de la campagne. @throws NoSuchElementException si absente. */
public byte[] export(String campaignId) {
CampaignJpaEntity campaign = campaign(campaignId);
String xhtml = buildXhtml(campaign);
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.withHtmlContent(xhtml, null);
builder.toStream(out);
builder.run();
return out.toByteArray();
} catch (IOException e) {
throw new UncheckedIOException("Echec de la generation du PDF", e);
}
}
private CampaignJpaEntity campaign(String campaignId) {
return campaignRepo.findById(Long.parseLong(campaignId))
.orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId));
}
// ====================================================================== XHTML
private String buildXhtml(CampaignJpaEntity campaign) {
List<TemplateField> npcTemplate = resolveTemplate(campaign.getGameSystemId(), true);
List<TemplateField> enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false);
StringBuilder body = new StringBuilder();
StringBuilder bookmarks = new StringBuilder();
cover(body, campaign);
narrative(body, bookmarks, campaign);
personas(body, bookmarks, "part-npcs", "Personnages non-joueurs (PNJ)", npcEntries(campaign), npcTemplate, false);
personas(body, bookmarks, "part-enemies", "Bestiaire", enemyEntries(campaign), enemyTemplate, true);
lore(body, bookmarks, campaign);
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><meta charset=\"utf-8\"/>\n"
+ "<style>" + CSS + "</style>\n"
+ "<bookmarks>" + bookmarks + "</bookmarks>\n"
+ "</head><body>" + body + "</body></html>";
}
private void cover(StringBuilder b, CampaignJpaEntity c) {
b.append("<div class=\"cover\">");
b.append("<div class=\"subtitle\">Livret de campagne</div>");
b.append("<h1 class=\"cover-title\">").append(esc(c.getName())).append("</h1>");
if (notBlank(c.getDescription())) {
b.append("<div class=\"cover-desc\">").append(multiline(c.getDescription())).append("</div>");
}
b.append("</div>");
}
// ----- Structure narrative : arcs -> quetes -> scenes -----
private void narrative(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) {
// Tri par ORDRE manuel (glisser-déposer) — cohérent avec l'arbre et les cartes.
List<ArcJpaEntity> arcs = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder);
if (arcs.isEmpty()) return;
b.append("<h1 class=\"part\" id=\"part-narrative\">Structure narrative</h1>");
bm.append(bookmark("Structure narrative", "part-narrative", () -> {
StringBuilder sub = new StringBuilder();
for (ArcJpaEntity arc : arcs) sub.append(bookmark(arc.getName(), "arc-" + arc.getId(), null));
return sub.toString();
}));
for (ArcJpaEntity arc : arcs) {
b.append("<div class=\"arc\"><h2 class=\"arc-head\" id=\"arc-").append(arc.getId()).append("\">")
.append("<span class=\"eyebrow eyebrow-light\">Arc</span>")
.append(esc(arc.getName())).append("</h2>");
illustrations(b, arc.getIllustrationImageIds());
block(b, "Description", arc.getDescription());
block(b, "Themes", arc.getThemes());
block(b, "Enjeux", arc.getStakes());
block(b, "Recompenses", arc.getRewards());
block(b, "Resolution", arc.getResolution());
block(b, "Notes MJ", arc.getGmNotes());
for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) {
b.append("<div class=\"quest\"><div class=\"quest-head\">")
.append("<span class=\"eyebrow\">Quête</span>")
.append(esc(ch.getName())).append("</div>");
illustrations(b, ch.getIllustrationImageIds());
block(b, "Description", ch.getDescription());
block(b, "Objectifs joueurs", ch.getPlayerObjectives());
block(b, "Enjeux narratifs", ch.getNarrativeStakes());
block(b, "Notes MJ", ch.getGmNotes());
for (SceneJpaEntity sc : sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder)) {
scene(b, sc);
}
b.append("</div>");
}
b.append("</div>");
}
}
private void scene(StringBuilder b, SceneJpaEntity sc) {
b.append("<div class=\"scene\"><div class=\"scene-head\">")
.append("<span class=\"eyebrow\">Scène</span>")
.append(esc(sc.getName())).append("</div>");
block(b, "Lieu", sc.getLocation());
block(b, "Moment", sc.getTiming());
block(b, "Ambiance", sc.getAtmosphere());
block(b, "Narration joueur", sc.getPlayerNarration());
block(b, "Notes secretes MJ", sc.getGmSecretNotes());
block(b, "Choix & consequences", sc.getChoicesConsequences());
block(b, "Difficulte du combat", sc.getCombatDifficulty());
illustrations(b, sc.getIllustrationImageIds());
// Battlemap (image uniquement ; les videos ne sont pas rendables en PDF).
String battlemap = fileImageUri(sc.getBattlemapMediaFileId());
if (battlemap != null) {
b.append("<div class=\"illus\"><img src=\"").append(battlemap).append("\"/></div>");
}
b.append("</div>");
}
// ----- PNJ / Ennemis (groupes par dossier) -----
/** Une fiche persona generique pour la mise en page (PNJ ou ennemi). */
private record PersonaRow(int order, String name, String folder, String level, String portraitId,
Map<String, String> values, Map<String, Map<String, String>> keyValueValues,
Map<String, List<String>> imageValues, Map<String, String> foundryStats) {}
private List<PersonaRow> npcEntries(CampaignJpaEntity c) {
List<PersonaRow> out = new ArrayList<>();
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(c.getId())) {
out.add(new PersonaRow(n.getOrder(), n.getName(), n.getFolder(), null, n.getPortraitImageId(),
n.getValues(), n.getKeyValueValues(), n.getImageValues(), null));
}
return out;
}
private List<PersonaRow> enemyEntries(CampaignJpaEntity c) {
List<PersonaRow> out = new ArrayList<>();
for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(c.getId())) {
out.add(new PersonaRow(e.getOrder(), e.getName(), e.getFolder(), e.getLevel(), e.getPortraitImageId(),
e.getValues(), e.getKeyValueValues(), e.getImageValues(), e.getFoundryStats()));
}
return out;
}
private void personas(StringBuilder b, StringBuilder bm, String partId, String title,
List<PersonaRow> rows, List<TemplateField> template, boolean enemy) {
if (rows.isEmpty()) return;
b.append("<h1 class=\"part\" id=\"").append(partId).append("\">").append(esc(title)).append("</h1>");
bm.append(bookmark(title, partId, null));
// Groupement par dossier (dossiers tries, non-classes en dernier).
Map<String, List<PersonaRow>> byFolder = new TreeMap<>();
List<PersonaRow> ungrouped = new ArrayList<>();
for (PersonaRow r : rows) {
String f = r.folder() != null ? r.folder().trim() : "";
if (f.isEmpty()) ungrouped.add(r);
else byFolder.computeIfAbsent(f, k -> new ArrayList<>()).add(r);
}
for (Map.Entry<String, List<PersonaRow>> e : byFolder.entrySet()) {
b.append("<h3 class=\"folder\">").append(esc(e.getKey().replace("/", " / "))).append("</h3>");
e.getValue().sort(java.util.Comparator.comparingInt(PersonaRow::order));
for (PersonaRow r : e.getValue()) personaCard(b, r, template, enemy);
}
if (!ungrouped.isEmpty()) {
if (!byFolder.isEmpty()) b.append("<h3 class=\"folder\">Sans dossier</h3>");
ungrouped.sort(java.util.Comparator.comparingInt(PersonaRow::order));
for (PersonaRow r : ungrouped) personaCard(b, r, template, enemy);
}
}
private void personaCard(StringBuilder b, PersonaRow r, List<TemplateField> template, boolean enemy) {
// Mise en page en TABLE (portrait | contenu) : openhtmltopdf gere mal le float
// + overflow (le texte se superposait au portrait).
b.append("<div class=\"card\"><table class=\"persona\"><tr>");
String portrait = imageUri(r.portraitId(), PORTRAIT_MAX);
if (portrait != null) {
b.append("<td class=\"persona-portrait\"><img src=\"").append(portrait).append("\"/></td>");
}
b.append("<td class=\"persona-content\">");
b.append("<div class=\"persona-name\">").append(esc(r.name()));
if (notBlank(r.level())) b.append(" <span class=\"level\">Niv. ").append(esc(r.level())).append("</span>");
b.append("</div>");
renderFields(b, template, r.values(), r.keyValueValues(), r.imageValues(), null);
if (enemy && r.foundryStats() != null) {
Map<String, String> clean = cleanStats(r.foundryStats());
if (!clean.isEmpty()) {
b.append("<div class=\"field-label\">Statistiques</div><table class=\"stats-table\"><tbody>");
for (Map.Entry<String, String> s : clean.entrySet()) {
b.append("<tr><th>").append(esc(s.getKey())).append("</th><td>")
.append(esc(s.getValue())).append("</td></tr>");
}
b.append("</tbody></table>");
}
}
b.append("</td></tr></table></div>");
}
/** Valeurs de stats considerees comme "vides"/bruit (masquees dans le livret). */
private static final Set<String> STAT_NOISE_VALUES = Set.of("0", "0.0", "false", "none", "null", "", "-", "");
/** Premier segment de chemin a elaguer du libelle (purement structurel). */
private static final Set<String> STAT_DROP_PREFIX = Set.of("attributes", "details", "system", "data");
/**
* Nettoie le snapshot de stats Foundry pour le livret : retire le bruit (valeurs
* 0/false/none/vides, options techniques type rollMode) et humanise les clés
* ("attributes.hp.value" -> "Hp value"). Conserve l'ordre alphabetique des clés.
*/
private static Map<String, String> cleanStats(Map<String, String> stats) {
Map<String, String> out = new LinkedHashMap<>();
for (Map.Entry<String, String> e : new TreeMap<>(stats).entrySet()) {
String key = e.getKey();
String val = e.getValue();
if (key == null || val == null) continue;
String v = val.trim();
if (STAT_NOISE_VALUES.contains(v.toLowerCase())) continue;
String lk = key.toLowerCase();
if (lk.contains("rollmode") || lk.endsWith(".defaultrollmode")) continue;
out.put(humanizeStatKey(key), v);
}
return out;
}
/** "attributes.hp.value" -> "Hp value" ; "details.creatureType" -> "Creature type". */
private static String humanizeStatKey(String key) {
String[] parts = key.split("\\.");
int start = (parts.length > 1 && STAT_DROP_PREFIX.contains(parts[0].toLowerCase())) ? 1 : 0;
String s = String.join(" ", Arrays.asList(parts).subList(start, parts.length));
s = s.replaceAll("([a-z0-9])([A-Z])", "$1 $2").toLowerCase().trim(); // camelCase -> mots
return s.isEmpty() ? key : Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
// ----- Lore (pages groupees par dossier) -----
private void lore(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) {
String loreId = campaign.getLoreId();
if (loreId == null || loreId.isBlank()) return;
Long lid;
try { lid = Long.parseLong(loreId); } catch (NumberFormatException ex) { return; }
List<PageJpaEntity> pages = pageRepo.findByLoreId(lid);
if (pages.isEmpty()) return;
b.append("<h1 class=\"part\" id=\"part-lore\">Lore</h1>");
bm.append(bookmark("Lore", "part-lore", null));
// Chemins de dossiers (LoreNode) + templates par id.
Map<Long, LoreNodeJpaEntity> nodes = new HashMap<>();
for (LoreNodeJpaEntity n : loreNodeRepo.findByLoreId(lid)) nodes.put(n.getId(), n);
Map<Long, TemplateJpaEntity> templates = new HashMap<>();
for (TemplateJpaEntity t : templateRepo.findByLoreId(lid)) templates.put(t.getId(), t);
Map<String, List<PageJpaEntity>> byPath = new TreeMap<>();
for (PageJpaEntity p : pages) {
byPath.computeIfAbsent(nodePath(p.getNodeId(), nodes), k -> new ArrayList<>()).add(p);
}
for (Map.Entry<String, List<PageJpaEntity>> e : byPath.entrySet()) {
String label = e.getKey().isEmpty() ? "Sans dossier" : e.getKey();
b.append("<h3 class=\"folder\">").append(esc(label)).append("</h3>");
e.getValue().sort(java.util.Comparator.comparingInt(PageJpaEntity::getOrder));
for (PageJpaEntity p : e.getValue()) {
b.append("<div class=\"card\"><div class=\"card-body\"><div class=\"persona-name\">")
.append(esc(p.getTitle())).append("</div>");
List<TemplateField> tpl = p.getTemplateId() != null
? fieldsOf(templates.get(p.getTemplateId())) : null;
renderFields(b, tpl, p.getValues(), p.getKeyValueValues(), p.getImageValues(), p.getTableValues());
b.append("</div></div>");
}
}
}
private String nodePath(Long nodeId, Map<Long, LoreNodeJpaEntity> nodes) {
if (nodeId == null) return "";
Deque<String> parts = new ArrayDeque<>();
Long cur = nodeId;
int guard = 0;
while (cur != null && guard++ < 30) {
LoreNodeJpaEntity n = nodes.get(cur);
if (n == null) break;
if (n.getName() != null) parts.addFirst(n.getName());
cur = n.getParentId();
}
return String.join(" / ", parts);
}
// ----- Rendu des champs pilotes par template -----
private void renderFields(StringBuilder b, List<TemplateField> template, Map<String, String> values,
Map<String, Map<String, String>> keyValueValues,
Map<String, List<String>> imageValues,
Map<String, List<Map<String, String>>> tableValues) {
// Repli : pas de template -> paires brutes cle=valeur.
if (template == null || template.isEmpty()) {
if (values != null) {
for (Map.Entry<String, String> e : values.entrySet()) block(b, e.getKey(), e.getValue());
}
return;
}
for (TemplateField f : template) {
if (f == null || f.getName() == null || f.getType() == null) continue;
FieldType type = f.getType();
switch (type) {
case TEXT, NUMBER -> block(b, f.getName(), values != null ? values.get(f.getName()) : null);
case KEY_VALUE_LIST -> {
Map<String, String> inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null;
List<String> labels = f.getLabels();
if (inner != null && labels != null) {
StringBuilder rows = new StringBuilder();
for (String label : labels) {
String v = inner.get(label);
if (notBlank(v)) rows.append("<tr><th>").append(esc(label)).append("</th><td>")
.append(esc(v)).append("</td></tr>");
}
if (rows.length() > 0) {
b.append("<div class=\"field-label\">").append(esc(f.getName()))
.append("</div><table class=\"stats-table\"><tbody>").append(rows)
.append("</tbody></table>");
}
}
}
case IMAGE -> {
List<String> ids = imageValues != null ? imageValues.get(f.getName()) : null;
illustrations(b, ids);
}
case TABLE -> {
List<Map<String, String>> data = tableValues != null ? tableValues.get(f.getName()) : null;
List<String> cols = f.getLabels();
if (data != null && !data.isEmpty() && cols != null && !cols.isEmpty()) {
b.append("<div class=\"field-label\">").append(esc(f.getName()))
.append("</div><table class=\"stats-table\"><thead><tr>");
for (String col : cols) b.append("<th>").append(esc(col)).append("</th>");
b.append("</tr></thead><tbody>");
for (Map<String, String> row : data) {
b.append("<tr>");
for (String col : cols) b.append("<td>").append(esc(row.get(col))).append("</td>");
b.append("</tr>");
}
b.append("</tbody></table>");
}
}
}
}
}
/** Bloc "label + valeur multiligne" si la valeur est non vide. */
private void block(StringBuilder b, String label, String value) {
if (!notBlank(value)) return;
b.append("<div class=\"field\"><div class=\"field-label\">").append(esc(label))
.append("</div><div class=\"field-value\">").append(multiline(value)).append("</div></div>");
}
/** Galerie d'illustrations (liste d'ids d'images) -> blocs image. */
private void illustrations(StringBuilder b, List<String> imageIds) {
if (imageIds == null) return;
for (String id : imageIds) {
String uri = imageUri(id, ILLUSTRATION_MAX);
if (uri != null) b.append("<div class=\"illus\"><img src=\"").append(uri).append("\"/></div>");
}
}
// ====================================================================== Images
/** Data-URI JPEG redimensionne d'une image LoreMind, ou null. */
private String imageUri(String imageId, int maxDim) {
if (imageId == null || imageId.isBlank()) return null;
ImageJpaEntity e;
try {
e = imageRepo.findById(Long.parseLong(imageId)).orElse(null);
} catch (NumberFormatException ex) {
return null;
}
if (e == null) return null;
return encode(imageStorage.download(e.getStorageKey()), maxDim, imageId);
}
/** Data-URI d'une battlemap (fichier stocke) seulement si c'est une image (pas une video). */
private String fileImageUri(String fileId) {
if (fileId == null || fileId.isBlank()) return null;
StoredFileJpaEntity e;
try {
e = storedFileRepo.findById(Long.parseLong(fileId)).orElse(null);
} catch (NumberFormatException ex) {
return null;
}
if (e == null) return null;
String ct = e.getContentType();
if (ct == null || !ct.startsWith("image/")) return null; // mp4/webm -> ignore
return encode(fileStorage.download(e.getStorageKey()), ILLUSTRATION_MAX, fileId);
}
/** Lit un flux image, le redimensionne (max maxDim) et le re-encode en data-URI JPEG. */
private String encode(InputStream in, int maxDim, String ref) {
if (in == null) return null;
try (in) {
BufferedImage src = ImageIO.read(in);
if (src == null) {
log.debug("Image PDF ignoree (format non decode) : {}", ref);
return null;
}
int w = src.getWidth(), h = src.getHeight();
double scale = Math.min(1.0, (double) maxDim / Math.max(w, h));
int nw = Math.max(1, (int) Math.round(w * scale));
int nh = Math.max(1, (int) Math.round(h * scale));
BufferedImage dst = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB);
Graphics2D g = dst.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setColor(Color.WHITE); // fond blanc : aplatit la transparence (JPEG sans alpha)
g.fillRect(0, 0, nw, nh);
g.drawImage(src, 0, 0, nw, nh, null);
g.dispose();
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(dst, "jpeg", out);
return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(out.toByteArray());
} catch (IOException | RuntimeException ex) {
log.warn("Image PDF ignoree ({}) : {}", ref, ex.getMessage());
return null;
}
}
// ====================================================================== Helpers
private List<TemplateField> resolveTemplate(String gameSystemId, boolean npc) {
if (gameSystemId == null || gameSystemId.isBlank()) return null;
try {
return gameSystemRepo.findById(Long.parseLong(gameSystemId))
.map(gs -> npc ? gs.getNpcTemplate() : gs.getEnemyTemplate())
.orElse(null);
} catch (NumberFormatException ex) {
return null;
}
}
private static List<TemplateField> fieldsOf(TemplateJpaEntity t) {
return t != null ? t.getFields() : null;
}
/** Tri par ORDRE manuel (glisser-déposer) — cohérent avec l'arbre et les cartes. */
private static <T> List<T> sortByOrder(List<T> list, java.util.function.ToIntFunction<T> order) {
List<T> copy = new ArrayList<>(list);
copy.sort(java.util.Comparator.comparingInt(order));
return copy;
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
/** Un signet PDF (avec enfants optionnels fournis par `children`). */
private static String bookmark(String name, String anchor, java.util.function.Supplier<String> children) {
String inner = children != null ? children.get() : "";
return "<bookmark name=\"" + esc(name == null ? "" : name) + "\" href=\"#" + anchor + "\">" + inner + "</bookmark>";
}
/** Echappe le texte pour XHTML et retire les caracteres interdits en XML 1.0. */
private static String esc(String s) {
if (s == null) return "";
StringBuilder b = new StringBuilder(s.length() + 16);
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c < 0x20 && c != '\n' && c != '\t') continue; // \r normalise en amont
switch (c) {
case '&' -> b.append("&amp;");
case '<' -> b.append("&lt;");
case '>' -> b.append("&gt;");
case '"' -> b.append("&quot;");
default -> b.append(c);
}
}
return b.toString();
}
/** Comme esc, mais les sauts de ligne deviennent des &lt;br/&gt;. */
private static String multiline(String s) {
if (s == null) return "";
return esc(s.replace("\r\n", "\n").replace('\r', '\n')).replace("\n", "<br/>");
}
// CSS print (CSS 2.1 + paged media supporte par openhtmltopdf : pas de flexbox/grid).
private static final String CSS = """
@page { size: A4; margin: 2cm 1.7cm;
@bottom-center { content: counter(page); font-size: 9pt; color: #999; } }
body { font-family: 'Helvetica', sans-serif; font-size: 10.5pt; color: #222; line-height: 1.45; }
.cover { text-align: center; padding-top: 7cm; page-break-after: always; }
.cover .subtitle { font-size: 12pt; letter-spacing: .3em; text-transform: uppercase; color: #8a7bc8; }
.cover-title { font-size: 32pt; text-transform: uppercase; letter-spacing: .03em; color: #2e2a4a; margin: .4cm 0; border: none; }
.cover-desc { margin: 1cm auto 0; max-width: 13cm; color: #444; text-align: left; }
.cover .meta { margin-top: 1.2cm; color: #777; font-size: 10pt; }
h1.part { page-break-before: always; font-size: 21pt; text-transform: uppercase; letter-spacing: .05em;
color: #2e2a4a; border-bottom: 2pt solid #8a7bc8; padding-bottom: 3pt; margin: 0 0 .5cm; }
h2 { font-size: 16pt; color: #4a3f7a; margin: 1.1em 0 .3em; }
h3.folder { color: #8a7bc8; text-transform: uppercase; letter-spacing: .04em; font-size: 11pt;
border-bottom: 1pt dotted #ccc; margin-top: 1em; }
/* Hierarchie narrative : Arc (bandeau) > Quete (en-tete teinte) > Scene (carte). */
.arc { margin: .5cm 0 .3cm; }
.arc-head { background: #2e2a4a; color: #fff; font-size: 15pt; font-weight: bold;
padding: .22cm .4cm; border-radius: 4pt; margin: 0 0 .35cm; }
.quest { margin: .5cm 0 .35cm; }
.quest-head { background: #f1eef9; border-left: 5pt solid #8a7bc8; padding: .15cm .4cm;
font-size: 13pt; font-weight: bold; color: #463b78; }
.scene { margin: .3cm 0 .35cm .35cm; border: 1pt solid #e6e6ee; border-left: 3pt solid #9bb06a;
border-radius: 4pt; padding: .25cm .4cm; background: #fbfbfd; }
.scene-head { font-size: 12pt; font-weight: bold; color: #5a6e3a; margin-bottom: .12cm; }
.eyebrow { display: block; font-size: 7pt; text-transform: uppercase; letter-spacing: .15em;
font-weight: normal; color: #9182bd; }
.eyebrow-light { color: #c9c0e8; }
.field { margin: .22cm 0; }
.field-label { font-size: 8pt; text-transform: uppercase; letter-spacing: .07em;
color: #8076a3; font-weight: bold; margin-bottom: .03cm; }
.field-value { color: #222; }
.card { page-break-inside: avoid; border: 1pt solid #e2e2e2; border-radius: 4pt;
padding: .35cm .4cm; margin: .35cm 0; background: #fafafa; }
.card-body { display: block; }
.persona { width: 100%; border-collapse: collapse; }
.persona-portrait { width: 3cm; vertical-align: top; padding: 0 .45cm 0 0; }
.persona-portrait img { width: 3cm; border: 1pt solid #ccc; border-radius: 3pt; }
.persona-content { vertical-align: top; }
.persona-name { font-size: 12pt; font-weight: bold; color: #2e2a4a; margin-bottom: .12cm; }
.level { font-size: 9pt; color: #8a7bc8; font-weight: normal; }
.illus { margin: .3cm 0; }
.illus img { width: 100%; border: 1pt solid #ccc; border-radius: 3pt; }
.stats-table { width: 100%; border-collapse: collapse; font-size: 9pt; margin: .15cm 0 .3cm; }
.stats-table th, .stats-table td { border: 1pt solid #e0e0e0; padding: 2pt 5pt; text-align: left; vertical-align: top; }
.stats-table th { background: #f0eef7; width: 35%; color: #555; }
""";
}

View File

@@ -70,4 +70,13 @@ public class ArcController {
} }
return ResponseEntity.ok(arcService.getDeletionImpact(id)); return ResponseEntity.ok(arcService.getDeletionImpact(id));
} }
/** Réordonne les arcs (drag-and-drop) : order = position dans orderedIds. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
arcService.reorderArcs(req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(List<String> orderedIds) {}
} }

View File

@@ -89,4 +89,13 @@ public class ChapterController {
} }
return ResponseEntity.ok(chapterService.getDeletionImpact(id)); return ResponseEntity.ok(chapterService.getDeletionImpact(id));
} }
/** Réordonne (et déplace) les chapitres d'un arc : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
chapterService.reorderChapters(req.arcId(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String arcId, List<String> orderedIds) {}
} }

View File

@@ -57,6 +57,15 @@ public class EnemyController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Réordonne (et reclasse) les ennemis d'un dossier : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
enemyService.reorderEnemies(req.folder(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String folder, List<String> orderedIds) {}
private EnemyService.EnemyData toData(EnemyRequest req) { private EnemyService.EnemyData toData(EnemyRequest req) {
return new EnemyService.EnemyData( return new EnemyService.EnemyData(
req.name(), req.level(), req.folder(), req.name(), req.level(), req.folder(),

View File

@@ -109,4 +109,13 @@ public class LoreNodeController {
} }
return ResponseEntity.ok(loreNodeService.getDeletionImpact(id)); return ResponseEntity.ok(loreNodeService.getDeletionImpact(id));
} }
/** Réordonne (et déplace) des dossiers : order = position, parentId = parent cible. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
loreNodeService.reorderNodes(req.parentId(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String parentId, List<String> orderedIds) {}
} }

View File

@@ -73,6 +73,15 @@ public class NpcController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Réordonne (et reclasse) les PNJ d'un dossier : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
npcService.reorderNpcs(req.folder(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String folder, List<String> orderedIds) {}
private NpcService.NpcData toData(NpcDTO dto, Integer order) { private NpcService.NpcData toData(NpcDTO dto, Integer order) {
return new NpcService.NpcData( return new NpcService.NpcData(
dto.getName(), dto.getName(),

View File

@@ -86,4 +86,13 @@ public class PageController {
pageService.deletePage(id); pageService.deletePage(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Réordonne (et déplace) les pages d'un dossier : order = position, nodeId = cible. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
pageService.reorderPages(req.nodeId(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String nodeId, List<String> orderedIds) {}
} }

View File

@@ -0,0 +1,57 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.transfer.pdf.PdfExportService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.NoSuchElementException;
/**
* Export d'une campagne en livret PDF.
* <p>
* {@code GET /api/campaigns/{campaignId}/pdf-export} -> application/pdf (structure
* narrative + PNJ/ennemis + lore + battlemaps).
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/pdf-export")
public class PdfExportController {
private final PdfExportService pdfExportService;
public PdfExportController(PdfExportService pdfExportService) {
this.pdfExportService = pdfExportService;
}
@GetMapping(produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> export(@PathVariable String campaignId) {
byte[] pdf;
String name;
try {
name = pdfExportService.campaignName(campaignId);
pdf = pdfExportService.export(campaignId);
} catch (NoSuchElementException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage());
}
String filename = slug(name) + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
/** Nom de fichier sur : alphanum + tirets, le reste en "_". */
private static String slug(String name) {
if (name == null || name.isBlank()) return "campagne";
String s = name.trim().replaceAll("[^a-zA-Z0-9-_]+", "_").replaceAll("_+", "_");
s = s.replaceAll("^_|_$", "");
return s.isBlank() ? "campagne" : s;
}
}

View File

@@ -90,6 +90,15 @@ public class RandomTableController {
} }
} }
/** Réordonne les tables aléatoires : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
service.reorderTables(req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(List<String> orderedIds) {}
public record GenerateRequest(String campaignId, String description, String diceFormula) {} public record GenerateRequest(String campaignId, String description, String diceFormula) {}
public record ImproviseRequest(String campaignId, String tableName, String resultLabel, String resultDetail) {} public record ImproviseRequest(String campaignId, String tableName, String resultLabel, String resultDetail) {}

View File

@@ -62,4 +62,13 @@ public class SceneController {
sceneService.deleteScene(id); sceneService.deleteScene(id);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Réordonne (et déplace) les scènes d'un chapitre : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
sceneService.reorderScenes(req.chapterId(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String chapterId, List<String> orderedIds) {}
} }

View File

@@ -14,4 +14,5 @@ public class LoreNodeDTO {
private String icon; private String icon;
private String parentId; private String parentId;
private String loreId; private String loreId;
private int order;
} }

View File

@@ -17,6 +17,7 @@ public class PageDTO {
private String nodeId; private String nodeId;
private String templateId; private String templateId;
private String title; private String title;
private int order;
private Map<String, String> values; private Map<String, String> values;
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */ /** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
private Map<String, List<String>> imageValues; private Map<String, List<String>> imageValues;

View File

@@ -21,6 +21,7 @@ public class LoreNodeMapper {
dto.setIcon(loreNode.getIcon()); dto.setIcon(loreNode.getIcon());
dto.setParentId(loreNode.getParentId()); dto.setParentId(loreNode.getParentId());
dto.setLoreId(loreNode.getLoreId()); dto.setLoreId(loreNode.getLoreId());
dto.setOrder(loreNode.getOrder());
return dto; return dto;
} }
@@ -35,6 +36,7 @@ public class LoreNodeMapper {
.icon(dto.getIcon()) .icon(dto.getIcon())
.parentId(dto.getParentId()) .parentId(dto.getParentId())
.loreId(dto.getLoreId()) .loreId(dto.getLoreId())
.order(dto.getOrder())
.build(); .build();
} }
} }

View File

@@ -21,6 +21,7 @@ public class PageMapper {
dto.setNodeId(page.getNodeId()); dto.setNodeId(page.getNodeId());
dto.setTemplateId(page.getTemplateId()); dto.setTemplateId(page.getTemplateId());
dto.setTitle(page.getTitle()); dto.setTitle(page.getTitle());
dto.setOrder(page.getOrder());
dto.setValues(CollectionUtils.copyMap(page.getValues())); dto.setValues(CollectionUtils.copyMap(page.getValues()));
dto.setImageValues(CollectionUtils.copyMap(page.getImageValues())); dto.setImageValues(CollectionUtils.copyMap(page.getImageValues()));
dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues())); dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues()));
@@ -41,6 +42,7 @@ public class PageMapper {
.nodeId(dto.getNodeId()) .nodeId(dto.getNodeId())
.templateId(dto.getTemplateId()) .templateId(dto.getTemplateId())
.title(dto.getTitle()) .title(dto.getTitle())
.order(dto.getOrder())
.values(CollectionUtils.copyMap(dto.getValues())) .values(CollectionUtils.copyMap(dto.getValues()))
.imageValues(CollectionUtils.copyMap(dto.getImageValues())) .imageValues(CollectionUtils.copyMap(dto.getImageValues()))
.keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues())) .keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues()))

View File

@@ -0,0 +1,9 @@
-- ============================================================================
-- V6 : colonne `order` sur les pages et dossiers de lore (réordonnancement
-- manuel par glisser-déposer, comme arcs/chapitres/scènes).
-- ============================================================================
-- `order` est un mot-clé SQL -> colonne quotée (cohérent avec arc/chapter/scene).
-- Défaut 0 : les lignes existantes prennent 0 (ordre indéfini jusqu'au 1er drag).
alter table pages add column "order" integer not null default 0;
alter table lore_nodes add column "order" integer not null default 0;

View File

@@ -0,0 +1,50 @@
package com.loremind.infrastructure.transfer.pdf;
import com.loremind.infrastructure.persistence.entity.CampaignJpaEntity;
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository;
import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Export PDF d'une campagne : le pipeline XHTML -> openhtmltopdf produit bien un PDF.
*/
@SpringBootTest
@Transactional
class PdfExportServiceTest {
@Autowired private PdfExportService pdfExportService;
@Autowired private CampaignJpaRepository campaignRepo;
@Autowired private EnemyJpaRepository enemyRepo;
@Autowired private NpcJpaRepository npcRepo;
@Test
void exportsCampaignToPdf() {
CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder()
.name("Les éclats stellaires").description("Une campagne de test & démo <héros>.")
.arcsCount(0).build());
npcRepo.save(NpcJpaEntity.builder()
.campaignId(camp.getId()).name("Azrak").folder("Azrak").order(0)
.values(Map.of("Notes", "Marchand suspect.\nDeuxième ligne.")).build());
enemyRepo.save(EnemyJpaEntity.builder()
.campaignId(camp.getId()).name("Bandit").folder("Foundry/Briarban").order(0)
.level("3").foundryStats(Map.of("attributes.hp.value", "11")).build());
byte[] pdf = pdfExportService.export(String.valueOf(camp.getId()));
assertNotNull(pdf);
assertTrue(pdf.length > 1000, "le PDF doit avoir un contenu substantiel");
// Signature de fichier PDF (%PDF-).
assertEquals("%PDF-", new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII));
assertEquals("Les éclats stellaires", pdfExportService.campaignName(String.valueOf(camp.getId())));
}
}

19
web/package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0-beta", "version": "1.0.0-beta",
"dependencies": { "dependencies": {
"@angular/animations": "^21.2.16", "@angular/animations": "^21.2.16",
"@angular/cdk": "^21.2.14",
"@angular/common": "^21.2.16", "@angular/common": "^21.2.16",
"@angular/compiler": "^21.2.16", "@angular/compiler": "^21.2.16",
"@angular/core": "^21.2.16", "@angular/core": "^21.2.16",
@@ -595,6 +596,22 @@
} }
} }
}, },
"node_modules/@angular/cdk": {
"version": "21.2.14",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.14.tgz",
"integrity": "sha512-806REq/CLf37nEhmmd8Q+ILN8z/RVG2vk2n8YZ/4TdHpcBCi5ux4AxLbpMmduLwGPOzPagJ6ggRzE5fnX0rmcQ==",
"license": "MIT",
"dependencies": {
"parse5": "^8.0.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^21.0.0 || ^22.0.0",
"@angular/core": "^21.0.0 || ^22.0.0",
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/cli": { "node_modules/@angular/cli": {
"version": "21.2.14", "version": "21.2.14",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.14.tgz", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.14.tgz",
@@ -11201,7 +11218,6 @@
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"entities": "^8.0.0" "entities": "^8.0.0"
@@ -11255,7 +11271,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"

View File

@@ -19,6 +19,7 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^21.2.16", "@angular/animations": "^21.2.16",
"@angular/cdk": "^21.2.14",
"@angular/common": "^21.2.16", "@angular/common": "^21.2.16",
"@angular/compiler": "^21.2.16", "@angular/compiler": "^21.2.16",
"@angular/core": "^21.2.16", "@angular/core": "^21.2.16",

View File

@@ -9,7 +9,10 @@
[titleRoute]="config.titleRoute || null" [titleRoute]="config.titleRoute || null"
[items]="config.items" [items]="config.items"
[createActions]="config.createActions" [createActions]="config.createActions"
[bottomPanel]="config.bottomPanel || null"> [bottomPanel]="config.bottomPanel || null"
[rootDropKinds]="config.rootDropKinds || null"
[rootDropParentId]="config.rootDropParentId ?? null"
[reorderContext]="config.reorderContext || null">
</app-secondary-sidebar> </app-secondary-sidebar>
} }

View File

@@ -6,7 +6,6 @@ import { forkJoin } from 'rxjs';
import { LucideAngularModule, BookOpen } from 'lucide-angular'; import { LucideAngularModule, BookOpen } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -40,7 +39,6 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -64,7 +62,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length; this.existingArcCount = treeData.arcs.length;

View File

@@ -7,7 +7,6 @@ import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -77,7 +76,6 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -121,7 +119,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId), arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -40,10 +40,12 @@
</p> </p>
} }
@if (hubQuests.length > 0) { @if (hubQuests.length > 0) {
<div class="hub-quest-grid"> <div class="hub-quest-grid" cdkDropList cdkDropListOrientation="mixed"
@for (q of hubQuests; track q) { (cdkDropListDropped)="dropQuest($event)">
@for (q of hubQuests; track q.id) {
<button type="button" <button type="button"
class="hub-quest-card" class="hub-quest-card"
cdkDrag [cdkDragData]="q"
(click)="openQuest(q)"> (click)="openQuest(q)">
<div class="hub-quest-card-head"> <div class="hub-quest-card-head">
@if (q.icon) { @if (q.icon) {

View File

@@ -1,13 +1,15 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { DataSyncService } from '../../../services/data-sync.service';
import { LucideAngularModule, Pencil, Trash2, AlertCircle } from 'lucide-angular'; import { LucideAngularModule, Pencil, Trash2, AlertCircle } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { resolveCampaignIcon } from '../../campaign-icons'; import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -27,7 +29,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
*/ */
@Component({ @Component({
selector: 'app-arc-view', selector: 'app-arc-view',
imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe], imports: [RouterModule, LucideAngularModule, ImageGalleryComponent, TranslatePipe, CdkDropList, CdkDrag],
templateUrl: './arc-view.component.html', templateUrl: './arc-view.component.html',
styleUrls: ['./arc-view.component.scss'] styleUrls: ['./arc-view.component.scss']
}) })
@@ -59,7 +61,6 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -67,11 +68,16 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private dataSync: DataSyncService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.route.paramMap.subscribe(pm => { this.dataSync.onChange(this.destroyRef, () => {
if (this.campaignId && this.arcId) this.load();
});
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const newCampaignId = pm.get('campaignId')!; const newCampaignId = pm.get('campaignId')!;
const newArcId = pm.get('arcId')!; const newArcId = pm.get('arcId')!;
if (newArcId !== this.arcId || newCampaignId !== this.campaignId) { if (newArcId !== this.arcId || newCampaignId !== this.campaignId) {
@@ -87,7 +93,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId), arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;
@@ -129,6 +135,15 @@ export class ArcViewComponent implements OnInit, OnDestroy {
} }
} }
/** Réordonne les quêtes (chapitres) de l'arc par glisser-déposer. */
dropQuest(event: CdkDragDrop<Chapter[]>): void {
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(this.hubQuests, event.previousIndex, event.currentIndex);
this.dataSync.persist(
this.campaignService.reorderChapters(this.arcId, this.hubQuests.map(q => q.id!)),
() => this.load());
}
openQuest(q: Chapter): void { openQuest(q: Chapter): void {
if (!q.id) return; if (!q.id) return;
this.router.navigate([ this.router.navigate([

View File

@@ -9,7 +9,7 @@ const t = { instant: (k: string) => k } as unknown as TranslateService;
function data(partial: Partial<CampaignTreeData> = {}): CampaignTreeData { function data(partial: Partial<CampaignTreeData> = {}): CampaignTreeData {
return { return {
arcs: [], chaptersByArc: {}, scenesByChapter: {}, arcs: [], chaptersByArc: {}, scenesByChapter: {},
characters: [], npcs: [], randomTables: [], enemies: [], npcs: [], randomTables: [], enemies: [],
...partial, ...partial,
}; };
} }
@@ -23,15 +23,15 @@ describe('buildCampaignTree', () => {
]); ]);
}); });
it('trie les arcs en ordre NUMÉRIQUE naturel (1, 2, 10)', () => { it('trie les arcs par ordre manuel (champ order, réagencé au glisser-déposer)', () => {
const arcs = [ const arcs = [
{ id: 'a', name: '10. Final' }, { id: 'a', name: 'Final', order: 2 },
{ id: 'b', name: '2. Voyage' }, { id: 'b', name: 'Voyage', order: 1 },
{ id: 'c', name: '1. Intro' }, { id: 'c', name: 'Intro', order: 0 },
]; ];
const tree = buildCampaignTree('c1', data({ arcs: arcs as never }), t); const tree = buildCampaignTree('c1', data({ arcs: arcs as never }), t);
const arcLabels = tree.filter((n) => n.id?.startsWith('arc-')).map((n) => n.label); const arcLabels = tree.filter((n) => n.id?.startsWith('arc-')).map((n) => n.label);
expect(arcLabels).toEqual(['1. Intro', '2. Voyage', '10. Final']); expect(arcLabels).toEqual(['Intro', 'Voyage', 'Final']);
}); });
it('imbrique chapitres et scènes et marque les chapitres à prérequis', () => { it('imbrique chapitres et scènes et marque les chapitres à prérequis', () => {
@@ -47,7 +47,7 @@ describe('buildCampaignTree', () => {
expect(chapter.children![0].id).toBe('scene-s1'); expect(chapter.children![0].id).toBe('scene-s1');
}); });
it('regroupe les PNJ par dossier et laisse les non classés à la racine', () => { it('regroupe les PNJ par dossier et place les non classés dans un pseudo-dossier', () => {
const npcs = [ const npcs = [
{ id: 'n1', name: 'Alice', folder: 'Ville' }, { id: 'n1', name: 'Alice', folder: 'Ville' },
{ id: 'n2', name: 'Bob', folder: 'Ville' }, { id: 'n2', name: 'Bob', folder: 'Ville' },
@@ -58,15 +58,19 @@ describe('buildCampaignTree', () => {
expect(npcsRoot.meta).toBe('3'); expect(npcsRoot.meta).toBe('3');
const folder = npcsRoot.children!.find((c) => c.id === 'npc-folder-Ville')!; const folder = npcsRoot.children!.find((c) => c.id === 'npc-folder-Ville')!;
expect(folder.children!.map((c) => c.label)).toEqual(['Alice', 'Bob']); expect(folder.children!.map((c) => c.label)).toEqual(['Alice', 'Bob']);
expect(npcsRoot.children!.some((c) => c.id === 'npc-n3')).toBe(true); // Les non classés vont dans un pseudo-dossier « Sans dossier » (et non en enfants
// directs) : tous les dossiers restent FRÈRES → glisser-déposer inter-dossiers fiable.
const none = npcsRoot.children!.find((c) => c.id === 'npc-folder-__none__')!;
expect(none.children!.some((c) => c.id === 'npc-n3')).toBe(true);
}); });
it("affiche le niveau de l'ennemi en méta", () => { it("affiche le niveau de l'ennemi en méta", () => {
const tree = buildCampaignTree('c', data({ const tree = buildCampaignTree('c', data({
enemies: [{ id: 'e1', name: 'Gobelin', level: 3 }] as never, enemies: [{ id: 'e1', name: 'Gobelin', level: 3, folder: 'Cave' }] as never,
}), t); }), t);
const enemiesRoot = tree.find((n) => n.id === 'enemies-root')!; const enemiesRoot = tree.find((n) => n.id === 'enemies-root')!;
expect(enemiesRoot.children![0].meta).toBe('Niv. 3'); const folder = enemiesRoot.children!.find((c) => c.id === 'enemy-folder-Cave')!;
expect(folder.children![0].meta).toBe('Niv. 3');
}); });
it('libelle « nouvelle quête » dans un arc HUB (vs « nouveau chapitre »)', () => { it('libelle « nouvelle quête » dans un arc HUB (vs « nouveau chapitre »)', () => {

View File

@@ -2,13 +2,12 @@ import { Observable, forkJoin, of } from 'rxjs';
import { switchMap, map } from 'rxjs/operators'; import { switchMap, map } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../services/campaign.service'; import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service';
import { NpcService } from '../services/npc.service'; import { NpcService } from '../services/npc.service';
import { RandomTableService } from '../services/random-table.service'; import { RandomTableService } from '../services/random-table.service';
import { EnemyService } from '../services/enemy.service'; import { EnemyService } from '../services/enemy.service';
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service'; import { TreeItem, SecondarySidebarConfig, GlobalItem, ReorderKind } from '../services/layout.service';
import { groupByFolder, byOrder } from '../shared/folder-grouping.util';
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model'; import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
import { Character } from '../services/character.model';
import { Npc } from '../services/npc.model'; import { Npc } from '../services/npc.model';
import { RandomTable } from '../services/random-table.model'; import { RandomTable } from '../services/random-table.model';
import { Enemy } from '../services/enemy.model'; import { Enemy } from '../services/enemy.model';
@@ -26,7 +25,6 @@ export interface CampaignTreeData {
arcs: Arc[]; arcs: Arc[];
chaptersByArc: Record<string, Chapter[]>; chaptersByArc: Record<string, Chapter[]>;
scenesByChapter: Record<string, Scene[]>; scenesByChapter: Record<string, Scene[]>;
characters: Character[];
npcs: Npc[]; npcs: Npc[];
randomTables: RandomTable[]; randomTables: RandomTable[];
enemies: Enemy[]; enemies: Enemy[];
@@ -35,7 +33,6 @@ export interface CampaignTreeData {
export function loadCampaignTreeData( export function loadCampaignTreeData(
service: CampaignService, service: CampaignService,
campaignId: string, campaignId: string,
characterService: CharacterService,
npcService: NpcService, npcService: NpcService,
// Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les // Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les
// tables aléatoires sont chargées et apparaissent dans la sidebar. // tables aléatoires sont chargées et apparaissent dans la sidebar.
@@ -49,7 +46,6 @@ export function loadCampaignTreeData(
// doivent passer par PlaythroughService et appeler characterService.getByPlaythrough). // doivent passer par PlaythroughService et appeler characterService.getByPlaythrough).
return forkJoin({ return forkJoin({
arcs: service.getArcs(campaignId), arcs: service.getArcs(campaignId),
characters: of([] as Character[]),
npcs: npcService.getByCampaign(campaignId), npcs: npcService.getByCampaign(campaignId),
randomTables: randomTableService randomTables: randomTableService
? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[]))) ? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[])))
@@ -58,9 +54,9 @@ export function loadCampaignTreeData(
? enemyService.getByCampaign(campaignId).pipe(catchError(() => of([] as Enemy[]))) ? enemyService.getByCampaign(campaignId).pipe(catchError(() => of([] as Enemy[])))
: of([] as Enemy[]) : of([] as Enemy[])
}).pipe( }).pipe(
switchMap(({ arcs, characters, npcs, randomTables, enemies }) => { switchMap(({ arcs, npcs, randomTables, enemies }) => {
if (arcs.length === 0) { if (arcs.length === 0) {
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables, enemies }); return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, npcs, randomTables, enemies });
} }
const chapterCalls = arcs.map(a => const chapterCalls = arcs.map(a =>
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters }))) service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
@@ -75,7 +71,7 @@ export function loadCampaignTreeData(
}); });
if (allChapters.length === 0) { if (allChapters.length === 0) {
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables, enemies }); return of({ arcs, chaptersByArc, scenesByChapter: {}, npcs, randomTables, enemies });
} }
const sceneCalls = allChapters.map(c => const sceneCalls = allChapters.map(c =>
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes }))) service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
@@ -84,7 +80,7 @@ export function loadCampaignTreeData(
map(sceneResults => { map(sceneResults => {
const scenesByChapter: Record<string, Scene[]> = {}; const scenesByChapter: Record<string, Scene[]> = {};
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; }); sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables, enemies }; return { arcs, chaptersByArc, scenesByChapter, npcs, randomTables, enemies };
}) })
); );
}) })
@@ -94,10 +90,25 @@ export function loadCampaignTreeData(
} }
export function buildCampaignTree(campaignId: string, data: CampaignTreeData, translate: TranslateService): TreeItem[] { export function buildCampaignTree(campaignId: string, data: CampaignTreeData, translate: TranslateService): TreeItem[] {
// Tri FR avec `numeric: true` pour que "1. Intro", "2. Voyage", "10. Final" soient // Tri par ORDRE manuel (réagencé par glisser-déposer, persisté en base ; `byOrder`
// classés 1, 2, 10 (et pas 1, 10, 2). `sensitivity: 'base'` ignore la casse. // et `groupByFolder` sont mutualisés avec les vues cartes). Repli sur 0 sinon.
const byName = (a: { name: string }, b: { name: string }) =>
a.name.localeCompare(b.name, 'fr', { numeric: true, sensitivity: 'base' }); /**
* Nœuds-dossiers d'une collection ordonnable : un nœud dépliable par dossier
* (+ un pseudo-dossier « Sans dossier »), via le regroupement partagé. Tous les
* dossiers sont FRÈRES (jamais imbriqués) → glisser-déposer inter-dossiers fiable.
*/
const folderChildren = <T extends { folder?: string | null; order?: number }>(
items: T[], kind: ReorderKind, toItem: (x: T) => TreeItem, idPrefix: string
): TreeItem[] =>
groupByFolder(items).map(g => ({
id: g.folder ? `${idPrefix}-folder-${g.folder}` : `${idPrefix}-folder-__none__`,
label: g.folder || translate.instant('campaignTree.unclassified'),
iconKey: 'folder',
children: g.items.map(toItem),
meta: String(g.items.length),
dropKinds: [kind], dropParentId: g.folder
}));
// IDs préfixés par type pour éviter les collisions dans LayoutService.expanded // 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 // (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1
@@ -105,39 +116,15 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
// Note refonte Playthrough : les PJ ne sont plus rattachés à la campagne mais // Note refonte Playthrough : les PJ ne sont plus rattachés à la campagne mais
// à une Partie (Playthrough). On ne les affiche donc plus dans la sidebar de // à une Partie (Playthrough). On ne les affiche donc plus dans la sidebar de
// campagne — seuls les PNJ (donnée de scénario) restent sous "Personnages". // campagne — seuls les PNJ (donnée de scénario) restent sous "Personnages".
const sortedNpcs = [...data.npcs].sort(byName); const sortedNpcs = [...data.npcs].sort(byOrder);
const npcItem = (n: Npc): TreeItem => ({ const npcItem = (n: Npc): TreeItem => ({
id: `npc-${n.id}`, id: `npc-${n.id}`,
label: n.name, label: n.name,
route: `/campaigns/${campaignId}/npcs/${n.id}` route: `/campaigns/${campaignId}/npcs/${n.id}`,
dragKind: 'npc', dragId: n.id
}); });
// Regroupement par DOSSIER : un sous-nœud (dépliable) par dossier, puis les PNJ const npcChildren = folderChildren(sortedNpcs, 'npc', npcItem, 'npc');
// non classés directement sous « PNJ ».
const npcsByFolder = new Map<string, Npc[]>();
const ungroupedNpcs: Npc[] = [];
for (const n of sortedNpcs) {
const f = (n.folder ?? '').trim();
if (f) {
if (!npcsByFolder.has(f)) npcsByFolder.set(f, []);
npcsByFolder.get(f)!.push(n);
} else {
ungroupedNpcs.push(n);
}
}
const npcFolderNodes: TreeItem[] = [...npcsByFolder.keys()]
.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' }))
.map(folder => {
const items = npcsByFolder.get(folder)!.map(npcItem);
return {
id: `npc-folder-${folder}`,
label: folder,
iconKey: 'folder',
children: items,
meta: String(items.length)
};
});
const npcChildren: TreeItem[] = [...npcFolderNodes, ...ungroupedNpcs.map(npcItem)];
const npcsNode: TreeItem = { const npcsNode: TreeItem = {
id: 'npcs-root', id: 'npcs-root',
@@ -161,51 +148,30 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
// --- Ennemis (bestiaire) : même structure que les PNJ — dossiers dépliables // --- Ennemis (bestiaire) : même structure que les PNJ — dossiers dépliables
// dans la sidebar + libellé cliquable vers la page de liste. // dans la sidebar + libellé cliquable vers la page de liste.
const sortedEnemies = [...(data.enemies ?? [])].sort(byName); const sortedEnemies = [...(data.enemies ?? [])].sort(byOrder);
const enemyItem = (e: Enemy): TreeItem => ({ const enemyItem = (e: Enemy): TreeItem => ({
id: `enemy-${e.id}`, id: `enemy-${e.id}`,
label: e.name, label: e.name,
route: `/campaigns/${campaignId}/enemies/${e.id}`, route: `/campaigns/${campaignId}/enemies/${e.id}`,
meta: e.level ? `Niv. ${e.level}` : undefined meta: e.level ? `Niv. ${e.level}` : undefined,
dragKind: 'enemy', dragId: e.id
}); });
const enemiesByFolder = new Map<string, Enemy[]>(); const enemyChildren = folderChildren(sortedEnemies, 'enemy', enemyItem, 'enemy');
const ungroupedEnemies: Enemy[] = [];
for (const e of sortedEnemies) {
const f = (e.folder ?? '').trim();
if (f) {
if (!enemiesByFolder.has(f)) enemiesByFolder.set(f, []);
enemiesByFolder.get(f)!.push(e);
} else {
ungroupedEnemies.push(e);
}
}
const enemyFolderNodes: TreeItem[] = [...enemiesByFolder.keys()]
.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' }))
.map(folder => {
const items = enemiesByFolder.get(folder)!.map(enemyItem);
return {
id: `enemy-folder-${folder}`,
label: folder,
iconKey: 'folder',
children: items,
meta: String(items.length)
};
});
const enemyChildren: TreeItem[] = [...enemyFolderNodes, ...ungroupedEnemies.map(enemyItem)];
const sortedArcs = [...data.arcs].sort(byName); const sortedArcs = [...data.arcs].sort(byOrder);
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => { const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byName); const sortedChapters = [...(data.chaptersByArc[arc.id!] ?? [])].sort(byOrder);
const chapterItems: TreeItem[] = sortedChapters.map(ch => { const chapterItems: TreeItem[] = sortedChapters.map(ch => {
const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byName); const sortedScenes = [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder);
const sceneItems: TreeItem[] = sortedScenes.map(sc => ({ const sceneItems: TreeItem[] = sortedScenes.map(sc => ({
id: `scene-${sc.id}`, id: `scene-${sc.id}`,
label: sc.name, label: sc.name,
iconKey: sc.icon ?? undefined, iconKey: sc.icon ?? undefined,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}` route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}/scenes/${sc.id}`,
dragKind: 'scene', dragId: sc.id
})); }));
return { return {
id: `chapter-${ch.id}`, id: `chapter-${ch.id}`,
@@ -214,6 +180,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
// Cadenas si le chapitre porte des conditions de déblocage (hub ou linéaire). // Cadenas si le chapitre porte des conditions de déblocage (hub ou linéaire).
meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined, meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined,
children: sceneItems, children: sceneItems,
dragKind: 'chapter', dragId: ch.id, dropKinds: ['scene'], dropParentId: ch.id,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`, route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
createActions: [{ createActions: [{
id: `new-scene-${ch.id}`, id: `new-scene-${ch.id}`,
@@ -228,6 +195,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
label: arc.name, label: arc.name,
iconKey: arc.icon ?? undefined, iconKey: arc.icon ?? undefined,
children: chapterItems, children: chapterItems,
dragKind: 'arc', dragId: arc.id, dropKinds: ['chapter'], dropParentId: arc.id,
route: `/campaigns/${campaignId}/arcs/${arc.id}`, route: `/campaigns/${campaignId}/arcs/${arc.id}`,
sectionHeaderBefore: idx === 0 ? translate.instant('campaignTree.sectionNarration') : undefined, sectionHeaderBefore: idx === 0 ? translate.instant('campaignTree.sectionNarration') : undefined,
@@ -241,12 +209,13 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
}; };
}); });
const sortedTables = [...(data.randomTables ?? [])].sort(byName); const sortedTables = [...(data.randomTables ?? [])].sort(byOrder);
const tableItems: TreeItem[] = sortedTables.map(t => ({ const tableItems: TreeItem[] = sortedTables.map(t => ({
id: `random-table-${t.id}`, id: `random-table-${t.id}`,
label: t.name, label: t.name,
iconKey: t.icon ?? 'dice', iconKey: t.icon ?? 'dice',
route: `/campaigns/${campaignId}/random-tables/${t.id}` route: `/campaigns/${campaignId}/random-tables/${t.id}`,
dragKind: 'table', dragId: t.id
})); }));
const tablesNode: TreeItem = { const tablesNode: TreeItem = {
@@ -254,6 +223,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData, tr
label: translate.instant('campaignTree.randomTables'), label: translate.instant('campaignTree.randomTables'),
iconKey: 'dice', iconKey: 'dice',
children: tableItems, children: tableItems,
dropKinds: ['table'], dropParentId: '',
meta: tableItems.length ? String(tableItems.length) : undefined, meta: tableItems.length ? String(tableItems.length) : undefined,
sectionHeaderBefore: translate.instant('campaignTree.sectionTools'), sectionHeaderBefore: translate.instant('campaignTree.sectionTools'),
createActions: [{ createActions: [{
@@ -340,6 +310,10 @@ export function buildCampaignSidebarConfig(
], ],
globalItems, globalItems,
globalBackLabel: translate.instant('campaignTree.allCampaigns'), globalBackLabel: translate.instant('campaignTree.allCampaigns'),
globalBackRoute: '/campaigns' globalBackRoute: '/campaigns',
// DnD : les arcs se réordonnent à la racine ; rechargement via le contexte campagne.
rootDropKinds: ['arc'],
rootDropParentId: campaignId,
reorderContext: { scope: 'campaign', id: campaignId }
}; };
} }

View File

@@ -28,6 +28,10 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-secondary" (click)="exportPdf()" [disabled]="exportingPdf">
<lucide-icon [img]="FileText" [size]="14"></lucide-icon>
{{ (exportingPdf ? 'campaignDetail.pdfExporting' : 'campaignDetail.pdfExport') | translate }}
</button>
<button type="button" class="btn-secondary" (click)="exportFoundry()" [disabled]="exportingFoundry"> <button type="button" class="btn-secondary" (click)="exportFoundry()" [disabled]="exportingFoundry">
<lucide-icon [img]="Download" [size]="14"></lucide-icon> <lucide-icon [img]="Download" [size]="14"></lucide-icon>
{{ (exportingFoundry ? 'campaignDetail.foundryExporting' : 'campaignDetail.foundryExport') | translate }} {{ (exportingFoundry ? 'campaignDetail.foundryExporting' : 'campaignDetail.foundryExport') | translate }}
@@ -136,17 +140,24 @@
</button> </button>
</div> </div>
@if (npcs.length > 0) { @if (npcs.length > 0) {
<div class="characters-grid"> @for (g of npcGroups; track g.folder) {
@for (npc of npcs; track npc) { @if (g.folder) {
<div class="character-card" (click)="viewNpc(npc)"> <h4 class="persona-folder">{{ g.folder.split('/').join(' / ') }}</h4>
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon> } @else if (npcGroups.length > 1) {
<div class="character-info"> <h4 class="persona-folder persona-folder--none">{{ 'campaignTree.unclassified' | translate }}</h4>
<span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div>
</div>
} }
</div> <div class="characters-grid">
@for (npc of g.items; track npc.id) {
<div class="character-card" (click)="viewNpc(npc)">
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div>
</div>
}
</div>
}
} }
@if (npcs.length === 0) { @if (npcs.length === 0) {
<div class="empty-state empty-state--compact"> <div class="empty-state empty-state--compact">
@@ -172,9 +183,10 @@
</div> </div>
</div> </div>
@if (arcs.length > 0) { @if (arcs.length > 0) {
<div class="arcs-grid"> <div class="arcs-grid" cdkDropList cdkDropListOrientation="mixed"
@for (arc of arcs; track arc) { (cdkDropListDropped)="dropArc($event)">
<div class="arc-card" (click)="openArc(arc)"> @for (arc of arcs; track arc.id) {
<div class="arc-card" cdkDrag [cdkDragData]="arc" (click)="openArc(arc)">
<lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon> <lucide-icon [img]="Swords" [size]="24" class="arc-icon"></lucide-icon>
<span class="arc-name">{{ arc.name }}</span> <span class="arc-name">{{ arc.name }}</span>
<span class="arc-meta">{{ 'campaignDetail.chapters' | translate:{ n: chapterCountByArc[arc.id!] || 0 } }}</span> <span class="arc-meta">{{ 'campaignDetail.chapters' | translate:{ n: chapterCountByArc[arc.id!] || 0 } }}</span>

View File

@@ -505,3 +505,15 @@
font-weight: 600; font-weight: 600;
} }
} }
// En-tête de dossier dans la grille des PNJ (regroupement par dossier).
.persona-folder {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #8a7bc8;
margin: 0.9rem 0 0.4rem;
&--none { color: #6b7280; }
}

View File

@@ -1,8 +1,12 @@
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DataSyncService } from '../../../services/data-sync.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download } from 'lucide-angular'; import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -11,7 +15,6 @@ import { CampaignService } from '../../../services/campaign.service';
import { LoreService } from '../../../services/lore.service'; import { LoreService } from '../../../services/lore.service';
import { GameSystemService } from '../../../services/game-system.service'; import { GameSystemService } from '../../../services/game-system.service';
import { GameSystem } from '../../../services/game-system.model'; import { GameSystem } from '../../../services/game-system.model';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -26,10 +29,11 @@ import { Campaign, Arc } from '../../../services/campaign.model';
import { Lore } from '../../../services/lore.model'; import { Lore } from '../../../services/lore.model';
import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig, CampaignTreeData } from '../../campaign-tree.helper';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
import { FolderGroup, groupByFolder, byOrder } from '../../../shared/folder-grouping.util';
@Component({ @Component({
selector: 'app-campaign-detail', selector: 'app-campaign-detail',
imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe], imports: [FormsModule, LucideAngularModule, RouterLink, TranslatePipe, CdkDropList, CdkDrag],
templateUrl: './campaign-detail.component.html', templateUrl: './campaign-detail.component.html',
styleUrls: ['./campaign-detail.component.scss'] styleUrls: ['./campaign-detail.component.scss']
}) })
@@ -46,9 +50,12 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Upload = Upload; readonly Upload = Upload;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Download = Download; readonly Download = Download;
readonly FileText = FileText;
/** Export Foundry en cours (anti double-clic). */ /** Export Foundry en cours (anti double-clic). */
exportingFoundry = false; exportingFoundry = false;
/** Export PDF en cours (anti double-clic). */
exportingPdf = false;
campaign: Campaign | null = null; campaign: Campaign | null = null;
arcs: Arc[] = []; arcs: Arc[] = [];
@@ -64,6 +71,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
linkedGameSystem: GameSystem | null = null; linkedGameSystem: GameSystem | null = null;
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */ /** Fiches de personnages non-joueurs (PNJ) de la campagne. */
npcs: Npc[] = []; npcs: Npc[] = [];
/** PNJ groupés par dossier (dossiers alpha, ordre manuel dans chacun) — comme la sidebar. */
npcGroups: FolderGroup<Npc>[] = [];
/** Sessions de jeu (passées et en cours) liées à cette campagne. */ /** Sessions de jeu (passées et en cours) liées à cette campagne. */
sessions: Session[] = []; sessions: Session[] = [];
/** /**
@@ -98,7 +107,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private campaignService: CampaignService, private campaignService: CampaignService,
private loreService: LoreService, private loreService: LoreService,
private gameSystemService: GameSystemService, private gameSystemService: GameSystemService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -107,37 +115,32 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private dataSync: DataSyncService,
private campaignSidebar: CampaignSidebarService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
// Synchro temps réel : si l'ordre des arcs change ailleurs (arbre de la sidebar),
// on recharge les cartes pour rester cohérent sans rafraîchir la page.
this.dataSync.onChange(this.destroyRef, () => {
if (this.campaign?.id) {
this.campaignService.getArcs(this.campaign.id).subscribe(a => this.arcs = [...a].sort(byOrder));
this.loadNpcs(this.campaign.id); // PNJ regroupés/ordonnés
this.campaignSidebar.show(this.campaign.id); // recharge l'arbre aussi
}
});
// switchMap annule automatiquement le load précédent si l'utilisateur // switchMap annule automatiquement le load précédent si l'utilisateur
// change de campagne avant que le forkJoin ne réponde — évite qu'une // change de campagne avant que le forkJoin ne réponde — évite qu'une
// réponse en retard écrase des données plus récentes (race condition). // réponse en retard écrase des données plus récentes (race condition).
this.route.paramMap.pipe( this.route.paramMap.pipe(
map(pm => pm.get('id')), map(pm => pm.get('id')),
filter((id): id is string => !!id && id !== this.campaign?.id), filter((id): id is string => !!id && id !== this.campaign?.id),
switchMap(id => forkJoin({ switchMap(id => this.loadCampaignBundle(id)),
campaign: this.campaignService.getCampaignById(id), takeUntilDestroyed(this.destroyRef)
allCampaigns: this.campaignService.getAllCampaigns(), ).subscribe(result => this.applyCampaignBundle(result));
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}))
).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
});
} }
private computeChapterCounts(data: CampaignTreeData): Record<string, number> { private computeChapterCounts(data: CampaignTreeData): Record<string, number> {
@@ -154,28 +157,39 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
* veut rafraîchir même si l'ID n'a pas changé. * veut rafraîchir même si l'ID n'a pas changé.
*/ */
private reload(id: string): void { private reload(id: string): void {
forkJoin({ this.loadCampaignBundle(id).subscribe(result => this.applyCampaignBundle(result));
}
/** Charge en un seul forkJoin : la campagne, ses voisines, l'arbre et les parties. */
private loadCampaignBundle(id: string) {
return forkJoin({
campaign: this.campaignService.getCampaignById(id), campaign: this.campaignService.getCampaignById(id),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe( treeData: loadCampaignTreeData(this.campaignService, id, this.npcService, this.randomTableService, this.enemyService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData)) catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
), ),
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[]))) playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = treeData.arcs;
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
}); });
} }
/** Applique le bundle chargé à l'état du composant (commun à ngOnInit et reload). */
private applyCampaignBundle(
{ campaign, allCampaigns, treeData, playthroughs }:
{ campaign: Campaign; allCampaigns: Campaign[]; treeData: CampaignTreeData; playthroughs: Playthrough[] }
): void {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = [...treeData.arcs].sort(byOrder);
this.chapterCountByArc = this.computeChapterCounts(treeData);
this.showLayout(allCampaigns, treeData);
this.pageTitleService.set(campaign.name);
}
/** /**
* Charge le Lore associé (si loreId présent). On swallow l'erreur : * Charge le Lore associé (si loreId présent). On swallow l'erreur :
* si le Lore a été supprimé entre-temps, on affiche simplement "Univers introuvable". * si le Lore a été supprimé entre-temps, on affiche simplement "Univers introuvable".
@@ -205,7 +219,10 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
private loadNpcs(campaignId: string): void { private loadNpcs(campaignId: string): void {
this.npcService.getByCampaign(campaignId).pipe( this.npcService.getByCampaign(campaignId).pipe(
catchError(() => of([] as Npc[])) catchError(() => of([] as Npc[]))
).subscribe(list => this.npcs = list); ).subscribe(list => {
this.npcs = list;
this.npcGroups = groupByFolder(list);
});
} }
// Sessions retirées de cette vue (vivent dans Playthrough Detail). // Sessions retirées de cette vue (vivent dans Playthrough Detail).
@@ -282,6 +299,45 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
}); });
} }
/** Génère et télécharge le livret PDF de la campagne. */
exportPdf(): void {
if (!this.campaign?.id || this.exportingPdf) return;
this.exportingPdf = true;
const name = this.campaign.name;
this.campaignService.exportPdf(this.campaign.id).subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${(name || 'campagne').replace(/[^a-zA-Z0-9-_]+/g, '_')}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
this.exportingPdf = false;
},
error: (err) => {
console.error('Échec de l\'export PDF', err);
this.exportingPdf = false;
}
});
}
/** Réordonne les arcs par glisser-déposer et persiste le nouvel ordre. */
dropArc(event: CdkDragDrop<Arc[]>): void {
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(this.arcs, event.previousIndex, event.currentIndex);
this.campaignService.reorderArcs(this.arcs.map(a => a.id!)).subscribe({
next: () => this.dataSync.notify(),
error: () => {
if (this.campaign?.id) {
this.campaignService.getArcs(this.campaign.id).subscribe(
a => this.arcs = [...a].sort((x, y) => (x.order ?? 0) - (y.order ?? 0)));
}
}
});
}
openArc(arc: Arc): void { openArc(arc: Arc): void {
if (!this.campaign || !arc.id) return; if (!this.campaign || !arc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]); this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);

View File

@@ -9,7 +9,6 @@ import {
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignImportService } from '../../../services/campaign-import.service'; import { CampaignImportService } from '../../../services/campaign-import.service';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -106,7 +105,6 @@ export class CampaignImportComponent implements OnInit {
private router: Router, private router: Router,
private service: CampaignImportService, private service: CampaignImportService,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -122,7 +120,7 @@ export class CampaignImportComponent implements OnInit {
// Pré-chargement de l'arborescence existante (pour fusionner à la revue). // Pré-chargement de l'arborescence existante (pour fusionner à la revue).
// En cas d'échec on dégrade : tout sera considéré comme nouveau. // En cas d'échec on dégrade : tout sera considéré comme nouveau.
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
.pipe(catchError(() => of(null))) .pipe(catchError(() => of(null)))
.subscribe(data => this.existingData = data); .subscribe(data => this.existingData = data);
} }

View File

@@ -6,7 +6,6 @@ import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular'; import { LucideAngularModule } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -42,7 +41,6 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -65,7 +63,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentArc = treeData.arcs.find(a => a.id === this.arcId); const currentArc = treeData.arcs.find(a => a.id === this.arcId);
this.arcName = currentArc?.name ?? ''; this.arcName = currentArc?.name ?? '';

View File

@@ -7,7 +7,6 @@ import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -87,7 +86,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -133,7 +131,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId), chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -5,7 +5,6 @@ import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -70,7 +69,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -94,7 +92,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId), chapter: this.campaignService.getChapterById(this.chapterId),
scenes: this.campaignService.getScenes(this.chapterId), scenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => { }).subscribe(({ campaign, allCampaigns, chapter, scenes, treeData }) => {
this.chapter = chapter; this.chapter = chapter;
this.scenes = scenes; this.scenes = scenes;

View File

@@ -7,7 +7,6 @@ import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angul
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { resolveCampaignIcon } from '../../campaign-icons'; import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -51,7 +50,6 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -83,7 +81,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
chapter: this.campaignService.getChapterById(this.chapterId), chapter: this.campaignService.getChapterById(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -24,28 +24,32 @@
@if (total === 0) { @if (total === 0) {
<p class="empty">{{ 'enemyList.empty' | translate }}</p> <p class="empty">{{ 'enemyList.empty' | translate }}</p>
} }
@for (g of groups; track g.folder) { @for (g of groups; track g.folder; let gi = $index) {
@if (g.folder) { @if (g.folder) {
<h2 class="sbl-folder"> <h2 class="sbl-folder">
<lucide-icon [img]="Folder" [size]="14"></lucide-icon> <lucide-icon [img]="Folder" [size]="14"></lucide-icon>
{{ g.folder.split('/').join(' / ') }} {{ g.folder.split('/').join(' / ') }}
<span class="sbl-folder-count">{{ g.enemies.length }}</span> <span class="sbl-folder-count">{{ g.items.length }}</span>
</h2> </h2>
} @else if (groups.length > 1) { } @else if (groups.length > 1) {
<h2 class="sbl-folder sbl-folder--none">{{ 'enemyList.unclassified' | translate }}</h2> <h2 class="sbl-folder sbl-folder--none">{{ 'enemyList.unclassified' | translate }}</h2>
} }
@for (e of g.enemies; track e.id) { <div class="sbl-group" cdkDropList [id]="'enemy-group-' + gi"
<button class="sbl-item" (click)="open(e)"> [cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
<lucide-icon [img]="Skull" [size]="16"></lucide-icon> (cdkDropListDropped)="drop(g, $event)">
<span class="sbl-item-name">{{ e.name }}</span> @for (e of g.items; track e.id) {
@if (e.level) { <button class="sbl-item" cdkDrag [cdkDragData]="e" (click)="open(e)">
<span class="sbl-item-level">{{ 'enemyList.levelShort' | translate:{ level: e.level } }}</span> <lucide-icon [img]="Skull" [size]="16"></lucide-icon>
} <span class="sbl-item-name">{{ e.name }}</span>
<span class="sbl-del" (click)="remove(e, $event)" [title]="'common.delete' | translate"> @if (e.level) {
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <span class="sbl-item-level">{{ 'enemyList.levelShort' | translate:{ level: e.level } }}</span>
</span> }
</button> <span class="sbl-del" (click)="remove(e, $event)" [title]="'common.delete' | translate">
} <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div>
} }
</div> </div>
</div> </div>

View File

@@ -1,6 +1,9 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { DestroyRef } from '@angular/core';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { DataSyncService } from '../../../services/data-sync.service';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder, Upload } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder, Upload } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -8,12 +11,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi
import { Enemy } from '../../../services/enemy.model'; import { Enemy } from '../../../services/enemy.model';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
import { FileDropDirective } from '../../../shared/file-drop.directive'; import { FileDropDirective } from '../../../shared/file-drop.directive';
import { FolderGroup, groupByFolder } from '../../../shared/folder-grouping.util';
/** Groupe d'affichage : un dossier (« Démons »…) et ses ennemis. */
interface FolderGroup {
folder: string;
enemies: Enemy[];
}
/** /**
* Liste des ennemis d'une campagne, groupés par dossier (comme les PNJ). * Liste des ennemis d'une campagne, groupés par dossier (comme les PNJ).
@@ -21,7 +19,7 @@ interface FolderGroup {
*/ */
@Component({ @Component({
selector: 'app-enemy-list', selector: 'app-enemy-list',
imports: [LucideAngularModule, TranslatePipe, FileDropDirective], imports: [LucideAngularModule, TranslatePipe, FileDropDirective, CdkDropList, CdkDrag],
templateUrl: './enemy-list.component.html', templateUrl: './enemy-list.component.html',
styleUrls: ['./enemy-list.component.scss'] styleUrls: ['./enemy-list.component.scss']
}) })
@@ -38,7 +36,7 @@ export class EnemyListComponent implements OnInit {
campaignId = ''; campaignId = '';
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */ /** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
groups: FolderGroup[] = []; groups: FolderGroup<Enemy>[] = [];
total = 0; total = 0;
constructor( constructor(
@@ -47,7 +45,9 @@ export class EnemyListComponent implements OnInit {
private service: EnemyService, private service: EnemyService,
private campaignSidebar: CampaignSidebarService, private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private dataSync: DataSyncService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -56,41 +56,44 @@ export class EnemyListComponent implements OnInit {
this.campaignSidebar.show(this.campaignId); this.campaignSidebar.show(this.campaignId);
this.load(); this.load();
} }
// Synchro temps réel avec l'arbre (et inversement) : recharge la liste ET l'arbre.
this.dataSync.onChange(this.destroyRef, () => {
this.load();
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
});
} }
load(): void { load(): void {
this.service.getByCampaign(this.campaignId).subscribe({ this.service.getByCampaign(this.campaignId).subscribe({
next: (list) => this.groups = this.groupByFolder(list), next: (list) => { this.total = list.length; this.groups = groupByFolder(list); },
error: () => this.groups = [] error: () => this.groups = []
}); });
} }
/** Même logique de groupement que les PNJ de la sidebar : dossiers triés, non-classés à la fin. */
private groupByFolder(enemies: Enemy[]): FolderGroup[] {
this.total = enemies.length;
const sorted = [...enemies].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
const byFolder = new Map<string, Enemy[]>();
const ungrouped: Enemy[] = [];
for (const e of sorted) {
const f = (e.folder ?? '').trim();
if (f) {
if (!byFolder.has(f)) byFolder.set(f, []);
byFolder.get(f)!.push(e);
} else {
ungrouped.push(e);
}
}
const groups: FolderGroup[] = [...byFolder.keys()]
.sort((a, b) => a.localeCompare(b, 'fr'))
.map(folder => ({ folder, enemies: byFolder.get(folder)! }));
if (ungrouped.length) groups.push({ folder: '', enemies: ungrouped });
return groups;
}
create(): void { create(): void {
this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']); this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']);
} }
// --- Glisser-déposer (réordonner + déplacer entre dossiers) --------------
/** Ids des zones de dépôt (une par groupe/dossier) pour les connecter entre elles. */
get groupListIds(): string[] {
return this.groups.map((_, i) => 'enemy-group-' + i);
}
/** Drop d'un ennemi : réordonne dans le dossier, ou le déplace vers un autre dossier. */
drop(target: FolderGroup<Enemy>, event: CdkDragDrop<Enemy[]>): void {
if (event.previousContainer === event.container) {
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(target.items, event.previousIndex, event.currentIndex);
} else {
transferArrayItem(event.previousContainer.data, event.container.data,
event.previousIndex, event.currentIndex);
}
const folder = target.folder || null;
this.dataSync.persist(this.service.reorder(folder, target.items.map(e => e.id!)), () => this.load());
}
/** /**
* Importe un catalogue de monstres Foundry (.json exporté par le module) dans * Importe un catalogue de monstres Foundry (.json exporté par le module) dans
* le bestiaire. Upsert côté backend (dédup par référence). * le bestiaire. Upsert côté backend (dédup par référence).

View File

@@ -6,7 +6,6 @@ import { LucideAngularModule, ArrowLeft, Upload, Trash2, Send, FileText, Loader,
import { NotebookService } from '../../../services/notebook.service'; import { NotebookService } from '../../../services/notebook.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model'; import { NotebookArchive, NotebookDetail, NotebookSource, NotebookMessage } from '../../../services/notebook.model';
@@ -66,7 +65,6 @@ export class NotebookDetailComponent implements OnInit {
private service: NotebookService, private service: NotebookService,
private campaignSidebar: CampaignSidebarService, private campaignSidebar: CampaignSidebarService,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private enemyService: EnemyService, private enemyService: EnemyService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
@@ -84,7 +82,7 @@ export class NotebookDetailComponent implements OnInit {
} }
private loadTree(): void { private loadTree(): void {
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService) loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, undefined, this.enemyService)
.subscribe({ .subscribe({
next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; }, next: (data) => { this.arcs = data.arcs; this.chaptersByArc = data.chaptersByArc; },
error: () => { /* cibles indisponibles : les cartes le signaleront */ } error: () => { /* cibles indisponibles : les cartes le signaleront */ }

View File

@@ -20,25 +20,29 @@
@if (total === 0) { @if (total === 0) {
<p class="empty">{{ 'npcList.empty' | translate }}</p> <p class="empty">{{ 'npcList.empty' | translate }}</p>
} }
@for (g of groups; track g.folder) { @for (g of groups; track g.folder; let gi = $index) {
@if (g.folder) { @if (g.folder) {
<h2 class="sbl-folder"> <h2 class="sbl-folder">
<lucide-icon [img]="Folder" [size]="14"></lucide-icon> <lucide-icon [img]="Folder" [size]="14"></lucide-icon>
{{ g.folder }} {{ g.folder }}
<span class="sbl-folder-count">{{ g.npcs.length }}</span> <span class="sbl-folder-count">{{ g.items.length }}</span>
</h2> </h2>
} @else if (groups.length > 1) { } @else if (groups.length > 1) {
<h2 class="sbl-folder sbl-folder--none">{{ 'npcList.unclassified' | translate }}</h2> <h2 class="sbl-folder sbl-folder--none">{{ 'npcList.unclassified' | translate }}</h2>
} }
@for (n of g.npcs; track n.id) { <div class="sbl-group" cdkDropList [id]="'npc-group-' + gi"
<button class="sbl-item" (click)="open(n)"> [cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
<lucide-icon [img]="Drama" [size]="16"></lucide-icon> (cdkDropListDropped)="drop(g, $event)">
<span class="sbl-item-name">{{ n.name }}</span> @for (n of g.items; track n.id) {
<span class="sbl-del" (click)="remove(n, $event)" [title]="'common.delete' | translate"> <button class="sbl-item" cdkDrag [cdkDragData]="n" (click)="open(n)">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon> <lucide-icon [img]="Drama" [size]="16"></lucide-icon>
</span> <span class="sbl-item-name">{{ n.name }}</span>
</button> <span class="sbl-del" (click)="remove(n, $event)" [title]="'common.delete' | translate">
} <lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div>
} }
</div> </div>
</div> </div>

View File

@@ -1,18 +1,16 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { DestroyRef } from '@angular/core';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { DataSyncService } from '../../../services/data-sync.service';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Drama, Folder } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Plus, Trash2, Drama, Folder } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { Npc } from '../../../services/npc.model'; import { Npc } from '../../../services/npc.model';
import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dialog.service';
import { FolderGroup, groupByFolder } from '../../../shared/folder-grouping.util';
/** Groupe d'affichage : un dossier (« Bard's Gate »…) et ses PNJ. */
interface FolderGroup {
folder: string;
npcs: Npc[];
}
/** /**
* Vue d'ensemble des PNJ d'une campagne, groupés par dossier — pendant « page » * Vue d'ensemble des PNJ d'une campagne, groupés par dossier — pendant « page »
@@ -21,7 +19,7 @@ interface FolderGroup {
*/ */
@Component({ @Component({
selector: 'app-npc-list', selector: 'app-npc-list',
imports: [LucideAngularModule, TranslatePipe], imports: [LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag],
templateUrl: './npc-list.component.html', templateUrl: './npc-list.component.html',
styleUrls: ['./npc-list.component.scss'] styleUrls: ['./npc-list.component.scss']
}) })
@@ -34,7 +32,7 @@ export class NpcListComponent implements OnInit {
campaignId = ''; campaignId = '';
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */ /** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
groups: FolderGroup[] = []; groups: FolderGroup<Npc>[] = [];
total = 0; total = 0;
constructor( constructor(
@@ -43,7 +41,9 @@ export class NpcListComponent implements OnInit {
private service: NpcService, private service: NpcService,
private campaignSidebar: CampaignSidebarService, private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private dataSync: DataSyncService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -52,41 +52,42 @@ export class NpcListComponent implements OnInit {
this.campaignSidebar.show(this.campaignId); this.campaignSidebar.show(this.campaignId);
this.load(); this.load();
} }
// Recharge la liste ET l'arbre (synchro temps réel).
this.dataSync.onChange(this.destroyRef, () => {
this.load();
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
});
} }
load(): void { load(): void {
this.service.getByCampaign(this.campaignId).subscribe({ this.service.getByCampaign(this.campaignId).subscribe({
next: (list) => this.groups = this.groupByFolder(list), next: (list) => { this.total = list.length; this.groups = groupByFolder(list); },
error: () => this.groups = [] error: () => this.groups = []
}); });
} }
/** Même logique de groupement que la sidebar : dossiers triés, non-classés à la fin. */
private groupByFolder(npcs: Npc[]): FolderGroup[] {
this.total = npcs.length;
const sorted = [...npcs].sort((a, b) => a.name.localeCompare(b.name, 'fr'));
const byFolder = new Map<string, Npc[]>();
const ungrouped: Npc[] = [];
for (const n of sorted) {
const f = (n.folder ?? '').trim();
if (f) {
if (!byFolder.has(f)) byFolder.set(f, []);
byFolder.get(f)!.push(n);
} else {
ungrouped.push(n);
}
}
const groups: FolderGroup[] = [...byFolder.keys()]
.sort((a, b) => a.localeCompare(b, 'fr'))
.map(folder => ({ folder, npcs: byFolder.get(folder)! }));
if (ungrouped.length) groups.push({ folder: '', npcs: ungrouped });
return groups;
}
create(): void { create(): void {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', 'create']); this.router.navigate(['/campaigns', this.campaignId, 'npcs', 'create']);
} }
// --- Glisser-déposer (réordonner + déplacer entre dossiers) --------------
get groupListIds(): string[] {
return this.groups.map((_, i) => 'npc-group-' + i);
}
drop(target: FolderGroup<Npc>, event: CdkDragDrop<Npc[]>): void {
if (event.previousContainer === event.container) {
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(target.items, event.previousIndex, event.currentIndex);
} else {
transferArrayItem(event.previousContainer.data, event.container.data,
event.previousIndex, event.currentIndex);
}
const folder = target.folder || null;
this.dataSync.persist(this.service.reorder(folder, target.items.map(n => n.id!)), () => this.load());
}
open(n: Npc): void { open(n: Npc): void {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', n.id]); this.router.navigate(['/campaigns', this.campaignId, 'npcs', n.id]);
} }

View File

@@ -83,7 +83,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService), treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService),
playthrough: this.playthroughService.getById(this.playthroughId), playthrough: this.playthroughService.getById(this.playthroughId),
sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))), sessions: this.sessionService.getSessions(this.playthroughId).pipe(catchError(() => of([] as Session[]))),
characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))), characters: this.characterService.getByPlaythrough(this.playthroughId).pipe(catchError(() => of([] as Character[]))),

View File

@@ -5,7 +5,6 @@ import { forkJoin } from 'rxjs';
import { LucideAngularModule, ArrowLeft } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -37,7 +36,6 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -63,7 +61,7 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService), treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService),
playthrough: this.playthroughService.getById(this.playthroughId) playthrough: this.playthroughService.getById(this.playthroughId)
}).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => { }).subscribe(({ campaign, allCampaigns, treeData, playthrough }) => {
this.playthrough = playthrough; this.playthrough = playthrough;

View File

@@ -6,7 +6,6 @@ import { forkJoin } from 'rxjs';
import { LucideAngularModule } from 'lucide-angular'; import { LucideAngularModule } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -41,7 +40,6 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -65,7 +63,7 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId); const currentChapter = (treeData.chaptersByArc[this.arcId] ?? []).find(c => c.id === this.chapterId);
this.chapterName = currentChapter?.name ?? ''; this.chapterName = currentChapter?.name ?? '';

View File

@@ -8,7 +8,6 @@ import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { StoredFileService } from '../../../services/stored-file.service'; import { StoredFileService } from '../../../services/stored-file.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -97,7 +96,6 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private storedFileService: StoredFileService, private storedFileService: StoredFileService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -154,7 +152,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId), scene: this.campaignService.getSceneById(this.sceneId),
chapterScenes: this.campaignService.getScenes(this.chapterId), chapterScenes: this.campaignService.getScenes(this.chapterId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -7,7 +7,6 @@ import { LucideAngularModule, Pencil, Trash2 } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { resolveCampaignIcon } from '../../campaign-icons'; import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
@@ -51,7 +50,6 @@ export class SceneViewComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -86,7 +84,7 @@ export class SceneViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
scene: this.campaignService.getSceneById(this.sceneId), scene: this.campaignService.getSceneById(this.sceneId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -48,9 +48,15 @@
</button> </button>
</div> </div>
@if (subfolders.length > 0) { @if (subfolders.length > 0) {
<div class="items-grid"> <div class="items-grid" cdkDropList cdkDropListOrientation="mixed"
@for (sub of subfolders; track sub) { [cdkDropListData]="subfolders" [cdkDropListEnterPredicate]="acceptFolders"
<div class="node-card" (click)="navigateToSubfolder(sub.id!)"> (cdkDropListDropped)="dropSubfolder($event)">
@for (sub of subfolders; track sub.id) {
<div class="node-card" cdkDrag [cdkDragData]="sub"
cdkDropList [id]="'sub-' + sub.id" [cdkDropListData]="sub"
[cdkDropListEnterPredicate]="acceptPages"
(cdkDropListDropped)="dropPageIntoFolder(sub, $event)"
(click)="navigateToSubfolder(sub.id!)">
<lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon> <lucide-icon [img]="Folder" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ sub.name }}</span> <span class="node-name">{{ sub.name }}</span>
</div> </div>
@@ -73,9 +79,12 @@
</button> </button>
</div> </div>
@if (pages.length > 0) { @if (pages.length > 0) {
<div class="items-grid"> <div class="items-grid" cdkDropList cdkDropListOrientation="mixed"
@for (page of pages; track page) { [cdkDropListData]="pages" [cdkDropListConnectedTo]="subfolderListIds"
<div class="node-card" (click)="navigateToPage(page.id!)"> [cdkDropListEnterPredicate]="acceptPages"
(cdkDropListDropped)="dropPage($event)">
@for (page of pages; track page.id) {
<div class="node-card" cdkDrag [cdkDragData]="page" (click)="navigateToPage(page.id!)">
<lucide-icon [img]="FileText" [size]="24" class="node-icon"></lucide-icon> <lucide-icon [img]="FileText" [size]="24" class="node-icon"></lucide-icon>
<span class="node-name">{{ page.title }}</span> <span class="node-name">{{ page.title }}</span>
</div> </div>

View File

@@ -152,3 +152,22 @@
font-size: 0.9rem; font-size: 0.9rem;
padding: 1rem 0.5rem; padding: 1rem 0.5rem;
} }
// --- Glisser-déposer (CDK) -------------------------------------------------
.node-card { cursor: grab; }
.node-card.cdk-drag-dragging { cursor: grabbing; }
// Carte en cours de déplacement (clone qui suit le curseur).
.cdk-drag-preview {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
border-color: #6c63ff !important;
}
// Emplacement d'origine pendant le drag (silhouette en pointillés).
.cdk-drag-placeholder { opacity: 0.4; }
.cdk-drag-animating { transition: transform 200ms cubic-bezier(0, 0, 0.2, 1); }
.items-grid.cdk-drop-list-dragging .node-card:not(.cdk-drag-placeholder) {
transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
}
// Surbrillance d'un sous-dossier quand une page est glissée dessus.
.node-card.cdk-drop-list-receiving,
.node-card.cdk-drop-list-dragging { border-color: #6c63ff; background: rgba(108, 99, 255, 0.12); }

View File

@@ -1,8 +1,11 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit, DestroyRef } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import { LucideAngularModule, LucideIconData, Folder, FileText, Pencil, Trash2, Plus, ChevronRight } from 'lucide-angular'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { DataSyncService } from '../../services/data-sync.service';
import { LucideAngularModule, LucideIconData, Folder, FileText, Pencil, Trash2, Plus, ChevronRight, GripVertical } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LoreService } from '../../services/lore.service'; import { LoreService } from '../../services/lore.service';
import { TemplateService } from '../../services/template.service'; import { TemplateService } from '../../services/template.service';
@@ -12,6 +15,7 @@ import { PageTitleService } from '../../services/page-title.service';
import { Lore, LoreNode } from '../../services/lore.model'; import { Lore, LoreNode } from '../../services/lore.model';
import { Page } from '../../services/page.model'; import { Page } from '../../services/page.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { byOrder } from '../../shared/folder-grouping.util';
import { resolveIcon } from '../lore-icons'; import { resolveIcon } from '../lore-icons';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
@@ -25,7 +29,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
*/ */
@Component({ @Component({
selector: 'app-folder-view', selector: 'app-folder-view',
imports: [LucideAngularModule, TranslatePipe], imports: [LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag],
templateUrl: './folder-view.component.html', templateUrl: './folder-view.component.html',
styleUrls: ['./folder-view.component.scss'] styleUrls: ['./folder-view.component.scss']
}) })
@@ -36,6 +40,7 @@ export class FolderViewComponent implements OnInit, OnDestroy {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Plus = Plus; readonly Plus = Plus;
readonly ChevronRight = ChevronRight; readonly ChevronRight = ChevronRight;
readonly GripVertical = GripVertical;
loreId = ''; loreId = '';
folderId = ''; folderId = '';
@@ -55,14 +60,17 @@ export class FolderViewComponent implements OnInit, OnDestroy {
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
private confirmDialog: ConfirmDialogService, private confirmDialog: ConfirmDialogService,
private translate: TranslateService private translate: TranslateService,
private dataSync: DataSyncService,
private destroyRef: DestroyRef
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.dataSync.onChange(this.destroyRef, () => this.load());
this.loreId = this.route.snapshot.paramMap.get('loreId')!; this.loreId = this.route.snapshot.paramMap.get('loreId')!;
// Réagit aux changements de :folderId pour que la navigation d'un dossier // 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. // à un autre via la sidebar ne démonte/remonte pas le composant à blanc.
this.route.paramMap.subscribe(pm => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(pm => {
const next = pm.get('folderId')!; const next = pm.get('folderId')!;
if (next !== this.folderId) { if (next !== this.folderId) {
this.folderId = next; this.folderId = next;
@@ -82,8 +90,9 @@ export class FolderViewComponent implements OnInit, OnDestroy {
this.lore = sidebar.lore; this.lore = sidebar.lore;
this.node = node; this.node = node;
this.pageTitleService.set(node.name); this.pageTitleService.set(node.name);
this.subfolders = sidebar.nodes.filter(n => n.parentId === this.folderId); // Tri par `order` — cohérent avec l'arbre (comparateur `byOrder` mutualisé).
this.pages = sidebar.pages.filter(p => p.nodeId === this.folderId); this.subfolders = sidebar.nodes.filter(n => n.parentId === this.folderId).sort(byOrder);
this.pages = sidebar.pages.filter(p => p.nodeId === this.folderId).sort(byOrder);
this.ancestors = this.buildAncestors(node, sidebar.nodes); this.ancestors = this.buildAncestors(node, sidebar.nodes);
}); });
} }
@@ -114,6 +123,49 @@ export class FolderViewComponent implements OnInit, OnDestroy {
return resolveIcon(this.node?.icon ?? null); return resolveIcon(this.node?.icon ?? null);
} }
// --- Glisser-déposer (réordonnancement + déplacement de pages) -----------
/** Ids des zones de dépôt « sous-dossier » (cibles connectées depuis la liste des pages). */
get subfolderListIds(): string[] {
return this.subfolders.map(s => 'sub-' + s.id);
}
/** Un drag transporte-t-il une Page (vs un dossier) ? La Page a un `title`. */
private isPageDrag(drag: CdkDrag): boolean {
const d = drag.data as { title?: string } | null;
return !!d && typeof d.title === 'string';
}
/** Prédicat de dépôt : n'accepter que des pages (cibles dossier + grille pages). */
acceptPages = (drag: CdkDrag): boolean => this.isPageDrag(drag);
/** Prédicat de dépôt : n'accepter que des dossiers (grille des sous-dossiers). */
acceptFolders = (drag: CdkDrag): boolean => !this.isPageDrag(drag);
/** Réordonne les pages du dossier courant. */
dropPage(event: CdkDragDrop<Page[]>): void {
if (event.previousContainer !== event.container) return; // déplacement géré par dropPageIntoFolder
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(this.pages, event.previousIndex, event.currentIndex);
this.dataSync.persist(this.pageService.reorder(this.folderId, this.pages.map(p => p.id!)), () => this.load());
}
/** Réordonne les sous-dossiers du dossier courant. */
dropSubfolder(event: CdkDragDrop<LoreNode[]>): void {
if (event.previousContainer !== event.container) return;
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(this.subfolders, event.previousIndex, event.currentIndex);
this.dataSync.persist(this.loreService.reorderNodes(this.folderId, this.subfolders.map(s => s.id!)), () => this.load());
}
/** Déplace une page (déposée sur la carte d'un sous-dossier) dans ce sous-dossier. */
dropPageIntoFolder(target: LoreNode, event: CdkDragDrop<LoreNode>): void {
const page = event.item.data as Page;
if (!page || page.nodeId === target.id) return;
// Succès → notify() → onChange → this.load() : le dossier courant se recharge
// et la page déplacée en disparaît.
this.dataSync.persist(this.pageService.reorder(target.id!, [page.id!]), () => this.load());
}
navigateToSubfolder(id: string): void { navigateToSubfolder(id: string): void {
this.router.navigate(['/lore', this.loreId, 'folders', id]); this.router.navigate(['/lore', this.loreId, 'folders', id]);
} }

View File

@@ -11,6 +11,7 @@ import { LayoutService } from '../../services/layout.service';
import { PageTitleService } from '../../services/page-title.service'; import { PageTitleService } from '../../services/page-title.service';
import { Lore, LoreNode } from '../../services/lore.model'; import { Lore, LoreNode } from '../../services/lore.model';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper'; import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
import { byOrder } from '../../shared/folder-grouping.util';
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service'; import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
@Component({ @Component({
@@ -68,7 +69,7 @@ export class LoreDetailComponent implements OnInit, OnDestroy {
// Bug d'affichage corrigé : on ne liste ici que les dossiers racine // Bug d'affichage corrigé : on ne liste ici que les dossiers racine
// (les sous-dossiers apparaissent dans l'arbre de la sidebar quand on // (les sous-dossiers apparaissent dans l'arbre de la sidebar quand on
// ouvre leur parent). parentId null OU chaîne vide = racine. // ouvre leur parent). parentId null OU chaîne vide = racine.
this.rootNodes = data.nodes.filter(n => !n.parentId); this.rootNodes = data.nodes.filter(n => !n.parentId).sort(byOrder); // même ordre que l'arbre
this.layoutService.show(buildLoreSidebarConfig(data)); this.layoutService.show(buildLoreSidebarConfig(data));
this.pageTitleService.set(data.lore.name); this.pageTitleService.set(data.lore.name);
// On sort du mode édition si on change de Lore en cours d'édition. // On sort du mode édition si on change de Lore en cours d'édition.

View File

@@ -6,6 +6,7 @@ import { PageService } from '../services/page.service';
import { Lore, LoreNode } from '../services/lore.model'; import { Lore, LoreNode } from '../services/lore.model';
import { Template } from '../services/template.model'; import { Template } from '../services/template.model';
import { Page } from '../services/page.model'; import { Page } from '../services/page.model';
import { byOrder } from '../shared/folder-grouping.util';
import { import {
SecondarySidebarConfig, TreeItem, GlobalItem, BottomPanel SecondarySidebarConfig, TreeItem, GlobalItem, BottomPanel
} from '../services/layout.service'; } from '../services/layout.service';
@@ -47,6 +48,8 @@ export function loadLoreSidebarData(
export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarConfig { export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarConfig {
const { lore, allLores, nodes, templates, pages } = data; const { lore, allLores, nodes, templates, pages } = data;
// Tri par ORDRE manuel (glisser-déposer) — comparateur `byOrder` mutualisé.
// Regroupe les pages par nodeId et les sous-dossiers par parentId pour un accès O(1). // Regroupe les pages par nodeId et les sous-dossiers par parentId pour un accès O(1).
const pagesByNode = new Map<string, Page[]>(); const pagesByNode = new Map<string, Page[]>();
for (const p of pages) { for (const p of pages) {
@@ -68,14 +71,15 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
* hiérarchie) — "Nouveau sous-dossier" et "Nouvelle page". * hiérarchie) — "Nouveau sous-dossier" et "Nouvelle page".
*/ */
const buildFolderItem = (node: LoreNode): TreeItem => { const buildFolderItem = (node: LoreNode): TreeItem => {
const subFolders = childrenByParent.get(node.id!) ?? []; const subFolders = (childrenByParent.get(node.id!) ?? []).slice().sort(byOrder);
const nodePages = pagesByNode.get(node.id!) ?? []; const nodePages = (pagesByNode.get(node.id!) ?? []).slice().sort(byOrder);
const children: TreeItem[] = [ const children: TreeItem[] = [
...subFolders.map(buildFolderItem), ...subFolders.map(buildFolderItem),
...nodePages.map(p => ({ ...nodePages.map(p => ({
id: `page-${p.id}`, id: `page-${p.id}`,
label: p.title, label: p.title,
route: `/lore/${lore.id}/pages/${p.id}` route: `/lore/${lore.id}/pages/${p.id}`,
dragKind: 'page' as const, dragId: p.id
})) }))
]; ];
// IDs préfixés par type — chaque entité a sa propre séquence IDENTITY en base, // IDs préfixés par type — chaque entité a sa propre séquence IDENTITY en base,
@@ -88,6 +92,8 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
route: `/lore/${lore.id}/folders/${node.id}`, route: `/lore/${lore.id}/folders/${node.id}`,
meta: nodePages.length > 0 ? String(nodePages.length) : undefined, meta: nodePages.length > 0 ? String(nodePages.length) : undefined,
children, children,
dragKind: 'folder', dragId: node.id,
dropKinds: ['folder', 'page'], dropParentId: node.id,
createActions: [ createActions: [
{ {
id: `create-folder-${node.id}`, id: `create-folder-${node.id}`,
@@ -106,7 +112,7 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
}; };
// L'arbre démarre aux dossiers racine (parentId nul ou vide). // L'arbre démarre aux dossiers racine (parentId nul ou vide).
const rootFolders = childrenByParent.get('__root__') ?? []; const rootFolders = (childrenByParent.get('__root__') ?? []).slice().sort(byOrder);
const treeItems: TreeItem[] = rootFolders.map(buildFolderItem); const treeItems: TreeItem[] = rootFolders.map(buildFolderItem);
const globalItems: GlobalItem[] = allLores.map(l => ({ const globalItems: GlobalItem[] = allLores.map(l => ({
@@ -116,7 +122,6 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
const templatesPanel: BottomPanel = { const templatesPanel: BottomPanel = {
id: 'templates', id: 'templates',
title: 'Templates', title: 'Templates',
initiallyOpen: true,
headerAction: { headerAction: {
label: 'Nouveau template', label: 'Nouveau template',
route: `/lore/${lore.id}/templates/create` route: `/lore/${lore.id}/templates/create`
@@ -139,7 +144,11 @@ export function buildLoreSidebarConfig(data: LoreSidebarData): SecondarySidebarC
globalItems, globalItems,
globalBackLabel: 'Tous les lores', globalBackLabel: 'Tous les lores',
globalBackRoute: '/lore', globalBackRoute: '/lore',
bottomPanel: templatesPanel bottomPanel: templatesPanel,
// DnD : les dossiers racine se réordonnent ; '' = parent racine (→ null côté service).
rootDropKinds: ['folder'],
rootDropParentId: '',
reorderContext: { scope: 'lore', id: lore.id! }
}; };
} }

View File

@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { forkJoin, Subscription } from 'rxjs'; import { forkJoin, Subscription } from 'rxjs';
import { CampaignService } from './campaign.service'; import { CampaignService } from './campaign.service';
import { CharacterService } from './character.service';
import { NpcService } from './npc.service'; import { NpcService } from './npc.service';
import { RandomTableService } from './random-table.service'; import { RandomTableService } from './random-table.service';
import { EnemyService } from './enemy.service'; import { EnemyService } from './enemy.service';
@@ -27,7 +26,6 @@ import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/c
export class CampaignSidebarService { export class CampaignSidebarService {
constructor( constructor(
private campaignService: CampaignService, private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService, private enemyService: EnemyService,
@@ -47,7 +45,6 @@ export class CampaignSidebarService {
treeData: loadCampaignTreeData( treeData: loadCampaignTreeData(
this.campaignService, this.campaignService,
campaignId, campaignId,
this.characterService,
this.npcService, this.npcService,
this.randomTableService, this.randomTableService,
this.enemyService this.enemyService

View File

@@ -47,6 +47,11 @@ export class CampaignService {
return this.http.get(`${this.apiUrl}/${id}/foundry-export`, { responseType: 'blob' }); return this.http.get(`${this.apiUrl}/${id}/foundry-export`, { responseType: 'blob' });
} }
/** Génère et télécharge le livret PDF de la campagne. */
exportPdf(id: string): Observable<Blob> {
return this.http.get(`${this.apiUrl}/${id}/pdf-export`, { responseType: 'blob' });
}
createCampaign(campaign: CampaignCreate): Observable<Campaign> { createCampaign(campaign: CampaignCreate): Observable<Campaign> {
return this.http.post<Campaign>(this.apiUrl, campaign); return this.http.post<Campaign>(this.apiUrl, campaign);
} }
@@ -89,6 +94,11 @@ export class CampaignService {
return this.http.get<ArcDeletionImpact>(`/api/arcs/${id}/deletion-impact`); return this.http.get<ArcDeletionImpact>(`/api/arcs/${id}/deletion-impact`);
} }
/** Réordonne les arcs d'une campagne (glisser-déposer) : order = position. */
reorderArcs(orderedIds: string[]): Observable<void> {
return this.http.put<void>('/api/arcs/reorder', { orderedIds });
}
// ========== CHAPTER ========== // ========== CHAPTER ==========
/** /**
* Liste les chapitres d'un arc. Si {@code playthroughId} est fourni, le backend * Liste les chapitres d'un arc. Si {@code playthroughId} est fourni, le backend
@@ -122,6 +132,11 @@ export class CampaignService {
return this.http.get<ChapterDeletionImpact>(`/api/chapters/${id}/deletion-impact`); return this.http.get<ChapterDeletionImpact>(`/api/chapters/${id}/deletion-impact`);
} }
/** Réordonne (et déplace) les chapitres d'un arc : order = position, arcId = arc cible. */
reorderChapters(arcId: string, orderedIds: string[]): Observable<void> {
return this.http.put<void>('/api/chapters/reorder', { arcId, orderedIds });
}
// ========== SCENE ========== // ========== SCENE ==========
getScenes(chapterId: string): Observable<Scene[]> { getScenes(chapterId: string): Observable<Scene[]> {
const params = new HttpParams().set('chapterId', chapterId); const params = new HttpParams().set('chapterId', chapterId);
@@ -144,6 +159,11 @@ export class CampaignService {
return this.http.delete<void>(`/api/scenes/${id}`); return this.http.delete<void>(`/api/scenes/${id}`);
} }
/** Réordonne (et déplace) les scènes d'un chapitre : order = position, chapterId = cible. */
reorderScenes(chapterId: string, orderedIds: string[]): Observable<void> {
return this.http.put<void>('/api/scenes/reorder', { chapterId, orderedIds });
}
search(q: string): Observable<Campaign[]> { search(q: string): Observable<Campaign[]> {
const params = new HttpParams().set('q', q); const params = new HttpParams().set('q', q);
return this.http.get<Campaign[]>(`${this.apiUrl}/search`, { params }); return this.http.get<Campaign[]>(`${this.apiUrl}/search`, { params });

View File

@@ -0,0 +1,38 @@
import { Injectable, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable, Subject } from 'rxjs';
/**
* Bus d'événement minimal pour synchroniser les vues qui affichent les MÊMES données
* (arbre de la sidebar ↔ vues en cartes). Après un réordonnancement/déplacement
* (depuis n'importe quelle vue), on émet `changed$` ; chaque vue concernée se recharge
* → tout reste cohérent SANS rafraîchir la page (F5).
*/
@Injectable({ providedIn: 'root' })
export class DataSyncService {
private readonly _changed = new Subject<void>();
/** Émis quand une donnée ordonnable a changé (reorder/move). */
readonly changed$ = this._changed.asObservable();
notify(): void {
this._changed.next();
}
/**
* Abonne `handler` aux changements de données, avec auto-désabonnement lié au
* cycle de vie du composant (`destroyRef`). Évite de recopier le couple
* `changed$.pipe(takeUntilDestroyed(...)).subscribe(...)` dans chaque vue.
*/
onChange(destroyRef: DestroyRef, handler: () => void): void {
this.changed$.pipe(takeUntilDestroyed(destroyRef)).subscribe(handler);
}
/**
* Persiste un réordonnancement : au succès, notifie les autres vues
* (`notify()`) ; en cas d'échec, exécute `rollback` (typiquement recharger pour
* annuler le déplacement optimiste à l'écran).
*/
persist<T>(reorder$: Observable<T>, rollback: () => void): void {
reorder$.subscribe({ next: () => this.notify(), error: () => rollback() });
}
}

View File

@@ -32,6 +32,11 @@ export class EnemyService {
return this.http.delete<void>(`${this.apiUrl}/${id}`); return this.http.delete<void>(`${this.apiUrl}/${id}`);
} }
/** Réordonne (et reclasse) les ennemis d'un dossier : order = position, folder = cible. */
reorder(folder: string | null, orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/reorder`, { folder, orderedIds });
}
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */ /** Recherche par nom — alimente la recherche globale (Ctrl+K). */
search(q: string): Observable<Enemy[]> { search(q: string): Observable<Enemy[]> {
return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } }); return this.http.get<Enemy[]>(`${this.apiUrl}/search`, { params: { q } });

View File

@@ -1,6 +1,15 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
/** Type d'entité déplaçable (réordonnancement par glisser-déposer dans l'arbre). */
export type ReorderKind = 'arc' | 'chapter' | 'scene' | 'npc' | 'enemy' | 'table' | 'folder' | 'page';
/** Contexte servant à recharger la bonne sidebar après un réordonnancement. */
export interface SidebarReorderContext {
scope: 'campaign' | 'lore';
id: string;
}
export interface TreeItem { export interface TreeItem {
id: string; id: string;
label: string; label: string;
@@ -9,6 +18,14 @@ export interface TreeItem {
isAction?: boolean; // style "action" (ex: "+ Nouveau chapitre") isAction?: boolean; // style "action" (ex: "+ Nouveau chapitre")
/** Clé d'icône optionnelle (ex: "users"). Résolue par le composant via `resolveIcon`. */ /** Clé d'icône optionnelle (ex: "users"). Résolue par le composant via `resolveIcon`. */
iconKey?: string; iconKey?: string;
/** Type d'entité déplaçable (active le drag sur ce nœud). Absent = non déplaçable. */
dragKind?: ReorderKind;
/** Id BRUT de l'entité (sans préfixe) — utilisé pour l'ordre persisté. */
dragId?: string;
/** Types acceptés en dépôt dans la liste des enfants de ce nœud (drop list). */
dropKinds?: ReorderKind[];
/** Parent transmis au réordonnancement (arcId, chapterId, nodeId, dossier…). */
dropParentId?: string | null;
/** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */ /** Petit badge affiché à droite (ex: "3" pour compter les pages d'un dossier). */
meta?: string; meta?: string;
/** /**
@@ -66,7 +83,6 @@ export interface BottomPanel {
id: string; // identifiant pour mémoriser l'état ouvert/fermé id: string; // identifiant pour mémoriser l'état ouvert/fermé
title: string; title: string;
items: BottomPanelItem[]; items: BottomPanelItem[];
initiallyOpen?: boolean;
/** Action "+" inline dans le header — créer un item sans déplier le panneau. */ /** Action "+" inline dans le header — créer un item sans déplier le panneau. */
headerAction?: { label: string; route: string }; headerAction?: { label: string; route: string };
} }
@@ -87,6 +103,12 @@ export interface SecondarySidebarConfig {
bottomPanel?: BottomPanel; // optionnel : présent côté Lore (Templates) bottomPanel?: BottomPanel; // optionnel : présent côté Lore (Templates)
/** @deprecated Remplacé par bottomPanel. Gardé pour compat des callers campagne. */ /** @deprecated Remplacé par bottomPanel. Gardé pour compat des callers campagne. */
footerLabel?: string; footerLabel?: string;
/** Types déplaçables acceptés à la RACINE de l'arbre (ex: arcs côté campagne). */
rootDropKinds?: ReorderKind[];
/** Parent transmis au reorder pour les éléments racine (campaignId, ou null = racine lore). */
rootDropParentId?: string | null;
/** Contexte de rechargement après un réordonnancement (présent = DnD activé). */
reorderContext?: SidebarReorderContext;
} }
/** /**

View File

@@ -21,6 +21,8 @@ export interface LoreNode {
/** ID du dossier parent (null = racine). */ /** ID du dossier parent (null = racine). */
parentId?: string | null; parentId?: string | null;
loreId: string; loreId: string;
/** Position parmi les dossiers frères (glisser-déposer). */
order?: number;
/** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */ /** Champs historiques non encore persistés côté backend — gardés pour compat de l'UI. */
type?: string; type?: string;
description?: string; description?: string;

View File

@@ -117,6 +117,15 @@ export class LoreService {
return this.http.delete<void>(`${this.nodesUrl}/${id}`).pipe(tap(() => this.invalidate())); return this.http.delete<void>(`${this.nodesUrl}/${id}`).pipe(tap(() => this.invalidate()));
} }
/**
* Réordonne (et déplace) des dossiers : `order` = position dans `orderedIds`, et
* chaque dossier reçoit `parentId` (null = racine). Anti-cycle géré côté backend.
*/
reorderNodes(parentId: string | null, orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.nodesUrl}/reorder`, { parentId, orderedIds })
.pipe(tap(() => this.invalidate()));
}
getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> { getLoreNodeDeletionImpact(id: string): Observable<LoreNodeDeletionImpact> {
return this.http.get<LoreNodeDeletionImpact>(`${this.nodesUrl}/${id}/deletion-impact`); return this.http.get<LoreNodeDeletionImpact>(`${this.nodesUrl}/${id}/deletion-impact`);
} }

View File

@@ -37,6 +37,11 @@ export class NpcService {
return this.http.delete<void>(`${this.apiUrl}/${id}`); return this.http.delete<void>(`${this.apiUrl}/${id}`);
} }
/** Réordonne (et reclasse) les PNJ d'un dossier : order = position, folder = dossier cible. */
reorder(folder: string | null, orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/reorder`, { folder, orderedIds });
}
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */ /** Recherche par nom — alimente la recherche globale (Ctrl+K). */
search(q: string): Observable<Npc[]> { search(q: string): Observable<Npc[]> {
return this.http.get<Npc[]>(`${this.apiUrl}/search`, { params: { q } }); return this.http.get<Npc[]>(`${this.apiUrl}/search`, { params: { q } });

View File

@@ -6,6 +6,8 @@ export interface Page {
nodeId: string; nodeId: string;
templateId?: string | null; templateId?: string | null;
title: string; title: string;
/** Position de la page dans son dossier (glisser-déposer). */
order?: number;
values?: Record<string, string>; values?: Record<string, string>;
/** /**
* Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images * Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images

View File

@@ -59,6 +59,15 @@ export class PageService {
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate())); return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(tap(() => this.invalidate()));
} }
/**
* Réordonne (et déplace) les pages d'un dossier : `order` = position dans
* `orderedIds`, et chaque page reçoit `nodeId` (déplacement par glisser-déposer).
*/
reorder(nodeId: string, orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/reorder`, { nodeId, orderedIds })
.pipe(tap(() => this.invalidate()));
}
search(q: string): Observable<Page[]> { search(q: string): Observable<Page[]> {
const params = new HttpParams().set('q', q); const params = new HttpParams().set('q', q);
return this.http.get<Page[]>(`${this.apiUrl}/search`, { params }); return this.http.get<Page[]>(`${this.apiUrl}/search`, { params });

View File

@@ -32,6 +32,11 @@ export class RandomTableService {
return this.http.delete<void>(`${this.apiUrl}/${id}`); return this.http.delete<void>(`${this.apiUrl}/${id}`);
} }
/** Réordonne les tables aléatoires d'une campagne : order = position. */
reorder(orderedIds: string[]): Observable<void> {
return this.http.put<void>(`${this.apiUrl}/reorder`, { orderedIds });
}
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */ /** Recherche par nom — alimente la recherche globale (Ctrl+K). */
search(q: string): Observable<RandomTable[]> { search(q: string): Observable<RandomTable[]> {
return this.http.get<RandomTable[]>(`${this.apiUrl}/search`, { params: { q } }); return this.http.get<RandomTable[]>(`${this.apiUrl}/search`, { params: { q } });

View File

@@ -0,0 +1,67 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { CampaignService } from './campaign.service';
import { NpcService } from './npc.service';
import { EnemyService } from './enemy.service';
import { RandomTableService } from './random-table.service';
import { PageService } from './page.service';
import { LoreService } from './lore.service';
import { TemplateService } from './template.service';
import { LayoutService, ReorderKind, SidebarReorderContext } from './layout.service';
import { CampaignSidebarService } from './campaign-sidebar.service';
import { DataSyncService } from './data-sync.service';
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore/lore-sidebar.helper';
/**
* Persiste un réordonnancement déclenché par glisser-déposer DANS L'ARBRE de la
* sidebar, puis recharge la sidebar depuis le backend (source de vérité).
*
* Le `kind` détermine l'endpoint (et le sens du `parentId` : arcId / chapterId /
* nodeId / dossier…). On recharge systématiquement après la réponse — succès comme
* erreur — pour resynchroniser l'affichage sur l'état persisté (zéro dérive).
*/
@Injectable({ providedIn: 'root' })
export class SidebarReorderService {
constructor(
private campaignService: CampaignService,
private npcService: NpcService,
private enemyService: EnemyService,
private randomTableService: RandomTableService,
private pageService: PageService,
private loreService: LoreService,
private templateService: TemplateService,
private layoutService: LayoutService,
private campaignSidebar: CampaignSidebarService,
private dataSync: DataSyncService
) {}
reorder(context: SidebarReorderContext, kind: ReorderKind, parentId: string | null, orderedIds: string[]): void {
this.persist(kind, parentId, orderedIds).subscribe({
next: () => { this.reload(context); this.dataSync.notify(); },
error: () => { this.reload(context); this.dataSync.notify(); }
});
}
private persist(kind: ReorderKind, parentId: string | null, orderedIds: string[]): Observable<void> {
switch (kind) {
case 'arc': return this.campaignService.reorderArcs(orderedIds);
case 'chapter': return this.campaignService.reorderChapters(parentId ?? '', orderedIds);
case 'scene': return this.campaignService.reorderScenes(parentId ?? '', orderedIds);
case 'npc': return this.npcService.reorder(parentId || null, orderedIds);
case 'enemy': return this.enemyService.reorder(parentId || null, orderedIds);
case 'table': return this.randomTableService.reorder(orderedIds);
case 'folder': return this.loreService.reorderNodes(parentId || null, orderedIds);
case 'page': return this.pageService.reorder(parentId ?? '', orderedIds);
default: return of(void 0);
}
}
private reload(context: SidebarReorderContext): void {
if (context.scope === 'campaign') {
this.campaignSidebar.show(context.id);
} else {
loadLoreSidebarData(context.id, this.loreService, this.templateService, this.pageService)
.subscribe(data => this.layoutService.show(buildLoreSidebarConfig(data)));
}
}
}

View File

@@ -116,8 +116,8 @@ export class SessionReferencePanelComponent implements OnChanges {
private ensureTreeLoaded(): void { private ensureTreeLoaded(): void {
if (this.treeLoaded || this.loadingTree || !this.campaignId) return; if (this.treeLoaded || this.loadingTree || !this.campaignId) return;
this.loadingTree = true; this.loadingTree = true;
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, undefined, this.enemyService).pipe( loadCampaignTreeData(this.campaignService, this.campaignId, this.npcService, undefined, this.enemyService).pipe(
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData)) catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
).subscribe(data => { ).subscribe(data => {
this.treeData = data; this.treeData = data;
this.loadingTree = false; this.loadingTree = false;

View File

@@ -0,0 +1,46 @@
/**
* Regroupement par dossier + tri par ordre manuel, mutualisé entre toutes les
* vues ordonnables (listes PNJ/ennemis, grille de la page campagne, sidebar).
* Une seule définition du tri des dossiers évite que la sidebar et les cartes
* divergent (cf. revue de propreté).
*/
/** Comparateur d'ordre manuel (`order` croissant, défaut 0). */
export const byOrder = (a: { order?: number }, b: { order?: number }): number =>
(a.order ?? 0) - (b.order ?? 0);
/** Comparateur de noms de dossier : alphabétique, insensible casse/accents (comme la sidebar). */
export const byFolderName = (a: string, b: string): number =>
a.localeCompare(b, 'fr', { sensitivity: 'base' });
/** Un dossier (`folder`, `''` = non-classé) et ses éléments, déjà triés par ordre. */
export interface FolderGroup<T> {
folder: string;
items: T[];
}
/**
* Groupe `items` par dossier : dossiers triés alphabétiquement, éléments
* non-classés (folder vide) regroupés en dernier. Chaque groupe est trié par
* `order`. Renvoie une structure neutre (`{ folder, items }`) que chaque vue
* adapte à son besoin (cartes, arbre…).
*/
export function groupByFolder<T extends { order?: number; folder?: string | null }>(items: T[]): FolderGroup<T>[] {
const sorted = [...items].sort(byOrder);
const byFolder = new Map<string, T[]>();
const ungrouped: T[] = [];
for (const it of sorted) {
const f = (it.folder ?? '').trim();
if (f) {
if (!byFolder.has(f)) byFolder.set(f, []);
byFolder.get(f)!.push(it);
} else {
ungrouped.push(it);
}
}
const groups: FolderGroup<T>[] = [...byFolder.keys()]
.sort(byFolderName)
.map(folder => ({ folder, items: byFolder.get(folder)! }));
if (ungrouped.length) groups.push({ folder: '', items: ungrouped });
return groups;
}

View File

@@ -33,75 +33,33 @@
} }
</div> </div>
} }
<!-- cdkDropListGroup : connecte toutes les drop lists de l'arbre (déplacement d'un
nœud d'une liste à l'autre). Chaque ligne est un <app-sidebar-tree-node> :
comme c'est un VRAI composant déclaré statiquement dans la drop list, son
cdkDrag résout sa drop list par DI (impossible via un template outlet-é). -->
<div class="tree"> <div class="tree">
@for (item of items; track item) { <div class="tree-list" cdkDropList
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: item, level: 0 }"></ng-container> id="sidebar-root"
} [cdkDropListData]="rootDragData"
</div> [cdkDropListConnectedTo]="dropListIds"
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template --> [cdkDropListDisabled]="!dndEnabled()"
<ng-template #treeNode let-item let-level="level"> [cdkDropListEnterPredicate]="rootEnterPredicate"
@if (level === 0 && item.sectionHeaderBefore) { (cdkDropListDropped)="onDrop(null, $event)">
<div class="tree-section-header"> @for (item of items; track item.id; let first = $first) {
{{ item.sectionHeaderBefore }} <!-- En-tête de section RENDU HORS du nœud (sinon il serait emporté par le
</div> drag du premier item). C'est un élément non-déplaçable de la drop list. -->
} @if (item.sectionHeaderBefore) {
<div class="tree-item" [style.padding-left.px]="level * 12"> <div class="tree-section-header" [class.first]="first">{{ item.sectionHeaderBefore }}</div>
<div class="tree-row">
@if (!item.isAction && isExpandable(item)) {
<button
type="button"
class="chevron-btn"
(click)="clickChevron($event, item)">
<lucide-icon
[img]="isExpanded(item.id) ? ChevronDown : ChevronRight"
[size]="12">
</lucide-icon>
</button>
} }
@if (item.isAction || !isExpandable(item)) { @if (dndEnabled() && item.dragKind) {
<span class="chevron-spacer"></span> <app-sidebar-tree-node cdkDrag [cdkDragData]="item" cdkDragLockAxis="y"
[item]="item" [level]="0"></app-sidebar-tree-node>
} @else {
<app-sidebar-tree-node [item]="item" [level]="0"></app-sidebar-tree-node>
} }
<button type="button" class="tree-btn"
[class.action]="item.isAction"
[class.active]="isActive(item)"
(click)="clickItem(item)">
@if (iconFor(item); as icon) {
<lucide-icon
[img]="icon"
[size]="14"
class="item-icon">
</lucide-icon>
}
{{ item.label }}
@if (!item.isAction && item.meta) {
<span class="tree-item-meta">{{ item.meta }}</span>
}
</button>
<!-- Actions de creation contextuelles, revelees au survol de la ligne -->
@if (item.createActions?.length) {
<span class="node-actions">
@for (a of item.createActions; track a) {
<button
type="button"
class="node-action-btn"
[title]="a.label"
[attr.aria-label]="a.label"
(click)="runCreateAction($event, a)">
<lucide-icon [img]="iconForAction(a)" [size]="16"></lucide-icon>
</button>
}
</span>
}
</div>
@if (isExpanded(item.id) && hasChildren(item)) {
<div class="tree-children">
@for (child of item.children; track child) {
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
}
</div>
} }
</div> </div>
</ng-template> </div>
<!-- Panneau bas (ex: Templates) ------------------------------------ --> <!-- Panneau bas (ex: Templates) ------------------------------------ -->
@if (bottomPanel) { @if (bottomPanel) {
<section class="bottom-panel"> <section class="bottom-panel">

View File

@@ -50,23 +50,6 @@
&:hover { color: white; background: #1f2937; } &:hover { color: white; background: #1f2937; }
} }
.btn-back {
display: flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
color: #6b7280;
font-size: 0.8rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: color 0.2s;
width: fit-content;
&:hover { color: white; }
}
.sidebar-title { .sidebar-title {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
@@ -132,153 +115,31 @@
.tree { .tree {
flex: 1; flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
overflow-y: auto; overflow-y: auto;
padding: 0.25rem 0; padding: 0.25rem 0;
} }
.tree-list {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
// NB : les styles d'une LIGNE d'arbre (tree-row, tree-btn, chevron, dossier, poignée…)
// vivent désormais dans sidebar-tree-node.component.scss (composant récursif).
// En-tête de section — groupe visuellement les nœuds racines (ex: Personnages / Narration). // En-tête de section (NARRATION / PERSONNAGES / OUTILS) — rendu ici, hors des nœuds.
// 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 { .tree-section-header {
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
color: #9ca3af; color: #9ca3af;
padding: 0.6rem 0.5rem 0.3rem;
}
.tree > .tree-section-header:not(:first-child) {
border-top: 1px solid #374151; border-top: 1px solid #374151;
margin-top: 0.35rem; margin-top: 0.35rem;
padding-top: 0.6rem; padding: 0.6rem 0.5rem 0.3rem;
&.first { border-top: none; margin-top: 0; padding-top: 0.3rem; }
} }
.tree-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: transparent;
border: none;
color: #d1d5db;
font-size: 0.82rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: background 0.15s;
&:hover { background: #1f2937; }
&.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;
color: #6b7280;
flex-shrink: 0;
}
}
.item-icon {
display: inline-flex;
align-items: center;
color: #a5b4fc;
flex-shrink: 0;
margin-right: 0.1rem;
}
.tree-row {
display: flex;
align-items: center;
gap: 0.15rem;
width: 100%;
position: relative;
// Hover-reveal : les boutons d'action de creation n'apparaissent qu'au
// survol de la ligne. Pattern Notion/VS Code.
&:hover .node-actions { opacity: 1; pointer-events: auto; }
}
.node-actions {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
flex-shrink: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
// Superpose les boutons d'action sur le .tree-item-meta eventuel
// (compteur de pages) : au hover on affiche les actions, au repos le meta.
position: absolute;
right: 0.25rem;
background: linear-gradient(to right, transparent, #1f2937 30%);
padding-left: 1rem;
}
.node-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(55, 65, 81, 0.6);
border: none;
border-radius: 6px;
cursor: pointer;
color: #d1d5db;
padding: 0;
transition: background 0.12s, color 0.12s;
&:hover { background: #4338ca; color: #ffffff; }
}
.chevron-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
background: transparent;
border: none;
border-radius: 3px;
cursor: pointer;
color: #6b7280;
padding: 0;
&:hover { background: #374151; color: white; }
}
.chevron-spacer {
display: inline-block;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.tree-children {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
/* --- Bottom panel (ex: Templates) ------------------------------------- */ /* --- Bottom panel (ex: Templates) ------------------------------------- */
.bottom-panel { .bottom-panel {
border-top: 1px solid #1e1e3a; border-top: 1px solid #1e1e3a;
@@ -372,3 +233,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
} }
// NB : aperçu/placeholder = styles GLOBAUX (src/styles.scss), car l'aperçu CDK est
// déplacé hors du composant. Ici, juste le retour visuel de la drop list RACINE.
.tree-list.cdk-drop-list-receiving { background: rgba(138, 123, 200, 0.08); border-radius: 4px; }

View File

@@ -1,14 +1,17 @@
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef } from '@angular/core'; import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef, forwardRef } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, Home, LucideIconData } from 'lucide-angular'; import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, Home, LucideIconData } from 'lucide-angular';
import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService } from '../../services/layout.service'; import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService, ReorderKind, SidebarReorderContext } from '../../services/layout.service';
import { SidebarReorderService } from '../../services/sidebar-reorder.service';
import { SidebarTreeNodeComponent } from './sidebar-tree-node.component';
import { resolveIcon } from '../../lore/lore-icons'; import { resolveIcon } from '../../lore/lore-icons';
@Component({ @Component({
selector: 'app-secondary-sidebar', selector: 'app-secondary-sidebar',
imports: [CommonModule, LucideAngularModule, TranslatePipe], imports: [CommonModule, LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag, forwardRef(() => SidebarTreeNodeComponent)],
templateUrl: './secondary-sidebar.component.html', templateUrl: './secondary-sidebar.component.html',
styleUrls: ['./secondary-sidebar.component.scss'] styleUrls: ['./secondary-sidebar.component.scss']
}) })
@@ -18,6 +21,10 @@ export class SecondarySidebarComponent implements OnDestroy {
@Input() titleRoute: string | null = null; @Input() titleRoute: string | null = null;
@Input() createActions: SidebarAction[] = []; @Input() createActions: SidebarAction[] = [];
@Input() bottomPanel: BottomPanel | null = null; @Input() bottomPanel: BottomPanel | null = null;
/** DnD : types déplaçables à la racine, parent racine, et contexte de rechargement. */
@Input() rootDropKinds: ReorderKind[] | null = null;
@Input() rootDropParentId: string | null = null;
@Input() reorderContext: SidebarReorderContext | null = null;
@Output() collapsedChange = new EventEmitter<boolean>(); @Output() collapsedChange = new EventEmitter<boolean>();
/** true = ouvert (on affiche les items) ; false = replié (titre seul). */ /** true = ouvert (on affiche les items) ; false = replié (titre seul). */
@@ -47,16 +54,35 @@ export class SecondarySidebarComponent implements OnDestroy {
private _items: TreeItem[] = []; private _items: TreeItem[] = [];
// Données de drag STABLES (références constantes entre détections de changement).
// Indispensable : recalculer le tableau à chaque CD réinitialiserait le tri CDK en
// plein drag (l'élément flotterait sans s'insérer). Reconstruites au set d'items.
private _dragData = new Map<string, TreeItem[]>();
private _predicates = new Map<string, (drag: CdkDrag) => boolean>();
private _nodesById = new Map<string, TreeItem>();
rootDragData: TreeItem[] = [];
/** Ids de TOUTES les drop lists, pour les connecter explicitement (registre global
* CDK → fonctionne à travers les composants, contrairement à cdkDropListGroup). */
dropListIds: string[] = ['sidebar-root'];
@Input() set items(value: TreeItem[]) { @Input() set items(value: TreeItem[]) {
this._items = value ?? []; this._items = value ?? [];
this._buildDragMaps();
this.autoExpandActiveAncestors(); this.autoExpandActiveAncestors();
} }
get items(): TreeItem[] { return this._items; } get items(): TreeItem[] { return this._items; }
/** Prédicat racine (stable) : lit `rootDropKinds` au moment de l'appel. */
rootEnterPredicate = (drag: CdkDrag): boolean => {
const k = (drag.data as TreeItem | undefined)?.dragKind;
return !!k && !!this.rootDropKinds && this.rootDropKinds.includes(k);
};
constructor( constructor(
private router: Router, private router: Router,
private layoutService: LayoutService, private layoutService: LayoutService,
private elementRef: ElementRef<HTMLElement> private elementRef: ElementRef<HTMLElement>,
private reorderService: SidebarReorderService
) { ) {
try { try {
const stored = localStorage.getItem(SecondarySidebarComponent.WIDTH_STORAGE_KEY); const stored = localStorage.getItem(SecondarySidebarComponent.WIDTH_STORAGE_KEY);
@@ -199,6 +225,109 @@ export class SecondarySidebarComponent implements OnDestroy {
return !!item.children && item.children.length > 0; return !!item.children && item.children.length > 0;
} }
// --- Glisser-déposer (réordonnancement dans l'arbre) --------------------
/** DnD actif uniquement si un contexte de rechargement est fourni (campagne/lore). */
dndEnabled(): boolean {
return !!this.reorderContext;
}
/**
* Reconstruit, pour chaque nœud, son tableau d'enfants déplaçables et son prédicat
* de dépôt — une seule fois (au set d'items) pour fournir des références STABLES.
* Filtrer les non-déplaçables garde les indices CDK alignés sur la donnée.
*/
private _buildDragMaps(): void {
this._dragData.clear();
this._predicates.clear();
this._nodesById.clear();
this.dropListIds = ['sidebar-root'];
this.rootDragData = this._items.filter(i => !!i.dragKind);
const walk = (nodes: TreeItem[]): void => {
for (const n of nodes) {
this._nodesById.set(n.id, n);
if (n.dropKinds) {
this._predicates.set(n.id, this._makePredicate(n.dropKinds));
this.dropListIds.push('lst-' + n.id);
}
if (n.children?.length) {
this._dragData.set(n.id, n.children.filter(c => !!c.dragKind));
walk(n.children);
}
}
};
walk(this._items);
}
private _makePredicate(kinds: ReorderKind[] | null): (drag: CdkDrag) => boolean {
return (drag: CdkDrag): boolean => {
const k = (drag.data as TreeItem | undefined)?.dragKind;
return !!k && !!kinds && kinds.includes(k);
};
}
/** Données (stables) de la drop list des enfants d'un nœud. */
dragDataFor(item: TreeItem): TreeItem[] {
return this._dragData.get(item.id) ?? [];
}
/** Prédicat (stable) de la drop list des enfants d'un nœud. */
enterPredicateFor(item: TreeItem): (drag: CdkDrag) => boolean {
return this._predicates.get(item.id) ?? (() => false);
}
/**
* Dépôt d'un élément. Le POINT DE LÂCHER fait autorité (override fiable de la
* détection de conteneur de CDK, qui est ambiguë avec des listes imbriquées) :
* - lâché sur un AUTRE dossier (compatible) → déplacement DANS ce dossier ;
* - sinon → réordonnancement dans la liste courante.
* Puis on persiste et on recharge depuis le backend (source de vérité).
*/
onDrop(parent: TreeItem | null, event: CdkDragDrop<TreeItem[]>): void {
if (!this.reorderContext) return;
const dragged = event.item.data as TreeItem;
const kind = dragged?.dragKind;
if (!kind || !dragged.dragId) return;
// 1) Le curseur est-il sur un dossier compatible, DIFFÉRENT du dossier d'origine ?
const folder = this.folderAtPoint(event.dropPoint, kind);
const currentParent = parent ? (parent.dropParentId ?? null) : (this.rootDropParentId ?? null);
if (folder && (folder.dropParentId ?? null) !== currentParent) {
this.moveIntoFolder(dragged, kind, folder);
return;
}
// 2) Sinon : réordonnancement (même liste) ou déplacement géré par CDK (autre liste).
const target = event.container.data;
if (event.previousContainer === event.container) {
if (event.previousIndex === event.currentIndex) return;
moveItemInArray(target, event.previousIndex, event.currentIndex);
} else {
transferArrayItem(event.previousContainer.data, target, event.previousIndex, event.currentIndex);
}
const orderedIds = target.filter(i => i.dragKind === kind).map(i => i.dragId!).filter(Boolean);
this.reorderService.reorder(this.reorderContext, kind, currentParent, orderedIds);
}
/** Dossier compatible sous le point de lâcher (via le DOM réel), ou null. */
private folderAtPoint(pt: { x: number; y: number } | undefined, kind: ReorderKind): TreeItem | null {
if (!pt) return null;
const el = document.elementFromPoint(pt.x, pt.y) as HTMLElement | null;
const nodeEl = el?.closest('[data-drop-node]') as HTMLElement | null;
const id = nodeEl?.getAttribute('data-drop-node');
const node = id ? this._nodesById.get(id) : undefined;
return node && node.dropKinds?.includes(kind) ? node : null;
}
/** Déplace l'élément à la FIN d'un dossier cible (déplacement inter-dossiers). */
private moveIntoFolder(dragged: TreeItem, kind: ReorderKind, folder: TreeItem): void {
const existing = (this._dragData.get(folder.id) ?? [])
.filter(i => i.dragKind === kind)
.map(i => i.dragId!)
.filter(id => id && id !== dragged.dragId);
this.reorderService.reorder(this.reorderContext!, kind, folder.dropParentId ?? null, [...existing, dragged.dragId!]);
}
/** True si le chevron doit s'afficher — seulement quand le noeud a de vrais enfants. */ /** True si le chevron doit s'afficher — seulement quand le noeud a de vrais enfants. */
isExpandable(item: TreeItem): boolean { isExpandable(item: TreeItem): boolean {
return this.hasChildren(item); return this.hasChildren(item);

View File

@@ -0,0 +1,64 @@
<div class="tree-row" [style.padding-left.px]="level * 12">
@if (sidebar.dndEnabled() && item.dragKind) {
<span class="drag-handle" cdkDragHandle [title]="'secondarySidebar.dragHandle' | translate">
<lucide-icon [img]="GripVertical" [size]="12"></lucide-icon>
</span>
}
@if (!item.isAction && sidebar.isExpandable(item)) {
<button type="button" class="chevron-btn" (click)="sidebar.clickChevron($event, item)">
<lucide-icon [img]="sidebar.isExpanded(item.id) ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
</button>
}
@if (item.isAction || !sidebar.isExpandable(item)) {
<span class="chevron-spacer"></span>
}
<button type="button" class="tree-btn"
[class.action]="item.isAction"
[class.active]="sidebar.isActive(item)"
(click)="sidebar.clickItem(item)">
@if (sidebar.iconFor(item); as icon) {
<lucide-icon [img]="icon" [size]="14" class="item-icon"></lucide-icon>
}
{{ item.label }}
@if (!item.isAction && item.meta) {
<span class="tree-item-meta">{{ item.meta }}</span>
}
</button>
@if (item.createActions?.length) {
<span class="node-actions">
@for (a of item.createActions; track a) {
<button type="button" class="node-action-btn"
[title]="a.label" [attr.aria-label]="a.label"
(click)="sidebar.runCreateAction($event, a)">
<lucide-icon [img]="sidebar.iconForAction(a)" [size]="16"></lucide-icon>
</button>
}
</span>
}
</div>
@if (sidebar.isExpanded(item.id) && sidebar.hasChildren(item)) {
@if (sidebar.dndEnabled() && item.dropKinds) {
<div class="tree-children" cdkDropList
[id]="'lst-' + item.id"
[cdkDropListData]="sidebar.dragDataFor(item)"
[cdkDropListConnectedTo]="sidebar.dropListIds"
[cdkDropListEnterPredicate]="sidebar.enterPredicateFor(item)"
(cdkDropListDropped)="sidebar.onDrop(item, $event)">
@for (child of item.children; track child.id) {
@if (sidebar.dndEnabled() && child.dragKind) {
<app-sidebar-tree-node cdkDrag [cdkDragData]="child" cdkDragLockAxis="y"
[item]="child" [level]="level + 1"></app-sidebar-tree-node>
} @else {
<app-sidebar-tree-node [item]="child" [level]="level + 1"></app-sidebar-tree-node>
}
}
</div>
} @else {
<div class="tree-children">
@for (child of item.children; track child.id) {
<app-sidebar-tree-node [item]="child" [level]="level + 1"></app-sidebar-tree-node>
}
</div>
}
}

View File

@@ -0,0 +1,138 @@
// Le nœud est l'élément déplaçable (cdkDrag) → il doit être un bloc dans la colonne.
// (Les en-têtes de section sont rendus HORS du nœud, dans la sidebar, pour qu'ils ne
// soient pas emportés quand on déplace le premier item d'une section.)
:host { display: block; }
.tree-row {
display: flex;
align-items: center;
gap: 0.15rem;
width: 100%;
position: relative;
// Hover-reveal : les actions de création n'apparaissent qu'au survol (Notion/VS Code).
&:hover .node-actions { opacity: 1; pointer-events: auto; }
}
.tree-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: transparent;
border: none;
color: #d1d5db;
font-size: 0.82rem;
padding: 0.4rem 0.5rem;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: background 0.15s;
&:hover { background: #1f2937; }
&.action { color: #6b7280; font-style: italic; }
&.action:hover { color: #a5b4fc; background: #1f2937; }
&.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;
color: #6b7280;
flex-shrink: 0;
}
}
.item-icon {
display: inline-flex;
align-items: center;
color: #a5b4fc;
flex-shrink: 0;
margin-right: 0.1rem;
}
.node-actions {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
flex-shrink: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
position: absolute;
right: 0.25rem;
background: linear-gradient(to right, transparent, #1f2937 30%);
padding-left: 1rem;
}
.node-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(55, 65, 81, 0.6);
border: none;
border-radius: 6px;
cursor: pointer;
color: #d1d5db;
padding: 0;
transition: background 0.12s, color 0.12s;
&:hover { background: #4338ca; color: #ffffff; }
}
.chevron-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
background: transparent;
border: none;
border-radius: 3px;
cursor: pointer;
color: #6b7280;
padding: 0;
&:hover { background: #374151; color: white; }
}
.chevron-spacer {
display: inline-block;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.tree-children {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
// Zone de dépôt qui reçoit un élément valide : léger fond pour guider l'œil.
.tree-children.cdk-drop-list-receiving { background: rgba(138, 123, 200, 0.08); border-radius: 4px; }
// Poignée de glisser-déposer : faible au repos, visible au survol de la ligne.
.drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
flex-shrink: 0;
margin-right: 1px;
color: #6b7280;
opacity: 0.25;
cursor: grab;
transition: opacity 0.15s, color 0.15s;
}
.tree-row:hover .drag-handle { opacity: 0.9; color: #8a7bc8; }
.drag-handle:active { cursor: grabbing; }

View File

@@ -0,0 +1,49 @@
import { Component, Input, HostBinding, forwardRef, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslatePipe } from '@ngx-translate/core';
import { CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop';
import { LucideAngularModule, ChevronRight, ChevronDown, GripVertical } from 'lucide-angular';
import { TreeItem } from '../../services/layout.service';
import { SecondarySidebarComponent } from './secondary-sidebar.component';
/**
* Un nœud de l'arbre de la sidebar (récursif).
*
* Pourquoi un COMPOSANT récursif et pas un `*ngTemplateOutlet` ? Parce que `cdkDrag`
* résout sa drop list par INJECTION au lieu de DÉCLARATION du template : un template
* outlet-é se résout au site de DÉCLARATION (hors des drop lists) → le drag n'a aucun
* conteneur → aucun réordonnancement (l'élément flotte). En rendant chaque enfant via
* `<app-sidebar-tree-node cdkDrag>` STATIQUEMENT dans la drop list du parent, le DI
* trouve la bonne liste et le tri/insertion fonctionne.
*
* Toute la logique (clic, expand, icônes, DnD) est déléguée au composant sidebar parent
* (injecté) pour éviter la duplication.
*/
@Component({
selector: 'app-sidebar-tree-node',
imports: [
CommonModule, LucideAngularModule, TranslatePipe,
CdkDropList, CdkDrag, CdkDragHandle,
forwardRef(() => SidebarTreeNodeComponent)
],
templateUrl: './sidebar-tree-node.component.html',
styleUrls: ['./sidebar-tree-node.component.scss']
})
export class SidebarTreeNodeComponent {
@Input({ required: true }) item!: TreeItem;
@Input() level = 0;
/** Marque l'hôte comme dossier (cible de dépôt détectée par position de lâcher).
* Posé sur l'HÔTE = ancêtre du header ET des enfants → un drop n'importe où sur le
* dossier le résout correctement. */
@HostBinding('attr.data-drop-node') get dropNodeAttr(): string | null {
return this.item?.dropKinds ? this.item.id : null;
}
readonly ChevronRight = ChevronRight;
readonly ChevronDown = ChevronDown;
readonly GripVertical = GripVertical;
/** Sidebar parente (toujours un ancêtre DI) : on lui délègue toute la logique. */
readonly sidebar = inject(SecondarySidebarComponent);
}

View File

@@ -260,7 +260,8 @@
}, },
"secondarySidebar": { "secondarySidebar": {
"backToCampaignHome": "Back to campaign home", "backToCampaignHome": "Back to campaign home",
"resizeHandle": "Drag to resize" "resizeHandle": "Drag to resize",
"dragHandle": "Drag to reorder"
}, },
"breadcrumb": { "breadcrumb": {
"ariaLabel": "Breadcrumb" "ariaLabel": "Breadcrumb"
@@ -592,6 +593,8 @@
"defaultPlaythroughName": "New playthrough {{n}}", "defaultPlaythroughName": "New playthrough {{n}}",
"foundryExport": "Export to Foundry", "foundryExport": "Export to Foundry",
"foundryExporting": "Exporting…", "foundryExporting": "Exporting…",
"pdfExport": "Export to PDF",
"pdfExporting": "Generating…",
"gameSystemChange": { "gameSystemChange": {
"title": "Change the game system?", "title": "Change the game system?",
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.", "message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",
@@ -1496,6 +1499,7 @@
}, },
"campaignTree": { "campaignTree": {
"npcs": "NPCs", "npcs": "NPCs",
"unclassified": "Unfiled",
"newNpc": "New NPC", "newNpc": "New NPC",
"newScene": "New scene", "newScene": "New scene",
"newQuest": "New quest", "newQuest": "New quest",

View File

@@ -260,7 +260,8 @@
}, },
"secondarySidebar": { "secondarySidebar": {
"backToCampaignHome": "Retour à l'accueil de la campagne", "backToCampaignHome": "Retour à l'accueil de la campagne",
"resizeHandle": "Glissez pour redimensionner" "resizeHandle": "Glissez pour redimensionner",
"dragHandle": "Glissez pour réordonner"
}, },
"breadcrumb": { "breadcrumb": {
"ariaLabel": "Fil d'Ariane" "ariaLabel": "Fil d'Ariane"
@@ -592,6 +593,8 @@
"defaultPlaythroughName": "Nouvelle partie {{n}}", "defaultPlaythroughName": "Nouvelle partie {{n}}",
"foundryExport": "Exporter pour Foundry", "foundryExport": "Exporter pour Foundry",
"foundryExporting": "Export…", "foundryExporting": "Export…",
"pdfExport": "Exporter en PDF",
"pdfExporting": "Génération…",
"gameSystemChange": { "gameSystemChange": {
"title": "Changer le système de jeu ?", "title": "Changer le système de jeu ?",
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.", "message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",
@@ -1496,6 +1499,7 @@
}, },
"campaignTree": { "campaignTree": {
"npcs": "PNJ", "npcs": "PNJ",
"unclassified": "Sans dossier",
"newNpc": "Nouveau PNJ", "newNpc": "Nouveau PNJ",
"newScene": "Nouvelle scène", "newScene": "Nouvelle scène",
"newQuest": "Nouvelle quête", "newQuest": "Nouvelle quête",

View File

@@ -1,6 +1,7 @@
{ {
"campaignTree": { "campaignTree": {
"npcs": "NPCs", "npcs": "NPCs",
"unclassified": "Unfiled",
"newNpc": "New NPC", "newNpc": "New NPC",
"newScene": "New scene", "newScene": "New scene",
"newQuest": "New quest", "newQuest": "New quest",

View File

@@ -1,6 +1,7 @@
{ {
"campaignTree": { "campaignTree": {
"npcs": "PNJ", "npcs": "PNJ",
"unclassified": "Sans dossier",
"newNpc": "Nouveau PNJ", "newNpc": "Nouveau PNJ",
"newScene": "Nouvelle scène", "newScene": "Nouvelle scène",
"newQuest": "Nouvelle quête", "newQuest": "Nouvelle quête",

View File

@@ -60,6 +60,8 @@
"defaultPlaythroughName": "New playthrough {{n}}", "defaultPlaythroughName": "New playthrough {{n}}",
"foundryExport": "Export to Foundry", "foundryExport": "Export to Foundry",
"foundryExporting": "Exporting…", "foundryExporting": "Exporting…",
"pdfExport": "Export to PDF",
"pdfExporting": "Generating…",
"gameSystemChange": { "gameSystemChange": {
"title": "Change the game system?", "title": "Change the game system?",
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.", "message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",

View File

@@ -60,6 +60,8 @@
"defaultPlaythroughName": "Nouvelle partie {{n}}", "defaultPlaythroughName": "Nouvelle partie {{n}}",
"foundryExport": "Exporter pour Foundry", "foundryExport": "Exporter pour Foundry",
"foundryExporting": "Export…", "foundryExporting": "Export…",
"pdfExport": "Exporter en PDF",
"pdfExporting": "Génération…",
"gameSystemChange": { "gameSystemChange": {
"title": "Changer le système de jeu ?", "title": "Changer le système de jeu ?",
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.", "message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",

View File

@@ -14,7 +14,8 @@
}, },
"secondarySidebar": { "secondarySidebar": {
"backToCampaignHome": "Back to campaign home", "backToCampaignHome": "Back to campaign home",
"resizeHandle": "Drag to resize" "resizeHandle": "Drag to resize",
"dragHandle": "Drag to reorder"
}, },
"breadcrumb": { "breadcrumb": {
"ariaLabel": "Breadcrumb" "ariaLabel": "Breadcrumb"

View File

@@ -14,7 +14,8 @@
}, },
"secondarySidebar": { "secondarySidebar": {
"backToCampaignHome": "Retour à l'accueil de la campagne", "backToCampaignHome": "Retour à l'accueil de la campagne",
"resizeHandle": "Glissez pour redimensionner" "resizeHandle": "Glissez pour redimensionner",
"dragHandle": "Glissez pour réordonner"
}, },
"breadcrumb": { "breadcrumb": {
"ariaLabel": "Fil d'Ariane" "ariaLabel": "Fil d'Ariane"

View File

@@ -19,3 +19,48 @@ body {
background: #0a0a14; background: #0a0a14;
color: #e0e0e0; color: #e0e0e0;
} }
/* ===== Glisser-déposer (Angular CDK) — retours visuels GLOBAUX =====
Globaux car l'aperçu (cdk-drag-preview) est déplacé dans <body> : les styles
encapsulés des composants ne s'y appliquent pas. */
/* Aperçu qui suit le curseur : ligne compacte et opaque (jamais transparente). */
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 6px;
background: #1b1b2b;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.55);
color: #e0e0e0;
font-size: 0.9rem;
padding: 2px 6px;
}
/* Ne pas traîner tout un sous-arbre déplié quand on déplace un dossier/arc/quête. */
.cdk-drag-preview .tree-children,
.cdk-drag-preview .node-actions { display: none; }
/* Emplacement cible : on GARDE la hauteur de la ligne (visibility, pas display) pour
qu'un vrai trou s'ouvre, et on affiche une barre d'insertion en pointillés. */
.cdk-drag-placeholder {
opacity: 1;
background: rgba(138, 123, 200, 0.18);
border: 1px dashed #8a7bc8;
border-radius: 4px;
box-sizing: border-box;
}
/* On masque le contenu (visibility = on garde la hauteur d'UNE ligne) mais on REPLIE
le sous-arbre (display:none) pour que le trou d'insertion fasse une seule ligne. */
.cdk-drag-placeholder > * { visibility: hidden; }
.cdk-drag-placeholder .tree-children { display: none; }
/* Les voisins glissent pour ouvrir/refermer l'espace pendant le déplacement. */
.cdk-drag-animating { transition: transform 180ms cubic-bezier(0, 0, 0.2, 1); }
.cdk-drop-list-dragging .cdk-drag:not(.cdk-drag-placeholder) {
transition: transform 180ms cubic-bezier(0, 0, 0.2, 1);
}
/* Curseurs de préhension (le drag part de la poignée). */
[cdkDrag] { cursor: default; }
.cdk-drag-handle, [cdkDragHandle] { cursor: grab; }
.cdk-drag-handle:active, [cdkDragHandle]:active { cursor: grabbing; }
/* Surbrillance d'une zone de dépôt qui reçoit un élément (ex: page sur dossier). */
.cdk-drop-list-receiving { outline: 2px dashed #6c63ff; outline-offset: 2px; }